fix: stabilize library paths and subscription matching

This commit is contained in:
ShukeBta
2026-06-28 12:15:10 +08:00
parent e4abb03ca5
commit a97dc35790
102 changed files with 4582 additions and 1519 deletions
+12 -3
View File
@@ -72,9 +72,10 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("organize.scrape_after", true) v.SetDefault("organize.scrape_after", true)
v.SetDefault("scrape.delay_min_ms", 250) v.SetDefault("scrape.delay_min_ms", 250)
v.SetDefault("scrape.delay_max_ms", 500) v.SetDefault("scrape.delay_max_ms", 500)
v.SetDefault("organizer.categories.concert_movie", "演唱会")
v.SetDefault("organizer.categories.documentary_movie", "纪录片")
v.SetDefault("organizer.categories.chinese_movie", "华语电影") v.SetDefault("organizer.categories.chinese_movie", "华语电影")
v.SetDefault("organizer.categories.animation_movie", "动画电影") v.SetDefault("organizer.categories.animation_movie", "动画电影")
v.SetDefault("organizer.categories.foreign_movie", "外语电影")
v.SetDefault("organizer.categories.euus_movie", "欧美电影") v.SetDefault("organizer.categories.euus_movie", "欧美电影")
v.SetDefault("organizer.categories.jk_movie", "日韩电影") v.SetDefault("organizer.categories.jk_movie", "日韩电影")
v.SetDefault("organizer.categories.domestic_tv", "国产剧") v.SetDefault("organizer.categories.domestic_tv", "国产剧")
@@ -82,11 +83,19 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("organizer.categories.jk_tv", "日韩剧") v.SetDefault("organizer.categories.jk_tv", "日韩剧")
v.SetDefault("organizer.categories.jp_anime", "日番") v.SetDefault("organizer.categories.jp_anime", "日番")
v.SetDefault("organizer.categories.cn_anime", "国漫") v.SetDefault("organizer.categories.cn_anime", "国漫")
v.SetDefault("organizer.categories.euus_anime", "欧美动漫") v.SetDefault("organizer.categories.kr_anime", "韩漫")
v.SetDefault("organizer.categories.us_anime", "美漫")
v.SetDefault("organizer.categories.other_anime", "其他")
v.SetDefault("organizer.categories.variety", "综艺") v.SetDefault("organizer.categories.variety", "综艺")
v.SetDefault("organizer.categories.documentary", "纪录片") v.SetDefault("organizer.categories.documentary", "纪录片")
v.SetDefault("organizer.categories.children", "儿童") v.SetDefault("organizer.categories.children", "儿童")
v.SetDefault("organizer.categories.uncategorized_tv", "未分类") v.SetDefault("organizer.categories.adult", "成人")
v.SetDefault("recognition_words.enabled", true)
v.SetDefault("recognition_words.shared_urls", []string{
"https://raw.githubusercontent.com/Putarku/MoviePilot-Help/main/Words/general.txt",
"https://raw.githubusercontent.com/Putarku/MoviePilot-Help/main/Words/TV.txt",
"https://raw.githubusercontent.com/Putarku/MoviePilot-Help/main/Words/anime.txt",
})
v.SetDefault("transcoder.encoder", "") v.SetDefault("transcoder.encoder", "")
v.SetDefault("transcoder.enabled", true) v.SetDefault("transcoder.enabled", true)
+1 -2
View File
@@ -31,7 +31,6 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
return return
} }
libs = service.FilterDeprecatedNativeCloudLibraries(libs) libs = service.FilterDeprecatedNativeCloudLibraries(libs)
libs = service.FilterInternalCloudAutoCategoryLibraries(libs)
role, _ := c.Get(middleware.CtxUserRole) role, _ := c.Get(middleware.CtxUserRole)
includeHidden := role == "admin" && (c.Query("include_hidden") == "1" || c.Query("all") == "1") includeHidden := role == "admin" && (c.Query("include_hidden") == "1" || c.Query("all") == "1")
if !includeHidden { if !includeHidden {
@@ -45,6 +44,7 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
} }
libs = filtered libs = filtered
} else { } else {
libs = service.FilterMergedCloudAutoCategoryLibraries(libs)
libs = service.NormalizeCloudLibraryDisplayNames(libs) libs = service.NormalizeCloudLibraryDisplayNames(libs)
} }
c.JSON(http.StatusOK, libs) c.JSON(http.StatusOK, libs)
@@ -63,7 +63,6 @@ func getLibraryHandler(svc *service.Container) gin.HandlerFunc {
return return
} }
libs := service.FilterDeprecatedNativeCloudLibraries([]model.Library{*lib}) libs := service.FilterDeprecatedNativeCloudLibraries([]model.Library{*lib})
libs = service.FilterInternalCloudAutoCategoryLibraries(libs)
if len(libs) == 0 { if len(libs) == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return return
+27 -3
View File
@@ -95,7 +95,7 @@ func TestListLibrariesIncludeHiddenNormalizesCloudDisplayNames(t *testing.T) {
} }
} }
func TestListLibrariesIncludeHiddenHidesInternalAutoCategoryLibraries(t *testing.T) { func TestListLibrariesShowsAutoCategoryLibraries(t *testing.T) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil { if err != nil {
@@ -118,8 +118,32 @@ func TestListLibrariesIncludeHiddenHidesInternalAutoCategoryLibraries(t *testing
} }
all := requestLibraries(t, svc, "admin", "admin", "/api/libraries?include_hidden=1") all := requestLibraries(t, svc, "admin", "admin", "/api/libraries?include_hidden=1")
if len(all) != 1 || all[0].ID != root.ID { if len(all) != 2 {
t.Fatalf("include_hidden list = %#v, want only user-mounted cloud library", all) t.Fatalf("include_hidden list = %#v, want root plus auto category library", all)
}
ids := map[string]bool{}
for _, lib := range all {
ids[lib.ID] = true
}
if !ids[root.ID] || !ids[auto.ID] {
t.Fatalf("include_hidden list = %#v, want root %s and auto category %s", all, root.ID, auto.ID)
}
visible := requestLibraries(t, svc, "user-1", "user", "/api/libraries")
if len(visible) != 2 {
t.Fatalf("visible list = %#v, want root plus auto category library", visible)
}
ids = map[string]bool{}
for _, lib := range visible {
ids[lib.ID] = true
}
if !ids[root.ID] || !ids[auto.ID] {
t.Fatalf("visible list = %#v, want root %s and auto category %s", visible, root.ID, auto.ID)
}
got := requestLibrary(t, svc, "user-1", "user", "/api/libraries/"+auto.ID, auto.ID)
if got.ID != auto.ID || got.Name != auto.Name {
t.Fatalf("auto category detail = %#v, want accessible category library", got)
} }
} }
+84
View File
@@ -0,0 +1,84 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
func recognitionWordsService(svc *service.Container) *service.RecognitionWordsService {
if svc == nil {
return nil
}
if svc.RecognitionWords != nil {
return svc.RecognitionWords
}
return service.NewRecognitionWordsService(svc.Log, svc.Repo)
}
func getRecognitionWordsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
rw := recognitionWordsService(svc)
if rw == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "recognition words service unavailable"})
return
}
c.JSON(http.StatusOK, rw.Config(c.Request.Context()))
}
}
func saveRecognitionWordsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
rw := recognitionWordsService(svc)
if rw == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "recognition words service unavailable"})
return
}
var req service.RecognitionWordsConfig
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := rw.SaveConfig(c.Request.Context(), req); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, rw.Config(c.Request.Context()))
}
}
func syncRecognitionWordsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
rw := recognitionWordsService(svc)
if rw == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "recognition words service unavailable"})
return
}
cfg, err := rw.SyncShared(c.Request.Context())
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, cfg)
}
}
func testRecognitionWordsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
rw := recognitionWordsService(svc)
if rw == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "recognition words service unavailable"})
return
}
var req struct {
Input string `json:"input"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, rw.Test(c.Request.Context(), req.Input))
}
}
+8
View File
@@ -25,6 +25,7 @@ func registerAdminRoutes(api *gin.RouterGroup, cfg *config.Config, svc *service.
registerAdminRepairRoutes(admin, svc) registerAdminRepairRoutes(admin, svc)
registerAdminAPIConfigRoutes(admin, svc) registerAdminAPIConfigRoutes(admin, svc)
registerAdminSchedulerRoutes(admin, svc) registerAdminSchedulerRoutes(admin, svc)
registerAdminRecognitionWordRoutes(admin, svc)
} }
func registerAdminUserRoutes(admin *gin.RouterGroup, svc *service.Container) { func registerAdminUserRoutes(admin *gin.RouterGroup, svc *service.Container) {
@@ -130,3 +131,10 @@ func registerAdminSchedulerRoutes(admin *gin.RouterGroup, svc *service.Container
admin.GET("/scheduler", schedulerStatusHandler(svc)) admin.GET("/scheduler", schedulerStatusHandler(svc))
admin.POST("/scheduler/:name/run", schedulerRunHandler(svc)) admin.POST("/scheduler/:name/run", schedulerRunHandler(svc))
} }
func registerAdminRecognitionWordRoutes(admin *gin.RouterGroup, svc *service.Container) {
admin.GET("/recognition-words", getRecognitionWordsHandler(svc))
admin.PUT("/recognition-words", saveRecognitionWordsHandler(svc))
admin.POST("/recognition-words/sync", syncRecognitionWordsHandler(svc))
admin.POST("/recognition-words/test", testRecognitionWordsHandler(svc))
}
+4 -9
View File
@@ -6,6 +6,7 @@ package handler
import ( import (
"net/http" "net/http"
"strconv"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -23,19 +24,13 @@ func siteResourceHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": "keyword required"}) c.JSON(http.StatusBadRequest, gin.H{"error": "keyword required"})
return return
} }
all, err := svc.Site.Search(c.Request.Context(), keyword) page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
items, err := svc.Site.SearchSite(c.Request.Context(), c.Param("id"), keyword, page)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
want := c.Param("id") c.JSON(http.StatusOK, gin.H{"items": items, "total": len(items)})
filtered := make([]service.SearchResult, 0, len(all))
for _, r := range all {
if r.SiteID == want {
filtered = append(filtered, r)
}
}
c.JSON(http.StatusOK, gin.H{"items": filtered})
} }
} }
+17
View File
@@ -5,6 +5,7 @@ import (
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/model" "github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/service" "github.com/ShukeBta/MediaStationGo/internal/service"
@@ -56,9 +57,17 @@ func updateSubscriptionHandler(svc *service.Container) gin.HandlerFunc {
Model(&model.Subscription{}). Model(&model.Subscription{}).
Where("id = ?", c.Param("id")). Where("id = ?", c.Param("id")).
Updates(updates).Error; err != nil { Updates(updates).Error; err != nil {
logSubscriptionWarn(svc, "subscription update failed",
zap.String("user_id", subscriptionRequestUserID(c)),
zap.String("subscription_id", c.Param("id")),
zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
logSubscriptionInfo(svc, "subscription updated",
zap.String("user_id", subscriptionRequestUserID(c)),
zap.String("subscription_id", c.Param("id")),
zap.Strings("fields", subscriptionUpdateFieldNames(updates)))
c.Status(http.StatusNoContent) c.Status(http.StatusNoContent)
} }
} }
@@ -146,6 +155,14 @@ func subscriptionPatchUpdates(patch subscriptionPatchReq) map[string]any {
return updates return updates
} }
func subscriptionUpdateFieldNames(updates map[string]any) []string {
names := make([]string, 0, len(updates))
for name := range updates {
names = append(names, name)
}
return names
}
// searchSubscriptionHandler runs a one-off keyword search against the // searchSubscriptionHandler runs a one-off keyword search against the
// configured tracker sites for the given subscription. We treat the // configured tracker sites for the given subscription. We treat the
// subscription's filter as the search term; this lets the UI preview // subscription's filter as the search term; this lets the UI preview
+54
View File
@@ -0,0 +1,54 @@
package handler
import (
"net/url"
"strings"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/middleware"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
func subscriptionRequestUserID(c *gin.Context) string {
if c == nil {
return ""
}
if uid, ok := c.Get(middleware.CtxUserID); ok {
if userID, ok := uid.(string); ok {
return userID
}
}
return ""
}
func subscriptionFeedKind(feedURL string) string {
raw := strings.TrimSpace(feedURL)
if raw == "" {
return "empty"
}
lower := strings.ToLower(raw)
if strings.HasPrefix(lower, "site-search://") {
return "site-search"
}
parsed, err := url.Parse(raw)
if err == nil && parsed.Scheme != "" {
return parsed.Scheme
}
return "unknown"
}
func logSubscriptionInfo(svc *service.Container, msg string, fields ...zap.Field) {
if svc == nil || svc.Log == nil {
return
}
svc.Log.Info(msg, fields...)
}
func logSubscriptionWarn(svc *service.Container, msg string, fields ...zap.Field) {
if svc == nil || svc.Log == nil {
return
}
svc.Log.Warn(msg, fields...)
}
+53
View File
@@ -6,6 +6,7 @@ import (
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/middleware" "github.com/ShukeBta/MediaStationGo/internal/middleware"
"github.com/ShukeBta/MediaStationGo/internal/model" "github.com/ShukeBta/MediaStationGo/internal/model"
@@ -80,9 +81,24 @@ func createSubscriptionHandler(svc *service.Container) gin.HandlerFunc {
} }
enrichSubscriptionArtwork(c.Request.Context(), svc, s) enrichSubscriptionArtwork(c.Request.Context(), svc, s)
if err := svc.Subscription.Create(c.Request.Context(), s); err != nil { if err := svc.Subscription.Create(c.Request.Context(), s); err != nil {
logSubscriptionWarn(svc, "subscription create failed",
zap.String("user_id", s.UserID),
zap.String("name", req.Name),
zap.String("feed_kind", subscriptionFeedKind(req.FeedURL)),
zap.Bool("enabled", enabled),
zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
logSubscriptionInfo(svc, "subscription created",
zap.String("user_id", s.UserID),
zap.String("subscription_id", s.ID),
zap.String("name", s.Name),
zap.String("feed_kind", subscriptionFeedKind(s.FeedURL)),
zap.String("media_type", s.MediaType),
zap.String("media_category", s.MediaCategory),
zap.Bool("enabled", s.Enabled),
zap.Bool("wash_enabled", s.WashEnabled))
enriched := []model.Subscription{*s} enriched := []model.Subscription{*s}
svc.Subscription.EnrichManagementProgress(c.Request.Context(), enriched) svc.Subscription.EnrichManagementProgress(c.Request.Context(), enriched)
*s = enriched[0] *s = enriched[0]
@@ -94,11 +110,18 @@ func listSubscriptionsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
items, err := svc.Subscription.List(c.Request.Context()) items, err := svc.Subscription.List(c.Request.Context())
if err != nil { if err != nil {
logSubscriptionWarn(svc, "subscription list failed",
zap.String("user_id", subscriptionRequestUserID(c)),
zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
svc.Subscription.EnrichManagementProgress(c.Request.Context(), items) svc.Subscription.EnrichManagementProgress(c.Request.Context(), items)
go enrichAndPersistSubscriptions(context.Background(), svc, append([]model.Subscription(nil), items...)) go enrichAndPersistSubscriptions(context.Background(), svc, append([]model.Subscription(nil), items...))
logSubscriptionInfo(svc, "subscription list returned",
zap.String("user_id", subscriptionRequestUserID(c)),
zap.Int("count", len(items)),
zap.Bool("history", false))
c.JSON(http.StatusOK, gin.H{"items": items}) c.JSON(http.StatusOK, gin.H{"items": items})
} }
} }
@@ -107,10 +130,17 @@ func listSubscriptionHistoryHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
items, err := svc.Subscription.History(c.Request.Context()) items, err := svc.Subscription.History(c.Request.Context())
if err != nil { if err != nil {
logSubscriptionWarn(svc, "subscription history list failed",
zap.String("user_id", subscriptionRequestUserID(c)),
zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
svc.Subscription.EnrichManagementProgress(c.Request.Context(), items) svc.Subscription.EnrichManagementProgress(c.Request.Context(), items)
logSubscriptionInfo(svc, "subscription list returned",
zap.String("user_id", subscriptionRequestUserID(c)),
zap.Int("count", len(items)),
zap.Bool("history", true))
c.JSON(http.StatusOK, gin.H{"items": items}) c.JSON(http.StatusOK, gin.H{"items": items})
} }
} }
@@ -118,9 +148,16 @@ func listSubscriptionHistoryHandler(svc *service.Container) gin.HandlerFunc {
func deleteSubscriptionHandler(svc *service.Container) gin.HandlerFunc { func deleteSubscriptionHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
if err := svc.Subscription.Delete(c.Request.Context(), c.Param("id")); err != nil { if err := svc.Subscription.Delete(c.Request.Context(), c.Param("id")); err != nil {
logSubscriptionWarn(svc, "subscription delete failed",
zap.String("user_id", subscriptionRequestUserID(c)),
zap.String("subscription_id", c.Param("id")),
zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
logSubscriptionInfo(svc, "subscription deleted",
zap.String("user_id", subscriptionRequestUserID(c)),
zap.String("subscription_id", c.Param("id")))
c.Status(http.StatusNoContent) c.Status(http.StatusNoContent)
} }
} }
@@ -129,9 +166,17 @@ func runSubscriptionHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
n, err := svc.Subscription.RunNow(c.Request.Context(), c.Param("id")) n, err := svc.Subscription.RunNow(c.Request.Context(), c.Param("id"))
if err != nil { if err != nil {
logSubscriptionWarn(svc, "subscription run now failed",
zap.String("user_id", subscriptionRequestUserID(c)),
zap.String("subscription_id", c.Param("id")),
zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
logSubscriptionInfo(svc, "subscription run now completed",
zap.String("user_id", subscriptionRequestUserID(c)),
zap.String("subscription_id", c.Param("id")),
zap.Int("queued", n))
c.JSON(http.StatusOK, gin.H{"queued": n}) c.JSON(http.StatusOK, gin.H{"queued": n})
} }
} }
@@ -140,9 +185,17 @@ func restoreSubscriptionHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
sub, err := svc.Subscription.Restore(c.Request.Context(), c.Param("id")) sub, err := svc.Subscription.Restore(c.Request.Context(), c.Param("id"))
if err != nil { if err != nil {
logSubscriptionWarn(svc, "subscription restore failed",
zap.String("user_id", subscriptionRequestUserID(c)),
zap.String("subscription_id", c.Param("id")),
zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
logSubscriptionInfo(svc, "subscription restored",
zap.String("user_id", subscriptionRequestUserID(c)),
zap.String("subscription_id", sub.ID),
zap.String("name", sub.Name))
enriched := []model.Subscription{*sub} enriched := []model.Subscription{*sub}
svc.Subscription.EnrichManagementProgress(c.Request.Context(), enriched) svc.Subscription.EnrichManagementProgress(c.Request.Context(), enriched)
c.JSON(http.StatusOK, enriched[0]) c.JSON(http.StatusOK, enriched[0])
+3 -3
View File
@@ -152,7 +152,7 @@ func TestCloudDrive2MutableProviderUsesWebDAV(t *testing.T) {
if _, err := mutable.Rename(context.Background(), "/TV", "电视剧"); err != nil { if _, err := mutable.Rename(context.Background(), "/TV", "电视剧"); err != nil {
t.Fatalf("rename: %v", err) t.Fatalf("rename: %v", err)
} }
moved, err := mutable.(MovableProvider).Move(context.Background(), "/Inbox/Movie.mkv", "/电影/外语电影/Movie (2026)", "Movie (2026).mkv") moved, err := mutable.(MovableProvider).Move(context.Background(), "/Inbox/Movie.mkv", "/电影/欧美电影/Movie (2026)", "Movie (2026).mkv")
if err != nil { if err != nil {
t.Fatalf("move: %v", err) t.Fatalf("move: %v", err)
} }
@@ -162,10 +162,10 @@ func TestCloudDrive2MutableProviderUsesWebDAV(t *testing.T) {
if destinations[0] != srv.URL+"/dav/%E7%94%B5%E8%A7%86%E5%89%A7" { if destinations[0] != srv.URL+"/dav/%E7%94%B5%E8%A7%86%E5%89%A7" {
t.Fatalf("rename Destination = %q", destinations[0]) t.Fatalf("rename Destination = %q", destinations[0])
} }
if destinations[1] != srv.URL+"/dav/%E7%94%B5%E5%BD%B1/%E5%A4%96%E8%AF%AD%E7%94%B5%E5%BD%B1/Movie%20%282026%29/Movie%20%282026%29.mkv" { if destinations[1] != srv.URL+"/dav/%E7%94%B5%E5%BD%B1/%E6%AC%A7%E7%BE%8E%E7%94%B5%E5%BD%B1/Movie%20%282026%29/Movie%20%282026%29.mkv" {
t.Fatalf("move Destination = %q", destinations[1]) t.Fatalf("move Destination = %q", destinations[1])
} }
if moved.ID != "/电影/外语电影/Movie (2026)/Movie (2026).mkv" { if moved.ID != "/电影/欧美电影/Movie (2026)/Movie (2026).mkv" {
t.Fatalf("moved entry = %#v", moved) t.Fatalf("moved entry = %#v", moved)
} }
} }
+139 -31
View File
@@ -13,7 +13,11 @@ import (
const cloudAutoCategoryQueryKey = "auto_category" const cloudAutoCategoryQueryKey = "auto_category"
func BuildCloudAutoCategoryLibraryPath(provider, displayDir string) string { func BuildCloudAutoCategoryLibraryPath(provider, displayDir string) string {
base := BuildCloudLibraryPath(provider, "", displayDir) return BuildCloudAutoCategoryLibraryPathWithScanDir(provider, "", displayDir)
}
func BuildCloudAutoCategoryLibraryPathWithScanDir(provider, scanDir, displayDir string) string {
base := BuildCloudLibraryPath(provider, scanDir, displayDir)
if base == "" || strings.TrimSpace(displayDir) == "" { if base == "" || strings.TrimSpace(displayDir) == "" {
return "" return ""
} }
@@ -42,40 +46,45 @@ func cloudRootMountNeedsAutoCategory(mount CloudMountInfo) bool {
} }
func cloudAutoCategoryDisplayDirForMediaPath(path string) string { func cloudAutoCategoryDisplayDirForMediaPath(path string) string {
displayDir, _ := cloudAutoCategoryDirsForMediaPath(path)
return displayDir
}
func cloudAutoCategoryDirsForMediaPath(path string) (string, string) {
info, ok := ParseCloudLibraryMount(path) info, ok := ParseCloudLibraryMount(path)
if !ok { if !ok {
return "" return "", ""
} }
parts := strmSlashParts(info.DisplayDir) parts := strmSlashParts(info.DisplayDir)
if len(parts) <= 1 { if len(parts) <= 1 {
return "" return "", ""
} }
parts = parts[:len(parts)-1] parts = parts[:len(parts)-1]
categoryParts := cloudAutoCategoryParts(parts) categoryParts, scanParts := cloudAutoCategoryParts(parts)
if len(categoryParts) == 0 { if len(categoryParts) == 0 {
return "" return "", ""
} }
return strings.Join(categoryParts, "/") return strings.Join(categoryParts, "/"), strings.Join(scanParts, "/")
} }
func cloudAutoCategoryParts(parts []string) []string { func cloudAutoCategoryParts(parts []string) ([]string, []string) {
for i, part := range parts { for i, part := range parts {
root := strmCanonicalRoot(part) root := strmCanonicalRoot(part)
if root != "" { if root != "" {
if i+1 >= len(parts) { if i+1 >= len(parts) {
return nil return nil, nil
} }
category := strings.TrimSpace(parts[i+1]) category := strings.TrimSpace(parts[i+1])
if cloudAutoCategoryRootMatches(root, category) { if cloudAutoCategoryRootMatches(root, category) {
return []string{root, category} return []string{root, strmCanonicalCategory(category)}, append([]string(nil), parts[:i+2]...)
} }
return nil return nil, nil
} }
if root := strmCategoryRoot(part); root != "" { if root := strmCategoryRoot(part); root != "" {
return []string{root, strings.TrimSpace(part)} return []string{root, strmCanonicalCategory(part)}, append([]string(nil), parts[:i+1]...)
} }
} }
return nil return nil, nil
} }
func cloudAutoCategoryRootMatches(root, category string) bool { func cloudAutoCategoryRootMatches(root, category string) bool {
@@ -92,33 +101,61 @@ func cloudAutoCategoryRootMatches(root, category string) bool {
return false return false
} }
func (s *ScannerService) ensureCloudAutoCategoryLibrary(ctx context.Context, rootLib *model.Library, provider, displayDir string) (*model.Library, error) { type cloudAutoCategoryTarget struct {
Library *model.Library
RootID string
}
func (s *ScannerService) ensureCloudAutoCategoryTarget(ctx context.Context, rootLib *model.Library, provider, displayDir, scanDir string) (cloudAutoCategoryTarget, error) {
displayDir = normalizeCloudMountDir(provider, displayDir) displayDir = normalizeCloudMountDir(provider, displayDir)
scanDir = normalizeCloudMountDir(provider, firstNonEmpty(scanDir, displayDir))
if s == nil || s.repo == nil || s.repo.DB == nil || rootLib == nil || provider == "" || displayDir == "" { if s == nil || s.repo == nil || s.repo.DB == nil || rootLib == nil || provider == "" || displayDir == "" {
return rootLib, nil return cloudAutoCategoryTarget{Library: rootLib}, nil
} }
if existing := s.findCloudLibraryByDisplayDir(ctx, provider, displayDir); existing != nil { path := BuildCloudAutoCategoryLibraryPathWithScanDir(provider, scanDir, displayDir)
return existing, nil
}
path := BuildCloudAutoCategoryLibraryPath(provider, displayDir)
if path == "" { if path == "" {
return rootLib, nil return cloudAutoCategoryTarget{Library: rootLib}, nil
} }
name := cloudMountDirBase(displayDir) name := cloudMountDirBase(displayDir)
if name == "" { if name == "" {
name = displayDir name = displayDir
} }
kind := InferCloudMountMediaType(displayDir, name)
target, existingAuto := s.findCloudAutoCategoryTarget(ctx, rootLib.ID, provider, displayDir, name, kind)
if target != nil {
root, err := s.ensureCloudLibraryRoot(ctx, target.ID, name, path)
if err != nil {
return cloudAutoCategoryTarget{}, err
}
if existingAuto != nil && existingAuto.ID != target.ID {
s.migrateCloudAutoCategoryLibrary(ctx, existingAuto, target, root)
}
return cloudAutoCategoryTarget{Library: target, RootID: libraryRootID(root)}, nil
}
if existingAuto != nil {
root, err := s.ensureCloudLibraryRoot(ctx, existingAuto.ID, name, path)
if err != nil {
return cloudAutoCategoryTarget{}, err
}
return cloudAutoCategoryTarget{Library: existingAuto, RootID: libraryRootID(root)}, nil
}
lib := &model.Library{ lib := &model.Library{
Name: name, Name: name,
Path: path, Path: path,
Type: InferCloudMountMediaType(displayDir, name), Type: kind,
Enabled: true, Enabled: true,
} }
if err := s.repo.Library.Create(ctx, lib); err != nil { root := model.LibraryRoot{Name: name, Path: path, Enabled: true}
if existing := s.findCloudLibraryByDisplayDir(ctx, provider, displayDir); existing != nil { if err := s.repo.Library.CreateWithRoots(ctx, lib, []model.LibraryRoot{root}); err != nil {
return existing, nil _, existing := s.findCloudAutoCategoryTarget(ctx, rootLib.ID, provider, displayDir, name, kind)
if existing != nil {
ensuredRoot, rootErr := s.ensureCloudLibraryRoot(ctx, existing.ID, name, path)
if rootErr != nil {
return cloudAutoCategoryTarget{}, rootErr
}
return cloudAutoCategoryTarget{Library: existing, RootID: libraryRootID(ensuredRoot)}, nil
} }
return nil, err return cloudAutoCategoryTarget{}, err
} }
if s.log != nil { if s.log != nil {
s.log.Info("created cloud auto category library", s.log.Info("created cloud auto category library",
@@ -127,29 +164,100 @@ func (s *ScannerService) ensureCloudAutoCategoryLibrary(ctx context.Context, roo
zap.String("provider", provider), zap.String("provider", provider),
zap.String("display_dir", displayDir)) zap.String("display_dir", displayDir))
} }
return lib, nil if len(lib.Roots) > 0 {
return cloudAutoCategoryTarget{Library: lib, RootID: lib.Roots[0].ID}, nil
}
return cloudAutoCategoryTarget{Library: lib}, nil
} }
func (s *ScannerService) findCloudLibraryByDisplayDir(ctx context.Context, provider, displayDir string) *model.Library { func (s *ScannerService) findCloudAutoCategoryTarget(ctx context.Context, rootLibraryID, provider, displayDir, name, kind string) (*model.Library, *model.Library) {
if s == nil || s.repo == nil || s.repo.Library == nil { if s == nil || s.repo == nil || s.repo.Library == nil {
return nil return nil, nil
} }
libs, err := s.repo.Library.List(ctx) libs, err := s.repo.Library.List(ctx)
if err != nil { if err != nil {
if s.log != nil { if s.log != nil {
s.log.Warn("list libraries for cloud auto category failed", zap.Error(err)) s.log.Warn("list libraries for cloud auto category failed", zap.Error(err))
} }
return nil return nil, nil
} }
displayDir = normalizeCloudMountDir(provider, displayDir) displayDir = normalizeCloudMountDir(provider, displayDir)
targetKey, _ := CloudLibraryMergeKey(model.Library{Name: name, Type: kind})
var target *model.Library
var existingAuto *model.Library
for _, lib := range libs { for _, lib := range libs {
info, ok := ParseCloudLibraryMount(lib.Path) info, ok := ParseCloudLibraryMount(lib.Path)
if !ok || info.Provider != provider || normalizeCloudMountDir(provider, info.DisplayDir) != displayDir { if ok && info.Provider == provider && normalizeCloudMountDir(provider, info.DisplayDir) == displayDir && CloudLibraryAutoCategory(lib) {
copy := lib
existingAuto = &copy
continue continue
} }
return &lib if lib.ID == rootLibraryID || CloudLibraryAutoCategory(lib) || !lib.Enabled || targetKey == "" {
continue
}
key, ok := CloudLibraryMergeKey(lib)
if target == nil && ok && key == targetKey {
copy := lib
target = &copy
}
}
return target, existingAuto
}
func (s *ScannerService) ensureCloudLibraryRoot(ctx context.Context, libraryID, name, pathValue string) (*model.LibraryRoot, error) {
if s == nil || s.repo == nil || s.repo.Library == nil {
return nil, nil
}
roots, err := s.repo.Library.ListRoots(ctx, libraryID)
if err != nil {
return nil, err
}
targetKey := libraryRootPathKey(pathValue)
for i := range roots {
if libraryRootPathKey(roots[i].Path) == targetKey {
if strings.TrimSpace(roots[i].Name) == "" && strings.TrimSpace(name) != "" {
_ = s.repo.Library.UpdateRoot(ctx, &roots[i], map[string]any{"name": strings.TrimSpace(name)})
roots[i].Name = strings.TrimSpace(name)
}
return &roots[i], nil
}
}
root := &model.LibraryRoot{
LibraryID: libraryID,
Name: strings.TrimSpace(name),
Path: pathValue,
Enabled: true,
SortOrder: len(roots),
}
if err := s.repo.Library.CreateRoot(ctx, root); err != nil {
return nil, err
}
return root, nil
}
func (s *ScannerService) migrateCloudAutoCategoryLibrary(ctx context.Context, source, target *model.Library, root *model.LibraryRoot) {
if s == nil || s.repo == nil || s.repo.DB == nil || source == nil || target == nil || source.ID == "" || target.ID == "" {
return
}
updates := map[string]any{"library_id": target.ID}
if rootID := libraryRootID(root); rootID != "" {
updates["library_root_id"] = rootID
}
if err := s.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("library_id = ?", source.ID).Updates(updates).Error; err != nil {
if s.log != nil {
s.log.Warn("migrate cloud auto category media failed",
zap.String("from_library_id", source.ID),
zap.String("to_library_id", target.ID),
zap.Error(err))
}
return
}
_ = hardDeleteLibraryRoots(ctx, s.repo.DB, source.ID)
if err := s.repo.Library.Delete(ctx, source.ID); err != nil && s.log != nil {
s.log.Warn("remove migrated cloud auto category library failed",
zap.String("library_id", source.ID),
zap.Error(err))
} }
return nil
} }
func (s *ScannerService) cloudScanLibraryScopeIDs(ctx context.Context, lib *model.Library, mount CloudMountInfo) []string { func (s *ScannerService) cloudScanLibraryScopeIDs(ctx context.Context, lib *model.Library, mount CloudMountInfo) []string {
+38
View File
@@ -0,0 +1,38 @@
package service
import (
"context"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
func cloudLibraryMediaCounts(ctx context.Context, repo *repository.Container, libs []model.Library) map[string]int64 {
counts := make(map[string]int64, len(libs))
if repo == nil || repo.DB == nil || len(libs) == 0 {
return counts
}
ids := make([]string, 0, len(libs))
for _, lib := range libs {
ids = append(ids, lib.ID)
}
if len(ids) == 0 {
return counts
}
var rows []struct {
LibraryID string
Count int64
}
if err := repo.DB.WithContext(ctx).
Model(&model.Media{}).
Select("library_id, COUNT(*) AS count").
Where("library_id IN ? AND deleted_at IS NULL", ids).
Group("library_id").
Scan(&rows).Error; err != nil {
return counts
}
for _, row := range rows {
counts[row.LibraryID] = row.Count
}
return counts
}
+122
View File
@@ -0,0 +1,122 @@
package service
import (
"strings"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
func betterDisplayCloudLibrary(candidate, current model.Library, counts map[string]int64) bool {
candidateCount := counts[candidate.ID]
currentCount := counts[current.ID]
if (candidateCount > 0) != (currentCount > 0) {
return candidateCount > 0
}
if candidate.Enabled != current.Enabled {
return candidate.Enabled
}
candidateCanonical := cloudLibraryPathIsCanonical(candidate)
currentCanonical := cloudLibraryPathIsCanonical(current)
if candidateCanonical != currentCanonical {
return candidateCanonical
}
if !candidate.CreatedAt.Equal(current.CreatedAt) {
return candidate.CreatedAt.After(current.CreatedAt)
}
return candidate.ID > current.ID
}
func mergeDisplayCloudLibraries(libs []model.Library) []model.Library {
if len(libs) == 0 {
return libs
}
localByKey := make(map[string]struct{}, len(libs))
for _, lib := range libs {
if _, ok := ParseCloudLibraryMount(lib.Path); ok || !lib.Enabled {
continue
}
if key, ok := CloudLibraryMergeKey(lib); ok {
localByKey[key] = struct{}{}
}
}
out := make([]model.Library, 0, len(libs))
for _, lib := range libs {
if displayName, ok := CloudLibraryDisplayName(lib); ok && displayName != "" {
lib.Name = displayName
if key, ok := CloudLibraryMergeKey(lib); ok {
if _, exists := localByKey[key]; exists && !CloudLibraryAutoCategory(lib) {
continue
}
}
} else if displayName := CanonicalLibraryDisplayName(lib); displayName != "" {
lib.Name = displayName
}
out = append(out, lib)
}
return out
}
func dedupeDisplayLibrariesByMergeKey(libs []model.Library, counts map[string]int64) []model.Library {
if len(libs) == 0 {
return libs
}
out := make([]model.Library, 0, len(libs))
byKey := make(map[string]int, len(libs))
for _, lib := range libs {
if displayName := CanonicalLibraryDisplayName(lib); displayName != "" {
lib.Name = displayName
}
if CloudLibraryAutoCategory(lib) {
out = append(out, lib)
continue
}
key, ok := CloudLibraryMergeKey(lib)
if !ok {
out = append(out, lib)
continue
}
if prev, exists := byKey[key]; exists {
if betterCanonicalDisplayLibrary(lib, out[prev], counts) {
out[prev] = lib
}
continue
}
byKey[key] = len(out)
out = append(out, lib)
}
return out
}
func betterCanonicalDisplayLibrary(candidate, current model.Library, counts map[string]int64) bool {
candidateScore := canonicalDisplayLibraryScore(candidate)
currentScore := canonicalDisplayLibraryScore(current)
if candidateScore != currentScore {
return candidateScore > currentScore
}
candidateCount := counts[candidate.ID]
currentCount := counts[current.ID]
if (candidateCount > 0) != (currentCount > 0) {
return candidateCount > 0
}
if candidate.Enabled != current.Enabled {
return candidate.Enabled
}
if !candidate.CreatedAt.Equal(current.CreatedAt) {
return candidate.CreatedAt.After(current.CreatedAt)
}
return candidate.ID > current.ID
}
func canonicalDisplayLibraryScore(lib model.Library) int {
score := 0
if canonical := CanonicalLibraryDisplayName(lib); canonical == "" || strings.EqualFold(strings.TrimSpace(lib.Name), canonical) {
score += 4
}
if canonical := canonicalLibraryCategoryName(lib.Type, pathBaseSlash(lib.Path)); canonical == "" {
score += 2
}
if _, ok := ParseCloudLibraryMount(lib.Path); !ok {
score++
}
return score
}
+234
View File
@@ -0,0 +1,234 @@
package service
import (
"strings"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
func NormalizeCloudLibraryDisplayNames(libs []model.Library) []model.Library {
out := make([]model.Library, 0, len(libs))
for _, lib := range libs {
if displayName, ok := CloudLibraryDisplayName(lib); ok && displayName != "" {
lib.Name = displayName
} else if displayName := CanonicalLibraryDisplayName(lib); displayName != "" {
lib.Name = displayName
}
out = append(out, lib)
}
return out
}
func NormalizeCloudLibraryDisplay(libs []model.Library) []model.Library {
return normalizeDisplayLibraries(libs)
}
func normalizeDisplayLibraries(libs []model.Library) []model.Library {
out := make([]model.Library, 0, len(libs))
for _, lib := range libs {
if displayName, ok := CloudLibraryDisplayName(lib); ok && displayName != "" {
lib.Name = displayName
} else if displayName := CanonicalLibraryDisplayName(lib); displayName != "" {
lib.Name = displayName
}
if displayType := CanonicalLibraryDisplayType(lib); displayType != "" {
lib.Type = displayType
}
if displayPath := CanonicalLibraryDisplayPath(lib); displayPath != "" {
lib.Path = displayPath
}
out = append(out, lib)
}
return out
}
func CloudLibraryDisplayName(lib model.Library) (string, bool) {
info, ok := ParseCloudLibraryMount(lib.Path)
if !ok {
return "", false
}
name := stripCloudProviderDisplayPrefix(strings.TrimSpace(lib.Name), info.Provider)
dir := firstNonEmpty(info.DisplayDir, info.ScanDir)
if name == "" || strings.EqualFold(name, CloudMountProviderLabel(info.Provider)) {
if base := cloudMountDirBase(dir); base != "" {
name = base
}
}
if name == "" {
name = CloudMountProviderLabel(info.Provider)
}
if canonical := canonicalLibraryCategoryName(lib.Type, name); canonical != "" {
name = canonical
} else if canonical := canonicalLibraryCategoryNameAny(name); canonical != "" {
name = canonical
}
return name, true
}
func CanonicalLibraryDisplayName(lib model.Library) string {
if canonical := canonicalLibraryCategoryName(lib.Type, lib.Name); canonical != "" {
return canonical
}
return canonicalLibraryCategoryNameAny(lib.Name)
}
func CanonicalLibraryDisplayType(lib model.Library) string {
if displayName, ok := CloudLibraryDisplayName(lib); ok {
if typ := canonicalLibraryCategoryDisplayType(displayName); typ != "" {
return typ
}
}
if typ := canonicalLibraryCategoryDisplayType(lib.Name); typ != "" {
return typ
}
return canonicalLibraryCategoryDisplayType(pathBaseSlash(lib.Path))
}
func CanonicalLibraryDisplayPath(lib model.Library) string {
raw := strings.TrimSpace(lib.Path)
if raw == "" {
return ""
}
if info, ok := ParseCloudLibraryMount(raw); ok {
dir := firstNonEmpty(info.DisplayDir, info.ScanDir)
displayDir := canonicalLibraryDisplayDir(dir)
if displayDir == "" {
return raw
}
if CloudLibraryAutoCategory(lib) {
return BuildCloudAutoCategoryLibraryPathWithScanDir(info.Provider, info.ScanDir, displayDir)
}
return BuildCloudLibraryPath(info.Provider, info.ScanDir, displayDir)
}
return canonicalLocalLibraryDisplayPath(raw)
}
func canonicalLibraryCategoryName(libraryType, name string) string {
typeKey := cloudLibraryMergeTypeKey(libraryType)
name = normalizeLibraryMergeName(name)
switch typeKey {
case "movie":
switch name {
case "国产电影", "大陆电影":
return "华语电影"
case "外语电影", "外国电影":
return "欧美电影"
case "日本电影", "韩国电影":
return "日韩电影"
case "音乐会", "concert":
return "演唱会"
case "纪录":
return "纪录片"
case "动漫电影":
return "动画电影"
}
case "tvshows":
switch name {
case "国剧", "大陆剧", "国产电视剧", "华语剧":
return "国产剧"
case "欧美电视剧", "美剧", "英剧", "未分类", "uncategorized":
return "欧美剧"
case "日剧", "韩剧":
return "日韩剧"
case "真人秀":
return "综艺"
case "纪录":
return "纪录片"
case "少儿":
return "儿童"
case "国产动漫", "国产动画":
return "国漫"
case "日漫", "番剧", "日本动漫", "日本动画":
return "日番"
case "韩国动漫", "韩国动画":
return "韩漫"
case "欧美动漫", "欧美动画", "西方动画":
return "美漫"
case "其他动漫", "其它动漫", "other":
return "其他"
}
case "adult":
switch name {
case "9kg", "番号", "jav", "nsfw", "adult":
return "成人"
}
}
return ""
}
func canonicalLibraryCategoryNameAny(name string) string {
for _, libraryType := range []string{"movie", "tv", "anime", "adult"} {
if canonical := canonicalLibraryCategoryName(libraryType, name); canonical != "" {
return canonical
}
}
return ""
}
func canonicalLibraryDisplayDir(raw string) string {
parts := strmSlashParts(raw)
if len(parts) == 0 {
return ""
}
return strings.Join(canonicalLibraryDisplayParts(parts), "/")
}
func canonicalLocalLibraryDisplayPath(raw string) string {
value := strings.TrimSpace(raw)
if value == "" {
return ""
}
sep := "/"
if strings.Contains(value, "\\") {
sep = "\\"
}
slash := strings.ReplaceAll(value, "\\", "/")
prefix := ""
for strings.HasPrefix(slash, "/") {
prefix += "/"
slash = strings.TrimPrefix(slash, "/")
}
parts := strings.Split(slash, "/")
canonical := canonicalLibraryDisplayParts(parts)
if len(canonical) == 0 {
return raw
}
out := prefix + strings.Join(canonical, "/")
if sep == "\\" {
out = strings.ReplaceAll(out, "/", "\\")
}
return out
}
func canonicalLibraryDisplayParts(parts []string) []string {
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" || part == "." {
continue
}
if canonical := canonicalLibraryCategoryNameAny(part); canonical != "" {
part = canonical
}
if len(out) > 0 && normalizeLibraryMergeName(out[len(out)-1]) == normalizeLibraryMergeName(part) {
continue
}
out = append(out, part)
}
return out
}
func canonicalLibraryCategoryDisplayType(name string) string {
switch normalizeLibraryMergeName(name) {
case "演唱会", "音乐会", "动画电影", "动漫电影", "华语电影", "国产电影", "大陆电影", "欧美电影", "外语电影", "外国电影", "日韩电影", "日本电影", "韩国电影":
return "movie"
case "国产剧", "国剧", "大陆剧", "国产电视剧", "华语剧", "欧美剧", "欧美电视剧", "美剧", "英剧", "未分类", "uncategorized", "日韩剧", "日剧", "韩剧", "综艺", "真人秀", "儿童", "少儿":
return "tv"
case "国漫", "国产动漫", "国产动画", "日番", "日漫", "番剧", "日本动漫", "日本动画", "韩漫", "韩国动漫", "韩国动画", "美漫", "欧美动漫", "欧美动画", "西方动画", "其他", "其他动漫", "其它动漫", "other":
return "anime"
case "成人", "9kg", "番号", "jav", "nsfw", "adult":
return "adult"
default:
return ""
}
}
+29 -187
View File
@@ -2,7 +2,6 @@ package service
import ( import (
"context" "context"
"strings"
"github.com/ShukeBta/MediaStationGo/internal/model" "github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository" "github.com/ShukeBta/MediaStationGo/internal/repository"
@@ -13,7 +12,7 @@ func FilterDisplayCloudLibraries(ctx context.Context, repo *repository.Container
return libs return libs
} }
libs = FilterDeprecatedNativeCloudLibraries(libs) libs = FilterDeprecatedNativeCloudLibraries(libs)
libs = FilterInternalCloudAutoCategoryLibraries(libs) libs = FilterMergedCloudAutoCategoryLibraries(libs)
counts := cloudLibraryMediaCounts(ctx, repo, libs) counts := cloudLibraryMediaCounts(ctx, repo, libs)
collapsed := make([]model.Library, 0, len(libs)) collapsed := make([]model.Library, 0, len(libs))
byKey := make(map[string]int, len(libs)) byKey := make(map[string]int, len(libs))
@@ -33,7 +32,7 @@ func FilterDisplayCloudLibraries(ctx context.Context, repo *repository.Container
collapsed = append(collapsed, lib) collapsed = append(collapsed, lib)
} }
collapsed = FilterShadowedCloudLibraries(collapsed) collapsed = FilterShadowedCloudLibraries(collapsed)
return mergeDisplayCloudLibraries(collapsed) return normalizeDisplayLibraries(dedupeDisplayLibrariesByMergeKey(mergeDisplayCloudLibraries(collapsed), counts))
} }
func FilterInternalCloudAutoCategoryLibraries(libs []model.Library) []model.Library { func FilterInternalCloudAutoCategoryLibraries(libs []model.Library) []model.Library {
@@ -50,6 +49,33 @@ func FilterInternalCloudAutoCategoryLibraries(libs []model.Library) []model.Libr
return out return out
} }
func FilterMergedCloudAutoCategoryLibraries(libs []model.Library) []model.Library {
if len(libs) == 0 {
return libs
}
nonAutoKeys := make(map[string]struct{}, len(libs))
for _, lib := range libs {
if CloudLibraryAutoCategory(lib) {
continue
}
if key, ok := CloudLibraryMergeKey(lib); ok {
nonAutoKeys[key] = struct{}{}
}
}
out := make([]model.Library, 0, len(libs))
for _, lib := range libs {
if CloudLibraryAutoCategory(lib) {
if key, ok := CloudLibraryMergeKey(lib); ok {
if _, merged := nonAutoKeys[key]; merged {
continue
}
}
}
out = append(out, lib)
}
return out
}
func FilterScannableCloudLibraries(ctx context.Context, repo *repository.Container, libs []model.Library) []model.Library { func FilterScannableCloudLibraries(ctx context.Context, repo *repository.Container, libs []model.Library) []model.Library {
if len(libs) == 0 { if len(libs) == 0 {
return libs return libs
@@ -95,187 +121,3 @@ func FilterDeprecatedNativeCloudLibraries(libs []model.Library) []model.Library
} }
return out return out
} }
func NormalizeCloudLibraryDisplayNames(libs []model.Library) []model.Library {
out := make([]model.Library, 0, len(libs))
for _, lib := range libs {
if displayName, ok := CloudLibraryDisplayName(lib); ok && displayName != "" {
lib.Name = displayName
}
out = append(out, lib)
}
return out
}
func cloudLibraryMediaCounts(ctx context.Context, repo *repository.Container, libs []model.Library) map[string]int64 {
counts := make(map[string]int64, len(libs))
if repo == nil || repo.DB == nil || len(libs) == 0 {
return counts
}
ids := make([]string, 0, len(libs))
for _, lib := range libs {
if _, ok := ParseCloudLibraryMount(lib.Path); ok {
ids = append(ids, lib.ID)
}
}
if len(ids) == 0 {
return counts
}
var rows []struct {
LibraryID string
Count int64
}
if err := repo.DB.WithContext(ctx).
Model(&model.Media{}).
Select("library_id, COUNT(*) AS count").
Where("library_id IN ? AND deleted_at IS NULL", ids).
Group("library_id").
Scan(&rows).Error; err != nil {
return counts
}
for _, row := range rows {
counts[row.LibraryID] = row.Count
}
return counts
}
func cloudLibraryDisplayKey(lib model.Library) (string, bool) {
info, ok := ParseCloudLibraryMount(lib.Path)
if !ok {
return "", false
}
dir := firstNonEmpty(info.DisplayDir, info.ScanDir)
return info.Provider + "\x00" + dir, true
}
func betterDisplayCloudLibrary(candidate, current model.Library, counts map[string]int64) bool {
candidateCount := counts[candidate.ID]
currentCount := counts[current.ID]
if (candidateCount > 0) != (currentCount > 0) {
return candidateCount > 0
}
if candidate.Enabled != current.Enabled {
return candidate.Enabled
}
candidateCanonical := cloudLibraryPathIsCanonical(candidate)
currentCanonical := cloudLibraryPathIsCanonical(current)
if candidateCanonical != currentCanonical {
return candidateCanonical
}
if !candidate.CreatedAt.Equal(current.CreatedAt) {
return candidate.CreatedAt.After(current.CreatedAt)
}
return candidate.ID > current.ID
}
func cloudLibraryPathIsCanonical(lib model.Library) bool {
info, ok := ParseCloudLibraryMount(lib.Path)
if !ok {
return false
}
return BuildCloudLibraryPath(info.Provider, info.ScanDir, info.DisplayDir) == strings.TrimSpace(lib.Path)
}
func mergeDisplayCloudLibraries(libs []model.Library) []model.Library {
if len(libs) == 0 {
return libs
}
localByKey := make(map[string]struct{}, len(libs))
for _, lib := range libs {
if _, ok := ParseCloudLibraryMount(lib.Path); ok || !lib.Enabled {
continue
}
if key, ok := CloudLibraryMergeKey(lib); ok {
localByKey[key] = struct{}{}
}
}
out := make([]model.Library, 0, len(libs))
for _, lib := range libs {
if displayName, ok := CloudLibraryDisplayName(lib); ok && displayName != "" {
lib.Name = displayName
if key, ok := CloudLibraryMergeKey(lib); ok {
if _, exists := localByKey[key]; exists {
continue
}
}
}
out = append(out, lib)
}
return out
}
func CloudLibraryDisplayName(lib model.Library) (string, bool) {
info, ok := ParseCloudLibraryMount(lib.Path)
if !ok {
return "", false
}
name := stripCloudProviderDisplayPrefix(strings.TrimSpace(lib.Name), info.Provider)
dir := firstNonEmpty(info.DisplayDir, info.ScanDir)
if name == "" || strings.EqualFold(name, CloudMountProviderLabel(info.Provider)) {
if base := cloudMountDirBase(dir); base != "" {
name = base
}
}
if name == "" {
name = CloudMountProviderLabel(info.Provider)
}
return name, true
}
func CloudLibraryMergeKey(lib model.Library) (string, bool) {
name := strings.TrimSpace(lib.Name)
if displayName, ok := CloudLibraryDisplayName(lib); ok {
name = displayName
}
name = normalizeLibraryMergeName(name)
if name == "" {
return "", false
}
typeKey := cloudLibraryMergeTypeKey(lib.Type)
return typeKey + "\x00" + cloudLibraryMergeNameKey(typeKey, name), true
}
func cloudLibraryMergeTypeKey(libraryType string) string {
switch strings.ToLower(strings.TrimSpace(libraryType)) {
case "tv", "anime", "variety":
return "tvshows"
default:
return strings.ToLower(strings.TrimSpace(libraryType))
}
}
func cloudLibraryMergeNameKey(typeKey, name string) string {
switch typeKey {
case "movie":
switch name {
case "国产电影", "大陆电影", "华语电影":
return "华语电影"
case "外语电影", "欧美电影", "日韩电影", "日本电影", "韩国电影":
return "外语电影"
case "纪录", "纪录片":
return "纪录片"
case "演唱会", "concert":
return "演唱会"
case "动画电影", "动漫电影":
return "动画电影"
}
case "tvshows":
switch name {
case "国产剧", "大陆剧", "华语剧", "国剧":
return "国产剧"
case "欧美剧", "美剧", "英剧":
return "欧美剧"
case "日韩剧", "日剧", "韩剧":
return "日韩剧"
case "国漫", "国产动漫", "国产动画":
return "国漫"
case "日番", "日漫", "番剧", "日本动漫", "日本动画":
return "日番"
case "欧美动漫", "欧美动画", "西方动画":
return "欧美动漫"
case "纪录", "纪录片":
return "纪录片"
}
}
return name
}
+107 -17
View File
@@ -2,6 +2,7 @@ package service
import ( import (
"slices" "slices"
"strings"
"testing" "testing"
"time" "time"
@@ -125,13 +126,13 @@ func TestFilterDisplayCloudLibrariesMergesCategoryNameAliases(t *testing.T) {
} }
filtered := FilterDisplayCloudLibraries(t.Context(), repos, []model.Library{foreignMovie, westernMovie, eastAsianMovie, jpAnime, jpAnimeCloud}) filtered := FilterDisplayCloudLibraries(t.Context(), repos, []model.Library{foreignMovie, westernMovie, eastAsianMovie, jpAnime, jpAnimeCloud})
if got := libraryNames(filtered); !slices.Equal(got, []string{"外语电影", "日番"}) { if got := libraryNames(filtered); !slices.Equal(got, []string{"欧美电影", "日韩电影", "日番"}) {
t.Fatalf("filtered names = %#v, want user-facing alias libraries only", got) t.Fatalf("filtered names = %#v, want legacy foreign movie merged into western movie plus anime aliases", got)
} }
movieMerged := MergedLibraryIDs([]model.Library{foreignMovie, westernMovie, eastAsianMovie, jpAnime, jpAnimeCloud}, foreignMovie) movieMerged := MergedLibraryIDs([]model.Library{foreignMovie, westernMovie, eastAsianMovie, jpAnime, jpAnimeCloud}, foreignMovie)
if !slices.Equal(movieMerged, []string{foreignMovie.ID, westernMovie.ID, eastAsianMovie.ID}) { if !slices.Equal(movieMerged, []string{foreignMovie.ID, westernMovie.ID}) {
t.Fatalf("movie merged ids = %#v, want foreign movie aliases", movieMerged) t.Fatalf("movie merged ids = %#v, want legacy foreign movie merged with western movie", movieMerged)
} }
animeMerged := MergedLibraryIDs([]model.Library{foreignMovie, westernMovie, eastAsianMovie, jpAnime, jpAnimeCloud}, jpAnime) animeMerged := MergedLibraryIDs([]model.Library{foreignMovie, westernMovie, eastAsianMovie, jpAnime, jpAnimeCloud}, jpAnime)
if !slices.Equal(animeMerged, []string{jpAnime.ID, jpAnimeCloud.ID}) { if !slices.Equal(animeMerged, []string{jpAnime.ID, jpAnimeCloud.ID}) {
@@ -139,6 +140,94 @@ func TestFilterDisplayCloudLibrariesMergesCategoryNameAliases(t *testing.T) {
} }
} }
func TestFilterDisplayCloudLibrariesCanonicalizesLegacyDisplayPaths(t *testing.T) {
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
repos := repository.New(db)
westernAnimation := model.Library{Name: "欧美动漫", Path: `F:\media\动漫\欧美动漫`, Type: "tv", Enabled: true}
uncategorizedCloud := model.Library{Name: "OpenList · 未分类", Path: BuildCloudLibraryPath("openlist", "/未分类", "/未分类"), Type: "movie", Enabled: true}
adult := model.Library{Name: "9KG", Path: `F:\media\成人\9KG`, Type: "movie", Enabled: true}
for _, lib := range []*model.Library{&westernAnimation, &uncategorizedCloud, &adult} {
if err := repos.Library.Create(t.Context(), lib); err != nil {
t.Fatal(err)
}
}
filtered := FilterDisplayCloudLibraries(t.Context(), repos, []model.Library{westernAnimation, uncategorizedCloud, adult})
if got := libraryNames(filtered); !slices.Equal(got, []string{"美漫", "欧美剧", "成人"}) {
t.Fatalf("filtered names = %#v, want canonical category names", got)
}
if got := []string{filtered[0].Type, filtered[1].Type, filtered[2].Type}; !slices.Equal(got, []string{"anime", "tv", "adult"}) {
t.Fatalf("filtered types = %#v, want canonical display types", got)
}
combined := strings.Join([]string{filtered[0].Path, filtered[1].Path, filtered[2].Path}, "\n")
for _, legacy := range []string{"欧美动漫", "未分类", "9KG"} {
if strings.Contains(combined, legacy) {
t.Fatalf("display paths contain legacy category %q: %s", legacy, combined)
}
}
}
func TestCanonicalLibraryDisplayPathPreservesAutoCategoryScanDir(t *testing.T) {
raw := BuildCloudAutoCategoryLibraryPathWithScanDir("openlist", "国漫", "动漫/国产动漫")
got := CanonicalLibraryDisplayPath(model.Library{Name: "国漫", Path: raw, Type: "anime", Enabled: true})
info, ok := ParseCloudLibraryMount(got)
if !ok {
t.Fatalf("canonical path did not parse: %q", got)
}
if !CloudLibraryAutoCategory(model.Library{Path: got}) {
t.Fatalf("canonical path lost auto_category flag: %q", got)
}
if info.ScanDir != "国漫" || info.DisplayDir != "动漫/国漫" {
t.Fatalf("canonical path info = %#v, want scan 国漫 and canonical display 动漫/国漫", info)
}
}
func TestListMediaVisibleDoesNotMergeDistinctMovieRegionLibraries(t *testing.T) {
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
repos := repository.New(db)
foreignMovie := model.Library{Name: "外语电影", Path: "/media/电影/外语电影", Type: "movie", Enabled: true}
westernMovie := model.Library{Name: "OpenList · 欧美电影", Path: BuildCloudLibraryPath("openlist", "/欧美电影", "/欧美电影"), Type: "movie", Enabled: true}
eastAsianMovie := model.Library{Name: "OpenList · 日韩电影", Path: BuildCloudLibraryPath("openlist", "/日韩电影", "/日韩电影"), Type: "movie", Enabled: true}
for _, lib := range []*model.Library{&foreignMovie, &westernMovie, &eastAsianMovie} {
if err := repos.Library.Create(t.Context(), lib); err != nil {
t.Fatal(err)
}
}
if err := repos.DB.Create(&model.Media{
LibraryID: westernMovie.ID,
Title: "Western Movie",
Path: "cloud://openlist/欧美电影/Western.Movie.2026.mkv",
}).Error; err != nil {
t.Fatal(err)
}
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos)
items, total, err := svc.ListMediaVisible(t.Context(), foreignMovie.ID, 1, 20, MediaVisibility{IncludeNSFW: true})
if err != nil {
t.Fatal(err)
}
if total != 1 || !slices.Equal(mediaTitles(items), []string{"Western Movie"}) {
t.Fatalf("legacy foreign movie items total=%d items=%#v, want merged western media", total, mediaTitles(items))
}
items, total, err = svc.ListMediaVisible(t.Context(), eastAsianMovie.ID, 1, 20, MediaVisibility{IncludeNSFW: true})
if err != nil {
t.Fatal(err)
}
if total != 0 || len(items) != 0 {
t.Fatalf("east asian movie items total=%d items=%#v, want empty isolated library", total, mediaTitles(items))
}
items, total, err = svc.ListMediaVisible(t.Context(), westernMovie.ID, 1, 20, MediaVisibility{IncludeNSFW: true})
if err != nil {
t.Fatal(err)
}
if total != 1 || !slices.Equal(mediaTitles(items), []string{"Western Movie"}) {
t.Fatalf("western movie items total=%d items=%#v, want own media only", total, mediaTitles(items))
}
}
func TestFilterDeprecatedNativeCloudLibrariesHidesPopulatedHistory(t *testing.T) { func TestFilterDeprecatedNativeCloudLibrariesHidesPopulatedHistory(t *testing.T) {
db := newServiceTestDB(t, &model.Library{}, &model.Media{}) db := newServiceTestDB(t, &model.Library{}, &model.Media{})
repos := repository.New(db) repos := repository.New(db)
@@ -284,12 +373,13 @@ func TestStartAllCloudLibraryScansIncludesMergedCloudMounts(t *testing.T) {
} }
} }
func TestAutoCategoryCloudLibrariesDoNotShadowRootOrScan(t *testing.T) { func TestAutoCategoryCloudLibrariesMergeIntoExistingDisplayLibrary(t *testing.T) {
db := newServiceTestDB(t, &model.Library{}, &model.Media{}) db := newServiceTestDB(t, &model.Library{}, &model.Media{})
repos := repository.New(db) repos := repository.New(db)
local := model.Library{Name: "欧美剧", Path: "/media/电视剧/欧美剧", Type: "tv", Enabled: true}
root := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "movie", Enabled: true} root := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "movie", Enabled: true}
auto := model.Library{Name: "欧美剧", Path: BuildCloudAutoCategoryLibraryPath("openlist", "电视剧/欧美剧"), Type: "tv", Enabled: true} auto := model.Library{Name: "欧美剧", Path: BuildCloudAutoCategoryLibraryPath("openlist", "电视剧/欧美剧"), Type: "tv", Enabled: true}
for _, lib := range []*model.Library{&root, &auto} { for _, lib := range []*model.Library{&local, &root, &auto} {
if err := repos.Library.Create(t.Context(), lib); err != nil { if err := repos.Library.Create(t.Context(), lib); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -303,12 +393,12 @@ func TestAutoCategoryCloudLibrariesDoNotShadowRootOrScan(t *testing.T) {
t.Fatalf("auto category should not shadow root scan: %#v", shadow) t.Fatalf("auto category should not shadow root scan: %#v", shadow)
} }
display := FilterDisplayCloudLibraries(t.Context(), repos, libs) display := FilterDisplayCloudLibraries(t.Context(), repos, libs)
if len(display) != 1 || display[0].ID != root.ID { if got := libraryNames(display); !slices.Equal(got, []string{"欧美剧", "OpenList"}) {
t.Fatalf("display libraries = %#v, want only user-mounted root", display) t.Fatalf("display libraries = %#v, want local library and user-mounted root only", got)
} }
scannable := FilterScannableCloudLibraries(t.Context(), repos, libs) scannable := FilterScannableCloudLibraries(t.Context(), repos, libs)
if len(scannable) != 1 || scannable[0].ID != root.ID { if got := libraryNames(scannable); !slices.Equal(got, []string{"欧美剧", "OpenList"}) {
t.Fatalf("scannable libraries = %#v, want only root", scannable) t.Fatalf("scannable libraries = %#v, want local library and root only", got)
} }
scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil) scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil)
@@ -317,11 +407,11 @@ func TestAutoCategoryCloudLibrariesDoNotShadowRootOrScan(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
if len(statuses) != 1 || statuses[0].LibraryID != root.ID { if len(statuses) != 1 || statuses[0].LibraryID != root.ID {
t.Fatalf("scan-all statuses = %#v, want only root queued", statuses) t.Fatalf("scan-all statuses = %#v, want only cloud root queued", statuses)
} }
} }
func TestRootCloudLibraryIncludesHiddenAutoCategoryMedia(t *testing.T) { func TestRootCloudLibraryIncludesAutoCategoryMedia(t *testing.T) {
db := newServiceTestDB(t, &model.Library{}, &model.Media{}) db := newServiceTestDB(t, &model.Library{}, &model.Media{})
repos := repository.New(db) repos := repository.New(db)
root := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "movie", Enabled: true} root := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "movie", Enabled: true}
@@ -345,13 +435,13 @@ func TestRootCloudLibraryIncludesHiddenAutoCategoryMedia(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
if total != 1 || len(items) != 1 { if total != 1 || len(items) != 1 {
t.Fatalf("root cloud items total=%d len=%d, want hidden auto-category media", total, len(items)) t.Fatalf("root cloud items total=%d len=%d, want auto-category media", total, len(items))
} }
if items[0].LibraryName != root.Name || items[0].LibraryPath != root.Path { if items[0].LibraryName != auto.Name || items[0].LibraryPath != auto.Path {
t.Fatalf("media library metadata = (%q, %q), want user-mounted root", items[0].LibraryName, items[0].LibraryPath) t.Fatalf("media library metadata = (%q, %q), want auto category", items[0].LibraryName, items[0].LibraryPath)
} }
if items[0].DisplayLibraryID != root.ID || items[0].DisplayLibraryPath != root.Path { if items[0].DisplayLibraryID != auto.ID || items[0].DisplayLibraryPath != auto.Path {
t.Fatalf("display library = (%q, %q), want user-mounted root", items[0].DisplayLibraryID, items[0].DisplayLibraryPath) t.Fatalf("display library = (%q, %q), want auto category", items[0].DisplayLibraryID, items[0].DisplayLibraryPath)
} }
} }
+2 -2
View File
@@ -85,11 +85,11 @@ func InferCloudMountMediaType(dir, name string) string {
switch { switch {
case strings.Contains(text, "成人") || strings.Contains(text, "adult") || strings.Contains(text, "jav") || strings.Contains(text, "9kg"): case strings.Contains(text, "成人") || strings.Contains(text, "adult") || strings.Contains(text, "jav") || strings.Contains(text, "9kg"):
return "adult" return "adult"
case containsAny(text, "动画电影", "华语电影", "外语电影", "欧美电影", "日韩电影", "韩国电影", "日本电影", "港台电影", "香港电影", "台湾电影", "大陆电影", "国产电影", "纪录片", "演唱会", "电影", "movie", "movies", "film", "films", "documentary", "concert"): case containsAny(text, "动画电影", "华语电影", "外语电影", "外国电影", "欧美电影", "日韩电影", "韩国电影", "日本电影", "港台电影", "香港电影", "台湾电影", "大陆电影", "国产电影", "纪录片", "演唱会", "音乐会", "电影", "movie", "movies", "film", "films", "documentary", "concert"):
return "movie" return "movie"
case containsAny(text, "综艺", "真人秀", "脱口秀", "晚会", "variety"): case containsAny(text, "综艺", "真人秀", "脱口秀", "晚会", "variety"):
return "variety" return "variety"
case containsAny(text, "国漫", "日漫", "日番", "番剧", "动漫", "欧美动漫", "动画剧集", "anime"): case containsAny(text, "国漫", "日漫", "日番", "韩漫", "美漫", "番剧", "动漫", "欧美动漫", "动画剧集", "anime"):
return "anime" return "anime"
case containsAny(text, "国产剧", "大陆剧", "华语剧", "欧美剧", "日韩剧", "韩剧", "日剧", "港剧", "台剧", "泰剧", "英剧", "美剧", "短剧", "电视剧", "剧集", "连续剧", "series", "tv", "shows"): case containsAny(text, "国产剧", "大陆剧", "华语剧", "欧美剧", "日韩剧", "韩剧", "日剧", "港剧", "台剧", "泰剧", "英剧", "美剧", "短剧", "电视剧", "剧集", "连续剧", "series", "tv", "shows"):
return "tv" return "tv"
+64 -141
View File
@@ -1,167 +1,90 @@
package service package service
import ( import (
"context"
"strings" "strings"
"github.com/ShukeBta/MediaStationGo/internal/model" "github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
) )
func MergedLibraryIDsForLibrary(ctx context.Context, repo *repository.Container, libraryID string) ([]string, error) { func cloudLibraryDisplayKey(lib model.Library) (string, bool) {
libraryID = strings.TrimSpace(libraryID) info, ok := ParseCloudLibraryMount(lib.Path)
if libraryID == "" || repo == nil || repo.Library == nil {
return []string{libraryID}, nil
}
lib, err := repo.Library.FindByID(ctx, libraryID)
if err != nil {
return nil, err
}
if lib == nil {
return []string{libraryID}, nil
}
libs, err := repo.Library.List(ctx)
if err != nil {
return nil, err
}
return MergedLibraryIDs(libs, *lib), nil
}
func MergedLibraryIDs(libs []model.Library, lib model.Library) []string {
ids := []string{}
seen := map[string]struct{}{}
add := func(more ...string) {
for _, id := range more {
id = strings.TrimSpace(id)
if id == "" {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
ids = append(ids, id)
}
}
add(lib.ID)
if rootAutoIDs := cloudRootAutoCategoryLibraryIDs(libs, lib); len(rootAutoIDs) > 0 {
add(rootAutoIDs...)
}
key, ok := CloudLibraryMergeKey(lib)
if !ok { if !ok {
return ids return "", false
} }
_, libIsCloud := ParseCloudLibraryMount(lib.Path) dir := firstNonEmpty(info.DisplayDir, info.ScanDir)
for _, candidate := range libs { return info.Provider + "\x00" + dir, true
if candidate.ID == lib.ID || !candidate.Enabled {
continue
}
candidateKey, ok := CloudLibraryMergeKey(candidate)
if !ok || candidateKey != key {
continue
}
_, candidateIsCloud := ParseCloudLibraryMount(candidate.Path)
if !libIsCloud && !candidateIsCloud {
continue
}
add(candidate.ID)
}
return ids
} }
func cloudRootAutoCategoryLibraryIDs(libs []model.Library, lib model.Library) []string { func cloudLibraryPathIsCanonical(lib model.Library) bool {
mount, ok := ParseCloudLibraryMount(lib.Path) info, ok := ParseCloudLibraryMount(lib.Path)
if !ok || !cloudRootMountNeedsAutoCategory(mount) { if !ok {
return nil return false
} }
ids := make([]string, 0) return BuildCloudLibraryPath(info.Provider, info.ScanDir, info.DisplayDir) == strings.TrimSpace(lib.Path)
for _, candidate := range libs {
if candidate.ID == lib.ID || !candidate.Enabled || !CloudLibraryAutoCategory(candidate) {
continue
}
info, ok := ParseCloudLibraryMount(candidate.Path)
if ok && info.Provider == mount.Provider {
ids = appendUniqueLibraryIDs(ids, candidate.ID)
}
}
return ids
} }
func ExpandMediaVisibilityForMergedCloudLibraries(ctx context.Context, repo *repository.Container, visibility MediaVisibility) MediaVisibility { func CloudLibraryMergeKey(lib model.Library) (string, bool) {
if repo == nil || repo.Library == nil { name := strings.TrimSpace(lib.Name)
return visibility if displayName, ok := CloudLibraryDisplayName(lib); ok {
name = displayName
} }
libs, err := repo.Library.List(ctx) name = normalizeLibraryMergeName(name)
if err != nil { if name == "" {
return visibility return "", false
} }
if len(visibility.AllowedLibraryIDs) > 0 { typeKey := cloudLibraryMergeTypeKey(lib.Type)
visibility.AllowedLibraryIDs = expandMergedLibraryIDsFromLibraries(libs, visibility.AllowedLibraryIDs) return typeKey + "\x00" + cloudLibraryMergeNameKey(typeKey, name), true
}
if len(visibility.HiddenLibraryIDs) > 0 {
visibility.HiddenLibraryIDs = expandMergedLibraryIDsFromLibraries(libs, visibility.HiddenLibraryIDs)
}
visibility.HiddenLibraryIDs = appendUniqueLibraryIDs(visibility.HiddenLibraryIDs, DeprecatedNativeCloudLibraryIDs(libs)...)
return visibility
} }
func expandMergedLibraryIDs(ctx context.Context, repo *repository.Container, ids []string) []string { func cloudLibraryMergeTypeKey(libraryType string) string {
if len(ids) == 0 { switch strings.ToLower(strings.TrimSpace(libraryType)) {
return ids case "tv", "anime", "variety":
return "tvshows"
default:
return strings.ToLower(strings.TrimSpace(libraryType))
} }
libs, err := repo.Library.List(ctx)
if err != nil {
return ids
}
return expandMergedLibraryIDsFromLibraries(libs, ids)
} }
func expandMergedLibraryIDsFromLibraries(libs []model.Library, ids []string) []string { func cloudLibraryMergeNameKey(typeKey, name string) string {
byID := make(map[string]model.Library, len(libs)) switch typeKey {
for _, lib := range libs { case "movie":
byID[lib.ID] = lib switch name {
} case "国产电影", "大陆电影":
out := make([]string, 0, len(ids)) return "华语电影"
for _, id := range ids { case "华语电影":
lib, ok := byID[id] return "华语电影"
if !ok { case "外语电影", "外国电影", "欧美电影":
out = appendUniqueLibraryIDs(out, id) return "欧美电影"
continue case "日韩电影", "日本电影", "韩国电影":
return "日韩电影"
case "纪录", "纪录片":
return "纪录片"
case "演唱会", "concert":
return "演唱会"
case "动画电影", "动漫电影":
return "动画电影"
} }
for _, mergedID := range MergedLibraryIDs(libs, lib) { case "tvshows":
out = appendUniqueLibraryIDs(out, mergedID) switch name {
case "国产剧", "大陆剧", "华语剧", "国剧":
return "国产剧"
case "欧美剧", "美剧", "英剧":
return "欧美剧"
case "日韩剧", "日剧", "韩剧":
return "日韩剧"
case "国漫", "国产动漫", "国产动画":
return "国漫"
case "日番", "日漫", "番剧", "日本动漫", "日本动画":
return "日番"
case "韩漫", "韩国动漫", "韩国动画":
return "韩漫"
case "美漫", "欧美动漫", "欧美动画", "西方动画":
return "美漫"
case "其他", "其他动漫", "其它动漫", "other":
return "其他"
case "纪录", "纪录片":
return "纪录片"
} }
} }
return out return name
}
func DeprecatedNativeCloudLibraryIDs(libs []model.Library) []string {
ids := make([]string, 0)
for _, lib := range libs {
info, ok := ParseCloudLibraryMount(lib.Path)
if ok && IsDeprecatedNativeCloudProvider(info.Provider) {
ids = appendUniqueLibraryIDs(ids, lib.ID)
}
}
return ids
}
func appendUniqueLibraryIDs(ids []string, more ...string) []string {
for _, id := range more {
id = strings.TrimSpace(id)
if id == "" {
continue
}
found := false
for _, existing := range ids {
if existing == id {
found = true
break
}
}
if !found {
ids = append(ids, id)
}
}
return ids
} }
+152
View File
@@ -0,0 +1,152 @@
package service
import (
"context"
"strings"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
func MergedLibraryIDsForLibrary(ctx context.Context, repo *repository.Container, libraryID string) ([]string, error) {
libraryID = strings.TrimSpace(libraryID)
if libraryID == "" || repo == nil || repo.Library == nil {
return []string{libraryID}, nil
}
lib, err := repo.Library.FindByID(ctx, libraryID)
if err != nil {
return nil, err
}
if lib == nil {
return []string{libraryID}, nil
}
libs, err := repo.Library.List(ctx)
if err != nil {
return nil, err
}
return MergedLibraryIDs(libs, *lib), nil
}
func MergedLibraryIDs(libs []model.Library, target model.Library) []string {
ids := appendUniqueLibraryIDs(nil, target.ID)
if rootAutoIDs := cloudRootAutoCategoryLibraryIDs(libs, target); len(rootAutoIDs) > 0 {
ids = appendUniqueLibraryIDs(ids, rootAutoIDs...)
}
targetKey, hasTargetKey := CloudLibraryMergeKey(target)
if !hasTargetKey {
return ids
}
_, targetIsCloud := ParseCloudLibraryMount(target.Path)
for _, candidate := range libs {
if candidate.ID == target.ID || strings.TrimSpace(candidate.ID) == "" || !candidate.Enabled {
continue
}
key, ok := CloudLibraryMergeKey(candidate)
if ok && key == targetKey {
_, candidateIsCloud := ParseCloudLibraryMount(candidate.Path)
if !targetIsCloud && !candidateIsCloud {
continue
}
ids = appendUniqueLibraryIDs(ids, candidate.ID)
}
}
return ids
}
func cloudRootAutoCategoryLibraryIDs(libs []model.Library, lib model.Library) []string {
mount, ok := ParseCloudLibraryMount(lib.Path)
if !ok || !cloudRootMountNeedsAutoCategory(mount) {
return nil
}
ids := make([]string, 0)
for _, candidate := range libs {
if candidate.ID == lib.ID || !candidate.Enabled || !CloudLibraryAutoCategory(candidate) {
continue
}
info, ok := ParseCloudLibraryMount(candidate.Path)
if ok && info.Provider == mount.Provider {
ids = appendUniqueLibraryIDs(ids, candidate.ID)
}
}
return ids
}
func ExpandMediaVisibilityForMergedCloudLibraries(ctx context.Context, repo *repository.Container, visibility MediaVisibility) MediaVisibility {
if repo == nil || repo.Library == nil {
return visibility
}
libs, err := repo.Library.List(ctx)
if err != nil {
return visibility
}
if len(visibility.AllowedLibraryIDs) > 0 {
visibility.AllowedLibraryIDs = expandMergedLibraryIDsFromLibraries(libs, visibility.AllowedLibraryIDs)
}
if len(visibility.HiddenLibraryIDs) > 0 {
visibility.HiddenLibraryIDs = expandMergedLibraryIDsFromLibraries(libs, visibility.HiddenLibraryIDs)
}
visibility.HiddenLibraryIDs = appendUniqueLibraryIDs(visibility.HiddenLibraryIDs, DeprecatedNativeCloudLibraryIDs(libs)...)
return visibility
}
func expandMergedLibraryIDs(ctx context.Context, repo *repository.Container, ids []string) []string {
if len(ids) == 0 || repo == nil || repo.Library == nil {
return ids
}
libs, err := repo.Library.List(ctx)
if err != nil {
return ids
}
return expandMergedLibraryIDsFromLibraries(libs, ids)
}
func expandMergedLibraryIDsFromLibraries(libs []model.Library, ids []string) []string {
byID := make(map[string]model.Library, len(libs))
for _, lib := range libs {
byID[lib.ID] = lib
}
out := make([]string, 0, len(ids))
for _, id := range ids {
id = strings.TrimSpace(id)
if id == "" {
continue
}
if lib, ok := byID[id]; ok {
out = appendUniqueLibraryIDs(out, MergedLibraryIDs(libs, lib)...)
continue
}
out = appendUniqueLibraryIDs(out, id)
}
return out
}
func DeprecatedNativeCloudLibraryIDs(libs []model.Library) []string {
ids := make([]string, 0)
for _, lib := range libs {
info, ok := ParseCloudLibraryMount(lib.Path)
if ok && IsDeprecatedNativeCloudProvider(info.Provider) {
ids = appendUniqueLibraryIDs(ids, lib.ID)
}
}
return ids
}
func appendUniqueLibraryIDs(ids []string, values ...string) []string {
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
exists := false
for _, id := range ids {
if id == value {
exists = true
break
}
}
if !exists {
ids = append(ids, value)
}
}
return ids
}
+5 -5
View File
@@ -51,7 +51,7 @@ func TestSyncDownloadTaskProgressMatchesSeasonFolderTorrentName(t *testing.T) {
Source: "qbittorrent", Source: "qbittorrent",
URL: "magnet:?xt=urn:btih:test", URL: "magnet:?xt=urn:btih:test",
Title: "The First Jasmine S01E01 1080p TX WEB-DL AAC2.0 H.264-MWeb", Title: "The First Jasmine S01E01 1080p TX WEB-DL AAC2.0 H.264-MWeb",
SavePath: "/downloads/未分类", SavePath: "/downloads/欧美剧",
Status: "queued", Status: "queued",
Progress: 0.5, Progress: 0.5,
} }
@@ -82,7 +82,7 @@ func TestProcessDownloadSnapshotQueuesCompletedPendingTaskOnFirstSnapshot(t *tes
Source: "qbittorrent", Source: "qbittorrent",
URL: "magnet:?xt=urn:btih:test", URL: "magnet:?xt=urn:btih:test",
Title: "Blades of the Guardians S02E01 1080p TX WEB-DL AAC2.0 H.264-MWeb", Title: "Blades of the Guardians S02E01 1080p TX WEB-DL AAC2.0 H.264-MWeb",
SavePath: "/downloads/未分类", SavePath: "/downloads/欧美剧",
Status: "queued", Status: "queued",
Progress: 0, Progress: 0,
} }
@@ -113,7 +113,7 @@ func TestProcessDownloadSnapshotDoesNotQueueActiveDownloadAtFullProgress(t *test
Source: "qbittorrent", Source: "qbittorrent",
URL: "magnet:?xt=urn:btih:test", URL: "magnet:?xt=urn:btih:test",
Title: "Still Downloading S01E01", Title: "Still Downloading S01E01",
SavePath: "/downloads/未分类", SavePath: "/downloads/欧美剧",
Status: "downloading", Status: "downloading",
Progress: 0.99, Progress: 0.99,
} }
@@ -151,7 +151,7 @@ func TestProcessDownloadSnapshotDoesNotQueueFullProgressWithoutQBitState(t *test
Source: "qbittorrent", Source: "qbittorrent",
URL: "magnet:?xt=urn:btih:test", URL: "magnet:?xt=urn:btih:test",
Title: "Missing State S01E01", Title: "Missing State S01E01",
SavePath: "/downloads/未分类", SavePath: "/downloads/欧美剧",
Status: "downloading", Status: "downloading",
Progress: 0.99, Progress: 0.99,
} }
@@ -188,7 +188,7 @@ func TestProcessDownloadSnapshotDoesNotTrustCompletionOnForActiveDownload(t *tes
Source: "qbittorrent", Source: "qbittorrent",
URL: "magnet:?xt=urn:btih:test", URL: "magnet:?xt=urn:btih:test",
Title: "Still Downloading With Completion Timestamp S01E01", Title: "Still Downloading With Completion Timestamp S01E01",
SavePath: "/downloads/未分类", SavePath: "/downloads/欧美剧",
Status: "downloading", Status: "downloading",
Progress: 0.5, Progress: 0.5,
} }
+6 -6
View File
@@ -13,7 +13,7 @@ func (s *ScraperService) ManualSearch(ctx context.Context, media *model.Media, q
return nil, errors.New("media required") return nil, errors.New("media required")
} }
lib, _ := s.repo.Library.FindByID(ctx, media.LibraryID) lib, _ := s.repo.Library.FindByID(ctx, media.LibraryID)
queries := manualSearchQueries(media, lib, query) queries := s.manualSearchQueries(ctx, media, lib, query)
if len(queries) == 0 { if len(queries) == 0 {
return nil, errors.New("search query required") return nil, errors.New("search query required")
} }
@@ -28,7 +28,7 @@ func (s *ScraperService) ManualSearch(ctx context.Context, media *model.Media, q
providers := manualSearchProviderSet(provider) providers := manualSearchProviderSet(provider)
year := mediaYearHint(media) year := mediaYearHint(media)
if year <= 0 { if year <= 0 {
_, year = CleanQuery(queries[0]) _, year = CleanQueryWithRecognition(ctx, s.repo, queries[0])
} }
out := make([]ExternalMediaResult, 0, 6) out := make([]ExternalMediaResult, 0, 6)
@@ -124,7 +124,7 @@ func (p manualSearchProviders) want(provider string) bool {
return ok return ok
} }
func manualSearchQueries(media *model.Media, lib *model.Library, query string) []string { func (s *ScraperService) manualSearchQueries(ctx context.Context, media *model.Media, lib *model.Library, query string) []string {
seen := map[string]struct{}{} seen := map[string]struct{}{}
out := make([]string, 0, 4) out := make([]string, 0, 4)
add := func(value string) { add := func(value string) {
@@ -140,16 +140,16 @@ func manualSearchQueries(media *model.Media, lib *model.Library, query string) [
out = append(out, value) out = append(out, value)
} }
add(query) add(ApplyRecognitionWords(ctx, s.repo, query))
if strings.TrimSpace(query) == "" && media != nil { if strings.TrimSpace(query) == "" && media != nil {
add(firstText(media.Title, media.OriginalName)) add(firstText(media.Title, media.OriginalName))
} }
if media != nil { if media != nil {
for _, candidate := range scrapeQueryCandidates(media, lib) { for _, candidate := range scrapeQueryCandidatesWithRecognition(ctx, s.repo, media, lib) {
add(candidate) add(candidate)
} }
if len(out) == 0 { if len(out) == 0 {
title, _ := CleanQuery(media.Path) title, _ := CleanQueryWithRecognition(ctx, s.repo, media.Path)
add(title) add(title)
} }
} }
+41 -27
View File
@@ -44,12 +44,9 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin
isChineseByText := containsHan(rawTitleText) || containsAnyText(strings.ToLower(rawTitleText), "华语", "国产", "国剧", "国漫") isChineseByText := containsHan(rawTitleText) || containsAnyText(strings.ToLower(rawTitleText), "华语", "国产", "国剧", "国漫")
isChineseByCategory := containsAnyText(categoryText, "华语", "国产", "国剧", "大陆剧", "国产电视剧", "国产电影", "国漫", "国产动漫", "国产动画") isChineseByCategory := containsAnyText(categoryText, "华语", "国产", "国剧", "大陆剧", "国产电视剧", "国产电影", "国漫", "国产动漫", "国产动画")
isChinese := isChineseByMetadata || (!hasMetadata && isChineseByText) isChinese := isChineseByMetadata || (!hasMetadata && isChineseByText)
// 动漫的中文译名几乎都是纯汉字(如日本动画「葬送的芙莉莲」),用 containsHan
// 判中文会把日本动画误判成国漫。动漫只在有元数据或显式中文标记时才算国漫,
// 否则默认日番(日本动画占绝大多数;未刮削的国漫刮出 origin_country=CN 后仍正确)。
isChineseAnime := isChineseByMetadata || (!hasMetadata && containsAnyText(text, "华语", "国产", "国漫", "國漫", "国创", "国产动漫", "国产动画")) isChineseAnime := isChineseByMetadata || (!hasMetadata && containsAnyText(text, "华语", "国产", "国漫", "國漫", "国创", "国产动漫", "国产动画"))
isJapanese := hasAny(languages, "JA", "JP") || hasAny(countries, "JP") || containsJapaneseKana(rawText) || strings.Contains(text, "日番") isJapanese := hasAny(languages, "JA", "JP") || hasAny(countries, "JP") || containsJapaneseKana(rawTitleText) || (!hasMetadata && strings.Contains(text, "日番"))
isKorean := hasAny(languages, "KO", "KR") || hasAny(countries, "KR", "KP") || containsKoreanHangul(rawText) isKorean := hasAny(languages, "KO", "KR") || hasAny(countries, "KR", "KP") || containsKoreanHangul(rawTitleText) || (!hasMetadata && containsAnyText(categoryText, "韩漫", "韩国动漫", "韩国动画"))
isEastAsianByCategory := containsAnyText(categoryText, "日韩剧", "日剧", "韩剧", "日韩电影") isEastAsianByCategory := containsAnyText(categoryText, "日韩剧", "日剧", "韩剧", "日韩电影")
isEastAsian := isJapanese || isKorean || hasAny(countries, "TH", "IN", "SG") || (!hasMetadata && isEastAsianByCategory) isEastAsian := isJapanese || isKorean || hasAny(countries, "TH", "IN", "SG") || (!hasMetadata && isEastAsianByCategory)
isWesternByMetadata := hasAny(countries, isWesternByMetadata := hasAny(countries,
@@ -58,9 +55,11 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin
) )
isWesternByCategory := containsAnyText(categoryText, "欧美剧", "欧美电视剧", "美剧", "英剧", "欧美电影", "外语电影") isWesternByCategory := containsAnyText(categoryText, "欧美剧", "欧美电视剧", "美剧", "英剧", "欧美电影", "外语电影")
isWestern := isWesternByMetadata || (!hasMetadata && isWesternByCategory) isWestern := isWesternByMetadata || (!hasMetadata && isWesternByCategory)
hasAnimeText := containsAnyText(text, "动画", "动漫", "番剧", "年番", "国漫", "日番", "bangumi", "anime", "b-global", "ani-one", "crunchyroll") isUSAnime := hasAny(countries, "US")
hasAnimeText := containsAnyText(text, "动画", "动漫", "番剧", "年番", "国漫", "日番", "韩漫", "美漫", "bangumi", "anime", "b-global", "ani-one", "crunchyroll")
hasVarietyText := containsAnyText(text, "综艺", "真人秀", "脱口秀", "晚会", "春晚", "gala", "festival gala", "reality", "talk show") hasVarietyText := containsAnyText(text, "综艺", "真人秀", "脱口秀", "晚会", "春晚", "gala", "festival gala", "reality", "talk show")
hasDocumentaryText := containsAnyText(text, "纪录", "纪录片", "documentary", "docu", "national geographic", "natgeo") hasDocumentaryText := containsAnyText(text, "纪录", "纪录片", "documentary", "docu", "national geographic", "natgeo")
hasConcertText := containsAnyText(text, "演唱会", "音乐会", "concert", "live concert")
isAdultText := containsAnyText(text, "adult", "nsfw", "成人", "番号", "jav", "9kg", "uncensored", "无码", "有码") || classifierJAVCodeRE.MatchString(strings.ToUpper(rawText)) isAdultText := containsAnyText(text, "adult", "nsfw", "成人", "番号", "jav", "9kg", "uncensored", "无码", "有码") || classifierJAVCodeRE.MatchString(strings.ToUpper(rawText))
hasGenre := func(values ...string) bool { hasGenre := func(values ...string) bool {
@@ -77,12 +76,33 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin
} }
return false return false
} }
animeCategory := func() string {
if isChineseAnime {
return categoryName(categories, "cn_anime", "国漫")
}
if isJapanese {
return categoryName(categories, "jp_anime", "日番")
}
if isKorean {
return categoryName(categories, "kr_anime", "韩漫")
}
if isUSAnime || (!hasMetadata && containsAnyText(categoryText, "美漫", "欧美动漫", "欧美动画", "西方动画")) {
return categoryName(categories, "us_anime", "美漫")
}
return categoryName(categories, "other_anime", "其他")
}
switch mediaType { switch mediaType {
case "movie": case "movie":
if isAdultText { if isAdultText {
return categoryName(categories, "adult", "成人") return categoryName(categories, "adult", "成人")
} }
if hasGenre("10402", "MUSIC", "音乐") || hasConcertText {
return categoryName(categories, "concert_movie", "演唱会")
}
if hasGenre("99", "DOCUMENTARY", "纪录", "纪录片") || hasDocumentaryText {
return categoryName(categories, "documentary_movie", "纪录片")
}
if hasGenre("16", "ANIMATION", "动画", "动漫") || hasAnimeText { if hasGenre("16", "ANIMATION", "动画", "动漫") || hasAnimeText {
return categoryName(categories, "animation_movie", "动画电影") return categoryName(categories, "animation_movie", "动画电影")
} }
@@ -92,41 +112,35 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin
if isChinese { if isChinese {
return categoryName(categories, "chinese_movie", "华语电影") return categoryName(categories, "chinese_movie", "华语电影")
} }
return categoryName(categories, "foreign_movie", "外语电影") if hasAny(languages, "JA", "KO") || (!hasAny(languages, "ZH", "ZH-CN", "ZH-TW", "CN", "BO", "ZA") && hasAny(countries, "JP", "KP", "KR")) {
return categoryName(categories, "jk_movie", "日韩电影")
}
return categoryName(categories, "euus_movie", "欧美电影")
case "anime": case "anime":
if isAdultText {
return categoryName(categories, "adult", "成人")
}
if !hasMetadata && sourceHint != "" { if !hasMetadata && sourceHint != "" {
return sourceHint return sourceHint
} }
if isChineseAnime { return animeCategory()
return categoryName(categories, "cn_anime", "国漫")
}
if isWesternByMetadata || (!hasMetadata && containsAnyText(categoryText, "欧美动漫", "欧美动画", "西方动画")) {
return categoryName(categories, "euus_anime", "欧美动漫")
}
return categoryName(categories, "jp_anime", "日番")
case "variety": case "variety":
return categoryName(categories, "variety", "综艺") return categoryName(categories, "variety", "综艺")
case "tv": case "tv":
if isAdultText { if isAdultText {
return categoryName(categories, "adult", "成人") return categoryName(categories, "adult", "成人")
} }
if hasGenre("10764", "10767", "REALITY", "TALK", "综艺", "真人秀", "脱口秀") || hasVarietyText {
return categoryName(categories, "variety", "综艺")
}
if hasGenre("99", "DOCUMENTARY", "纪录", "纪录片") || hasDocumentaryText { if hasGenre("99", "DOCUMENTARY", "纪录", "纪录片") || hasDocumentaryText {
return categoryName(categories, "documentary", "纪录片") return categoryName(categories, "documentary", "纪录片")
} }
if hasGenre("10762", "KIDS", "儿童") { if hasGenre("10762", "KIDS", "儿童") {
return categoryName(categories, "children", "儿童") return categoryName(categories, "children", "儿童")
} }
if hasGenre("10764", "10767", "REALITY", "TALK", "综艺", "真人秀", "脱口秀") || hasVarietyText {
return categoryName(categories, "variety", "综艺")
}
if hasGenre("16", "ANIMATION", "动画", "动漫") || hasAnimeText { if hasGenre("16", "ANIMATION", "动画", "动漫") || hasAnimeText {
if isChineseAnime { return animeCategory()
return categoryName(categories, "cn_anime", "国漫")
}
if isWesternByMetadata || (!hasMetadata && containsAnyText(categoryText, "欧美动漫", "欧美动画", "西方动画")) {
return categoryName(categories, "euus_anime", "欧美动漫")
}
return categoryName(categories, "jp_anime", "日番")
} }
if !hasMetadata && sourceHint != "" { if !hasMetadata && sourceHint != "" {
return sourceHint return sourceHint
@@ -140,7 +154,7 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin
if isWestern { if isWestern {
return categoryName(categories, "euus_tv", "欧美剧") return categoryName(categories, "euus_tv", "欧美剧")
} }
return categoryName(categories, "uncategorized_tv", "未分类") return categoryName(categories, "euus_tv", "欧美剧")
case "adult": case "adult":
return categoryName(categories, "adult", "成人") return categoryName(categories, "adult", "成人")
} }
@@ -166,11 +180,11 @@ func normalizeMediaType(mediaType, title, category string) string {
return "adult" return "adult"
case containsAnyText(raw, "综艺", "真人秀"): case containsAnyText(raw, "综艺", "真人秀"):
return "variety" return "variety"
case (containsAnyText(raw, "国漫", "日漫", "日番", "动漫", "动画") || classifierAnimeRE.MatchString(raw)) && !containsAnyText(raw, "动画电影"): case (containsAnyText(raw, "国漫", "日漫", "日番", "韩漫", "美漫", "欧美动漫", "其他动漫", "动漫", "动画") || classifierAnimeRE.MatchString(raw)) && !containsAnyText(raw, "动画电影"):
return "anime" return "anime"
case containsAnyText(raw, "电视剧", "国产剧", "欧美剧", "日韩剧", "日剧", "韩剧", "剧集") || classifierTVRE.MatchString(raw): case containsAnyText(raw, "电视剧", "国产剧", "欧美剧", "日韩剧", "日剧", "韩剧", "剧集") || classifierTVRE.MatchString(raw):
return "tv" return "tv"
case containsAnyText(raw, "电影") || classifierMovieRE.MatchString(raw): case containsAnyText(raw, "电影", "演唱会") || classifierMovieRE.MatchString(raw):
return "movie" return "movie"
} }
text := strings.ToLower(title + " " + category) text := strings.ToLower(title + " " + category)
@@ -6,26 +6,29 @@ type sourceCategoryHintDef struct {
Key string Key string
Fallback string Fallback string
MediaType string MediaType string
Aliases []string
} }
var sourceCategoryHints = []sourceCategoryHintDef{ var sourceCategoryHints = []sourceCategoryHintDef{
{Key: "concert_movie", Fallback: "演唱会", MediaType: "movie", Aliases: []string{"concert", "音乐会"}},
{Key: "documentary_movie", Fallback: "纪录片", MediaType: "movie", Aliases: []string{"纪录", "documentary"}},
{Key: "animation_movie", Fallback: "动画电影", MediaType: "movie"}, {Key: "animation_movie", Fallback: "动画电影", MediaType: "movie"},
{Key: "chinese_movie", Fallback: "华语电影", MediaType: "movie"}, {Key: "chinese_movie", Fallback: "华语电影", MediaType: "movie"},
{Key: "jk_movie", Fallback: "日韩电影", MediaType: "movie"}, {Key: "jk_movie", Fallback: "日韩电影", MediaType: "movie"},
{Key: "euus_movie", Fallback: "欧美电影", MediaType: "movie"}, {Key: "euus_movie", Fallback: "欧美电影", MediaType: "movie", Aliases: []string{"外语电影", "外国电影", "western movie", "foreign movie"}},
{Key: "foreign_movie", Fallback: "外语电影", MediaType: "movie"},
{Key: "domestic_tv", Fallback: "国产剧", MediaType: "tv"}, {Key: "domestic_tv", Fallback: "国产剧", MediaType: "tv"},
{Key: "euus_tv", Fallback: "欧美剧", MediaType: "tv"}, {Key: "euus_tv", Fallback: "欧美剧", MediaType: "tv"},
{Key: "jk_tv", Fallback: "日韩剧", MediaType: "tv"}, {Key: "jk_tv", Fallback: "日韩剧", MediaType: "tv", Aliases: []string{"日剧", "韩剧", "泰剧"}},
{Key: "cn_anime", Fallback: "国漫", MediaType: "anime"}, {Key: "cn_anime", Fallback: "国漫", MediaType: "anime"},
{Key: "jp_anime", Fallback: "日番", MediaType: "anime"}, {Key: "jp_anime", Fallback: "日番", MediaType: "anime"},
{Key: "euus_anime", Fallback: "欧美动漫", MediaType: "anime"}, {Key: "kr_anime", Fallback: "韩漫", MediaType: "anime"},
{Key: "us_anime", Fallback: "美漫", MediaType: "anime", Aliases: []string{"欧美动漫", "欧美动画", "西方动画"}},
{Key: "other_anime", Fallback: "其他", MediaType: "anime", Aliases: []string{"其他动漫", "其它动漫", "other"}},
{Key: "variety", Fallback: "综艺", MediaType: "variety"}, {Key: "variety", Fallback: "综艺", MediaType: "variety"},
{Key: "documentary", Fallback: "纪录片", MediaType: "tv"}, {Key: "documentary", Fallback: "纪录片", MediaType: "tv"},
{Key: "children", Fallback: "儿童", MediaType: "tv"}, {Key: "children", Fallback: "儿童", MediaType: "tv"},
{Key: "adult", Fallback: "成人", MediaType: "adult"}, {Key: "euus_tv", Fallback: "欧美剧", MediaType: "tv", Aliases: []string{"未分类", "uncategorized"}},
{Key: "adult_9kg", Fallback: "9KG", MediaType: "adult"}, {Key: "adult", Fallback: "成人", MediaType: "adult", Aliases: []string{"9KG", "番号", "JAV", "adult", "nsfw"}},
{Key: "adult_jav", Fallback: "番号", MediaType: "adult"},
} }
func sourceCategoryHint(category, mediaType string, categories map[string]string) string { func sourceCategoryHint(category, mediaType string, categories map[string]string) string {
@@ -37,7 +40,8 @@ func sourceCategoryHint(category, mediaType string, categories map[string]string
if !sourceCategoryCompatible(mediaType, hint.MediaType) { if !sourceCategoryCompatible(mediaType, hint.MediaType) {
continue continue
} }
for _, name := range []string{hint.Fallback, categoryName(categories, hint.Key, hint.Fallback)} { names := append([]string{hint.Fallback, categoryName(categories, hint.Key, hint.Fallback)}, hint.Aliases...)
for _, name := range names {
if _, ok := tokens[strings.ToLower(strings.TrimSpace(name))]; ok { if _, ok := tokens[strings.ToLower(strings.TrimSpace(name))]; ok {
return categoryName(categories, hint.Key, hint.Fallback) return categoryName(categories, hint.Key, hint.Fallback)
} }
+55 -14
View File
@@ -46,7 +46,7 @@ func TestClassifyMediaCategoryMatchesSmartRules(t *testing.T) {
Countries: []string{"NL"}, Countries: []string{"NL"},
Genres: []string{"Comedy"}, Genres: []string{"Comedy"},
}, },
want: "外语电影", want: "欧美电影",
}, },
{ {
name: "movie animation source category fallback", name: "movie animation source category fallback",
@@ -99,7 +99,7 @@ func TestClassifyMediaCategoryMatchesSmartRules(t *testing.T) {
MediaType: "movie", MediaType: "movie",
Title: "Dune 2021 2160p", Title: "Dune 2021 2160p",
}, },
want: "外语电影", want: "欧美电影",
}, },
{ {
name: "chinese tv title without metadata", name: "chinese tv title without metadata",
@@ -110,12 +110,12 @@ func TestClassifyMediaCategoryMatchesSmartRules(t *testing.T) {
want: "国产剧", want: "国产剧",
}, },
{ {
name: "latin tv title without metadata stays uncategorized", name: "latin tv title without metadata falls back to western tv",
input: mediaClassifyInput{ input: mediaClassifyInput{
MediaType: "tv", MediaType: "tv",
Title: "The Last of Us S01E01 1080p", Title: "The Last of Us S01E01 1080p",
}, },
want: "未分类", want: "欧美剧",
}, },
{ {
name: "latin tv keeps explicit western source category", name: "latin tv keeps explicit western source category",
@@ -133,7 +133,7 @@ func TestClassifyMediaCategoryMatchesSmartRules(t *testing.T) {
Title: "The Last of Us S01E01 1080p", Title: "The Last of Us S01E01 1080p",
Category: "downloads 电视剧", Category: "downloads 电视剧",
}, },
want: "未分类", want: "欧美剧",
}, },
{ {
name: "gala title overrides wrong western source category", name: "gala title overrides wrong western source category",
@@ -150,7 +150,7 @@ func TestClassifyMediaCategoryMatchesSmartRules(t *testing.T) {
MediaType: "tv", MediaType: "tv",
Title: "Motherhood.of.Taihang.S01E01.2026.1080p.iQIYI.WEB-DL", Title: "Motherhood.of.Taihang.S01E01.2026.1080p.iQIYI.WEB-DL",
}, },
want: "未分类", want: "欧美剧",
}, },
{ {
name: "metadata classifies romanized chinese drama", name: "metadata classifies romanized chinese drama",
@@ -170,12 +170,12 @@ func TestClassifyMediaCategoryMatchesSmartRules(t *testing.T) {
want: "综艺", want: "综艺",
}, },
{ {
name: "japanese anime localized chinese title defaults to jp without metadata", name: "japanese anime localized chinese title without metadata falls back to other",
input: mediaClassifyInput{ input: mediaClassifyInput{
MediaType: "anime", MediaType: "anime",
Title: "葬送的芙莉莲", Title: "葬送的芙莉莲",
}, },
want: "日番", want: "其他",
}, },
{ {
name: "chinese anime explicit marker without metadata", name: "chinese anime explicit marker without metadata",
@@ -197,7 +197,7 @@ func TestClassifyMediaCategoryMatchesSmartRules(t *testing.T) {
want: "日番", want: "日番",
}, },
{ {
name: "western anime metadata uses western anime category", name: "western anime metadata uses us anime category",
input: mediaClassifyInput{ input: mediaClassifyInput{
MediaType: "anime", MediaType: "anime",
Title: "Family Guy", Title: "Family Guy",
@@ -205,26 +205,26 @@ func TestClassifyMediaCategoryMatchesSmartRules(t *testing.T) {
Genres: []string{"16"}, Genres: []string{"16"},
Category: "日番", Category: "日番",
}, },
want: "欧美动漫", want: "美漫",
}, },
{ {
name: "tv animation with western metadata uses western anime category", name: "tv animation with western metadata uses us anime category",
input: mediaClassifyInput{ input: mediaClassifyInput{
MediaType: "tv", MediaType: "tv",
Title: "The Simpsons", Title: "The Simpsons",
Countries: []string{"US"}, Countries: []string{"US"},
Genres: []string{"Animation"}, Genres: []string{"Animation"},
}, },
want: "欧美动漫", want: "美漫",
}, },
{ {
name: "western anime source category is preserved without metadata", name: "western anime legacy source category maps to us anime without metadata",
input: mediaClassifyInput{ input: mediaClassifyInput{
MediaType: "anime", MediaType: "anime",
Title: "The Simpsons S01E01 1080p", Title: "The Simpsons S01E01 1080p",
Category: "downloads 欧美动漫", Category: "downloads 欧美动漫",
}, },
want: "欧美动漫", want: "美漫",
}, },
{ {
name: "anime with CN country metadata is cn", name: "anime with CN country metadata is cn",
@@ -245,6 +245,47 @@ func TestClassifyMediaCategoryMatchesSmartRules(t *testing.T) {
}, },
want: "欧美电影", want: "欧美电影",
}, },
{
name: "movie concert by music genre",
input: mediaClassifyInput{
MediaType: "movie",
Title: "Taylor Swift The Eras Tour",
Genres: []string{"10402"},
},
want: "演唱会",
},
{
name: "movie documentary before region",
input: mediaClassifyInput{
MediaType: "movie",
Title: "Planet Earth",
Languages: []string{"en"},
Countries: []string{"GB"},
Genres: []string{"99"},
},
want: "纪录片",
},
{
name: "movie korean language uses jk movie",
input: mediaClassifyInput{
MediaType: "movie",
Title: "Parasite",
Languages: []string{"ko"},
Countries: []string{"KR"},
Genres: []string{"18"},
},
want: "日韩电影",
},
{
name: "anime korean metadata uses korean anime category",
input: mediaClassifyInput{
MediaType: "anime",
Title: "Korean Animation",
Countries: []string{"KR"},
Genres: []string{"16"},
},
want: "韩漫",
},
{ {
name: "jav code is adult", name: "jav code is adult",
input: mediaClassifyInput{ input: mediaClassifyInput{
+4 -4
View File
@@ -80,13 +80,13 @@ func (r mediaDisplayLibraryResolver) DisplayLibraryForMedia(media model.Media) (
} }
own, hasOwn := r.byID[media.LibraryID] own, hasOwn := r.byID[media.LibraryID]
if hasOwn { if hasOwn {
if CloudLibraryAutoCategory(own) { if key, ok := CloudLibraryMergeKey(own); ok {
if lib, ok := r.rootCloudDisplayLibraryForAutoCategory(own); ok { if lib, exists := r.displayByMergeKey[key]; exists {
return lib, true return lib, true
} }
} }
if key, ok := CloudLibraryMergeKey(own); ok { if CloudLibraryAutoCategory(own) {
if lib, exists := r.displayByMergeKey[key]; exists { if lib, ok := r.rootCloudDisplayLibraryForAutoCategory(own); ok {
return lib, true return lib, true
} }
} }
+99 -4
View File
@@ -32,6 +32,16 @@ func (s *MediaService) CreateLibraryWithRoots(ctx context.Context, name, kind st
return nil, err return nil, err
} }
kind = inferLibraryKind(name, roots[0].Path, kind) kind = inferLibraryKind(name, roots[0].Path, kind)
if existing, err := s.findLogicalLibrary(ctx, name, kind); err != nil {
return nil, err
} else if existing != nil {
lib, err := s.appendLibraryRoots(ctx, existing, roots)
if err != nil {
return nil, err
}
s.invalidateMediaCache(ctx)
return lib, nil
}
lib := &model.Library{Name: strings.TrimSpace(name), Path: roots[0].Path, Type: kind, Enabled: true} lib := &model.Library{Name: strings.TrimSpace(name), Path: roots[0].Path, Type: kind, Enabled: true}
if err := s.repo.Library.CreateWithRoots(ctx, lib, roots); err != nil { if err := s.repo.Library.CreateWithRoots(ctx, lib, roots); err != nil {
return nil, err return nil, err
@@ -40,6 +50,53 @@ func (s *MediaService) CreateLibraryWithRoots(ctx context.Context, name, kind st
return lib, nil return lib, nil
} }
func (s *MediaService) findLogicalLibrary(ctx context.Context, name, kind string) (*model.Library, error) {
if s == nil || s.repo == nil || s.repo.Library == nil {
return nil, nil
}
libs, err := s.repo.Library.List(ctx)
if err != nil {
return nil, err
}
nameKey := strings.ToLower(strings.TrimSpace(name))
typeKey := strings.ToLower(strings.TrimSpace(kind))
for i := range libs {
if strings.ToLower(strings.TrimSpace(libs[i].Name)) == nameKey &&
strings.ToLower(strings.TrimSpace(libs[i].Type)) == typeKey {
return &libs[i], nil
}
}
return nil, nil
}
func (s *MediaService) appendLibraryRoots(ctx context.Context, lib *model.Library, roots []model.LibraryRoot) (*model.Library, error) {
if lib == nil {
return nil, errors.New("library not found")
}
if err := s.ensureLibraryRoots(ctx, lib.ID); err != nil {
return nil, err
}
existing, err := s.repo.Library.ListRoots(ctx, lib.ID)
if err != nil {
return nil, err
}
for i := range roots {
root := roots[i]
root.LibraryID = lib.ID
root.SortOrder = len(existing) + i
if err := s.ensureLibraryRootPathUnique(ctx, lib.ID, "", root.Path); err != nil {
return nil, err
}
if err := s.repo.Library.CreateRoot(ctx, &root); err != nil {
return nil, err
}
}
if err := s.syncLibraryPrimaryRoot(ctx, lib.ID); err != nil {
return nil, err
}
return s.repo.Library.FindByID(ctx, lib.ID)
}
func normalizeLibraryRootInputs(inputs []LibraryRootInput, requirePath bool) ([]model.LibraryRoot, error) { func normalizeLibraryRootInputs(inputs []LibraryRootInput, requirePath bool) ([]model.LibraryRoot, error) {
roots := make([]model.LibraryRoot, 0, len(inputs)) roots := make([]model.LibraryRoot, 0, len(inputs))
seen := map[string]struct{}{} seen := map[string]struct{}{}
@@ -51,11 +108,11 @@ func normalizeLibraryRootInputs(inputs []LibraryRootInput, requirePath bool) ([]
} }
continue continue
} }
abs, err := resolveAccessibleLibraryPath(rawPath) abs, err := normalizeLibraryRootPath(rawPath)
if err != nil { if err != nil {
return nil, err return nil, err
} }
key := strings.ToLower(filepath.Clean(abs)) key := libraryRootPathKey(abs)
if _, ok := seen[key]; ok { if _, ok := seen[key]; ok {
return nil, fmt.Errorf("duplicate library path: %s", abs) return nil, fmt.Errorf("duplicate library path: %s", abs)
} }
@@ -186,7 +243,7 @@ func (s *MediaService) ensureLibraryRoots(ctx context.Context, libraryID string)
} }
root := &model.LibraryRoot{ root := &model.LibraryRoot{
LibraryID: libraryID, LibraryID: libraryID,
Name: filepath.Base(filepath.Clean(lib.Path)), Name: libraryRootNameForPath(lib.Path),
Path: lib.Path, Path: lib.Path,
Enabled: lib.Enabled, Enabled: lib.Enabled,
SortOrder: 0, SortOrder: 0,
@@ -208,13 +265,51 @@ func (s *MediaService) ensureLibraryRootPathUnique(ctx context.Context, libraryI
return err return err
} }
key := strings.ToLower(filepath.Clean(strings.TrimSpace(pathValue))) key := strings.ToLower(filepath.Clean(strings.TrimSpace(pathValue)))
key = libraryRootPathKey(pathValue)
for _, existing := range roots { for _, existing := range roots {
if existing.ID == exceptRootID { if existing.ID == exceptRootID {
continue continue
} }
if strings.ToLower(filepath.Clean(strings.TrimSpace(existing.Path))) == key { if libraryRootPathKey(existing.Path) == key {
return fmt.Errorf("duplicate library path: %s", pathValue) return fmt.Errorf("duplicate library path: %s", pathValue)
} }
} }
return nil return nil
} }
func normalizeLibraryRootPath(rawPath string) (string, error) {
rawPath = strings.TrimSpace(rawPath)
if info, ok := ParseCloudLibraryMount(rawPath); ok {
displayDir := canonicalLibraryDisplayDir(firstNonEmpty(info.DisplayDir, info.ScanDir))
if displayDir == "" {
displayDir = firstNonEmpty(info.DisplayDir, info.ScanDir)
}
if CloudLibraryAutoCategory(model.Library{Path: rawPath}) {
return BuildCloudAutoCategoryLibraryPathWithScanDir(info.Provider, info.ScanDir, displayDir), nil
}
return BuildCloudLibraryPath(info.Provider, info.ScanDir, displayDir), nil
}
return resolveAccessibleLibraryPath(rawPath)
}
func libraryRootPathKey(pathValue string) string {
pathValue = strings.TrimSpace(pathValue)
if info, ok := ParseCloudLibraryMount(pathValue); ok {
auto := "0"
if CloudLibraryAutoCategory(model.Library{Path: pathValue}) {
auto = "1"
}
return strings.ToLower(info.Provider + "\x00" + info.ScanDir + "\x00" + info.DisplayDir + "\x00" + auto)
}
return strings.ToLower(filepath.Clean(pathValue))
}
func libraryRootNameForPath(pathValue string) string {
if info, ok := ParseCloudLibraryMount(pathValue); ok {
if base := cloudMountDirBase(firstNonEmpty(info.DisplayDir, info.ScanDir)); base != "" {
return base
}
return CloudMountProviderLabel(info.Provider)
}
return filepath.Base(filepath.Clean(pathValue))
}
@@ -0,0 +1,101 @@
package service
import (
"path/filepath"
"testing"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/config"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
func TestCreateLibraryWithRootsAppendsToExistingLogicalLibrary(t *testing.T) {
rootA := t.TempDir()
rootB := t.TempDir()
db := newServiceTestDB(t, &model.Library{}, &model.LibraryRoot{}, &model.Media{})
repos := repository.New(db)
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos)
first, err := svc.CreateLibraryWithRoots(t.Context(), "欧美电影", "movie", []LibraryRootInput{
{Name: "硬盘1", Path: rootA},
})
if err != nil {
t.Fatal(err)
}
second, err := svc.CreateLibraryWithRoots(t.Context(), "欧美电影", "movie", []LibraryRootInput{
{Name: "硬盘2", Path: rootB},
})
if err != nil {
t.Fatal(err)
}
if second.ID != first.ID {
t.Fatalf("second library id = %q, want existing %q", second.ID, first.ID)
}
var libraryCount int64
if err := db.Model(&model.Library{}).Count(&libraryCount).Error; err != nil {
t.Fatal(err)
}
if libraryCount != 1 {
t.Fatalf("library count = %d, want one logical library", libraryCount)
}
roots, err := repos.Library.ListRoots(t.Context(), first.ID)
if err != nil {
t.Fatal(err)
}
if len(roots) != 2 {
t.Fatalf("roots = %#v, want 2", roots)
}
if roots[0].Path != filepath.Clean(rootA) || roots[1].Path != filepath.Clean(rootB) {
t.Fatalf("root paths = %#v, want %q then %q", roots, filepath.Clean(rootA), filepath.Clean(rootB))
}
}
func TestCreateLibraryWithRootsKeepsDifferentTypesSeparate(t *testing.T) {
rootMovie := t.TempDir()
rootTV := t.TempDir()
db := newServiceTestDB(t, &model.Library{}, &model.LibraryRoot{}, &model.Media{})
repos := repository.New(db)
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos)
if _, err := svc.CreateLibraryWithRoots(t.Context(), "综合", "movie", []LibraryRootInput{{Path: rootMovie}}); err != nil {
t.Fatal(err)
}
if _, err := svc.CreateLibraryWithRoots(t.Context(), "综合", "tv", []LibraryRootInput{{Path: rootTV}}); err != nil {
t.Fatal(err)
}
var libraryCount int64
if err := db.Model(&model.Library{}).Count(&libraryCount).Error; err != nil {
t.Fatal(err)
}
if libraryCount != 2 {
t.Fatalf("library count = %d, want separate libraries for different types", libraryCount)
}
}
func TestCreateLibraryWithRootsAcceptsCloudRoot(t *testing.T) {
db := newServiceTestDB(t, &model.Library{}, &model.LibraryRoot{}, &model.Media{})
repos := repository.New(db)
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos)
lib, err := svc.CreateLibraryWithRoots(t.Context(), "国漫", "anime", []LibraryRootInput{{
Name: "OpenList",
Path: "cloud://openlist/动漫/国漫?dir=国漫&auto_category=1",
}})
if err != nil {
t.Fatal(err)
}
roots, err := repos.Library.ListRoots(t.Context(), lib.ID)
if err != nil {
t.Fatal(err)
}
if len(roots) != 1 {
t.Fatalf("roots = %#v, want one cloud root", roots)
}
info, ok := ParseCloudLibraryMount(roots[0].Path)
if !ok || info.DisplayDir != "动漫/国漫" || info.ScanDir != "国漫" || !CloudLibraryAutoCategory(model.Library{Path: roots[0].Path}) {
t.Fatalf("cloud root = %#v info=%#v", roots[0], info)
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"github.com/ShukeBta/MediaStationGo/internal/model" "github.com/ShukeBta/MediaStationGo/internal/model"
) )
var episodicPathRE = regexp.MustCompile(`(?i)[\\/](?:电视剧|剧集|国产剧|欧美剧|日韩剧|日剧|韩剧|综艺|纪录片|动漫|番剧|国漫|日番|欧美动漫|欧美动画|儿童|tv|series|shows?|season[\s._-]*\d|s\d{1,2}(?:[\s._-]|[\\/])|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)[\\/]`) var episodicPathRE = regexp.MustCompile(`(?i)[\\/](?:电视剧|剧集|国产剧|欧美剧|日韩剧|日剧|韩剧|综艺|纪录片|儿童|动漫|番剧|国漫|日番|韩漫|美漫|欧美动漫|欧美动画|其他动漫|tv|series|shows?|season[\s._-]*\d|s\d{1,2}(?:[\s._-]|[\\/])|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)[\\/]`)
func mediaSeriesKey(media model.Media) string { func mediaSeriesKey(media model.Media) string {
return compactSeriesKey(mediaSeriesRawKey(media)) return compactSeriesKey(mediaSeriesRawKey(media))
@@ -190,7 +190,7 @@ func TestOrganizeDirectoryMovieMetadataOverridesWrongTVFolder(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("organize directory: %v", err) t.Fatalf("organize directory: %v", err)
} }
want := filepath.Join(dest, "电影", "外语电影", "杀的就是你 (2026)", "杀的就是你 (2026).mkv") want := filepath.Join(dest, "电影", "欧美电影", "杀的就是你 (2026)", "杀的就是你 (2026).mkv")
if res.Organized != 1 || res.Reclassified != 0 { if res.Organized != 1 || res.Reclassified != 0 {
t.Fatalf("result = %+v, want organized movie only; paths=%v", res, paths) t.Fatalf("result = %+v, want organized movie only; paths=%v", res, paths)
} }
@@ -200,8 +200,8 @@ func TestOrganizeDirectoryMovieMetadataOverridesWrongTVFolder(t *testing.T) {
if len(paths) == 0 || paths[0] != "/search/movie" { if len(paths) == 0 || paths[0] != "/search/movie" {
t.Fatalf("first metadata search path = %q, want /search/movie; all=%v", firstQuery(paths), paths) t.Fatalf("first metadata search path = %q, want /search/movie; all=%v", firstQuery(paths), paths)
} }
if len(res.Items) != 1 || res.Items[0].MediaType != "movie" || res.Items[0].Category != "外语电影" { if len(res.Items) != 1 || res.Items[0].MediaType != "movie" || res.Items[0].Category != "欧美电影" {
t.Fatalf("organize item = %#v, want movie/外语电影", res.Items) t.Fatalf("organize item = %#v, want movie/欧美电影", res.Items)
} }
} }
@@ -238,7 +238,7 @@ func TestOrganizeDirectoryReclassifiesMovieFromDirtyGeneratedEpisodePath(t *test
root := t.TempDir() root := t.TempDir()
dest := filepath.Join(root, "media") dest := filepath.Join(root, "media")
euusLib := model.Library{Name: "欧美剧", Path: filepath.Join(dest, "电视剧", "欧美剧"), Type: "tv", Enabled: true} euusLib := model.Library{Name: "欧美剧", Path: filepath.Join(dest, "电视剧", "欧美剧"), Type: "tv", Enabled: true}
foreignMovieLib := model.Library{Name: "外语电影", Path: filepath.Join(dest, "电影", "外语电影"), Type: "movie", Enabled: true} foreignMovieLib := model.Library{Name: "欧美电影", Path: filepath.Join(dest, "电影", "欧美电影"), Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &euusLib); err != nil { if err := repos.Library.Create(t.Context(), &euusLib); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -477,7 +477,7 @@ func TestOrganizeDirectoryEpisodeMarkerOverridesMovieSourceFolder(t *testing.T)
root := t.TempDir() root := t.TempDir()
srcRoot := filepath.Join(root, "downloads") srcRoot := filepath.Join(root, "downloads")
dest := filepath.Join(root, "media") dest := filepath.Join(root, "media")
sourceFile := filepath.Join(srcRoot, "外语电影", "The.Last.of.Us.S01E01.2023.1080p.mkv") sourceFile := filepath.Join(srcRoot, "欧美电影", "The.Last.of.Us.S01E01.2023.1080p.mkv")
writeOrgFile(t, sourceFile, "episode") writeOrgFile(t, sourceFile, "episode")
organizer := NewOrganizerService(cfg, zap.NewNop(), repos) organizer := NewOrganizerService(cfg, zap.NewNop(), repos)
@@ -80,6 +80,46 @@ func TestOrganizeDirectoryUsesExplicitCategoryLibraryRoot(t *testing.T) {
} }
} }
func TestOrganizeDirectoryDoesNotTargetLegacyCategoryLibrary(t *testing.T) {
root := t.TempDir()
src := filepath.Join(root, "downloads", "Some.Movie.2026.1080p.mkv")
dest := filepath.Join(root, "media")
writeOrgFile(t, src, "movie")
repos := newOrganizerTestRepo(t)
legacyRoot := filepath.Join(dest, "电影", "外语电影")
currentRoot := filepath.Join(dest, "电影", "欧美电影")
legacyLib := model.Library{Name: "外语电影", Path: legacyRoot, Type: "movie", Enabled: true}
currentLib := model.Library{Name: "欧美电影", Path: currentRoot, Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &legacyLib); err != nil {
t.Fatal(err)
}
if err := repos.Library.Create(t.Context(), &currentLib); err != nil {
t.Fatal(err)
}
org := NewOrganizerService(&config.Config{}, zap.NewNop(), repos)
res, err := org.OrganizeDirectory(t.Context(), OrganizeOptions{
SourcePath: src,
DestPath: dest,
MediaType: "movie",
MediaCategory: "外语电影",
TransferMode: TransferCopy,
})
if err != nil {
t.Fatalf("organize legacy category: %v", err)
}
if res.Organized != 1 || len(res.Items) != 1 {
t.Fatalf("result = %+v, want one organized item", res)
}
if !pathWithin(res.Items[0].Target, currentRoot) {
t.Fatalf("target = %q, want current category root %q", res.Items[0].Target, currentRoot)
}
if pathWithin(res.Items[0].Target, legacyRoot) {
t.Fatalf("target must not use legacy category root %q", res.Items[0].Target)
}
}
func TestOrganizeDirectoryTreatsCategoryDestAsCollectionRoot(t *testing.T) { func TestOrganizeDirectoryTreatsCategoryDestAsCollectionRoot(t *testing.T) {
root := t.TempDir() root := t.TempDir()
src := filepath.Join(root, "downloads", "Some.Show.S01E01.2026.1080p.mkv") src := filepath.Join(root, "downloads", "Some.Show.S01E01.2026.1080p.mkv")
@@ -172,7 +212,7 @@ func TestOrganizeDirectoryCreatesMissingCategoryLibraryForVisibility(t *testing.
srcRoot := filepath.Join(root, "downloads") srcRoot := filepath.Join(root, "downloads")
dest := filepath.Join(root, "media") dest := filepath.Join(root, "media")
source := filepath.Join(srcRoot, "Gourd.Brothers.S01E01.2026.1080p.mkv") source := filepath.Join(srcRoot, "Gourd.Brothers.S01E01.2026.1080p.mkv")
target := filepath.Join(dest, "电视剧", "未分类", "Gourd Brothers", "Season 01", "Gourd Brothers - S01E01.mkv") target := filepath.Join(dest, "电视剧", "欧美剧", "Gourd Brothers", "Season 01", "Gourd Brothers - S01E01.mkv")
writeOrgFile(t, source, "source") writeOrgFile(t, source, "source")
writeOrgFile(t, target, "already-there") writeOrgFile(t, target, "already-there")
@@ -182,7 +222,7 @@ func TestOrganizeDirectoryCreatesMissingCategoryLibraryForVisibility(t *testing.
SourcePath: srcRoot, SourcePath: srcRoot,
DestPath: dest, DestPath: dest,
MediaType: "tv", MediaType: "tv",
MediaCategory: "未分类", MediaCategory: "欧美剧",
TransferMode: TransferCopy, TransferMode: TransferCopy,
AllowReplaceExisting: false, AllowReplaceExisting: false,
}) })
@@ -194,11 +234,11 @@ func TestOrganizeDirectoryCreatesMissingCategoryLibraryForVisibility(t *testing.
} }
var lib model.Library var lib model.Library
if err := repos.DB.Where("path = ?", filepath.Join(dest, "电视剧", "未分类")).First(&lib).Error; err != nil { if err := repos.DB.Where("path = ?", filepath.Join(dest, "电视剧", "欧美剧")).First(&lib).Error; err != nil {
t.Fatalf("missing auto-created category library: %v", err) t.Fatalf("missing auto-created category library: %v", err)
} }
if lib.Name != "未分类" || lib.Type != "tv" || !lib.Enabled { if lib.Name != "欧美剧" || lib.Type != "tv" || !lib.Enabled {
t.Fatalf("auto-created library = %+v, want enabled tv 未分类", lib) t.Fatalf("auto-created library = %+v, want enabled tv 欧美剧", lib)
} }
scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil) scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil)
@@ -231,7 +271,7 @@ func TestOrganizeDirectoryCanDisableAutoAddLibrary(t *testing.T) {
SourcePath: srcRoot, SourcePath: srcRoot,
DestPath: dest, DestPath: dest,
MediaType: "tv", MediaType: "tv",
MediaCategory: "未分类", MediaCategory: "欧美剧",
TransferMode: TransferCopy, TransferMode: TransferCopy,
}) })
if err != nil { if err != nil {
@@ -240,13 +280,13 @@ func TestOrganizeDirectoryCanDisableAutoAddLibrary(t *testing.T) {
if res.Organized != 1 || len(res.Items) != 1 { if res.Organized != 1 || len(res.Items) != 1 {
t.Fatalf("result = %+v, want one organized item", res) t.Fatalf("result = %+v, want one organized item", res)
} }
want := filepath.Join(dest, "电视剧", "未分类", "Some Show", "Season 01", "Some Show - S01E01.mkv") want := filepath.Join(dest, "电视剧", "欧美剧", "Some Show", "Season 01", "Some Show - S01E01.mkv")
if _, err := os.Stat(want); err != nil { if _, err := os.Stat(want); err != nil {
t.Fatalf("expected organized file at %q: %v", want, err) t.Fatalf("expected organized file at %q: %v", want, err)
} }
var count int64 var count int64
if err := repos.DB.Model(&model.Library{}).Where("path = ?", filepath.Join(dest, "电视剧", "未分类")).Count(&count).Error; err != nil { if err := repos.DB.Model(&model.Library{}).Where("path = ?", filepath.Join(dest, "电视剧", "欧美剧")).Count(&count).Error; err != nil {
t.Fatal(err) t.Fatal(err)
} }
if count != 0 { if count != 0 {
@@ -282,9 +322,9 @@ func TestOrganizeDirectorySmartClassifiesUncategorizedSources(t *testing.T) {
for _, want := range []string{ for _, want := range []string{
filepath.Join(dest, "电影", "华语电影", "流浪地球2 (2023)", "流浪地球2 (2023).mkv"), filepath.Join(dest, "电影", "华语电影", "流浪地球2 (2023)", "流浪地球2 (2023).mkv"),
filepath.Join(dest, "电影", "外语电影", "Dune (2021)", "Dune (2021).mkv"), filepath.Join(dest, "电影", "欧美电影", "Dune (2021)", "Dune (2021).mkv"),
filepath.Join(dest, "电视剧", "国产剧", "狂飙", "Season 01", "狂飙 - S01E01.mkv"), filepath.Join(dest, "电视剧", "国产剧", "狂飙", "Season 01", "狂飙 - S01E01.mkv"),
filepath.Join(dest, "电视剧", "未分类", "The Last Of Us", "Season 01", "The Last Of Us - S01E01.mkv"), filepath.Join(dest, "电视剧", "欧美剧", "The Last Of Us", "Season 01", "The Last Of Us - S01E01.mkv"),
} { } {
if _, err := os.Stat(want); err != nil { if _, err := os.Stat(want); err != nil {
t.Fatalf("expected smart classified file at %q: %v; items=%+v", want, err, res.Items) t.Fatalf("expected smart classified file at %q: %v; items=%+v", want, err, res.Items)
+27 -5
View File
@@ -106,24 +106,46 @@ func (o *OrganizerService) directoryCategoryTypes() map[string]organizeDirectory
add(fallback, mediaType) add(fallback, mediaType)
add(categoryName(categories, key, fallback), mediaType) add(categoryName(categories, key, fallback), mediaType)
} }
addAlias := func(alias, canonicalKey, fallback, mediaType string) {
alias = strings.TrimSpace(alias)
if alias == "" {
return
}
out[strings.ToLower(alias)] = organizeDirectoryLayout{
MediaType: mediaType,
Category: categoryName(categories, canonicalKey, fallback),
}
}
addConfigured("concert_movie", "演唱会", "movie")
addConfigured("documentary_movie", "纪录片", "movie")
addConfigured("animation_movie", "动画电影", "movie") addConfigured("animation_movie", "动画电影", "movie")
addConfigured("chinese_movie", "华语电影", "movie") addConfigured("chinese_movie", "华语电影", "movie")
addConfigured("jk_movie", "日韩电影", "movie") addConfigured("jk_movie", "日韩电影", "movie")
addConfigured("euus_movie", "欧美电影", "movie") addConfigured("euus_movie", "欧美电影", "movie")
addConfigured("foreign_movie", "外语电影", "movie") addAlias("外语电影", "euus_movie", "欧美电影", "movie")
addAlias("外国电影", "euus_movie", "欧美电影", "movie")
addConfigured("domestic_tv", "国产剧", "tv") addConfigured("domestic_tv", "国产剧", "tv")
addConfigured("euus_tv", "欧美剧", "tv") addConfigured("euus_tv", "欧美剧", "tv")
addConfigured("jk_tv", "日韩剧", "tv") addConfigured("jk_tv", "日韩剧", "tv")
addConfigured("cn_anime", "国漫", "anime") addConfigured("cn_anime", "国漫", "anime")
addConfigured("jp_anime", "日番", "anime") addConfigured("jp_anime", "日番", "anime")
addConfigured("euus_anime", "欧美动漫", "anime") addConfigured("kr_anime", "韩漫", "anime")
addConfigured("us_anime", "美漫", "anime")
addConfigured("other_anime", "其他", "anime")
addAlias("欧美动漫", "us_anime", "美漫", "anime")
addAlias("欧美动画", "us_anime", "美漫", "anime")
addAlias("西方动画", "us_anime", "美漫", "anime")
addAlias("其他动漫", "other_anime", "其他", "anime")
addAlias("其它动漫", "other_anime", "其他", "anime")
addConfigured("variety", "综艺", "variety") addConfigured("variety", "综艺", "variety")
addConfigured("documentary", "纪录片", "tv") addConfigured("documentary", "纪录片", "tv")
addConfigured("children", "儿童", "tv") addConfigured("children", "儿童", "tv")
addConfigured("uncategorized_tv", "未分类", "tv") addAlias("未分类", "euus_tv", "欧美剧", "tv")
addAlias("uncategorized", "euus_tv", "欧美剧", "tv")
addConfigured("adult", "成人", "adult") addConfigured("adult", "成人", "adult")
addConfigured("adult_9kg", "9KG", "adult") addAlias("9KG", "adult", "成人", "adult")
addConfigured("adult_jav", "番号", "adult") addAlias("番号", "adult", "成人", "adult")
addAlias("JAV", "adult", "成人", "adult")
return out return out
} }
@@ -115,7 +115,7 @@ func (o *OrganizerService) organizeLibraryMatchesExpectedPhysicalRoot(libPath, c
if strings.TrimSpace(category) == "" { if strings.TrimSpace(category) == "" {
return true return true
} }
physicalRoot := o.categoryPhysicalRootDir(category) physicalRoot := o.mediaTypeRootDirForCategory(mediaType, category)
if physicalRoot == "" { if physicalRoot == "" {
physicalRoot = mediaTypeRootDir(mediaType) physicalRoot = mediaTypeRootDir(mediaType)
} }
@@ -126,7 +126,7 @@ func (o *OrganizerService) organizeLibraryMatchesExpectedPhysicalRoot(libPath, c
} }
func (o *OrganizerService) organizeLibraryPhysicalRootScore(libPath, collectionRoot, mediaType, category string) int { func (o *OrganizerService) organizeLibraryPhysicalRootScore(libPath, collectionRoot, mediaType, category string) int {
physicalRoot := o.categoryPhysicalRootDir(category) physicalRoot := o.mediaTypeRootDirForCategory(mediaType, category)
if physicalRoot == "" { if physicalRoot == "" {
physicalRoot = mediaTypeRootDir(mediaType) physicalRoot = mediaTypeRootDir(mediaType)
} }
@@ -43,7 +43,7 @@ func (o *OrganizerService) lookupOrganizeMetadata(ctx context.Context, src, sour
SeasonNum: lookupSeason, SeasonNum: lookupSeason,
EpisodeNum: lookupEpisode, EpisodeNum: lookupEpisode,
} }
for _, candidate := range scrapeQueryCandidates(media, lib) { for _, candidate := range scrapeQueryCandidatesWithRecognition(ctx, o.repo, media, lib) {
key := organizeMetadataCacheKey(lib.Type, candidate, year) key := organizeMetadataCacheKey(lib.Type, candidate, year)
if cache != nil { if cache != nil {
if cached, ok := cache[key]; ok { if cached, ok := cache[key]; ok {
@@ -56,7 +56,7 @@ func (o *OrganizerService) resolveOrganizeSourceIdentity(ctx context.Context, re
src := req.Source src := req.Source
ext := filepath.Ext(src) ext := filepath.Ext(src)
season, episode := ParseEpisode(src) season, episode := ParseEpisode(src)
title, year := CleanQuery(src) title, year := CleanQueryWithRecognition(ctx, o.repo, src)
if organizeWeakFileTitle(title) { if organizeWeakFileTitle(title) {
if folderTitle, folderYear := organizeTitleFromParentFolder(src, req.SourceRoot, season > 0 || episode > 0); folderTitle != "" { if folderTitle, folderYear := organizeTitleFromParentFolder(src, req.SourceRoot, season > 0 || episode > 0); folderTitle != "" {
title = folderTitle title = folderTitle
@@ -133,7 +133,7 @@ func organizeStandaloneMovieSourceHint(src string, identity organizeSourceIdenti
if identity.Season > 0 || identity.Episode > 0 { if identity.Season > 0 || identity.Episode > 0 {
return organizeEpisodeLooksSourcedFromMovieYear(src, identity) return organizeEpisodeLooksSourcedFromMovieYear(src, identity)
} }
_, year := CleanQuery(filepath.Base(src)) _, year := CleanQueryWithRecognition(context.Background(), nil, filepath.Base(src))
if year <= 0 { if year <= 0 {
year = identity.Year year = identity.Year
} }
@@ -184,9 +184,9 @@ func (o *OrganizerService) applyOrganizeSourceCategory(
} else if category := o.smartClassifySourceFile(ctx, req.Source, req.SourceRoot, layout.MediaType, identity.Title, identity.ParsedTitle, metadataMatch); category != "" { } else if category := o.smartClassifySourceFile(ctx, req.Source, req.SourceRoot, layout.MediaType, identity.Title, identity.ParsedTitle, metadataMatch); category != "" {
layout.Category = category layout.Category = category
} }
if forcedType == "" { if impliedType, normalizedCategory := o.mediaTypeForDirectoryCategory(layout.Category); impliedType != "" {
if impliedType, normalizedCategory := o.mediaTypeForDirectoryCategory(layout.Category); impliedType != "" { layout.Category = normalizedCategory
layout.Category = normalizedCategory if forcedType == "" {
if layout.MediaType == "" || layout.MediaType == "tv" || layout.MediaType == "anime" || pathLayout.Category != layout.Category { if layout.MediaType == "" || layout.MediaType == "tv" || layout.MediaType == "anime" || pathLayout.Category != layout.Category {
layout.MediaType = impliedType layout.MediaType = impliedType
} }
@@ -45,38 +45,41 @@ func (o *OrganizerService) organizeCategoryAliases(mediaType, category string) m
} }
} }
categories := o.categoryMap() categories := o.categoryMap()
add(category)
switch normalizeOrganizeCategoryKey(category) { switch normalizeOrganizeCategoryKey(category) {
case normalizeOrganizeCategoryKey(categoryName(categories, "jp_anime", "日番")), "日番", "日漫", "日本动漫", "日本動畫", "日本动画": case normalizeOrganizeCategoryKey(categoryName(categories, "jp_anime", "日番")), "日番", "日漫", "日本动漫", "日本動畫", "日本动画":
add("日番", "日漫", "日本动漫", "日本动画") add("日番", categoryName(categories, "jp_anime", "日番"))
case normalizeOrganizeCategoryKey(categoryName(categories, "cn_anime", "国漫")), "国漫", "国产动漫", "國漫": case normalizeOrganizeCategoryKey(categoryName(categories, "cn_anime", "国漫")), "国漫", "国产动漫", "國漫":
add("国漫", "国产动漫") add("国漫", categoryName(categories, "cn_anime", "国漫"))
case normalizeOrganizeCategoryKey(categoryName(categories, "euus_anime", "欧美动漫")), "欧美动漫", "欧美动画", "西方动画": case normalizeOrganizeCategoryKey(categoryName(categories, "kr_anime", "韩漫")), "韩漫", "韩国动漫", "韩国动画":
add("欧美动漫", "欧美动画", "西方动画") add("韩漫", categoryName(categories, "kr_anime", "韩漫"))
case normalizeOrganizeCategoryKey(categoryName(categories, "us_anime", "美漫")), "美漫", "欧美动漫", "欧美动画", "西方动画":
add("美漫", categoryName(categories, "us_anime", "美漫"))
case normalizeOrganizeCategoryKey(categoryName(categories, "other_anime", "其他")), "其他", "其他动漫", "其它动漫":
add("其他", categoryName(categories, "other_anime", "其他"))
case normalizeOrganizeCategoryKey(categoryName(categories, "domestic_tv", "国产剧")), "国产剧", "国剧", "大陆剧", "国产电视剧": case normalizeOrganizeCategoryKey(categoryName(categories, "domestic_tv", "国产剧")), "国产剧", "国剧", "大陆剧", "国产电视剧":
add("国产剧", "国剧", "大陆剧", "国产电视剧") add("国产剧", categoryName(categories, "domestic_tv", "国产剧"))
case normalizeOrganizeCategoryKey(categoryName(categories, "euus_tv", "欧美剧")), "欧美剧", "欧美电视剧": case normalizeOrganizeCategoryKey(categoryName(categories, "euus_tv", "欧美剧")), "欧美剧", "欧美电视剧":
add("欧美剧", "欧美电视剧") add("欧美剧", categoryName(categories, "euus_tv", "欧美剧"))
case normalizeOrganizeCategoryKey(categoryName(categories, "jk_tv", "日韩剧")), "日韩剧", "日剧", "韩剧": case normalizeOrganizeCategoryKey(categoryName(categories, "jk_tv", "日韩剧")), "日韩剧", "日剧", "韩剧":
add("日韩剧", "日剧", "韩剧") add("日韩剧", categoryName(categories, "jk_tv", "日韩剧"))
case normalizeOrganizeCategoryKey(categoryName(categories, "variety", "综艺")), "综艺", "真人秀": case normalizeOrganizeCategoryKey(categoryName(categories, "variety", "综艺")), "综艺", "真人秀":
add("综艺", "真人秀") add("综艺", categoryName(categories, "variety", "综艺"))
case normalizeOrganizeCategoryKey(categoryName(categories, "documentary", "纪录片")), "纪录片", "纪录": case normalizeOrganizeCategoryKey(categoryName(categories, "documentary", "纪录片")), "纪录片", "纪录":
add("纪录片", "纪录") add("纪录片", categoryName(categories, "documentary", "纪录片"))
case normalizeOrganizeCategoryKey(categoryName(categories, "children", "儿童")), "儿童", "少儿": case normalizeOrganizeCategoryKey(categoryName(categories, "children", "儿童")), "儿童", "少儿":
add("儿童", "少儿") add("儿童", categoryName(categories, "children", "儿童"))
case normalizeOrganizeCategoryKey(categoryName(categories, "chinese_movie", "华语电影")), "华语电影", "国产电影", "大陆电影": case normalizeOrganizeCategoryKey(categoryName(categories, "chinese_movie", "华语电影")), "华语电影", "国产电影", "大陆电影":
add("华语电影", "国产电影", "大陆电影") add("华语电影", categoryName(categories, "chinese_movie", "华语电影"))
case normalizeOrganizeCategoryKey(categoryName(categories, "foreign_movie", "外语电影")), "外语电影": case normalizeOrganizeCategoryKey(categoryName(categories, "euus_movie", "欧美电影")), "欧美电影", "外语电影", "外国电影":
add("外语电影") add("欧美电影", categoryName(categories, "euus_movie", "欧美电影"))
case normalizeOrganizeCategoryKey(categoryName(categories, "jk_movie", "日韩电影")), "日韩电影", "日本电影", "韩国电影":
add("日韩电影", categoryName(categories, "jk_movie", "日韩电影"))
case normalizeOrganizeCategoryKey(categoryName(categories, "concert_movie", "演唱会")), "演唱会", "音乐会":
add("演唱会", categoryName(categories, "concert_movie", "演唱会"))
case normalizeOrganizeCategoryKey(categoryName(categories, "animation_movie", "动画电影")), "动画电影", "动漫电影": case normalizeOrganizeCategoryKey(categoryName(categories, "animation_movie", "动画电影")), "动画电影", "动漫电影":
add("动画电影", "动漫电影") add("动画电影", categoryName(categories, "animation_movie", "动画电影"))
case normalizeOrganizeCategoryKey(categoryName(categories, "adult", "成人")), "成人": case normalizeOrganizeCategoryKey(categoryName(categories, "adult", "成人")), "成人", "9kg", "番号", "jav":
add("成人") add("成人", categoryName(categories, "adult", "成人"))
case normalizeOrganizeCategoryKey(categoryName(categories, "adult_9kg", "9KG")), "9kg":
add("9KG")
case normalizeOrganizeCategoryKey(categoryName(categories, "adult_jav", "番号")), "番号", "jav":
add("番号", "JAV")
} }
return aliases return aliases
} }
+3 -1
View File
@@ -83,8 +83,10 @@ func (o *OrganizerService) buildOrganizeMediaDestination(ctx context.Context, re
category = o.classifyMedia(ctx, m, mediaType) category = o.classifyMedia(ctx, m, mediaType)
} }
if impliedType, normalizedCategory := o.mediaTypeForDirectoryCategory(category); impliedType != "" { if impliedType, normalizedCategory := o.mediaTypeForDirectoryCategory(category); impliedType != "" {
mediaType = impliedType
category = normalizedCategory category = normalizedCategory
if normalizeOrganizeMediaType(req.mediaType) == "" {
mediaType = impliedType
}
} }
root := o.organizeRoot(req.baseRoot, mediaType, category) root := o.organizeRoot(req.baseRoot, mediaType, category)
targetLibraryID := "" targetLibraryID := ""
+71 -8
View File
@@ -26,12 +26,73 @@ func (o *OrganizerService) organizeRoot(libraryPath, mediaType, category string)
} }
func (o *OrganizerService) mediaTypeRootDirForCategory(mediaType, category string) string { func (o *OrganizerService) mediaTypeRootDirForCategory(mediaType, category string) string {
if root := o.categoryPhysicalRootDirForType(mediaType, category); root != "" {
return root
}
if root := o.categoryPhysicalRootDir(category); root != "" { if root := o.categoryPhysicalRootDir(category); root != "" {
return root return root
} }
return mediaTypeRootDir(mediaType) return mediaTypeRootDir(mediaType)
} }
func (o *OrganizerService) categoryPhysicalRootDirForType(mediaType, category string) string {
key := normalizeOrganizeCategoryKey(category)
if key == "" {
return ""
}
categories := o.categoryMap()
match := func(values ...string) bool {
for _, value := range values {
if key == normalizeOrganizeCategoryKey(value) {
return true
}
}
return false
}
switch normalizeMediaType(mediaType, "", "") {
case "movie":
if match(
categoryName(categories, "concert_movie", "演唱会"),
categoryName(categories, "documentary_movie", "纪录片"),
categoryName(categories, "animation_movie", "动画电影"),
categoryName(categories, "chinese_movie", "华语电影"),
categoryName(categories, "euus_movie", "欧美电影"),
categoryName(categories, "jk_movie", "日韩电影"),
"演唱会", "音乐会", "纪录片", "纪录", "动画电影", "动漫电影", "华语电影", "国产电影", "外语电影", "外国电影", "欧美电影", "日韩电影",
) {
return "电影"
}
case "tv", "variety":
if match(
categoryName(categories, "domestic_tv", "国产剧"),
categoryName(categories, "euus_tv", "欧美剧"),
categoryName(categories, "jk_tv", "日韩剧"),
categoryName(categories, "variety", "综艺"),
categoryName(categories, "documentary", "纪录片"),
categoryName(categories, "children", "儿童"),
"国产剧", "欧美剧", "日韩剧", "日剧", "韩剧", "综艺", "真人秀", "纪录片", "纪录", "儿童", "少儿", "未分类",
) {
return "电视剧"
}
case "anime":
if match(
categoryName(categories, "cn_anime", "国漫"),
categoryName(categories, "jp_anime", "日番"),
categoryName(categories, "kr_anime", "韩漫"),
categoryName(categories, "us_anime", "美漫"),
categoryName(categories, "other_anime", "其他"),
"国漫", "国产动漫", "日番", "番剧", "日漫", "日本动漫", "日本动画", "韩漫", "韩国动漫", "美漫", "欧美动漫", "欧美动画", "西方动画", "其他", "其他动漫", "其它动漫",
) {
return "动漫"
}
case "adult":
if match(categoryName(categories, "adult", "成人"), "成人", "9kg", "番号", "jav") {
return "成人"
}
}
return ""
}
func (o *OrganizerService) categoryPhysicalRootDir(category string) string { func (o *OrganizerService) categoryPhysicalRootDir(category string) string {
key := normalizeOrganizeCategoryKey(category) key := normalizeOrganizeCategoryKey(category)
if key == "" { if key == "" {
@@ -50,9 +111,10 @@ func (o *OrganizerService) categoryPhysicalRootDir(category string) string {
case match( case match(
categoryName(categories, "cn_anime", "国漫"), categoryName(categories, "cn_anime", "国漫"),
categoryName(categories, "jp_anime", "日番"), categoryName(categories, "jp_anime", "日番"),
categoryName(categories, "euus_anime", "欧美动漫"), categoryName(categories, "kr_anime", "韩漫"),
categoryName(categories, "children", "儿童"), categoryName(categories, "us_anime", "美漫"),
"国漫", "国产动漫", "日番", "番剧", "日漫", "日本动漫", "日本动画", "欧美动漫", "欧美动画", "西方动画", "儿童", "少儿", categoryName(categories, "other_anime", "其他"),
"国漫", "国产动漫", "日番", "番剧", "日漫", "日本动漫", "日本动画", "韩漫", "韩国动漫", "美漫", "欧美动漫", "欧美动画", "西方动画", "其他", "其他动漫", "其它动漫",
): ):
return "动漫" return "动漫"
case match( case match(
@@ -61,20 +123,21 @@ func (o *OrganizerService) categoryPhysicalRootDir(category string) string {
categoryName(categories, "jk_tv", "日韩剧"), categoryName(categories, "jk_tv", "日韩剧"),
categoryName(categories, "variety", "综艺"), categoryName(categories, "variety", "综艺"),
categoryName(categories, "documentary", "纪录片"), categoryName(categories, "documentary", "纪录片"),
categoryName(categories, "uncategorized_tv", "未分类"), categoryName(categories, "children", "儿童"),
"国产剧", "欧美剧", "日韩剧", "日剧", "韩剧", "综艺", "真人秀", "纪录片", "纪录", "未分类", "国产剧", "欧美剧", "日韩剧", "日剧", "韩剧", "综艺", "真人秀", "纪录片", "纪录", "儿童", "少儿", "未分类",
): ):
return "电视剧" return "电视剧"
case match( case match(
categoryName(categories, "concert_movie", "演唱会"),
categoryName(categories, "documentary_movie", "纪录片"),
categoryName(categories, "animation_movie", "动画电影"), categoryName(categories, "animation_movie", "动画电影"),
categoryName(categories, "chinese_movie", "华语电影"), categoryName(categories, "chinese_movie", "华语电影"),
categoryName(categories, "foreign_movie", "外语电影"),
categoryName(categories, "euus_movie", "欧美电影"), categoryName(categories, "euus_movie", "欧美电影"),
categoryName(categories, "jk_movie", "日韩电影"), categoryName(categories, "jk_movie", "日韩电影"),
"动画电影", "动漫电影", "华语电影", "国产电影", "外语电影", "欧美电影", "日韩电影", "演唱会", "音乐会", "动画电影", "动漫电影", "华语电影", "国产电影", "外语电影", "外国电影", "欧美电影", "日韩电影",
): ):
return "电影" return "电影"
case match(categoryName(categories, "adult", "成人"), categoryName(categories, "adult_9kg", "9KG"), categoryName(categories, "adult_jav", "番号"), "成人", "9kg", "番号", "jav"): case match(categoryName(categories, "adult", "成人"), "成人", "9kg", "番号", "jav"):
return "成人" return "成人"
default: default:
return "" return ""
@@ -194,7 +194,7 @@ func TestReclassifyMisclassifiedMediaMovesWesternAnimationToWesternAnimeLibrary(
root := t.TempDir() root := t.TempDir()
dest := filepath.Join(root, "media") dest := filepath.Join(root, "media")
jpAnimeLib := model.Library{Name: "日番", Path: filepath.Join(dest, "动漫", "日番"), Type: "anime", Enabled: true} jpAnimeLib := model.Library{Name: "日番", Path: filepath.Join(dest, "动漫", "日番"), Type: "anime", Enabled: true}
westernAnimeLib := model.Library{Name: "欧美动漫", Path: filepath.Join(dest, "动漫", "欧美动漫"), Type: "anime", Enabled: true} westernAnimeLib := model.Library{Name: "美漫", Path: filepath.Join(dest, "动漫", "美漫"), Type: "anime", Enabled: true}
for _, lib := range []*model.Library{&jpAnimeLib, &westernAnimeLib} { for _, lib := range []*model.Library{&jpAnimeLib, &westernAnimeLib} {
if err := repos.Library.Create(t.Context(), lib); err != nil { if err := repos.Library.Create(t.Context(), lib); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -271,7 +271,7 @@ func TestReclassifyMisclassifiedMediaHonorsManualMovieHint(t *testing.T) {
root := t.TempDir() root := t.TempDir()
dest := filepath.Join(root, "media") dest := filepath.Join(root, "media")
euusLib := model.Library{Name: "欧美剧", Path: filepath.Join(dest, "电视剧", "欧美剧"), Type: "tv", Enabled: true} euusLib := model.Library{Name: "欧美剧", Path: filepath.Join(dest, "电视剧", "欧美剧"), Type: "tv", Enabled: true}
foreignMovieLib := model.Library{Name: "外语电影", Path: filepath.Join(dest, "电影", "外语电影"), Type: "movie", Enabled: true} foreignMovieLib := model.Library{Name: "欧美电影", Path: filepath.Join(dest, "电影", "欧美电影"), Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &euusLib); err != nil { if err := repos.Library.Create(t.Context(), &euusLib); err != nil {
t.Fatal(err) t.Fatal(err)
} }
+197
View File
@@ -0,0 +1,197 @@
package service
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
const (
RecognitionWordsEnabledKey = "recognition_words.enabled"
RecognitionWordsLocalTextKey = "recognition_words.local_text"
RecognitionWordsSharedURLsKey = "recognition_words.shared_urls"
RecognitionWordsSharedTextKey = "recognition_words.shared_text"
RecognitionWordsSyncedAtKey = "recognition_words.synced_at"
)
var DefaultRecognitionWordURLs = []string{
"https://raw.githubusercontent.com/Putarku/MoviePilot-Help/main/Words/general.txt",
"https://raw.githubusercontent.com/Putarku/MoviePilot-Help/main/Words/TV.txt",
"https://raw.githubusercontent.com/Putarku/MoviePilot-Help/main/Words/anime.txt",
}
type RecognitionWordsService struct {
log *zap.Logger
repo *repository.Container
client *http.Client
}
type RecognitionWordsConfig struct {
Enabled bool `json:"enabled"`
LocalText string `json:"local_text"`
SharedURLs []string `json:"shared_urls"`
SharedText string `json:"shared_text,omitempty"`
SyncedAt string `json:"synced_at,omitempty"`
RuleCount int `json:"rule_count"`
}
type RecognitionWordsTestResult struct {
Input string `json:"input"`
Output string `json:"output"`
Title string `json:"title"`
Year int `json:"year"`
Changed bool `json:"changed"`
}
func NewRecognitionWordsService(log *zap.Logger, repo *repository.Container) *RecognitionWordsService {
return &RecognitionWordsService{
log: log,
repo: repo,
client: recognitionWordHTTPClient(),
}
}
func (s *RecognitionWordsService) Config(ctx context.Context) RecognitionWordsConfig {
cfg := recognitionWordsConfig(ctx, s.repo)
cfg.RuleCount = len(parseRecognitionWordRules(recognitionWordsCombinedText(cfg)))
return cfg
}
func (s *RecognitionWordsService) SaveConfig(ctx context.Context, cfg RecognitionWordsConfig) error {
if s == nil || s.repo == nil || s.repo.Setting == nil {
return fmt.Errorf("setting repository unavailable")
}
if err := s.repo.Setting.Set(ctx, RecognitionWordsEnabledKey, strconv.FormatBool(cfg.Enabled)); err != nil {
return err
}
if err := s.repo.Setting.Set(ctx, RecognitionWordsLocalTextKey, cfg.LocalText); err != nil {
return err
}
rawURLs, err := json.Marshal(normalizeRecognitionWordURLs(cfg.SharedURLs))
if err != nil {
return err
}
return s.repo.Setting.Set(ctx, RecognitionWordsSharedURLsKey, string(rawURLs))
}
func (s *RecognitionWordsService) SyncShared(ctx context.Context) (RecognitionWordsConfig, error) {
cfg := s.Config(ctx)
urls := cfg.SharedURLs
if len(urls) == 0 {
urls = DefaultRecognitionWordURLs
}
var combined []string
for _, rawURL := range urls {
text, err := s.fetchSharedWords(ctx, rawURL)
if err != nil {
return cfg, err
}
combined = append(combined, "# "+rawURL, text)
}
now := time.Now().Format(time.RFC3339)
if err := s.repo.Setting.Set(ctx, RecognitionWordsSharedTextKey, strings.Join(combined, "\n")); err != nil {
return cfg, err
}
if err := s.repo.Setting.Set(ctx, RecognitionWordsSyncedAtKey, now); err != nil {
return cfg, err
}
return s.Config(ctx), nil
}
func (s *RecognitionWordsService) Test(ctx context.Context, input string) RecognitionWordsTestResult {
output := ApplyRecognitionWords(ctx, s.repo, input)
title, year := CleanQuery(output)
return RecognitionWordsTestResult{
Input: input,
Output: output,
Title: title,
Year: year,
Changed: strings.TrimSpace(input) != strings.TrimSpace(output),
}
}
func ApplyRecognitionWords(ctx context.Context, repo *repository.Container, raw string) string {
cfg := recognitionWordsConfig(ctx, repo)
if !cfg.Enabled {
return raw
}
rules := parseRecognitionWordRules(recognitionWordsCombinedText(cfg))
return applyRecognitionWordRules(raw, rules)
}
func CleanQueryWithRecognition(ctx context.Context, repo *repository.Container, raw string) (string, int) {
return CleanQuery(ApplyRecognitionWords(ctx, repo, raw))
}
func recognitionWordsConfig(ctx context.Context, repo *repository.Container) RecognitionWordsConfig {
cfg := RecognitionWordsConfig{Enabled: true, SharedURLs: DefaultRecognitionWordURLs}
if repo == nil || repo.DB == nil || repo.Setting == nil || !repo.DB.Migrator().HasTable(&model.Setting{}) {
return cfg
}
if value, err := repo.Setting.Get(ctx, RecognitionWordsEnabledKey); err == nil && strings.TrimSpace(value) != "" {
cfg.Enabled = parseBoolSetting(value, true)
}
if value, err := repo.Setting.Get(ctx, RecognitionWordsLocalTextKey); err == nil {
cfg.LocalText = value
}
if value, err := repo.Setting.Get(ctx, RecognitionWordsSharedURLsKey); err == nil && strings.TrimSpace(value) != "" {
cfg.SharedURLs = parseRecognitionWordURLs(value)
}
if value, err := repo.Setting.Get(ctx, RecognitionWordsSharedTextKey); err == nil {
cfg.SharedText = value
}
if value, err := repo.Setting.Get(ctx, RecognitionWordsSyncedAtKey); err == nil {
cfg.SyncedAt = value
}
return cfg
}
func recognitionWordsCombinedText(cfg RecognitionWordsConfig) string {
return strings.TrimSpace(cfg.LocalText + "\n" + cfg.SharedText)
}
func parseRecognitionWordURLs(raw string) []string {
var values []string
if err := json.Unmarshal([]byte(raw), &values); err == nil {
return normalizeRecognitionWordURLs(values)
}
return normalizeRecognitionWordURLs(strings.FieldsFunc(raw, func(r rune) bool {
return r == '\n' || r == '\r' || r == ',' || r == ';'
}))
}
func normalizeRecognitionWordURLs(values []string) []string {
seen := map[string]struct{}{}
out := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
return out
}
type recognitionWordRule struct {
raw string
block string
replaceFrom string
replaceTo string
offsetLeft string
offsetRight string
offsetExpr string
}
+155
View File
@@ -0,0 +1,155 @@
package service
import (
"fmt"
"regexp"
"strconv"
"strings"
)
func parseRecognitionWordRules(raw string) []recognitionWordRule {
var out []recognitionWordRule
for _, line := range strings.Split(raw, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "//") {
continue
}
out = append(out, parseRecognitionWordRule(line))
}
return out
}
func parseRecognitionWordRule(line string) recognitionWordRule {
rule := recognitionWordRule{raw: line}
for _, part := range strings.Split(line, "&&") {
part = strings.TrimSpace(part)
switch {
case strings.Contains(part, "=>"):
pieces := strings.SplitN(part, "=>", 2)
rule.replaceFrom = strings.TrimSpace(pieces[0])
rule.replaceTo = normalizeRecognitionReplacement(strings.TrimSpace(pieces[1]))
case strings.Contains(part, "<>") && strings.Contains(part, ">>"):
beforeAfter := strings.SplitN(part, ">>", 2)
bounds := strings.SplitN(beforeAfter[0], "<>", 2)
rule.offsetLeft = strings.TrimSpace(bounds[0])
rule.offsetRight = strings.TrimSpace(bounds[1])
rule.offsetExpr = strings.TrimSpace(beforeAfter[1])
default:
rule.block = part
}
}
return rule
}
func normalizeRecognitionReplacement(value string) string {
re := regexp.MustCompile(`\\([0-9]+)`)
return re.ReplaceAllString(value, "$$$1")
}
func applyRecognitionWordRules(raw string, rules []recognitionWordRule) string {
out := strings.TrimSpace(raw)
for _, rule := range rules {
if rule.block != "" {
out = applyRecognitionBlock(out, rule.block)
}
if rule.replaceFrom != "" {
out = applyRecognitionReplace(out, rule.replaceFrom, rule.replaceTo)
}
if rule.offsetLeft != "" || rule.offsetRight != "" {
out = applyRecognitionOffset(out, rule.offsetLeft, rule.offsetRight, rule.offsetExpr)
}
}
return strings.Join(strings.Fields(out), " ")
}
func applyRecognitionBlock(raw, block string) string {
if re, err := regexp.Compile(block); err == nil {
return re.ReplaceAllString(raw, " ")
}
return strings.ReplaceAll(raw, block, " ")
}
func applyRecognitionReplace(raw, from, to string) string {
if re, err := regexp.Compile(from); err == nil {
return re.ReplaceAllString(raw, to)
}
return strings.ReplaceAll(raw, from, to)
}
func applyRecognitionOffset(raw, left, right, expr string) string {
if strings.TrimSpace(expr) == "" {
return raw
}
leftPattern := firstNonEmpty(left, `^`)
rightPattern := firstNonEmpty(right, `$`)
re, err := regexp.Compile(`(?i)(` + leftPattern + `)(\d{1,5})(` + rightPattern + `)`)
if err != nil {
return raw
}
return re.ReplaceAllStringFunc(raw, func(match string) string {
return applyRecognitionOffsetMatch(re, match, expr)
})
}
func applyRecognitionOffsetMatch(re *regexp.Regexp, match, expr string) string {
parts := re.FindStringSubmatch(match)
if len(parts) < 4 {
return match
}
ep, err := strconv.Atoi(parts[2])
if err != nil {
return match
}
next, ok := evalRecognitionEpisodeExpr(expr, ep)
if !ok || next < 0 {
return match
}
format := "%d"
if width := len(parts[2]); width > 1 && width <= 2 {
format = "%0" + strconv.Itoa(width) + "d"
}
return parts[1] + fmt.Sprintf(format, next) + parts[3]
}
func evalRecognitionEpisodeExpr(expr string, ep int) (int, bool) {
value := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(expr), " ", ""))
if value == "" || value == "EP" {
return ep, true
}
if n, err := strconv.Atoi(value); err == nil {
return n, true
}
if out, ok := evalRecognitionEpisodeAddSub(value, ep); ok {
return out, true
}
return evalRecognitionEpisodeMul(value, ep)
}
func evalRecognitionEpisodeAddSub(value string, ep int) (int, bool) {
for _, op := range []string{"+", "-"} {
if !strings.HasPrefix(value, "EP"+op) {
continue
}
n, err := strconv.Atoi(strings.TrimPrefix(value, "EP"+op))
if err != nil {
return 0, false
}
if op == "+" {
return ep + n, true
}
return ep - n, true
}
return 0, false
}
func evalRecognitionEpisodeMul(value string, ep int) (int, bool) {
if strings.HasPrefix(value, "EP*") {
n, err := strconv.Atoi(strings.TrimPrefix(value, "EP*"))
return ep * n, err == nil
}
if strings.HasSuffix(value, "*EP") {
n, err := strconv.Atoi(strings.TrimSuffix(value, "*EP"))
return ep * n, err == nil
}
return 0, false
}
@@ -0,0 +1,139 @@
package service
import (
"context"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"time"
)
func recognitionWordHTTPClient() *http.Client {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.DialContext = dialRecognitionWordContext
return &http.Client{
Timeout: 20 * time.Second,
Transport: transport,
CheckRedirect: func(req *http.Request, _ []*http.Request) error {
return validateRecognitionWordURL(req.Context(), req.URL.String())
},
}
}
func dialRecognitionWordContext(ctx context.Context, network, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, err
}
if isLocalHostname(host) {
return nil, fmt.Errorf("recognition word URL host is not allowed: %s", host)
}
dialer := &net.Dialer{Timeout: 20 * time.Second}
if ip := net.ParseIP(host); ip != nil {
if err := validateRecognitionWordIP(host, ip); err != nil {
return nil, err
}
return dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
}
addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, fmt.Errorf("resolve recognition word URL host %s: %w", host, err)
}
if len(addrs) == 0 {
return nil, fmt.Errorf("resolve recognition word URL host %s: no addresses", host)
}
for _, addr := range addrs {
if err := validateRecognitionWordIP(host, addr.IP); err != nil {
return nil, err
}
}
var lastErr error
for _, addr := range addrs {
conn, err := dialer.DialContext(ctx, network, net.JoinHostPort(addr.IP.String(), port))
if err == nil {
return conn, nil
}
lastErr = err
}
return nil, lastErr
}
func (s *RecognitionWordsService) fetchSharedWords(ctx context.Context, rawURL string) (string, error) {
rawURL = strings.TrimSpace(rawURL)
if err := validateRecognitionWordURL(ctx, rawURL); err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return "", err
}
resp, err := s.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("fetch %s failed: %s", rawURL, resp.Status)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return "", err
}
return string(body), nil
}
func validateRecognitionWordURL(ctx context.Context, rawURL string) error {
parsed, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil {
return fmt.Errorf("invalid recognition word URL %q: %w", rawURL, err)
}
if parsed.User != nil {
return fmt.Errorf("recognition word URL must not include userinfo: %s", rawURL)
}
switch strings.ToLower(parsed.Scheme) {
case "http", "https":
default:
return fmt.Errorf("recognition word URL must use http or https: %s", rawURL)
}
host := strings.TrimSpace(parsed.Hostname())
if host == "" {
return fmt.Errorf("recognition word URL host is required: %s", rawURL)
}
if isLocalHostname(host) {
return fmt.Errorf("recognition word URL host is not allowed: %s", host)
}
if ip := net.ParseIP(host); ip != nil {
return validateRecognitionWordIP(host, ip)
}
addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return fmt.Errorf("resolve recognition word URL host %s: %w", host, err)
}
if len(addrs) == 0 {
return fmt.Errorf("resolve recognition word URL host %s: no addresses", host)
}
for _, addr := range addrs {
if err := validateRecognitionWordIP(host, addr.IP); err != nil {
return err
}
}
return nil
}
func isLocalHostname(host string) bool {
host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")
return host == "localhost" || host == "localhost.localdomain"
}
func validateRecognitionWordIP(host string, ip net.IP) error {
if ip == nil {
return fmt.Errorf("recognition word URL host %s resolved to an invalid address", host)
}
if ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsMulticast() {
return fmt.Errorf("recognition word URL host %s resolved to a restricted address: %s", host, ip.String())
}
return nil
}
@@ -0,0 +1,60 @@
package service
import (
"strings"
"testing"
)
func TestApplyRecognitionWordRules(t *testing.T) {
rules := parseRecognitionWordRules(`
BADWORD
Wrong.Title => 正确标题
One\.Piece\.S01E(89[2-9]|9\d{2}|10\d{2})\.1999 => 海贼王.S21E\1.1999 && S21E <> \. >> EP-892
`)
got := applyRecognitionWordRules("BADWORD Wrong.Title One.Piece.S01E1076.1999.1080p", rules)
want := "正确标题 海贼王.S21E184.1999.1080p"
if got != want {
t.Fatalf("recognized = %q, want %q", got, want)
}
}
func TestCleanQueryWithRecognitionDisabledByDefaultRepoNil(t *testing.T) {
title, year := CleanQueryWithRecognition(t.Context(), nil, "Dune.2021.2160p.WEB-DL.mkv")
if title != "dune" || year != 2021 {
t.Fatalf("CleanQueryWithRecognition = %q/%d, want dune/2021", title, year)
}
}
func TestValidateRecognitionWordURLRejectsUnsafeTargets(t *testing.T) {
tests := []string{
"file:///etc/passwd",
"https://user:pass@example.com/words.txt",
"http://localhost/words.txt",
"http://127.0.0.1/words.txt",
"http://10.0.0.1/words.txt",
"http://172.16.0.1/words.txt",
"http://192.168.1.1/words.txt",
"http://[::1]/words.txt",
}
for _, rawURL := range tests {
if err := validateRecognitionWordURL(t.Context(), rawURL); err == nil {
t.Fatalf("validateRecognitionWordURL(%q) succeeded, want rejection", rawURL)
}
}
}
func TestValidateRecognitionWordURLAllowsPublicHTTPTargets(t *testing.T) {
if err := validateRecognitionWordURL(t.Context(), "https://1.1.1.1/words.txt"); err != nil {
t.Fatalf("public IP should be allowed: %v", err)
}
if err := validateRecognitionWordURL(t.Context(), "http://8.8.8.8/words.txt"); err != nil {
t.Fatalf("public HTTP IP should be allowed: %v", err)
}
}
func TestRecognitionWordDialRejectsUnsafeTargets(t *testing.T) {
_, err := dialRecognitionWordContext(t.Context(), "tcp", "127.0.0.1:80")
if err == nil || !strings.Contains(err.Error(), "restricted address") {
t.Fatalf("dialRecognitionWordContext err = %v, want restricted address", err)
}
}
@@ -120,7 +120,7 @@ func TestScanRootCloudLibraryCreatesAutoCategoryLibraries(t *testing.T) {
wantLibraries := map[string]string{ wantLibraries := map[string]string{
"cloud://openlist/电视剧/欧美剧/The Show/The.Show.S01E01.mkv": byDisplayDir["电视剧/欧美剧"].ID, "cloud://openlist/电视剧/欧美剧/The Show/The.Show.S01E01.mkv": byDisplayDir["电视剧/欧美剧"].ID,
"cloud://openlist/电影/华语电影/Movie.2024.mkv": byDisplayDir["电影/华语电影"].ID, "cloud://openlist/电影/华语电影/Movie.2024.mkv": byDisplayDir["电影/华语电影"].ID,
"cloud://openlist/国漫/剑来/剑来.S01E01.mkv": byDisplayDir["动漫/国漫"].ID, "cloud://openlist/动漫/国漫/剑来/剑来.S01E01.mkv": byDisplayDir["动漫/国漫"].ID,
} }
for _, row := range rows { for _, row := range rows {
if row.LibraryID != wantLibraries[row.Path] { if row.LibraryID != wantLibraries[row.Path] {
@@ -162,6 +162,203 @@ func TestScanRootCloudLibraryCreatesAutoCategoryLibraries(t *testing.T) {
} }
} }
func TestScanRootCloudAutoCategoryAppendsExistingLibraryRoot(t *testing.T) {
upstream := newOpenListAPIServer(t, func(path string, page, perPage int) ([]openListTestEntry, int) {
switch path {
case "/":
return []openListTestEntry{{Name: "电影", IsDir: true}}, 1
case "/电影":
return []openListTestEntry{{Name: "华语电影", IsDir: true}}, 1
case "/电影/华语电影":
return []openListTestEntry{{Name: "Movie.2024.mkv", Size: 202}}, 1
default:
t.Fatalf("unexpected openlist path %q", path)
return nil, 0
}
})
defer upstream.Close()
db := newServiceTestDB(t, &model.Library{}, &model.LibraryRoot{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{})
repos := repository.New(db)
storage := newOpenListStorageForTest(t, repos, upstream.URL)
local := model.Library{Name: "华语电影", Path: "/media/电影/华语电影", Type: "movie", Enabled: true}
if err := repos.Library.CreateWithRoots(t.Context(), &local, []model.LibraryRoot{{
Name: "华语电影",
Path: local.Path,
Enabled: true,
}}); err != nil {
t.Fatal(err)
}
root := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &root); err != nil {
t.Fatal(err)
}
scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil)
scanner.SetStorageConfig(storage)
res, err := scanner.ScanLibrary(t.Context(), root.ID)
if err != nil {
t.Fatalf("scan root cloud: %v", err)
}
if res.Added != 1 {
t.Fatalf("added = %d, want 1", res.Added)
}
libs, err := repos.Library.List(t.Context())
if err != nil {
t.Fatal(err)
}
for _, lib := range libs {
if CloudLibraryAutoCategory(lib) {
t.Fatalf("auto category should append to existing library, got extra library %#v", lib)
}
}
roots, err := repos.Library.ListRoots(t.Context(), local.ID)
if err != nil {
t.Fatal(err)
}
if len(roots) != 2 {
t.Fatalf("roots = %#v, want local root plus cloud root", roots)
}
cloudRoot := roots[1]
if cloudRoot.Name != "华语电影" || !CloudLibraryAutoCategory(model.Library{Path: cloudRoot.Path}) {
t.Fatalf("cloud root = %#v, want auto-category 华语电影 root", cloudRoot)
}
info, ok := ParseCloudLibraryMount(cloudRoot.Path)
if !ok || info.DisplayDir != "电影/华语电影" || info.ScanDir != "电影/华语电影" {
t.Fatalf("cloud root mount = %#v, want display/scan 电影/华语电影", info)
}
var media model.Media
if err := repos.DB.First(&media, "path = ?", "cloud://openlist/电影/华语电影/Movie.2024.mkv").Error; err != nil {
t.Fatal(err)
}
if media.LibraryID != local.ID || media.LibraryRootID != cloudRoot.ID {
t.Fatalf("media placement = library %s root %s, want %s/%s", media.LibraryID, media.LibraryRootID, local.ID, cloudRoot.ID)
}
}
func TestScanRootCloudAutoCategoryPreservesFlatScanDir(t *testing.T) {
upstream := newOpenListAPIServer(t, func(path string, page, perPage int) ([]openListTestEntry, int) {
switch path {
case "/":
return []openListTestEntry{{Name: "国漫", IsDir: true}}, 1
case "/国漫":
return []openListTestEntry{{Name: "剑来", IsDir: true}}, 1
case "/国漫/剑来":
return []openListTestEntry{{Name: "剑来.S01E01.mkv", Size: 303}}, 1
default:
t.Fatalf("unexpected openlist path %q", path)
return nil, 0
}
})
defer upstream.Close()
db := newServiceTestDB(t, &model.Library{}, &model.LibraryRoot{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{})
repos := repository.New(db)
storage := newOpenListStorageForTest(t, repos, upstream.URL)
local := model.Library{Name: "国漫", Path: "/media/动漫/国漫", Type: "anime", Enabled: true}
if err := repos.Library.CreateWithRoots(t.Context(), &local, []model.LibraryRoot{{
Name: "国漫",
Path: local.Path,
Enabled: true,
}}); err != nil {
t.Fatal(err)
}
root := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &root); err != nil {
t.Fatal(err)
}
scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil)
scanner.SetStorageConfig(storage)
if _, err := scanner.ScanLibrary(t.Context(), root.ID); err != nil {
t.Fatalf("scan root cloud: %v", err)
}
roots, err := repos.Library.ListRoots(t.Context(), local.ID)
if err != nil {
t.Fatal(err)
}
if len(roots) != 2 {
t.Fatalf("roots = %#v, want local root plus flat cloud root", roots)
}
cloudRoot := roots[1]
info, ok := ParseCloudLibraryMount(cloudRoot.Path)
if !ok || info.DisplayDir != "动漫/国漫" || info.ScanDir != "国漫" {
t.Fatalf("flat cloud root mount = %#v, want display 动漫/国漫 and scan 国漫", info)
}
res, err := scanner.ScanLibraryRoot(t.Context(), local.ID, cloudRoot.ID)
if err != nil {
t.Fatalf("scan flat cloud root: %v", err)
}
if res.Skipped != 1 && res.Updated != 1 {
t.Fatalf("flat cloud root rescan = %#v, want existing media refreshed/skipped", res)
}
}
func TestScanRootCloudAutoCategoryMigratesExistingAutoLibrary(t *testing.T) {
upstream := newOpenListAPIServer(t, func(path string, page, perPage int) ([]openListTestEntry, int) {
switch path {
case "/":
return []openListTestEntry{{Name: "电视剧", IsDir: true}}, 1
case "/电视剧":
return []openListTestEntry{{Name: "欧美剧", IsDir: true}}, 1
case "/电视剧/欧美剧":
return []openListTestEntry{{Name: "The Show", IsDir: true}}, 1
case "/电视剧/欧美剧/The Show":
return []openListTestEntry{{Name: "The.Show.S01E01.mkv", Size: 101}}, 1
default:
t.Fatalf("unexpected openlist path %q", path)
return nil, 0
}
})
defer upstream.Close()
db := newServiceTestDB(t, &model.Library{}, &model.LibraryRoot{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{})
repos := repository.New(db)
storage := newOpenListStorageForTest(t, repos, upstream.URL)
local := model.Library{Name: "欧美剧", Path: "/media/电视剧/欧美剧", Type: "tv", Enabled: true}
if err := repos.Library.CreateWithRoots(t.Context(), &local, []model.LibraryRoot{{
Name: "欧美剧",
Path: local.Path,
Enabled: true,
}}); err != nil {
t.Fatal(err)
}
root := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "movie", Enabled: true}
oldAuto := model.Library{Name: "欧美剧", Path: BuildCloudAutoCategoryLibraryPath("openlist", "电视剧/欧美剧"), Type: "tv", Enabled: true}
for _, lib := range []*model.Library{&root, &oldAuto} {
if err := repos.Library.Create(t.Context(), lib); err != nil {
t.Fatal(err)
}
}
mediaPath := "cloud://openlist/电视剧/欧美剧/The Show/The.Show.S01E01.mkv"
if err := repos.DB.Create(&model.Media{LibraryID: oldAuto.ID, Title: "The Show", Path: mediaPath}).Error; err != nil {
t.Fatal(err)
}
scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil)
scanner.SetStorageConfig(storage)
if _, err := scanner.ScanLibrary(t.Context(), root.ID); err != nil {
t.Fatalf("scan root cloud: %v", err)
}
if old, err := repos.Library.FindByID(t.Context(), oldAuto.ID); err != nil || old != nil {
t.Fatalf("old auto library = %#v, err=%v; want removed", old, err)
}
roots, err := repos.Library.ListRoots(t.Context(), local.ID)
if err != nil {
t.Fatal(err)
}
if len(roots) != 2 {
t.Fatalf("roots = %#v, want local root plus migrated cloud root", roots)
}
var media model.Media
if err := repos.DB.First(&media, "path = ?", mediaPath).Error; err != nil {
t.Fatal(err)
}
if media.LibraryID != local.ID || media.LibraryRootID != roots[1].ID {
t.Fatalf("migrated media placement = %s/%s, want %s/%s", media.LibraryID, media.LibraryRootID, local.ID, roots[1].ID)
}
}
func TestScanCloudLibraryListsChildDirectoriesConcurrently(t *testing.T) { func TestScanCloudLibraryListsChildDirectoriesConcurrently(t *testing.T) {
var active int32 var active int32
var maxActive int32 var maxActive int32
@@ -242,3 +439,19 @@ func TestScanCloudLibraryListsChildDirectoriesConcurrently(t *testing.T) {
t.Fatalf("scan result = %#v, want visited=2 added=2", res) t.Fatalf("scan result = %#v, want visited=2 added=2", res)
} }
} }
func newOpenListStorageForTest(t *testing.T, repos *repository.Container, serverURL string) *StorageConfigService {
t.Helper()
log := zap.NewNop()
storage := NewStorageConfigService(log, repos, NewCryptoService("", log))
if _, err := storage.Save(t.Context(), StorageInput{
Type: "openlist",
Config: map[string]any{
"server": serverURL,
"token": "openlist-token",
},
}); err != nil {
t.Fatal(err)
}
return storage
}
+28 -7
View File
@@ -171,24 +171,45 @@ func (c *cloudScanCandidateCollector) addFileCandidate(displayDir string, entry
c.req.progress.publish(c.scanner, c.lib.ID, c.req.result, "listing", c.req.progress.markFileDiscovered()) c.req.progress.publish(c.scanner, c.lib.ID, c.req.result, "listing", c.req.progress.markFileDiscovered())
displayPath := joinCloudDisplayPath(displayDir, entry.Name) displayPath := joinCloudDisplayPath(displayDir, entry.Name)
path := cloudMediaPath(c.req.provider, displayPath) path := cloudMediaPath(c.req.provider, displayPath)
localMeta := c.scanner.cloudFileMetadata(c.ctx, c.req.provider, displayPath, entry.Name, sidecars, dirMeta, librarySupportsSeasons(c.lib))
localMeta = c.scanner.enrichCloudMetadataFromExternalIDs(c.ctx, c.lib, path, localMeta)
if localMeta != nil {
c.scanner.cacheCloudMetadataArtworkNow(c.ctx, localMeta)
}
candidate := cloudCandidate{ candidate := cloudCandidate{
ref: ref, ref: ref,
name: entry.Name, name: entry.Name,
size: entry.Size, size: entry.Size,
path: path, path: path,
localMeta: localMeta,
} }
if c.req.autoCategoryRoot { if c.req.autoCategoryRoot {
candidate.categoryDisplayDir = cloudAutoCategoryDisplayDirForMediaPath(path) candidate.categoryDisplayDir, candidate.categoryScanDir = cloudAutoCategoryDirsForMediaPath(path)
if candidate.categoryDisplayDir != "" {
displayPath = canonicalCloudAutoCategoryMediaDisplayPath(displayPath, candidate.categoryDisplayDir, candidate.categoryScanDir)
candidate.path = cloudMediaPath(c.req.provider, displayPath)
}
} }
localMeta := c.scanner.cloudFileMetadata(c.ctx, c.req.provider, displayPath, entry.Name, sidecars, dirMeta, librarySupportsSeasons(c.lib))
localMeta = c.scanner.enrichCloudMetadataFromExternalIDs(c.ctx, c.lib, candidate.path, localMeta)
if localMeta != nil {
c.scanner.cacheCloudMetadataArtworkNow(c.ctx, localMeta)
}
candidate.localMeta = localMeta
c.addCandidate(displayDir, entry, candidate) c.addCandidate(displayDir, entry, candidate)
} }
func canonicalCloudAutoCategoryMediaDisplayPath(displayPath, categoryDisplayDir, categoryScanDir string) string {
displayPath = strings.Trim(strings.TrimSpace(strings.ReplaceAll(displayPath, "\\", "/")), "/")
categoryDisplayDir = strings.Trim(strings.TrimSpace(strings.ReplaceAll(categoryDisplayDir, "\\", "/")), "/")
categoryScanDir = strings.Trim(strings.TrimSpace(strings.ReplaceAll(categoryScanDir, "\\", "/")), "/")
if displayPath == "" || categoryDisplayDir == "" || categoryScanDir == "" || displayPath == categoryDisplayDir || categoryDisplayDir == categoryScanDir {
return displayPath
}
if displayPath == categoryScanDir {
return categoryDisplayDir
}
prefix := strings.TrimRight(categoryScanDir, "/") + "/"
if strings.HasPrefix(displayPath, prefix) {
return strings.TrimRight(categoryDisplayDir, "/") + "/" + strings.TrimPrefix(displayPath, prefix)
}
return displayPath
}
func (c *cloudScanCandidateCollector) markRefSeen(ref string) bool { func (c *cloudScanCandidateCollector) markRefSeen(ref string) bool {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
+13 -12
View File
@@ -10,10 +10,10 @@ import (
"github.com/ShukeBta/MediaStationGo/internal/model" "github.com/ShukeBta/MediaStationGo/internal/model"
) )
func (s *ScannerService) ingestCloudFile(ctx context.Context, lib *model.Library, typ, ref, path, name string, size int64, localMeta *LocalMetadata, existingMedia map[string]existingCloudMedia, writeBatch *localMediaWriteBatch, probeBudget *int, res *ScanResult) { func (s *ScannerService) ingestCloudFile(ctx context.Context, lib *model.Library, rootID, typ, ref, path, name string, size int64, localMeta *LocalMetadata, existingMedia map[string]existingCloudMedia, writeBatch *localMediaWriteBatch, probeBudget *int, res *ScanResult) {
res.Visited++ res.Visited++
ext := strings.ToLower(filepath.Ext(name)) ext := strings.ToLower(filepath.Ext(name))
title, year := CleanQuery(name) title, year := CleanQueryWithRecognition(ctx, s.repo, name)
if title == "" { if title == "" {
title = strings.TrimSuffix(filepath.Base(name), ext) title = strings.TrimSuffix(filepath.Base(name), ext)
} }
@@ -31,16 +31,17 @@ func (s *ScannerService) ingestCloudFile(ctx context.Context, lib *model.Library
} }
expectedSTRMURL := BuildRelativeCloudPlayURL(typ, ref) expectedSTRMURL := BuildRelativeCloudPlayURL(typ, ref)
m := &model.Media{ m := &model.Media{
LibraryID: lib.ID, LibraryID: lib.ID,
Title: title, LibraryRootID: strings.TrimSpace(rootID),
Year: year, Title: title,
Path: path, Year: year,
SizeBytes: size, Path: path,
Container: strings.TrimPrefix(ext, "."), SizeBytes: size,
STRMURL: expectedSTRMURL, Container: strings.TrimPrefix(ext, "."),
ScrapeStatus: "pending", STRMURL: expectedSTRMURL,
SeasonNum: parsedSeason, ScrapeStatus: "pending",
EpisodeNum: parsedEpisode, SeasonNum: parsedSeason,
EpisodeNum: parsedEpisode,
} }
if ext == ".strm" { if ext == ".strm" {
if targetURL, err := s.resolveCloudSTRMTarget(ctx, typ, ref); err == nil && targetURL != "" { if targetURL, err := s.resolveCloudSTRMTarget(ctx, typ, ref); err == nil && targetURL != "" {
+36 -10
View File
@@ -15,6 +15,7 @@ type cloudScanImportRequest struct {
existingMedia map[string]existingCloudMedia existingMedia map[string]existingCloudMedia
writeBatch *localMediaWriteBatch writeBatch *localMediaWriteBatch
probeBudget *int probeBudget *int
defaultRootID string
progress *cloudScanProgressState progress *cloudScanProgressState
result *ScanResult result *ScanResult
} }
@@ -34,6 +35,14 @@ type cloudLibraryScanCompletion struct {
} }
func (s *ScannerService) scanCloudLibrary(ctx context.Context, lib *model.Library, mount CloudMountInfo, autoScrape bool) (*ScanResult, error) { func (s *ScannerService) scanCloudLibrary(ctx context.Context, lib *model.Library, mount CloudMountInfo, autoScrape bool) (*ScanResult, error) {
return s.scanCloudLibraryWithRoot(ctx, lib, mount, "", autoScrape)
}
func (s *ScannerService) scanCloudLibraryRoot(ctx context.Context, lib *model.Library, root *model.LibraryRoot, mount CloudMountInfo, autoScrape bool) (*ScanResult, error) {
return s.scanCloudLibraryWithRoot(ctx, lib, mount, libraryRootID(root), autoScrape)
}
func (s *ScannerService) scanCloudLibraryWithRoot(ctx context.Context, lib *model.Library, mount CloudMountInfo, defaultRootID string, autoScrape bool) (*ScanResult, error) {
res := &ScanResult{LibraryID: lib.ID} res := &ScanResult{LibraryID: lib.ID}
if s.storage == nil { if s.storage == nil {
return res, fmt.Errorf("cloud storage service unavailable") return res, fmt.Errorf("cloud storage service unavailable")
@@ -78,6 +87,7 @@ func (s *ScannerService) scanCloudLibrary(ctx context.Context, lib *model.Librar
existingMedia: existingMedia, existingMedia: existingMedia,
writeBatch: writeBatch, writeBatch: writeBatch,
probeBudget: &probeBudget, probeBudget: &probeBudget,
defaultRootID: defaultRootID,
progress: progress, progress: progress,
result: res, result: res,
}) })
@@ -86,7 +96,12 @@ func (s *ScannerService) scanCloudLibrary(ctx context.Context, lib *model.Librar
} }
scopeIDs = appendUniqueLibraryIDs(scopeIDs, imported.scopeLibraryIDs...) scopeIDs = appendUniqueLibraryIDs(scopeIDs, imported.scopeLibraryIDs...)
writeBatch.Flush() writeBatch.Flush()
removed, err := s.pruneMissingCloudMediaForLibraries(ctx, scopeIDs, imported.seen) var removed int64
if defaultRootID != "" {
removed, err = s.pruneMissingCloudMediaForRoot(ctx, lib.ID, defaultRootID, imported.seen)
} else {
removed, err = s.pruneMissingCloudMediaForLibraries(ctx, scopeIDs, imported.seen)
}
if err != nil { if err != nil {
s.log.Warn("prune missing cloud media failed", zap.String("library_id", lib.ID), zap.Error(err)) s.log.Warn("prune missing cloud media failed", zap.String("library_id", lib.ID), zap.Error(err))
} else { } else {
@@ -102,38 +117,49 @@ func (s *ScannerService) scanCloudLibrary(ctx context.Context, lib *model.Librar
return res, nil return res, nil
} }
type cloudScanTarget struct {
lib *model.Library
rootID string
}
func (s *ScannerService) importCloudScanCandidates(ctx context.Context, rootLib *model.Library, req cloudScanImportRequest) (cloudScanImportResult, error) { func (s *ScannerService) importCloudScanCandidates(ctx context.Context, rootLib *model.Library, req cloudScanImportRequest) (cloudScanImportResult, error) {
imported := cloudScanImportResult{ imported := cloudScanImportResult{
seen: make(map[string]struct{}), seen: make(map[string]struct{}),
touchedLibraryIDs: []string{}, touchedLibraryIDs: []string{},
scopeLibraryIDs: []string{}, scopeLibraryIDs: []string{},
} }
targetLibs := map[string]*model.Library{"": rootLib} targetLibs := map[string]cloudScanTarget{"": {lib: rootLib, rootID: req.defaultRootID}}
for _, candidate := range req.candidates { for _, candidate := range req.candidates {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return imported, ctx.Err() return imported, ctx.Err()
default: default:
} }
targetLib := rootLib target := targetLibs[""]
if candidate.categoryDisplayDir != "" { if candidate.categoryDisplayDir != "" {
if cached, ok := targetLibs[candidate.categoryDisplayDir]; ok { categoryKey := candidate.categoryDisplayDir + "\x00" + candidate.categoryScanDir
targetLib = cached if cached, ok := targetLibs[categoryKey]; ok {
} else if categoryLib, err := s.ensureCloudAutoCategoryLibrary(ctx, rootLib, req.provider, candidate.categoryDisplayDir); err == nil && categoryLib != nil { target = cached
targetLib = categoryLib } else if categoryTarget, err := s.ensureCloudAutoCategoryTarget(ctx, rootLib, req.provider, candidate.categoryDisplayDir, candidate.categoryScanDir); err == nil && categoryTarget.Library != nil {
targetLibs[candidate.categoryDisplayDir] = categoryLib target = cloudScanTarget{lib: categoryTarget.Library, rootID: categoryTarget.RootID}
imported.scopeLibraryIDs = appendUniqueLibraryIDs(imported.scopeLibraryIDs, categoryLib.ID) targetLibs[categoryKey] = target
imported.scopeLibraryIDs = appendUniqueLibraryIDs(imported.scopeLibraryIDs, categoryTarget.Library.ID)
} else if err != nil { } else if err != nil {
s.log.Warn("ensure cloud auto category library failed", s.log.Warn("ensure cloud auto category library failed",
zap.String("library_id", rootLib.ID), zap.String("library_id", rootLib.ID),
zap.String("provider", req.provider), zap.String("provider", req.provider),
zap.String("category", candidate.categoryDisplayDir), zap.String("category", candidate.categoryDisplayDir),
zap.String("scan_dir", candidate.categoryScanDir),
zap.Error(err)) zap.Error(err))
} }
} }
targetLib := target.lib
if targetLib == nil {
targetLib = rootLib
}
imported.touchedLibraryIDs = appendUniqueLibraryIDs(imported.touchedLibraryIDs, targetLib.ID) imported.touchedLibraryIDs = appendUniqueLibraryIDs(imported.touchedLibraryIDs, targetLib.ID)
imported.seen[candidate.path] = struct{}{} imported.seen[candidate.path] = struct{}{}
s.ingestCloudFile(ctx, targetLib, req.provider, candidate.ref, candidate.path, candidate.name, candidate.size, candidate.localMeta, req.existingMedia, req.writeBatch, req.probeBudget, req.result) s.ingestCloudFile(ctx, targetLib, target.rootID, req.provider, candidate.ref, candidate.path, candidate.name, candidate.size, candidate.localMeta, req.existingMedia, req.writeBatch, req.probeBudget, req.result)
req.progress.publish(s, rootLib.ID, req.result, "importing", req.result.Visited == 1 || req.result.Visited%100 == 0) req.progress.publish(s, rootLib.ID, req.result, "importing", req.result.Visited == 1 || req.result.Visited%100 == 0)
} }
return imported, nil return imported, nil
@@ -12,6 +12,7 @@ type cloudCandidate struct {
size int64 size int64
path string path string
categoryDisplayDir string categoryDisplayDir string
categoryScanDir string
localMeta *LocalMetadata localMeta *LocalMetadata
} }
+2 -2
View File
@@ -88,13 +88,13 @@ func TestInferCloudMountMediaType(t *testing.T) {
cases := map[string]string{ cases := map[string]string{
"/日漫": "anime", "/日漫": "anime",
"/国漫": "anime", "/国漫": "anime",
"/欧美动漫": "anime", "/美漫": "anime",
"/电视剧/国产剧": "tv", "/电视剧/国产剧": "tv",
"/电视剧/欧美剧": "tv", "/电视剧/欧美剧": "tv",
"/电视剧/日韩剧": "tv", "/电视剧/日韩剧": "tv",
"/电影/动画电影": "movie", "/电影/动画电影": "movie",
"/电影/华语电影": "movie", "/电影/华语电影": "movie",
"/电影/外语电影": "movie", "/电影/欧美电影": "movie",
"/综艺": "variety", "/综艺": "variety",
} }
for dir, want := range cases { for dir, want := range cases {
+1 -1
View File
@@ -136,7 +136,7 @@ type localScanMediaInput struct {
} }
func (s *ScannerService) buildLocalScanMedia(in localScanMediaInput) *model.Media { func (s *ScannerService) buildLocalScanMedia(in localScanMediaInput) *model.Media {
title, year := CleanQuery(in.path) title, year := CleanQueryWithRecognition(context.Background(), s.repo, in.path)
if title == "" { if title == "" {
title = strings.TrimSuffix(filepath.Base(in.path), in.ext) title = strings.TrimSuffix(filepath.Base(in.path), in.ext)
} }
+25
View File
@@ -131,6 +131,31 @@ func (s *ScannerService) pruneMissingCloudMedia(ctx context.Context, libraryID s
return s.pruneMissingCloudMediaForLibraries(ctx, []string{libraryID}, seen) return s.pruneMissingCloudMediaForLibraries(ctx, []string{libraryID}, seen)
} }
func (s *ScannerService) pruneMissingCloudMediaForRoot(ctx context.Context, libraryID, rootID string, seen map[string]struct{}) (int64, error) {
if strings.TrimSpace(libraryID) == "" || strings.TrimSpace(rootID) == "" {
return 0, nil
}
var rows []struct {
ID string
Path string
}
if err := s.repo.DB.WithContext(ctx).
Model(&model.Media{}).
Select("id, path").
Where("library_id = ? AND library_root_id = ? AND path LIKE ?", libraryID, rootID, "cloud://%").
Find(&rows).Error; err != nil {
return 0, err
}
stale := make([]string, 0)
for _, row := range rows {
if _, ok := seen[row.Path]; ok {
continue
}
stale = append(stale, row.ID)
}
return s.deleteMediaByIDs(ctx, stale, true)
}
func (s *ScannerService) pruneMissingCloudMediaForLibraries(ctx context.Context, libraryIDs []string, seen map[string]struct{}) (int64, error) { func (s *ScannerService) pruneMissingCloudMediaForLibraries(ctx context.Context, libraryIDs []string, seen map[string]struct{}) (int64, error) {
if len(libraryIDs) == 0 { if len(libraryIDs) == 0 {
return 0, nil return 0, nil
+3
View File
@@ -32,6 +32,9 @@ func (s *ScannerService) ScanLibraryRoot(ctx context.Context, libraryID, rootID
if root == nil { if root == nil {
return nil, errors.New("library root not found") return nil, errors.New("library root not found")
} }
if mount, ok := ParseCloudLibraryMount(root.Path); ok {
return s.scanCloudLibraryRoot(ctx, lib, root, mount, true)
}
return s.scanLocalLibraryRoot(ctx, lib, root, true) return s.scanLocalLibraryRoot(ctx, lib, root, true)
} }
+1 -1
View File
@@ -61,7 +61,7 @@ func (s *ScraperService) EnrichOneWithOptions(ctx context.Context, m *model.Medi
return s.applyProviderMatchWithOptions(ctx, m, lib, match, options) return s.applyProviderMatchWithOptions(ctx, m, lib, match, options)
} }
candidates := scrapeQueryCandidates(m, lib) candidates := scrapeQueryCandidatesWithRecognition(ctx, s.repo, m, lib)
var query string var query string
match := (*Match)(nil) match := (*Match)(nil)
for _, candidate := range candidates { for _, candidate := range candidates {
+15 -1
View File
@@ -1,11 +1,13 @@
package service package service
import ( import (
"context"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strings" "strings"
"github.com/ShukeBta/MediaStationGo/internal/model" "github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
) )
var ( var (
@@ -16,10 +18,22 @@ var (
) )
func scrapeQueryCandidates(m *model.Media, lib *model.Library) []string { func scrapeQueryCandidates(m *model.Media, lib *model.Library) []string {
return scrapeQueryCandidatesWithNormalizer(m, lib, func(raw string) (string, int) {
return CleanQuery(raw)
})
}
func scrapeQueryCandidatesWithRecognition(ctx context.Context, repo *repository.Container, m *model.Media, lib *model.Library) []string {
return scrapeQueryCandidatesWithNormalizer(m, lib, func(raw string) (string, int) {
return CleanQueryWithRecognition(ctx, repo, raw)
})
}
func scrapeQueryCandidatesWithNormalizer(m *model.Media, lib *model.Library, clean func(string) (string, int)) []string {
seen := map[string]struct{}{} seen := map[string]struct{}{}
var out []string var out []string
add := func(raw string) { add := func(raw string) {
cleaned, _ := CleanQuery(raw) cleaned, _ := clean(raw)
if cleaned == "" { if cleaned == "" {
cleaned = strings.TrimSpace(raw) cleaned = strings.TrimSpace(raw)
} }
+2 -2
View File
@@ -132,9 +132,9 @@ func isGenericMediaCategoryFolder(name string) bool {
"欧美剧", "欧美电视剧", "欧美剧", "欧美电视剧",
"日韩剧", "日剧", "韩剧", "日韩剧", "日剧", "韩剧",
"华语电影", "国产电影", "大陆电影", "华语电影", "国产电影", "大陆电影",
"外语电影", "欧美电影", "日韩电影", "外语电影", "外国电影", "欧美电影", "日韩电影", "演唱会", "音乐会",
"动画电影", "动漫电影", "动画电影", "动漫电影",
"国漫", "国产动漫", "日番", "日漫", "日本动漫", "日本动画", "欧美动漫", "欧美动画", "西方动画", "国漫", "国产动漫", "日番", "日漫", "日本动漫", "日本动画", "韩漫", "韩国动漫", "韩国动画", "美漫", "欧美动漫", "欧美动画", "西方动画", "其他动漫", "其它动漫",
"综艺", "真人秀", "综艺", "真人秀",
"纪录片", "纪录", "纪录片", "纪录",
"儿童", "少儿", "儿童", "少儿",
+1
View File
@@ -74,6 +74,7 @@ type Container struct {
Device *DeviceService Device *DeviceService
Cache *RuntimeCacheService Cache *RuntimeCacheService
Sessions *SessionTrackerService Sessions *SessionTrackerService
RecognitionWords *RecognitionWordsService
stopCtx context.Context stopCtx context.Context
stopCancel context.CancelFunc stopCancel context.CancelFunc
+1
View File
@@ -63,6 +63,7 @@ func (b *serviceContainerBuilder) initProviderServices() {
b.c.TheTVDB = NewTheTVDBProvider(b.cfg, b.log) b.c.TheTVDB = NewTheTVDBProvider(b.cfg, b.log)
b.c.Douban = NewDoubanProvider(b.cfg, b.log) b.c.Douban = NewDoubanProvider(b.cfg, b.log)
b.c.Fanart = NewFanartProvider(b.cfg, b.log) b.c.Fanart = NewFanartProvider(b.cfg, b.log)
b.c.RecognitionWords = NewRecognitionWordsService(b.log, b.repos)
adult := NewAdultProvider(b.log, b.c.APIConfig) adult := NewAdultProvider(b.log, b.c.APIConfig)
b.c.Scraper = NewScraperService( b.c.Scraper = NewScraperService(
+10 -260
View File
@@ -4,10 +4,8 @@ package service
import ( import (
"context" "context"
"fmt" "fmt"
"html"
"net/http" "net/http"
"net/url" "net/url"
"regexp"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -76,7 +74,11 @@ func (a *NexusPHPAdapter) Search(ctx context.Context, cfg SiteConfig, keyword st
return nil, fmt.Errorf("search failed: status %d", status) return nil, fmt.Errorf("search failed: status %d", status)
} }
return parseNexusPHPHTML(string(data), cfg.Name, cfg.URL) body := string(data)
if nexusPHPPageLooksLogin(body) {
return nil, fmt.Errorf("search failed: not logged in or cookie expired")
}
return parseNexusPHPHTML(body, cfg.Name, cfg.URL)
} }
func (a *NexusPHPAdapter) Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SiteSearchResult, error) { func (a *NexusPHPAdapter) Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SiteSearchResult, error) {
@@ -95,7 +97,11 @@ func (a *NexusPHPAdapter) Browse(ctx context.Context, cfg SiteConfig, category s
return nil, fmt.Errorf("browse failed: status %d", status) return nil, fmt.Errorf("browse failed: status %d", status)
} }
return parseNexusPHPHTML(string(data), cfg.Name, cfg.URL) body := string(data)
if nexusPHPPageLooksLogin(body) {
return nil, fmt.Errorf("browse failed: not logged in or cookie expired")
}
return parseNexusPHPHTML(body, cfg.Name, cfg.URL)
} }
func (a *NexusPHPAdapter) GetDetail(ctx context.Context, cfg SiteConfig, id string) (*TorrentDetail, error) { func (a *NexusPHPAdapter) GetDetail(ctx context.Context, cfg SiteConfig, id string) (*TorrentDetail, error) {
@@ -114,259 +120,3 @@ func (a *NexusPHPAdapter) GetDetail(ctx context.Context, cfg SiteConfig, id stri
func (a *NexusPHPAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id string) (string, error) { func (a *NexusPHPAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id string) (string, error) {
return cfg.URL + "/download.php?id=" + id, nil return cfg.URL + "/download.php?id=" + id, nil
} }
// parseNexusPHPHTML 解析 NexusPHP 种子列表 HTML。
func parseNexusPHPHTML(html, siteName, baseURL string) (*SiteSearchResult, error) {
result := &SiteSearchResult{
SiteName: siteName,
Items: []TorrentItem{},
Page: 1,
}
for _, row := range nexusPHPTorrentRows(html) {
item := parseNexusPHPRow(row, baseURL)
if item.ID != "" {
result.Items = append(result.Items, item)
}
}
result.Total = len(result.Items)
return result, nil
}
// parseNexusPHPRow 解析单行种子条目。
func parseNexusPHPRow(row, baseURL string) TorrentItem {
item := TorrentItem{}
// Extract torrent ID and title
if link := firstNexusPHPLink(row, "details.php"); link != nil {
item.ID = link.query.Get("id")
item.Title = nexusPHPTitleFromLink(*link)
item.Subtitle = nexusPHPSubtitle(row)
item.DetailURL = resolveSiteURL(baseURL, link.href)
}
// Extract download link
if link := firstNexusPHPLink(row, "download.php"); link != nil {
item.DownloadURL = resolveSiteURL(baseURL, link.href)
}
// Extract size
sizeRegex := regexp.MustCompile(`(?i)(\d+\.?\d*)\s*(GiB|MiB|TiB|KiB|GB|MB|TB|KB)`)
if sizeMatches := sizeRegex.FindStringSubmatch(row); len(sizeMatches) >= 3 {
item.Size = parseSizeString(sizeMatches[1], sizeMatches[2])
}
// Extract seeders and leechers
if value, ok := nexusPHPIntByClass(row, "seeders"); ok {
item.Seeders = value
}
if value, ok := nexusPHPIntByClass(row, "leechers"); ok {
item.Leechers = value
}
if value, ok := nexusPHPIntByClass(row, "snatched"); ok {
item.Snatched = value
}
// Extract snatched
snatchedRegex := regexp.MustCompile(`snatched[^"]*"[^>]*>(\d+)`)
if item.Snatched == 0 {
if m := snatchedRegex.FindStringSubmatch(row); len(m) >= 2 {
item.Snatched, _ = strconv.Atoi(m[1])
}
}
// Check for free flag
freeRegex := regexp.MustCompile(`(?i)(class="free|free2|twoupfree|free_download|促销|免费)`)
item.Free = freeRegex.MatchString(row)
// Extract upload time
timeRegex := regexp.MustCompile(`(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2})`)
if m := timeRegex.FindStringSubmatch(row); len(m) >= 2 {
if t, err := time.Parse("2006-01-02 15:04", m[1]); err == nil {
item.UploadTime = t
}
}
// Extract category
catRegex := regexp.MustCompile(`cat=(\d+)[^"]*"[^>]*title="([^"]+)"`)
if m := catRegex.FindStringSubmatch(row); len(m) >= 3 {
item.Category = strings.TrimSpace(m[2])
}
return item
}
type nexusPHPLink struct {
href string
attrs string
text string
query url.Values
}
func nexusPHPTorrentRows(pageHTML string) []string {
rowRegex := regexp.MustCompile(`(?is)<tr\b[^>]*>.*?</tr>`)
rows := rowRegex.FindAllString(pageHTML, -1)
out := make([]string, 0, len(rows))
for _, row := range rows {
if strings.Contains(strings.ToLower(row), "details.php") {
out = append(out, row)
}
}
return out
}
func firstNexusPHPLink(row, path string) *nexusPHPLink {
pattern := regexp.MustCompile(`(?is)<a\b([^>]*href\s*=\s*["']([^"']*)["'][^>]*)>(.*?)</a>`)
for _, match := range pattern.FindAllStringSubmatch(row, -1) {
if len(match) < 4 {
continue
}
href := html.UnescapeString(strings.TrimSpace(match[2]))
parsed, err := url.Parse(href)
if err != nil || !nexusPHPLinkPathMatches(parsed, path) {
continue
}
return &nexusPHPLink{
href: href,
attrs: match[1],
text: cleanNexusPHPText(match[3]),
query: parsed.Query(),
}
}
return nil
}
func nexusPHPLinkPathMatches(parsed *url.URL, want string) bool {
if parsed == nil {
return false
}
path := strings.TrimSpace(parsed.Path)
if path == "" {
path = strings.TrimSpace(parsed.Opaque)
}
path = strings.Trim(strings.ToLower(path), "/")
want = strings.Trim(strings.ToLower(strings.TrimSpace(want)), "/")
if path == "" || want == "" {
return false
}
return path == want || strings.HasSuffix(path, "/"+want)
}
func nexusPHPTitleFromLink(link nexusPHPLink) string {
for _, attr := range []string{"title", "data-title"} {
if value := htmlAttr(link.attrs, attr); value != "" {
return value
}
}
return link.text
}
func nexusPHPSubtitle(row string) string {
for _, pattern := range []*regexp.Regexp{
regexp.MustCompile(`(?is)<span\b[^>]*(?:class|id)\s*=\s*["'][^"']*(?:subtitle|small_descr|descr|sub)[^"']*["'][^>]*>(.*?)</span>`),
regexp.MustCompile(`(?is)<font\b[^>]*(?:class|id)\s*=\s*["'][^"']*(?:subtitle|small_descr|descr|sub)[^"']*["'][^>]*>(.*?)</font>`),
} {
if match := pattern.FindStringSubmatch(row); len(match) >= 2 {
return cleanNexusPHPText(match[1])
}
}
return ""
}
func nexusPHPIntByClass(row, className string) (int, bool) {
pattern := regexp.MustCompile(`(?is)<td\b[^>]*(?:class|id)\s*=\s*["'][^"']*` + regexp.QuoteMeta(className) + `[^"']*["'][^>]*>(.*?)</td>`)
if match := pattern.FindStringSubmatch(row); len(match) >= 2 {
text := cleanNexusPHPText(match[1])
valueMatch := regexp.MustCompile(`\d+`).FindString(text)
if valueMatch != "" {
value, _ := strconv.Atoi(valueMatch)
return value, true
}
}
return 0, false
}
func htmlAttr(attrs, name string) string {
pattern := regexp.MustCompile(`(?is)\b` + regexp.QuoteMeta(name) + `\s*=\s*["']([^"']*)["']`)
if match := pattern.FindStringSubmatch(attrs); len(match) >= 2 {
return cleanNexusPHPText(match[1])
}
return ""
}
func cleanNexusPHPText(value string) string {
return strings.Join(strings.Fields(html.UnescapeString(stripHTML(value))), " ")
}
func resolveSiteURL(baseURL, href string) string {
base, err := url.Parse(strings.TrimRight(baseURL, "/") + "/")
if err != nil {
return strings.TrimSpace(href)
}
ref, err := url.Parse(strings.TrimSpace(href))
if err != nil {
return strings.TrimSpace(href)
}
return base.ResolveReference(ref).String()
}
// parseNexusPHPDetailHTML 解析种子详情页。
func parseNexusPHPDetailHTML(html, id, baseURL string) (*TorrentDetail, error) {
detail := &TorrentDetail{
ID: id,
DetailURL: baseURL + "/details.php?id=" + id,
}
// Title
titleRegex := regexp.MustCompile(`<h1[^>]*>([^<]+)</h1>`)
if m := titleRegex.FindStringSubmatch(html); len(m) >= 2 {
detail.Title = strings.TrimSpace(m[1])
}
// Subtitle
subRegex := regexp.MustCompile(`<span[^>]*class="[^"]*sub[^"]*"[^>]*>([^<]+)</span>`)
if m := subRegex.FindStringSubmatch(html); len(m) >= 2 {
detail.Subtitle = strings.TrimSpace(m[1])
}
// Info hash
hashRegex := regexp.MustCompile(`(?i)info_hash[^<]*</td>\s*<td[^>]*>([^<]+)</td>`)
if m := hashRegex.FindStringSubmatch(html); len(m) >= 2 {
detail.InfoHash = strings.TrimSpace(m[1])
}
// IMDB ID
imdbRegex := regexp.MustCompile(`(?i)imdb[^<]*</td>\s*<td[^>]*>[^<]*(tt\d+)`)
if m := imdbRegex.FindStringSubmatch(html); len(m) >= 2 {
detail.ImdbID = m[1]
}
// Size
sizeRegex := regexp.MustCompile(`(?i)size[^<]*</td>\s*<td[^>]*>(\d+\.?\d*)\s*(GB|MB|TB|KB)`)
if m := sizeRegex.FindStringSubmatch(html); len(m) >= 3 {
detail.Size = parseSizeString(m[1], m[2])
}
// Seeders / Leechers / Snatched
slRegex := regexp.MustCompile(`seeders[^<]*</td>\s*<td[^>]*>(\d+)</td>\s*<td[^>]*>\s*</td>\s*<td[^>]*>\s*</td>\s*<td[^>]*>leechers[^<]*</td>\s*<td[^>]*>(\d+)`)
if m := slRegex.FindStringSubmatch(html); len(m) >= 3 {
detail.Seeders, _ = strconv.Atoi(m[1])
detail.Leechers, _ = strconv.Atoi(m[2])
}
snRegex := regexp.MustCompile(`(?i)times completed[^<]*</td>\s*<td[^>]*>(\d+)`)
if m := snRegex.FindStringSubmatch(html); len(m) >= 2 {
detail.Snatched, _ = strconv.Atoi(m[1])
}
// Description
descRegex := regexp.MustCompile(`(?i)<div[^>]*id="kdescr"[^>]*>(.*?)</div>`)
if m := descRegex.FindStringSubmatch(html); len(m) >= 2 {
detail.Description = stripHTML(m[1])
}
detail.DownloadURL = baseURL + "/download.php?id=" + id
detail.Free = strings.Contains(html, "free") || strings.Contains(html, "免费")
return detail, nil
}
@@ -0,0 +1,43 @@
package service
import (
"regexp"
"strconv"
"strings"
)
// parseNexusPHPDetailHTML 解析种子详情页。
func parseNexusPHPDetailHTML(html, id, baseURL string) (*TorrentDetail, error) {
detail := &TorrentDetail{
ID: id,
DetailURL: baseURL + "/details.php?id=" + id,
}
if m := regexp.MustCompile(`<h1[^>]*>([^<]+)</h1>`).FindStringSubmatch(html); len(m) >= 2 {
detail.Title = strings.TrimSpace(m[1])
}
if m := regexp.MustCompile(`<span[^>]*class="[^"]*sub[^"]*"[^>]*>([^<]+)</span>`).FindStringSubmatch(html); len(m) >= 2 {
detail.Subtitle = strings.TrimSpace(m[1])
}
if m := regexp.MustCompile(`(?i)info_hash[^<]*</td>\s*<td[^>]*>([^<]+)</td>`).FindStringSubmatch(html); len(m) >= 2 {
detail.InfoHash = strings.TrimSpace(m[1])
}
if m := regexp.MustCompile(`(?i)imdb[^<]*</td>\s*<td[^>]*>[^<]*(tt\d+)`).FindStringSubmatch(html); len(m) >= 2 {
detail.ImdbID = m[1]
}
if m := regexp.MustCompile(`(?i)size[^<]*</td>\s*<td[^>]*>(\d+\.?\d*)\s*(GB|MB|TB|KB)`).FindStringSubmatch(html); len(m) >= 3 {
detail.Size = parseSizeString(m[1], m[2])
}
if m := regexp.MustCompile(`seeders[^<]*</td>\s*<td[^>]*>(\d+)</td>\s*<td[^>]*>\s*</td>\s*<td[^>]*>\s*</td>\s*<td[^>]*>leechers[^<]*</td>\s*<td[^>]*>(\d+)`).FindStringSubmatch(html); len(m) >= 3 {
detail.Seeders, _ = strconv.Atoi(m[1])
detail.Leechers, _ = strconv.Atoi(m[2])
}
if m := regexp.MustCompile(`(?i)times completed[^<]*</td>\s*<td[^>]*>(\d+)`).FindStringSubmatch(html); len(m) >= 2 {
detail.Snatched, _ = strconv.Atoi(m[1])
}
if m := regexp.MustCompile(`(?i)<div[^>]*id="kdescr"[^>]*>(.*?)</div>`).FindStringSubmatch(html); len(m) >= 2 {
detail.Description = stripHTML(m[1])
}
detail.DownloadURL = baseURL + "/download.php?id=" + id
detail.Free = strings.Contains(html, "free") || strings.Contains(html, "免费")
return detail, nil
}
@@ -0,0 +1,204 @@
package service
import (
"html"
"net/url"
"regexp"
"strconv"
"strings"
"time"
)
// parseNexusPHPHTML 解析 NexusPHP 种子列表 HTML。
func parseNexusPHPHTML(html, siteName, baseURL string) (*SiteSearchResult, error) {
result := &SiteSearchResult{
SiteName: siteName,
Items: []TorrentItem{},
Page: 1,
}
for _, row := range nexusPHPTorrentRows(html) {
item := parseNexusPHPRow(row, baseURL)
if item.ID != "" {
result.Items = append(result.Items, item)
}
}
result.Total = len(result.Items)
return result, nil
}
func nexusPHPPageLooksLogin(pageHTML string) bool {
lower := strings.ToLower(pageHTML)
if strings.Contains(lower, "details.php") || strings.Contains(lower, "download.php") {
return false
}
for _, marker := range []string{
"takelogin.php",
"id=\"loginform\"",
"id='loginform'",
"name=\"loginform\"",
"name='loginform'",
"type=\"password\"",
"type='password'",
} {
if strings.Contains(lower, marker) {
return true
}
}
return false
}
// parseNexusPHPRow 解析单行种子条目。
func parseNexusPHPRow(row, baseURL string) TorrentItem {
item := TorrentItem{}
if link := firstNexusPHPLink(row, "details.php"); link != nil {
item.ID = link.query.Get("id")
item.Title = nexusPHPTitleFromLink(*link)
item.Subtitle = nexusPHPSubtitle(row)
item.DetailURL = resolveSiteURL(baseURL, link.href)
}
if link := firstNexusPHPLink(row, "download.php"); link != nil {
item.DownloadURL = resolveSiteURL(baseURL, link.href)
}
if sizeMatches := regexp.MustCompile(`(?i)(\d+\.?\d*)\s*(GiB|MiB|TiB|KiB|GB|MB|TB|KB)`).FindStringSubmatch(row); len(sizeMatches) >= 3 {
item.Size = parseSizeString(sizeMatches[1], sizeMatches[2])
}
if value, ok := nexusPHPIntByClass(row, "seeders"); ok {
item.Seeders = value
}
if value, ok := nexusPHPIntByClass(row, "leechers"); ok {
item.Leechers = value
}
if value, ok := nexusPHPIntByClass(row, "snatched"); ok {
item.Snatched = value
}
if item.Snatched == 0 {
if m := regexp.MustCompile(`snatched[^"]*"[^>]*>(\d+)`).FindStringSubmatch(row); len(m) >= 2 {
item.Snatched, _ = strconv.Atoi(m[1])
}
}
item.Free = regexp.MustCompile(`(?i)(class="free|free2|twoupfree|free_download|促销|免费)`).MatchString(row)
if m := regexp.MustCompile(`(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2})`).FindStringSubmatch(row); len(m) >= 2 {
if t, err := time.Parse("2006-01-02 15:04", m[1]); err == nil {
item.UploadTime = t
}
}
if m := regexp.MustCompile(`cat=(\d+)[^"]*"[^>]*title="([^"]+)"`).FindStringSubmatch(row); len(m) >= 3 {
item.Category = strings.TrimSpace(m[2])
}
return item
}
type nexusPHPLink struct {
href string
attrs string
text string
query url.Values
}
func nexusPHPTorrentRows(pageHTML string) []string {
rows := regexp.MustCompile(`(?is)<tr\b[^>]*>.*?</tr>`).FindAllString(pageHTML, -1)
out := make([]string, 0, len(rows))
for _, row := range rows {
if strings.Contains(strings.ToLower(row), "details.php") {
out = append(out, row)
}
}
return out
}
func firstNexusPHPLink(row, path string) *nexusPHPLink {
pattern := regexp.MustCompile(`(?is)<a\b([^>]*href\s*=\s*["']([^"']*)["'][^>]*)>(.*?)</a>`)
for _, match := range pattern.FindAllStringSubmatch(row, -1) {
if len(match) < 4 {
continue
}
href := html.UnescapeString(strings.TrimSpace(match[2]))
parsed, err := url.Parse(href)
if err != nil || !nexusPHPLinkPathMatches(parsed, path) {
continue
}
return &nexusPHPLink{
href: href,
attrs: match[1],
text: cleanNexusPHPText(match[3]),
query: parsed.Query(),
}
}
return nil
}
func nexusPHPLinkPathMatches(parsed *url.URL, want string) bool {
if parsed == nil {
return false
}
path := strings.TrimSpace(parsed.Path)
if path == "" {
path = strings.TrimSpace(parsed.Opaque)
}
path = strings.Trim(strings.ToLower(path), "/")
want = strings.Trim(strings.ToLower(strings.TrimSpace(want)), "/")
if path == "" || want == "" {
return false
}
return path == want || strings.HasSuffix(path, "/"+want)
}
func nexusPHPTitleFromLink(link nexusPHPLink) string {
for _, attr := range []string{"title", "data-title"} {
if value := htmlAttr(link.attrs, attr); value != "" {
return value
}
}
return link.text
}
func nexusPHPSubtitle(row string) string {
for _, pattern := range []*regexp.Regexp{
regexp.MustCompile(`(?is)<span\b[^>]*(?:class|id)\s*=\s*["'][^"']*(?:subtitle|small_descr|descr|sub)[^"']*["'][^>]*>(.*?)</span>`),
regexp.MustCompile(`(?is)<font\b[^>]*(?:class|id)\s*=\s*["'][^"']*(?:subtitle|small_descr|descr|sub)[^"']*["'][^>]*>(.*?)</font>`),
} {
if match := pattern.FindStringSubmatch(row); len(match) >= 2 {
return cleanNexusPHPText(match[1])
}
}
return ""
}
func nexusPHPIntByClass(row, className string) (int, bool) {
pattern := regexp.MustCompile(`(?is)<td\b[^>]*(?:class|id)\s*=\s*["'][^"']*` + regexp.QuoteMeta(className) + `[^"']*["'][^>]*>(.*?)</td>`)
if match := pattern.FindStringSubmatch(row); len(match) >= 2 {
text := cleanNexusPHPText(match[1])
valueMatch := regexp.MustCompile(`\d+`).FindString(text)
if valueMatch != "" {
value, _ := strconv.Atoi(valueMatch)
return value, true
}
}
return 0, false
}
func htmlAttr(attrs, name string) string {
pattern := regexp.MustCompile(`(?is)\b` + regexp.QuoteMeta(name) + `\s*=\s*["']([^"']*)["']`)
if match := pattern.FindStringSubmatch(attrs); len(match) >= 2 {
return cleanNexusPHPText(match[1])
}
return ""
}
func cleanNexusPHPText(value string) string {
return strings.Join(strings.Fields(html.UnescapeString(stripHTML(value))), " ")
}
func resolveSiteURL(baseURL, href string) string {
base, err := url.Parse(strings.TrimRight(baseURL, "/") + "/")
if err != nil {
return strings.TrimSpace(href)
}
ref, err := url.Parse(strings.TrimSpace(href))
if err != nil {
return strings.TrimSpace(href)
}
return base.ResolveReference(ref).String()
}
+19
View File
@@ -212,6 +212,25 @@ func TestNexusPHPSearchUsesSearchstr(t *testing.T) {
} }
} }
func TestNexusPHPSearchReportsExpiredCookieLoginPage(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`<html><form id="loginform" action="takelogin.php"><input type="password" /></form></html>`))
}))
defer server.Close()
adapter := NewNexusPHPAdapter()
_, err := adapter.Search(t.Context(), SiteConfig{
Name: "Nexus",
URL: server.URL,
AuthType: "cookie",
Cookie: "uid=1; pass=expired",
Timeout: 5 * time.Second,
}, "测试", 1)
if err == nil || !strings.Contains(err.Error(), "cookie expired") {
t.Fatalf("Search error = %v, want cookie expired hint", err)
}
}
func TestParseNexusPHPHTMLModernRows(t *testing.T) { func TestParseNexusPHPHTMLModernRows(t *testing.T) {
page := ` page := `
<table class="torrents"> <table class="torrents">
+84 -22
View File
@@ -102,28 +102,10 @@ func (s *SiteService) Search(ctx context.Context, keyword string) ([]SearchResul
if result == nil { if result == nil {
return return
} }
items := result.Items siteResults := siteSearchResultsFromItems(site, result, keyword)
if items == nil { mu.Lock()
items = []TorrentItem{} results = append(results, siteResults...)
} mu.Unlock()
for _, item := range items {
mu.Lock()
results = append(results, SearchResult{
SiteName: site.Name,
SiteID: site.ID,
Title: item.Title,
Subtitle: item.Subtitle,
TorrentURL: item.DetailURL,
DownloadURL: item.DownloadURL,
Category: item.Category,
SearchKeyword: keyword,
Size: item.Size,
Seeders: item.Seeders,
Leechers: item.Leechers,
Free: item.Free,
})
mu.Unlock()
}
}(sites[i]) }(sites[i])
} }
wg.Wait() wg.Wait()
@@ -152,3 +134,83 @@ func (s *SiteService) Search(ctx context.Context, keyword string) ([]SearchResul
} }
return results, nil return results, nil
} }
// SearchSite runs a keyword search against one configured site, regardless of
// whether the site is enabled globally. This is used by per-site diagnostics in
// the management UI, where the user expects the selected site to be tested
// directly instead of a full fan-out followed by filtering.
func (s *SiteService) SearchSite(ctx context.Context, siteID, keyword string, page int) ([]SearchResult, error) {
if strings.TrimSpace(keyword) == "" {
return []SearchResult{}, nil
}
if page <= 0 {
page = 1
}
site, err := s.FindByID(ctx, siteID)
if err != nil {
return nil, err
}
if site == nil {
return nil, fmt.Errorf("site not found")
}
adapter := NewSiteAdapter(site)
if adapter == nil {
return nil, fmt.Errorf("%s: unsupported site type %s", site.Name, site.Type)
}
cfg := s.siteModelToConfig(site)
timeout := time.Duration(site.Timeout) * time.Second
if timeout <= 0 {
timeout = 30 * time.Second
}
ctxWithTimeout, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
result, err := adapter.Search(ctxWithTimeout, cfg, keyword, page)
if err != nil {
if s.log != nil {
s.log.Warn("single site search failed",
zap.String("site", site.Name),
zap.String("type", site.Type),
zap.String("url", site.URL),
zap.String("keyword", keyword),
zap.Duration("timeout", timeout),
zap.Error(err))
}
return nil, err
}
out := siteSearchResultsFromItems(*site, result, keyword)
sort.Slice(out, func(i, j int) bool {
return out[i].Seeders > out[j].Seeders
})
if s.log != nil {
s.log.Info("single site search completed",
zap.String("site", site.Name),
zap.String("keyword", keyword),
zap.Int("results_count", len(out)))
}
return out, nil
}
func siteSearchResultsFromItems(site model.Site, result *SiteSearchResult, keyword string) []SearchResult {
if result == nil || len(result.Items) == 0 {
return []SearchResult{}
}
out := make([]SearchResult, 0, len(result.Items))
for _, item := range result.Items {
out = append(out, SearchResult{
SiteName: site.Name,
SiteID: site.ID,
Title: item.Title,
Subtitle: item.Subtitle,
TorrentURL: item.DetailURL,
DownloadURL: item.DownloadURL,
Category: item.Category,
SearchKeyword: keyword,
Size: item.Size,
Seeders: item.Seeders,
Leechers: item.Leechers,
Free: item.Free,
})
}
return out
}
+36
View File
@@ -146,3 +146,39 @@ func TestSiteSearchReturnsErrorWhenAllEnabledSitesFail(t *testing.T) {
t.Fatalf("error = %q, want site failure context", err.Error()) t.Fatalf("error = %q, want site failure context", err.Error())
} }
} }
func TestSearchSiteQueriesSelectedSiteEvenWhenDisabled(t *testing.T) {
var gotQuery string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotQuery = r.URL.RawQuery
_, _ = w.Write([]byte(`<table><tr><td><a href="details.php?id=321" title="Selected Site Result">Selected Site Result</a><a href="download.php?id=321">下载</a></td></tr></table>`))
}))
defer upstream.Close()
db := newServiceTestDB(t, &model.Site{})
repos := repository.New(db)
svc := NewSiteService(zap.NewNop(), repos, "")
site := &model.Site{
Name: "Selected Nexus",
Type: "nexusphp",
URL: upstream.URL,
AuthType: "cookie",
Cookie: "uid=1; pass=token",
Enabled: false,
Timeout: 5,
}
if err := svc.Create(context.Background(), site); err != nil {
t.Fatal(err)
}
results, err := svc.SearchSite(context.Background(), site.ID, "Selected", 1)
if err != nil {
t.Fatalf("SearchSite returned error: %v", err)
}
if !strings.Contains(gotQuery, "searchstr=Selected") {
t.Fatalf("query = %q, want searchstr=Selected", gotQuery)
}
if len(results) != 1 || results[0].SiteID != site.ID || results[0].Title != "Selected Site Result" {
t.Fatalf("results = %#v", results)
}
}
+1
View File
@@ -56,6 +56,7 @@ func (s *StorageService) Compute(ctx context.Context) (*Breakdown, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
libs = NormalizeCloudLibraryDisplay(libs)
out := &Breakdown{ByLibrary: make([]LibraryUsage, 0, len(libs))} out := &Breakdown{ByLibrary: make([]LibraryUsage, 0, len(libs))}
for _, l := range libs { for _, l := range libs {
var usage LibraryUsage var usage LibraryUsage
+50
View File
@@ -0,0 +1,50 @@
package service
import (
"slices"
"testing"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
"go.uber.org/zap"
)
func TestStorageBreakdownUsesCanonicalLibraryDisplay(t *testing.T) {
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
repos := repository.New(db)
libs := []model.Library{
{Name: "外语电影", Path: "/media/电影/外语电影", Type: "movie", Enabled: true},
{Name: "欧美动漫", Path: "/media/动漫/欧美动漫", Type: "tv", Enabled: true},
{Name: "9KG", Path: "/media/成人/9KG", Type: "movie", Enabled: true},
}
for i := range libs {
if err := repos.Library.Create(t.Context(), &libs[i]); err != nil {
t.Fatal(err)
}
if err := repos.Media.Upsert(t.Context(), &model.Media{
LibraryID: libs[i].ID,
Title: libs[i].Name,
Path: libs[i].Path + "/item.mkv",
SizeBytes: 1024,
}); err != nil {
t.Fatal(err)
}
}
breakdown, err := NewStorageService(zap.NewNop(), repos).Compute(t.Context())
if err != nil {
t.Fatal(err)
}
gotNames := make([]string, 0, len(breakdown.ByLibrary))
gotTypes := make([]string, 0, len(breakdown.ByLibrary))
for _, row := range breakdown.ByLibrary {
gotNames = append(gotNames, row.Name)
gotTypes = append(gotTypes, row.Type)
}
if want := []string{"欧美电影", "美漫", "成人"}; !slices.Equal(gotNames, want) {
t.Fatalf("library names = %#v, want %#v", gotNames, want)
}
if want := []string{"movie", "anime", "adult"}; !slices.Equal(gotTypes, want) {
t.Fatalf("library types = %#v, want %#v", gotTypes, want)
}
}
+44 -4
View File
@@ -108,7 +108,7 @@ func strmCategoryPartsFromPath(parts []string) []string {
return append([]string{root}, strmSanitizedTail(parts[i+1:])...) return append([]string{root}, strmSanitizedTail(parts[i+1:])...)
} }
if root := strmCategoryRoot(part); root != "" { if root := strmCategoryRoot(part); root != "" {
return []string{root, part} return []string{root, strmCanonicalCategory(part)}
} }
} }
return nil return nil
@@ -143,11 +143,11 @@ func strmCanonicalRoot(part string) string {
func strmCategoryRoot(part string) string { func strmCategoryRoot(part string) string {
key := strings.ToLower(strings.TrimSpace(part)) key := strings.ToLower(strings.TrimSpace(part))
switch key { switch key {
case "动画电影", "动漫电影", "华语电影", "国产电影", "外语电影", "欧美电影", "日韩电影": case "演唱会", "音乐会", "纪录片", "纪录", "动画电影", "动漫电影", "华语电影", "国产电影", "外语电影", "外国电影", "欧美电影", "日韩电影", "日本电影", "韩国电影":
return "电影" return "电影"
case "国产剧", "欧美剧", "日韩剧", "日剧", "韩剧", "综艺", "真人秀", "纪录片", "纪录", "未分类": case "国产剧", "欧美剧", "日韩剧", "日剧", "韩剧", "综艺", "真人秀", "儿童", "少儿", "未分类":
return "电视剧" return "电视剧"
case "国漫", "国产动漫", "日番", "番剧", "日漫", "日本动漫", "日本动画", "欧美动漫", "欧美动画", "西方动画", "儿童", "少儿": case "国漫", "国产动漫", "日番", "番剧", "日漫", "日本动漫", "日本动画", "韩漫", "韩国动漫", "韩国动画", "美漫", "欧美动漫", "欧美动画", "西方动画", "其他", "其他动漫", "其它动漫":
return "动漫" return "动漫"
case "番号": case "番号":
return "成人" return "成人"
@@ -155,3 +155,43 @@ func strmCategoryRoot(part string) string {
return "" return ""
} }
} }
func strmCanonicalCategory(part string) string {
key := strings.ToLower(strings.TrimSpace(part))
switch key {
case "音乐会":
return "演唱会"
case "纪录":
return "纪录片"
case "动漫电影":
return "动画电影"
case "国产电影":
return "华语电影"
case "外语电影", "外国电影":
return "欧美电影"
case "日本电影", "韩国电影":
return "日韩电影"
case "日剧", "韩剧":
return "日韩剧"
case "真人秀":
return "综艺"
case "少儿":
return "儿童"
case "未分类":
return "欧美剧"
case "国产动漫":
return "国漫"
case "番剧", "日漫", "日本动漫", "日本动画":
return "日番"
case "韩国动漫", "韩国动画":
return "韩漫"
case "欧美动漫", "欧美动画", "西方动画":
return "美漫"
case "其他动漫", "其它动漫":
return "其他"
case "番号":
return "成人"
default:
return strings.TrimSpace(part)
}
}
+10 -1
View File
@@ -166,10 +166,16 @@ func (s *SubscriptionService) Delete(ctx context.Context, id string) error {
// by the admin UI's "test now" button. // by the admin UI's "test now" button.
func (s *SubscriptionService) RunNow(ctx context.Context, id string) (int, error) { func (s *SubscriptionService) RunNow(ctx context.Context, id string) (int, error) {
var sub model.Subscription var sub model.Subscription
if err := s.repo.DB.Where("id = ?", id).First(&sub).Error; err != nil { if err := s.repo.DB.WithContext(ctx).Where("id = ?", id).First(&sub).Error; err != nil {
return 0, err return 0, err
} }
if sub.ArchivedAt != nil { if sub.ArchivedAt != nil {
if s.log != nil {
s.log.Info("subscription run skipped because it is archived",
zap.String("subscription_id", sub.ID),
zap.String("subscription", sub.Name),
zap.String("archive_reason", sub.ArchiveReason))
}
return 0, nil return 0, nil
} }
return s.runOne(ctx, &sub) return s.runOne(ctx, &sub)
@@ -236,6 +242,9 @@ func (s *SubscriptionService) runAll(ctx context.Context) {
s.log.Warn("subscription list failed", zap.Error(err)) s.log.Warn("subscription list failed", zap.Error(err))
return return
} }
if s.log != nil {
s.log.Info("subscription sweep started", zap.Int("count", len(subs)))
}
for i := range subs { for i := range subs {
if !subs[i].Enabled { if !subs[i].Enabled {
continue continue
+1 -1
View File
@@ -56,7 +56,7 @@ func (s *SubscriptionService) lookupSubscriptionMetadata(ctx context.Context, me
for _, libType := range subscriptionMetadataLibraryTypes(mediaType, title) { for _, libType := range subscriptionMetadataLibraryTypes(mediaType, title) {
lib := &model.Library{Type: libType, Enabled: true} lib := &model.Library{Type: libType, Enabled: true}
for _, query := range queries { for _, query := range queries {
cleaned, year := CleanQuery(query) cleaned, year := CleanQueryWithRecognition(ctx, s.repo, query)
if cleaned == "" { if cleaned == "" {
cleaned = strings.TrimSpace(query) cleaned = strings.TrimSpace(query)
} }
@@ -0,0 +1,189 @@
package service
import (
"strings"
"unicode"
)
// defaultExcludeWords 是默认过滤的「垃圾版本」排除清单,对所有订阅生效。
// 拉丁词在 containsAnyExcludeToken 里按词边界匹配以避免子串误伤。
const defaultExcludeWords = "cam,ts,tc,telesync,telecine,hdcam,hdts,枪版,抢先,抢鲜,预告,trailer,sample"
// defaultCompatibilityExcludeWords 是面向自动订阅的兼容性默认排除清单。
// 仅在用户未真正自定义排除词时启用,避免默认命中 DoVi/H.265/10bit/杜比音轨等版本。
const defaultCompatibilityExcludeWords = "dovi,dv,dolby vision,dolby,杜比视界,杜比,h265,h.265,h-265,h_265,h 265,hevc,x265,10bit,10-bit,10 bit,hi10p,atmos,truehd,ddp,dd+,eac3"
const legacyFrontendExcludeWords = "cam,ts,tc,枪版"
func containsAnyToken(titleFold, csv string) bool {
for _, token := range strings.FieldsFunc(strings.ToLower(csv), func(r rune) bool {
return r == ',' || r == '/' || r == '|' || r == ';' || r == ','
}) {
token = strings.TrimSpace(token)
if token != "" && strings.Contains(titleFold, token) {
return true
}
}
return false
}
// containsAnyExcludeToken 用于排除词匹配:纯 ASCII 字母数字的词按词边界匹配(避免 "ts"
// 误伤 "tsukihime"、"cam" 误伤 "camp" 之类的子串误判),含 CJK/符号的词仍按子串匹配。
func containsAnyExcludeToken(titleFold, csv string) bool {
for _, token := range excludeWordTokens(csv) {
if matchesExcludeToken(titleFold, token) {
return true
}
}
return false
}
func excludeWordTokens(csv string) []string {
parts := make([]string, 0)
for _, token := range strings.FieldsFunc(strings.ToLower(csv), isExcludeSeparator) {
token = strings.TrimSpace(token)
if token == "" {
continue
}
parts = append(parts, token)
if shouldExpandDottedExcludeToken(token) {
parts = append(parts, dottedExcludeTokenParts(token)...)
}
}
return parts
}
func isExcludeSeparator(r rune) bool {
switch r {
case ',', '/', '|', ';', ',', '、', '\n', '\r', '\t':
return true
default:
return false
}
}
func shouldExpandDottedExcludeToken(token string) bool {
return strings.Count(token, ".") >= 2
}
func dottedExcludeTokenParts(token string) []string {
rawParts := strings.Split(token, ".")
parts := make([]string, 0, len(rawParts))
for _, part := range rawParts {
part = strings.TrimSpace(part)
if len(part) < 2 || isDigitsOnly(part) {
continue
}
parts = append(parts, part)
}
return parts
}
func isDigitsOnly(value string) bool {
if value == "" {
return false
}
for _, r := range value {
if !unicode.IsDigit(r) {
return false
}
}
return true
}
func matchesExcludeToken(titleFold, token string) bool {
if token == "" {
return false
}
if isASCIIWordToken(token) {
return matchesWordBoundary(titleFold, token) || matchesReleasePrefixToken(titleFold, token)
}
return strings.Contains(titleFold, token)
}
func isASCIIWordToken(token string) bool {
for _, r := range token {
if r > unicode.MaxASCII || !(unicode.IsLetter(r) || unicode.IsDigit(r)) {
return false
}
}
return token != ""
}
// matchesWordBoundary 判断 token 是否作为独立词出现在 title 中,词边界为「非字母数字」。
func matchesWordBoundary(titleFold, token string) bool {
from := 0
for {
idx := strings.Index(titleFold[from:], token)
if idx < 0 {
return false
}
start := from + idx
end := start + len(token)
leftOK := start == 0 || !isASCIIAlnumByte(titleFold[start-1])
rightOK := end >= len(titleFold) || !isASCIIAlnumByte(titleFold[end])
if leftOK && rightOK {
return true
}
from = start + 1
if from >= len(titleFold) {
return false
}
}
}
func matchesReleasePrefixToken(titleFold, token string) bool {
if !isReleasePrefixExcludeToken(token) {
return false
}
from := 0
for {
idx := strings.Index(titleFold[from:], token)
if idx < 0 {
return false
}
start := from + idx
end := start + len(token)
leftOK := start == 0 || !isASCIIAlnumByte(titleFold[start-1])
if leftOK && releasePrefixSuffixOK(token, titleFold[end:]) {
return true
}
from = start + 1
if from >= len(titleFold) {
return false
}
}
}
func isReleasePrefixExcludeToken(token string) bool {
switch token {
case "ddp", "dolby":
return true
default:
return false
}
}
func releasePrefixSuffixOK(token, suffix string) bool {
if suffix == "" {
return false
}
switch token {
case "ddp":
return isASCIIDigitByte(suffix[0])
case "dolby":
return strings.HasPrefix(suffix, "vision") ||
strings.HasPrefix(suffix, "atmos") ||
strings.HasPrefix(suffix, "digital")
default:
return false
}
}
func isASCIIAlnumByte(b byte) bool {
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || isASCIIDigitByte(b)
}
func isASCIIDigitByte(b byte) bool {
return b >= '0' && b <= '9'
}
+54
View File
@@ -0,0 +1,54 @@
package service
import (
"net/url"
"strings"
"time"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
func subscriptionRunLogFields(sub *model.Subscription) []zap.Field {
fields := []zap.Field{}
if sub == nil {
return fields
}
return append(fields,
zap.String("subscription_id", sub.ID),
zap.String("subscription", sub.Name),
zap.String("feed_kind", subscriptionFeedKind(sub.FeedURL)),
zap.String("filter", sub.Filter),
zap.String("media_type", sub.MediaType),
zap.String("media_category", sub.MediaCategory),
zap.String("search_mode", sub.SearchMode),
zap.Bool("enabled", sub.Enabled),
zap.Bool("wash_enabled", sub.WashEnabled),
zap.String("wash_priority", sub.WashPriority),
zap.Int("total_episodes", sub.TotalEpisodes),
)
}
func appendSubscriptionRunResultFields(fields []zap.Field, queued int, started time.Time) []zap.Field {
return append(fields,
zap.Int("queued", queued),
zap.Int64("duration_ms", time.Since(started).Milliseconds()),
)
}
func subscriptionFeedKind(feedURL string) string {
raw := strings.TrimSpace(feedURL)
if raw == "" {
return "empty"
}
lower := strings.ToLower(raw)
if strings.HasPrefix(lower, "site-search://") {
return "site-search"
}
parsed, err := url.Parse(raw)
if err == nil && parsed.Scheme != "" {
return parsed.Scheme
}
return "unknown"
}
+23
View File
@@ -0,0 +1,23 @@
package service
import (
"regexp"
"strings"
)
var (
seriesPackRE = regexp.MustCompile(`(?i)(complete|batch|合集|全集|全\s*\d+\s*[集话話期]|整季|全季|s\d{1,2}\s*(?:complete|batch|pack)|season\s*\d{1,2}\s*(?:complete|batch|pack)|s\d{1,2}e\d{1,3}\s*[-~–—]\s*(?:s\d{1,2})?e?\d{1,3}|第\s*\d+\s*[-~–—]\s*\d+\s*[集话話期])`)
seasonOnlyRE = regexp.MustCompile(`(?i)(?:^|[\s._-])(?:s|season)\s*\d{1,2}(?:[\s._-]|$)|第\s*\d+\s*季`)
)
func isSeriesPackTitle(title string) bool {
title = strings.TrimSpace(title)
if title == "" {
return false
}
if seriesPackRE.MatchString(title) {
return true
}
_, episode := ParseEpisode(title)
return episode == 0 && seasonOnlyRE.MatchString(title)
}
+42 -5
View File
@@ -19,8 +19,21 @@ type rssSubscriptionRunState struct {
washOff bool washOff bool
} }
func (s *SubscriptionService) runOne(ctx context.Context, sub *model.Subscription) (int, error) { func (s *SubscriptionService) runOne(ctx context.Context, sub *model.Subscription) (queued int, err error) {
s.prepareSubscriptionForRun(ctx, sub) s.prepareSubscriptionForRun(ctx, sub)
started := time.Now()
if s.log != nil {
s.log.Info("subscription run started", subscriptionRunLogFields(sub)...)
defer func() {
fields := appendSubscriptionRunResultFields(subscriptionRunLogFields(sub), queued, started)
if err != nil {
fields = append(fields, zap.Error(err))
s.log.Warn("subscription run finished with error", fields...)
return
}
s.log.Info("subscription run finished", fields...)
}()
}
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(sub.FeedURL)), "site-search://") { if strings.HasPrefix(strings.ToLower(strings.TrimSpace(sub.FeedURL)), "site-search://") {
return s.runSiteSearch(ctx, sub) return s.runSiteSearch(ctx, sub)
} }
@@ -50,7 +63,7 @@ func (s *SubscriptionService) runOne(ctx context.Context, sub *model.Subscriptio
washOff: !sub.WashEnabled, washOff: !sub.WashEnabled,
} }
candidates := selectRSSSubscriptionCandidates(feed.Channel.Items, sub, filter, runState.seenSet, runState.availability) candidates := selectRSSSubscriptionCandidates(feed.Channel.Items, sub, filter, runState.seenSet, runState.availability)
queued := s.enqueueRSSSubscriptionCandidates(ctx, sub, candidates, runState) queued = s.enqueueRSSSubscriptionCandidates(ctx, sub, candidates, runState)
s.finishRSSSubscriptionRun(ctx, sub, guidKey, runState, queued) s.finishRSSSubscriptionRun(ctx, sub, guidKey, runState, queued)
return queued, nil return queued, nil
} }
@@ -105,6 +118,15 @@ func (s *SubscriptionService) enqueueRSSSubscriptionCandidate(ctx context.Contex
zap.Error(err)) zap.Error(err))
return false return false
} }
if s.log != nil {
s.log.Info("rss subscription candidate queued",
zap.String("subscription_id", sub.ID),
zap.String("subscription", sub.Name),
zap.String("title", item.Title),
zap.String("media_type", mediaType),
zap.String("media_category", mediaCategory),
zap.String("save_path", savePath))
}
state.markTitleAvailable(item.Title) state.markTitleAvailable(item.Title)
state.markSeen(candidate.GUID) state.markSeen(candidate.GUID)
return true return true
@@ -116,11 +138,26 @@ func (s *SubscriptionService) finishRSSSubscriptionRun(ctx context.Context, sub
if len(state.seen) > 200 { if len(state.seen) > 200 {
state.seen = state.seen[len(state.seen)-200:] state.seen = state.seen[len(state.seen)-200:]
} }
_ = s.repo.Setting.Set(ctx, guidKey, strings.Join(state.seen, "\n")) if err := s.repo.Setting.Set(ctx, guidKey, strings.Join(state.seen, "\n")); err != nil && s.log != nil {
s.log.Warn("subscription seen state update failed",
zap.String("subscription_id", sub.ID),
zap.String("subscription", sub.Name),
zap.Error(err))
}
now := time.Now() now := time.Now()
_ = s.repo.DB.Model(sub).Updates(map[string]any{"last_run_at": &now}).Error if err := s.repo.DB.Model(sub).Updates(map[string]any{"last_run_at": &now}).Error; err != nil && s.log != nil {
_ = s.archiveCompletedSubscription(ctx, sub, state.availability) s.log.Warn("subscription last_run_at update failed",
zap.String("subscription_id", sub.ID),
zap.String("subscription", sub.Name),
zap.Error(err))
}
if err := s.archiveCompletedSubscription(ctx, sub, state.availability); err != nil && s.log != nil {
s.log.Warn("subscription archive check failed",
zap.String("subscription_id", sub.ID),
zap.String("subscription", sub.Name),
zap.Error(err))
}
if queued > 0 { if queued > 0 {
s.hub.Publish("subscription", map[string]any{ s.hub.Publish("subscription", map[string]any{
"id": sub.ID, "id": sub.ID,
+1 -237
View File
@@ -1,28 +1,11 @@
package service package service
import ( import (
"regexp"
"strings" "strings"
"unicode"
"github.com/ShukeBta/MediaStationGo/internal/model" "github.com/ShukeBta/MediaStationGo/internal/model"
) )
var (
seriesPackRE = regexp.MustCompile(`(?i)(complete|batch|合集|全集|全\s*\d+\s*[集话話期]|整季|全季|s\d{1,2}\s*(?:complete|batch|pack)|season\s*\d{1,2}\s*(?:complete|batch|pack)|s\d{1,2}e\d{1,3}\s*[-~–—]\s*(?:s\d{1,2})?e?\d{1,3}|第\s*\d+\s*[-~–—]\s*\d+\s*[集话話期])`)
seasonOnlyRE = regexp.MustCompile(`(?i)(?:^|[\s._-])(?:s|season)\s*\d{1,2}(?:[\s._-]|$)|第\s*\d+\s*季`)
)
// defaultExcludeWords 是默认过滤的「垃圾版本」排除清单,对所有订阅生效。
// 拉丁词在 containsAnyExcludeToken 里按词边界匹配以避免子串误伤。
const defaultExcludeWords = "cam,ts,tc,telesync,telecine,hdcam,hdts,枪版,抢先,抢鲜,预告,trailer,sample"
// defaultCompatibilityExcludeWords 是面向自动订阅的兼容性默认排除清单。
// 仅在用户未真正自定义排除词时启用,避免默认命中 DoVi/H.265/10bit/杜比音轨等版本。
const defaultCompatibilityExcludeWords = "dovi,dv,dolby vision,dolby,杜比视界,杜比,h265,h.265,h-265,h_265,h 265,hevc,x265,10bit,10-bit,10 bit,hi10p,atmos,truehd,ddp,dd+,eac3"
const legacyFrontendExcludeWords = "cam,ts,tc,枪版"
func matchesSubscriptionRules(sub *model.Subscription, title string) bool { func matchesSubscriptionRules(sub *model.Subscription, title string) bool {
titleFold := strings.ToLower(title) titleFold := strings.ToLower(title)
if containsAnyExcludeToken(titleFold, defaultExcludeWords) { if containsAnyExcludeToken(titleFold, defaultExcludeWords) {
@@ -58,214 +41,7 @@ func shouldApplyDefaultCompatibilityExcludes(excludeWords string) bool {
} }
func normalizeExcludeWords(csv string) string { func normalizeExcludeWords(csv string) string {
parts := make([]string, 0) return strings.Join(excludeWordTokens(csv), ",")
for _, token := range strings.FieldsFunc(strings.ToLower(csv), func(r rune) bool {
return r == ',' || r == '/' || r == '|' || r == ';' || r == ','
}) {
token = strings.TrimSpace(token)
if token != "" {
parts = append(parts, token)
}
}
return strings.Join(parts, ",")
}
func subscriptionCandidateScore(sub *model.Subscription, item SearchResult) int {
title := strings.ToLower(subscriptionSearchResultText(item))
score := item.Seeders
if sub == nil || !sub.WashEnabled {
if item.Free {
score += 25
}
return score
}
resolutionScore := detectResolutionScore(title)
qualityScore := detectQualityScore(title)
effectScore := detectEffectScore(title)
priority := "balanced"
if sub != nil && strings.TrimSpace(sub.WashPriority) != "" {
priority = strings.ToLower(strings.TrimSpace(sub.WashPriority))
}
switch priority {
case "resolution":
score += resolutionScore*1000 + qualityScore*100 + effectScore*50
case "quality":
score += qualityScore*1000 + resolutionScore*200 + effectScore*50
case "effects":
score += effectScore*1000 + resolutionScore*200 + qualityScore*100
case "seeders":
score += qualityScore*3 + resolutionScore*2 + effectScore
default:
score += resolutionScore*500 + qualityScore*300 + effectScore*150
}
if item.Free {
score += 25
}
return score
}
func containsAnyToken(titleFold, csv string) bool {
for _, token := range strings.FieldsFunc(strings.ToLower(csv), func(r rune) bool {
return r == ',' || r == '/' || r == '|' || r == ';' || r == ','
}) {
token = strings.TrimSpace(token)
if token != "" && strings.Contains(titleFold, token) {
return true
}
}
return false
}
// containsAnyExcludeToken 用于排除词匹配:纯 ASCII 字母数字的词按词边界匹配(避免 "ts"
// 误伤 "tsukihime"、"cam" 误伤 "camp" 之类的子串误判),含 CJK/符号的词仍按子串匹配。
func containsAnyExcludeToken(titleFold, csv string) bool {
for _, token := range strings.FieldsFunc(strings.ToLower(csv), func(r rune) bool {
return r == ',' || r == '/' || r == '|' || r == ';' || r == ','
}) {
token = strings.TrimSpace(token)
if token == "" {
continue
}
if isASCIIWordToken(token) {
if matchesWordBoundary(titleFold, token) {
return true
}
continue
}
if strings.Contains(titleFold, token) {
return true
}
}
return false
}
func isASCIIWordToken(token string) bool {
for _, r := range token {
if r > unicode.MaxASCII || !(unicode.IsLetter(r) || unicode.IsDigit(r)) {
return false
}
}
return token != ""
}
// matchesWordBoundary 判断 token 是否作为独立词出现在 title 中,词边界为「非字母数字」。
func matchesWordBoundary(titleFold, token string) bool {
isWordRune := func(r rune) bool {
return unicode.IsLetter(r) || unicode.IsDigit(r)
}
from := 0
for {
idx := strings.Index(titleFold[from:], token)
if idx < 0 {
return false
}
start := from + idx
end := start + len(token)
leftOK := start == 0 || !isWordRune(rune(titleFold[start-1]))
rightOK := end >= len(titleFold) || !isWordRune(rune(titleFold[end]))
if leftOK && rightOK {
return true
}
from = start + 1
if from >= len(titleFold) {
return false
}
}
}
func containsAnyEffect(titleFold, csv string) bool {
for _, token := range strings.FieldsFunc(strings.ToLower(csv), func(r rune) bool {
return r == ',' || r == '/' || r == '|' || r == ';' || r == ','
}) {
token = strings.TrimSpace(token)
if token == "" {
continue
}
switch token {
case "dolby-vision", "dolby vision", "dv":
if strings.Contains(titleFold, "dolby vision") || strings.Contains(titleFold, "dovi") || regexp.MustCompile(`\bdv\b`).MatchString(titleFold) {
return true
}
default:
if strings.Contains(titleFold, token) {
return true
}
}
}
return false
}
func titleMatchesResolution(titleFold, resolution string) bool {
switch strings.ToLower(strings.TrimSpace(resolution)) {
case "2160p", "4k", "uhd":
return strings.Contains(titleFold, "2160p") || strings.Contains(titleFold, "4k") || strings.Contains(titleFold, "uhd")
case "1080p":
return strings.Contains(titleFold, "1080p") || strings.Contains(titleFold, "fhd")
case "720p":
return strings.Contains(titleFold, "720p")
default:
return strings.Contains(titleFold, strings.ToLower(strings.TrimSpace(resolution)))
}
}
func titleMatchesQuality(titleFold, quality string) bool {
switch strings.ToLower(strings.TrimSpace(quality)) {
case "webdl", "web-dl":
return strings.Contains(titleFold, "web-dl") || strings.Contains(titleFold, "webdl")
case "bluray", "blu-ray":
return strings.Contains(titleFold, "bluray") || strings.Contains(titleFold, "blu-ray") || strings.Contains(titleFold, "bdrip")
case "remux":
return strings.Contains(titleFold, "remux")
case "hdtv":
return strings.Contains(titleFold, "hdtv")
default:
return strings.Contains(titleFold, strings.ToLower(strings.TrimSpace(quality)))
}
}
func detectResolutionScore(titleFold string) int {
switch {
case titleMatchesResolution(titleFold, "2160p"):
return 4
case titleMatchesResolution(titleFold, "1080p"):
return 3
case titleMatchesResolution(titleFold, "720p"):
return 2
default:
return 1
}
}
func detectQualityScore(titleFold string) int {
switch {
case titleMatchesQuality(titleFold, "remux"):
return 5
case titleMatchesQuality(titleFold, "bluray"):
return 4
case titleMatchesQuality(titleFold, "web-dl"):
return 3
case titleMatchesQuality(titleFold, "hdtv"):
return 2
default:
return 1
}
}
func detectEffectScore(titleFold string) int {
score := 0
if containsAnyEffect(titleFold, "dolby-vision") {
score += 4
}
if strings.Contains(titleFold, "hdr10+") {
score += 3
} else if strings.Contains(titleFold, "hdr") {
score += 2
}
if strings.Contains(titleFold, "atmos") {
score += 2
}
return score
} }
func isSubscriptionSeriesType(mediaType string) bool { func isSubscriptionSeriesType(mediaType string) bool {
@@ -276,15 +52,3 @@ func isSubscriptionSeriesType(mediaType string) bool {
return false return false
} }
} }
func isSeriesPackTitle(title string) bool {
title = strings.TrimSpace(title)
if title == "" {
return false
}
if seriesPackRE.MatchString(title) {
return true
}
_, episode := ParseEpisode(title)
return episode == 0 && seasonOnlyRE.MatchString(title)
}
@@ -24,6 +24,42 @@ func TestMatchesSubscriptionRulesUserExcludeWords(t *testing.T) {
} }
} }
func TestMatchesSubscriptionRulesReleaseStyleExcludeWords(t *testing.T) {
cases := []struct {
name string
sub *model.Subscription
title string
}{
{
name: "default excludes ddp channel suffix",
sub: &model.Subscription{},
title: "Some Show 2026 S01E01 1080p WEB-DL DDP5.1 H264",
},
{
name: "default excludes dolby glued word",
sub: &model.Subscription{},
title: "Some Movie 2026 1080p WEB-DL DolbyVision H264",
},
{
name: "custom dotted list excludes split tokens",
sub: &model.Subscription{ExcludeWords: "DoVi.H265.10bit.杜比"},
title: "Some Movie 2026 1080p WEB-DL H265",
},
{
name: "custom dotted list excludes cjk split token",
sub: &model.Subscription{ExcludeWords: "DoVi.H265.10bit.杜比"},
title: "某电影 2026 1080p 杜比全景声",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if matchesSubscriptionRules(c.sub, c.title) {
t.Fatalf("expected exclude words to reject %q", c.title)
}
})
}
}
func TestMatchesSubscriptionRulesDefaultExcludesJunkReleases(t *testing.T) { func TestMatchesSubscriptionRulesDefaultExcludesJunkReleases(t *testing.T) {
sub := &model.Subscription{} sub := &model.Subscription{}
for _, title := range []string{ for _, title := range []string{
+137
View File
@@ -0,0 +1,137 @@
package service
import (
"regexp"
"strings"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
func subscriptionCandidateScore(sub *model.Subscription, item SearchResult) int {
title := strings.ToLower(subscriptionSearchResultText(item))
score := item.Seeders
if sub == nil || !sub.WashEnabled {
if item.Free {
score += 25
}
return score
}
resolutionScore := detectResolutionScore(title)
qualityScore := detectQualityScore(title)
effectScore := detectEffectScore(title)
priority := "balanced"
if sub != nil && strings.TrimSpace(sub.WashPriority) != "" {
priority = strings.ToLower(strings.TrimSpace(sub.WashPriority))
}
switch priority {
case "resolution":
score += resolutionScore*1000 + qualityScore*100 + effectScore*50
case "quality":
score += qualityScore*1000 + resolutionScore*200 + effectScore*50
case "effects":
score += effectScore*1000 + resolutionScore*200 + qualityScore*100
case "seeders":
score += qualityScore*3 + resolutionScore*2 + effectScore
default:
score += resolutionScore*500 + qualityScore*300 + effectScore*150
}
if item.Free {
score += 25
}
return score
}
func containsAnyEffect(titleFold, csv string) bool {
for _, token := range strings.FieldsFunc(strings.ToLower(csv), func(r rune) bool {
return r == ',' || r == '/' || r == '|' || r == ';' || r == ','
}) {
token = strings.TrimSpace(token)
if token == "" {
continue
}
switch token {
case "dolby-vision", "dolby vision", "dv":
if strings.Contains(titleFold, "dolby vision") || strings.Contains(titleFold, "dovi") || regexp.MustCompile(`\bdv\b`).MatchString(titleFold) {
return true
}
default:
if strings.Contains(titleFold, token) {
return true
}
}
}
return false
}
func titleMatchesResolution(titleFold, resolution string) bool {
switch strings.ToLower(strings.TrimSpace(resolution)) {
case "2160p", "4k", "uhd":
return strings.Contains(titleFold, "2160p") || strings.Contains(titleFold, "4k") || strings.Contains(titleFold, "uhd")
case "1080p":
return strings.Contains(titleFold, "1080p") || strings.Contains(titleFold, "fhd")
case "720p":
return strings.Contains(titleFold, "720p")
default:
return strings.Contains(titleFold, strings.ToLower(strings.TrimSpace(resolution)))
}
}
func titleMatchesQuality(titleFold, quality string) bool {
switch strings.ToLower(strings.TrimSpace(quality)) {
case "webdl", "web-dl":
return strings.Contains(titleFold, "web-dl") || strings.Contains(titleFold, "webdl")
case "bluray", "blu-ray":
return strings.Contains(titleFold, "bluray") || strings.Contains(titleFold, "blu-ray") || strings.Contains(titleFold, "bdrip")
case "remux":
return strings.Contains(titleFold, "remux")
case "hdtv":
return strings.Contains(titleFold, "hdtv")
default:
return strings.Contains(titleFold, strings.ToLower(strings.TrimSpace(quality)))
}
}
func detectResolutionScore(titleFold string) int {
switch {
case titleMatchesResolution(titleFold, "2160p"):
return 4
case titleMatchesResolution(titleFold, "1080p"):
return 3
case titleMatchesResolution(titleFold, "720p"):
return 2
default:
return 1
}
}
func detectQualityScore(titleFold string) int {
switch {
case titleMatchesQuality(titleFold, "remux"):
return 5
case titleMatchesQuality(titleFold, "bluray"):
return 4
case titleMatchesQuality(titleFold, "web-dl"):
return 3
case titleMatchesQuality(titleFold, "hdtv"):
return 2
default:
return 1
}
}
func detectEffectScore(titleFold string) int {
score := 0
if containsAnyEffect(titleFold, "dolby-vision") {
score += 4
}
if strings.Contains(titleFold, "hdr10+") {
score += 3
} else if strings.Contains(titleFold, "hdr") {
score += 2
}
if strings.Contains(titleFold, "atmos") {
score += 2
}
return score
}
+36 -4
View File
@@ -69,15 +69,42 @@ func (s *SubscriptionService) runSiteSearch(ctx context.Context, sub *model.Subs
func (s *SubscriptionService) finishSiteSearchRun(ctx context.Context, sub *model.Subscription, guidKey string, state *siteSearchRunState) LocalAvailability { func (s *SubscriptionService) finishSiteSearchRun(ctx context.Context, sub *model.Subscription, guidKey string, state *siteSearchRunState) LocalAvailability {
availability := s.finalizePendingAvailability(sub, state.Availability) availability := s.finalizePendingAvailability(sub, state.Availability)
seen := trimSiteSearchSeen(state.Seen) seen := trimSiteSearchSeen(state.Seen)
_ = s.repo.Setting.Set(ctx, guidKey, strings.Join(seen, "\n")) if err := s.repo.Setting.Set(ctx, guidKey, strings.Join(seen, "\n")); err != nil && s.log != nil {
s.log.Warn("site-search subscription seen state update failed",
zap.String("subscription_id", sub.ID),
zap.String("subscription", sub.Name),
zap.Error(err))
}
now := time.Now() now := time.Now()
_ = s.repo.DB.Model(sub).Updates(map[string]any{"last_run_at": &now}).Error if err := s.repo.DB.Model(sub).Updates(map[string]any{"last_run_at": &now}).Error; err != nil && s.log != nil {
_ = s.archiveCompletedSubscription(ctx, sub, availability) s.log.Warn("site-search subscription last_run_at update failed",
zap.String("subscription_id", sub.ID),
zap.String("subscription", sub.Name),
zap.Error(err))
}
if err := s.archiveCompletedSubscription(ctx, sub, availability); err != nil && s.log != nil {
s.log.Warn("site-search subscription archive check failed",
zap.String("subscription_id", sub.ID),
zap.String("subscription", sub.Name),
zap.Error(err))
}
return availability return availability
} }
func (s *SubscriptionService) handleSiteSearchQueueResult(sub *model.Subscription, keyword string, queueResult siteSearchQueueResult, selectionStats siteSearchSelectionStats, availability LocalAvailability) (int, error) { func (s *SubscriptionService) handleSiteSearchQueueResult(sub *model.Subscription, keyword string, queueResult siteSearchQueueResult, selectionStats siteSearchSelectionStats, availability LocalAvailability) (int, error) {
if queueResult.Queued > 0 { if queueResult.Queued > 0 {
if s.log != nil {
fields := subscriptionSiteSearchLogFields(sub, keyword)
fields = appendSiteSearchSelectionLogFields(fields, selectionStats)
fields = appendAvailabilityLogFields(fields, availability)
fields = append(fields,
zap.Int("queued", queueResult.Queued),
zap.Strings("resources", queueResult.Resources),
zap.Bool("archived", sub.ArchivedAt != nil),
zap.String("archive_reason", sub.ArchiveReason),
)
s.log.Info("site-search subscription queued resources", fields...)
}
s.hub.Publish("subscription", map[string]any{ s.hub.Publish("subscription", map[string]any{
"id": sub.ID, "id": sub.ID,
"name": sub.Name, "name": sub.Name,
@@ -186,7 +213,12 @@ func (s *SubscriptionService) finishSiteSearchNoResults(sub *model.Subscription,
s.log.Info("site-search subscription no results", fields...) s.log.Info("site-search subscription no results", fields...)
} }
now := time.Now() now := time.Now()
_ = s.repo.DB.Model(sub).Updates(map[string]any{"last_run_at": &now}).Error if err := s.repo.DB.Model(sub).Updates(map[string]any{"last_run_at": &now}).Error; err != nil && s.log != nil {
s.log.Warn("site-search subscription last_run_at update failed",
zap.String("subscription_id", sub.ID),
zap.String("subscription", sub.Name),
zap.Error(err))
}
return 0, nil return 0, nil
} }
+30
View File
@@ -0,0 +1,30 @@
import { api } from './client'
export interface RecognitionWordsConfig {
enabled: boolean
local_text: string
shared_urls: string[]
shared_text?: string
synced_at?: string
rule_count: number
}
export interface RecognitionWordsTestResult {
input: string
output: string
title: string
year: number
changed: boolean
}
export const recognitionWordsAPI = {
get: () => api.get<RecognitionWordsConfig>('/admin/recognition-words').then((r) => r.data),
save: (payload: RecognitionWordsConfig) =>
api.put<RecognitionWordsConfig>('/admin/recognition-words', payload).then((r) => r.data),
sync: () => api.post<RecognitionWordsConfig>('/admin/recognition-words/sync').then((r) => r.data),
test: (input: string) =>
api.post<RecognitionWordsTestResult>('/admin/recognition-words/test', { input }).then((r) => r.data),
}
+13 -2
View File
@@ -42,10 +42,14 @@ export function buildSubscriptionAliases(item: {
export const subscriptionsAPI = { export const subscriptionsAPI = {
list: () => list: () =>
api.get<{ items: Subscription[] }>('/subscriptions').then((r) => r.data.items), api
.get<{ items: Subscription[] }>('/subscriptions', subscriptionListRequestConfig())
.then((r) => r.data.items),
history: () => history: () =>
api.get<{ items: Subscription[] }>('/subscriptions/history').then((r) => r.data.items), api
.get<{ items: Subscription[] }>('/subscriptions/history', subscriptionListRequestConfig())
.then((r) => r.data.items),
create: (input: { create: (input: {
name: string name: string
@@ -86,3 +90,10 @@ export const subscriptionsAPI = {
runNow: (id: string) => runNow: (id: string) =>
api.post<{ queued: number }>(`/subscriptions/${id}/run`).then((r) => r.data), api.post<{ queued: number }>(`/subscriptions/${id}/run`).then((r) => r.data),
} }
function subscriptionListRequestConfig() {
return {
headers: { 'Cache-Control': 'no-cache' },
params: { _ts: Date.now() },
}
}
+204
View File
@@ -0,0 +1,204 @@
import { Link } from 'react-router-dom'
import { Menu, MessageSquareText, Search, Sparkles } from 'lucide-react'
import type { PlayProfile, User } from '../types'
import { LayoutSearchBox } from './LayoutSearchBox'
import { LayoutThemeToggle } from './LayoutThemeToggle'
import { LayoutUserMenu } from './LayoutUserMenu'
import type { useLayoutProfiles } from './useLayoutProfiles'
import type { useLayoutSearch } from './useLayoutSearch'
import type { ThemeMode, useThemeMode } from './useThemeMode'
type LayoutSearchState = ReturnType<typeof useLayoutSearch>
type LayoutProfileState = ReturnType<typeof useLayoutProfiles>
type LayoutThemeState = ReturnType<typeof useThemeMode>
type LayoutPermissionState = {
can: (key: string) => boolean
isAdmin: boolean
}
type LayoutHeaderProps = {
search: LayoutSearchState
permissions: LayoutPermissionState
theme: LayoutThemeState
onOpenMobileDrawer: () => void
user: User | null | undefined
activeProfileId: string | null
profile: LayoutProfileState
onLogout: () => void
}
export function LayoutHeader({
search,
permissions,
theme,
onOpenMobileDrawer,
user,
activeProfileId,
profile,
onLogout,
}: LayoutHeaderProps) {
return (
<header className="flex h-20 shrink-0 items-center justify-between border-b border-[var(--app-border)] bg-[var(--app-header-bg)] px-4 backdrop-blur-md z-30 md:px-8">
<LayoutHeaderSearch search={search} onOpenMobileDrawer={onOpenMobileDrawer} />
<LayoutHeaderActions
permissions={permissions}
themeMode={theme.mode}
onThemeChange={theme.setMode}
user={user}
isProfileOpen={profile.isProfileOpen}
profiles={profile.profiles}
activeProfileId={activeProfileId}
activeProfile={profile.activeProfile}
onToggleProfile={() => profile.setIsProfileOpen((open) => !open)}
onCloseProfile={() => profile.setIsProfileOpen(false)}
onUseDefaultProfile={profile.useDefaultProfile}
onSwitchProfile={profile.switchProfile}
onLogout={onLogout}
/>
</header>
)
}
function LayoutHeaderSearch({
search,
onOpenMobileDrawer,
}: {
search: LayoutSearchState
onOpenMobileDrawer: () => void
}) {
return (
<div className="flex items-center gap-3 flex-1 max-w-lg md:gap-4">
<button
onClick={onOpenMobileDrawer}
className="rounded-xl border border-[var(--app-border)] p-2.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-colors lg:hidden"
>
<Menu size={18} />
</button>
<LayoutSearchBox
query={search.query}
focused={search.focused}
loading={search.loading}
error={search.error}
cards={search.cards}
total={search.total}
onQueryChange={search.setQuery}
onFocusedChange={search.setFocused}
onSubmit={search.submit}
/>
</div>
)
}
type LayoutHeaderActionsProps = {
permissions: LayoutPermissionState
themeMode: ThemeMode
onThemeChange: (mode: ThemeMode) => void
user: User | null | undefined
isProfileOpen: boolean
profiles: PlayProfile[]
activeProfileId: string | null
activeProfile: PlayProfile | null
onToggleProfile: () => void
onCloseProfile: () => void
onUseDefaultProfile: () => void
onSwitchProfile: (profile: PlayProfile) => void
onLogout: () => void
}
function LayoutHeaderActions({
permissions,
themeMode,
onThemeChange,
user,
isProfileOpen,
profiles,
activeProfileId,
activeProfile,
onToggleProfile,
onCloseProfile,
onUseDefaultProfile,
onSwitchProfile,
onLogout,
}: LayoutHeaderActionsProps) {
return (
<div className="flex shrink-0 items-center gap-2 sm:gap-3 md:gap-4">
<LayoutQuickActions permissions={permissions} />
<LayoutThemeToggle mode={themeMode} onChange={onThemeChange} />
<span className="hidden h-6 w-px bg-[var(--app-border)] sm:block" />
<LayoutProfileMenu
user={user}
isProfileOpen={isProfileOpen}
profiles={profiles}
activeProfileId={activeProfileId}
activeProfile={activeProfile}
onToggleProfile={onToggleProfile}
onCloseProfile={onCloseProfile}
onUseDefaultProfile={onUseDefaultProfile}
onSwitchProfile={onSwitchProfile}
onLogout={onLogout}
/>
</div>
)
}
function LayoutQuickActions({ permissions }: { permissions: LayoutPermissionState }) {
return (
<>
<Link
to="/search"
className="rounded-xl border border-[var(--app-border)] p-2.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-colors sm:hidden"
>
<Search size={18} />
</Link>
{permissions.can('can_view_discover') && (
<Link
to="/discover"
className="hidden md:flex items-center gap-2 rounded-xl border border-[var(--app-border)] px-4 py-2.5 text-xs font-bold text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-all"
>
<Sparkles size={14} className="text-brand-500" />
<span>发现新片</span>
</Link>
)}
{permissions.isAdmin && (
<Link
to="/notify-channels"
title="通知配置"
aria-label="打开通知配置"
className="relative rounded-xl border border-[var(--app-border)] p-2.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-all"
>
<MessageSquareText size={18} />
</Link>
)}
</>
)
}
function LayoutProfileMenu({
user,
isProfileOpen,
profiles,
activeProfileId,
activeProfile,
onToggleProfile,
onCloseProfile,
onUseDefaultProfile,
onSwitchProfile,
onLogout,
}: Omit<LayoutHeaderActionsProps, 'permissions' | 'themeMode' | 'onThemeChange'>) {
return (
<LayoutUserMenu
user={user}
isOpen={isProfileOpen}
profiles={profiles}
activeProfileId={activeProfileId}
activeProfile={activeProfile}
onToggle={onToggleProfile}
onClose={onCloseProfile}
onUseDefaultProfile={onUseDefaultProfile}
onSwitchProfile={onSwitchProfile}
onLogout={onLogout}
/>
)
}
+3 -203
View File
@@ -1,29 +1,12 @@
import { Link, Outlet } from 'react-router-dom' import { Outlet } from 'react-router-dom'
import { AnimatePresence, motion } from 'framer-motion' import { AnimatePresence, motion } from 'framer-motion'
import { Menu, MessageSquareText, Search, Sparkles } from 'lucide-react'
import clsx from 'clsx' import clsx from 'clsx'
import type { PlayProfile, User } from '../types'
import { AppFooter } from './AppFooter' import { AppFooter } from './AppFooter'
import { LayoutSearchBox } from './LayoutSearchBox'
import { LayoutSidebarContent, type LayoutSidebarContentProps } from './LayoutSidebarContent' import { LayoutSidebarContent, type LayoutSidebarContentProps } from './LayoutSidebarContent'
import { LayoutThemeToggle } from './LayoutThemeToggle'
import { LayoutUserMenu } from './LayoutUserMenu'
import type { ThemeMode } from './useThemeMode'
import type { useLayoutSearch } from './useLayoutSearch'
import type { useLayoutProfiles } from './useLayoutProfiles'
import type { useLayoutSidebar } from './useLayoutSidebar' import type { useLayoutSidebar } from './useLayoutSidebar'
import type { useThemeMode } from './useThemeMode'
type LayoutSearchState = ReturnType<typeof useLayoutSearch>
type LayoutProfileState = ReturnType<typeof useLayoutProfiles>
type LayoutSidebarState = ReturnType<typeof useLayoutSidebar> type LayoutSidebarState = ReturnType<typeof useLayoutSidebar>
type LayoutThemeState = ReturnType<typeof useThemeMode>
type LayoutPermissionState = {
can: (key: string) => boolean
isAdmin: boolean
}
type LayoutSidebarProps = { type LayoutSidebarProps = {
children: React.ReactNode children: React.ReactNode
@@ -36,17 +19,6 @@ type LayoutMobileSidebarProps = {
onClose: () => void onClose: () => void
} }
type LayoutHeaderProps = {
search: LayoutSearchState
permissions: LayoutPermissionState
theme: LayoutThemeState
onOpenMobileDrawer: () => void
user: User | null | undefined
activeProfileId: string | null
profile: LayoutProfileState
onLogout: () => void
}
type LayoutSidebarsProps = Omit< type LayoutSidebarsProps = Omit<
LayoutSidebarContentProps, LayoutSidebarContentProps,
'isSidebarOpen' | 'isMobileDrawerOpen' | 'openGroups' | 'isRouteIn' | 'onToggleGroup' | 'onToggleSidebar' | 'onCloseMobileDrawer' 'isSidebarOpen' | 'isMobileDrawerOpen' | 'openGroups' | 'isRouteIn' | 'onToggleGroup' | 'onToggleSidebar' | 'onCloseMobileDrawer'
@@ -58,6 +30,8 @@ type LayoutWorkspaceProps = {
routeKey: string routeKey: string
} }
export { LayoutHeader } from './LayoutHeaderSections'
export function LayoutDesktopSidebar({ children, isSidebarOpen }: LayoutSidebarProps) { export function LayoutDesktopSidebar({ children, isSidebarOpen }: LayoutSidebarProps) {
return ( return (
<aside <aside
@@ -134,38 +108,6 @@ export function LayoutSidebars({
) )
} }
export function LayoutHeader({
search,
permissions,
theme,
onOpenMobileDrawer,
user,
activeProfileId,
profile,
onLogout,
}: LayoutHeaderProps) {
return (
<header className="flex h-20 shrink-0 items-center justify-between border-b border-[var(--app-border)] bg-[var(--app-header-bg)] px-4 backdrop-blur-md z-30 md:px-8">
<LayoutHeaderSearch search={search} onOpenMobileDrawer={onOpenMobileDrawer} />
<LayoutHeaderActions
permissions={permissions}
themeMode={theme.mode}
onThemeChange={theme.setMode}
user={user}
isProfileOpen={profile.isProfileOpen}
profiles={profile.profiles}
activeProfileId={activeProfileId}
activeProfile={profile.activeProfile}
onToggleProfile={() => profile.setIsProfileOpen((open) => !open)}
onCloseProfile={() => profile.setIsProfileOpen(false)}
onUseDefaultProfile={profile.useDefaultProfile}
onSwitchProfile={profile.switchProfile}
onLogout={onLogout}
/>
</header>
)
}
export function LayoutWorkspace({ routeKey }: LayoutWorkspaceProps) { export function LayoutWorkspace({ routeKey }: LayoutWorkspaceProps) {
return ( return (
<main className="flex-1 overflow-y-auto px-4 py-6 md:px-8 md:py-10"> <main className="flex-1 overflow-y-auto px-4 py-6 md:px-8 md:py-10">
@@ -193,145 +135,3 @@ export function LayoutFrameFooter() {
} }
export { LayoutSidebarContent } export { LayoutSidebarContent }
function LayoutHeaderSearch({
search,
onOpenMobileDrawer,
}: {
search: LayoutSearchState
onOpenMobileDrawer: () => void
}) {
return (
<div className="flex items-center gap-3 flex-1 max-w-lg md:gap-4">
<button
onClick={onOpenMobileDrawer}
className="rounded-xl border border-[var(--app-border)] p-2.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-colors lg:hidden"
>
<Menu size={18} />
</button>
<LayoutSearchBox
query={search.query}
focused={search.focused}
loading={search.loading}
error={search.error}
cards={search.cards}
total={search.total}
onQueryChange={search.setQuery}
onFocusedChange={search.setFocused}
onSubmit={search.submit}
/>
</div>
)
}
type LayoutHeaderActionsProps = {
permissions: LayoutPermissionState
themeMode: ThemeMode
onThemeChange: (mode: ThemeMode) => void
user: User | null | undefined
isProfileOpen: boolean
profiles: PlayProfile[]
activeProfileId: string | null
activeProfile: PlayProfile | null
onToggleProfile: () => void
onCloseProfile: () => void
onUseDefaultProfile: () => void
onSwitchProfile: (profile: PlayProfile) => void
onLogout: () => void
}
function LayoutHeaderActions({
permissions,
themeMode,
onThemeChange,
user,
isProfileOpen,
profiles,
activeProfileId,
activeProfile,
onToggleProfile,
onCloseProfile,
onUseDefaultProfile,
onSwitchProfile,
onLogout,
}: LayoutHeaderActionsProps) {
return (
<div className="flex shrink-0 items-center gap-2 sm:gap-3 md:gap-4">
<LayoutQuickActions permissions={permissions} />
<LayoutThemeToggle mode={themeMode} onChange={onThemeChange} />
<span className="hidden h-6 w-px bg-[var(--app-border)] sm:block" />
<LayoutProfileMenu
user={user}
isProfileOpen={isProfileOpen}
profiles={profiles}
activeProfileId={activeProfileId}
activeProfile={activeProfile}
onToggleProfile={onToggleProfile}
onCloseProfile={onCloseProfile}
onUseDefaultProfile={onUseDefaultProfile}
onSwitchProfile={onSwitchProfile}
onLogout={onLogout}
/>
</div>
)
}
function LayoutQuickActions({ permissions }: { permissions: LayoutPermissionState }) {
return (
<>
<Link
to="/search"
className="rounded-xl border border-[var(--app-border)] p-2.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-colors sm:hidden"
>
<Search size={18} />
</Link>
{permissions.can('can_view_discover') && (
<Link
to="/discover"
className="hidden md:flex items-center gap-2 rounded-xl border border-[var(--app-border)] px-4 py-2.5 text-xs font-bold text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-all"
>
<Sparkles size={14} className="text-brand-500" />
<span>发现新片</span>
</Link>
)}
{permissions.isAdmin && (
<Link
to="/notify-channels"
title="通知配置"
aria-label="打开通知配置"
className="relative rounded-xl border border-[var(--app-border)] p-2.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-all"
>
<MessageSquareText size={18} />
</Link>
)}
</>
)
}
function LayoutProfileMenu({
user,
isProfileOpen,
profiles,
activeProfileId,
activeProfile,
onToggleProfile,
onCloseProfile,
onUseDefaultProfile,
onSwitchProfile,
onLogout,
}: Omit<LayoutHeaderActionsProps, 'permissions' | 'themeMode' | 'onThemeChange'>) {
return (
<LayoutUserMenu
user={user}
isOpen={isProfileOpen}
profiles={profiles}
activeProfileId={activeProfileId}
activeProfile={activeProfile}
onToggle={onToggleProfile}
onClose={onCloseProfile}
onUseDefaultProfile={onUseDefaultProfile}
onSwitchProfile={onSwitchProfile}
onLogout={onLogout}
/>
)
}
+3 -21
View File
@@ -1,6 +1,5 @@
import type { LucideIcon } from 'lucide-react' import type { LucideIcon } from 'lucide-react'
import { import {
Activity,
Cast, Cast,
Clock, Clock,
CloudDownload, CloudDownload,
@@ -13,15 +12,12 @@ import {
KeySquare, KeySquare,
Library, Library,
ListMusic, ListMusic,
MessageSquareText,
Rss, Rss,
Search, Search,
Settings, Settings,
Sliders, Sliders,
Sparkles, Sparkles,
Trash2,
User, User,
UserCog,
} from 'lucide-react' } from 'lucide-react'
export type LayoutNavGroupID = 'media' | 'personal' | 'downloads' | 'tools' | 'system' export type LayoutNavGroupID = 'media' | 'personal' | 'downloads' | 'tools' | 'system'
@@ -69,20 +65,17 @@ export const LAYOUT_NAV_GROUPS: LayoutNavGroup[] = [
{ to: '/favourites', label: '我的收藏', icon: Heart }, { to: '/favourites', label: '我的收藏', icon: Heart },
{ to: '/playlists', label: '播放列表', icon: ListMusic }, { to: '/playlists', label: '播放列表', icon: ListMusic },
{ to: '/history', label: '观看历史', icon: Clock }, { to: '/history', label: '观看历史', icon: Clock },
{ to: '/profile', label: '账号信息', icon: User },
{ to: '/play-profiles', label: '观影 Profile', icon: UserCog },
], ],
}, },
{ {
id: 'downloads', id: 'downloads',
label: '下载与订阅', label: '下载与订阅',
icon: CloudDownload, icon: CloudDownload,
activePaths: ['/downloads', '/download-clients', '/subscriptions', '/site-search'], activePaths: ['/downloads', '/download-clients', '/subscriptions', '/site-search', '/sites'],
items: [ items: [
{ to: '/downloads', label: '下载中心', icon: CloudDownload, permission: 'can_manage_downloads' }, { to: '/downloads', label: '下载中心', icon: CloudDownload, permission: 'can_manage_downloads' },
{ to: '/subscriptions', label: '订阅管理', icon: Rss, permission: 'can_manage_subscriptions' }, { to: '/subscriptions', label: '订阅管理', icon: Rss, permission: 'can_manage_subscriptions' },
{ to: '/site-search', label: '站点检索', icon: Search, permission: 'can_manage_sites' }, { to: '/sites', label: '站点管理', icon: Globe, permission: 'can_manage_sites' },
{ to: '/download-clients', label: '下载器管理', icon: Sliders, adminOnly: true },
], ],
}, },
{ {
@@ -93,14 +86,6 @@ export const LAYOUT_NAV_GROUPS: LayoutNavGroup[] = [
adminOnly: true, adminOnly: true,
items: [ items: [
{ to: '/storage', label: '存储与文件', icon: HardDrive }, { to: '/storage', label: '存储与文件', icon: HardDrive },
{ to: '/storage-config', label: '外部存储', icon: CloudDownload },
{ to: '/files', label: '文件管理', icon: Library },
{ to: '/strm', label: 'STRM 管理', icon: Cast },
{ to: '/duplicates', label: '重复文件', icon: Image },
{ to: '/tasks', label: '任务队列', icon: Activity },
{ to: '/scheduler', label: '计划任务', icon: Clock },
{ to: '/recycle', label: '回收站', icon: Trash2 },
{ to: '/stats', label: '运行状态', icon: Activity },
], ],
}, },
{ {
@@ -111,11 +96,8 @@ export const LAYOUT_NAV_GROUPS: LayoutNavGroup[] = [
adminOnly: true, adminOnly: true,
items: [ items: [
{ to: '/admin', label: '媒体与用户', icon: Settings }, { to: '/admin', label: '媒体与用户', icon: Settings },
{ to: '/sites', label: '站点管理', icon: Globe },
{ to: '/notify-channels', label: '通知渠道', icon: MessageSquareText },
{ to: '/assistant', label: 'AI 会话', icon: Sparkles },
{ to: '/license', label: '授权许可', icon: KeySquare },
{ to: '/settings', label: '系统设置', icon: Sliders }, { to: '/settings', label: '系统设置', icon: Sliders },
{ to: '/license', label: '授权许可', icon: KeySquare },
], ],
}, },
] ]
+36 -2
View File
@@ -98,22 +98,56 @@ body {
:root[data-theme='dark'] .bg-white, :root[data-theme='dark'] .bg-white,
:root[data-theme='dark'] .bg-gray-50, :root[data-theme='dark'] .bg-gray-50,
:root[data-theme='dark'] .bg-gray-50\/50, :root[data-theme='dark'] .bg-gray-50\/50,
:root[data-theme='dark'] .bg-gray-50\/60,
:root[data-theme='dark'] .bg-gray-50\/70, :root[data-theme='dark'] .bg-gray-50\/70,
:root[data-theme='dark'] .bg-gray-50\/80,
:root[data-theme='dark'] .bg-gray-50\/90,
:root[data-theme='dark'] .bg-white\/50,
:root[data-theme='dark'] .bg-white\/60,
:root[data-theme='dark'] .bg-white\/70,
:root[data-theme='dark'] .bg-white\/75,
:root[data-theme='dark'] .bg-white\/80, :root[data-theme='dark'] .bg-white\/80,
:root[data-theme='dark'] .bg-white\/82, :root[data-theme='dark'] .bg-white\/82,
:root[data-theme='dark'] .bg-white\/85 { :root[data-theme='dark'] .bg-white\/85,
:root[data-theme='dark'] .bg-white\/90,
:root[data-theme='dark'] .bg-white\/95 {
background-color: var(--app-panel) !important; background-color: var(--app-panel) !important;
} }
:root[data-theme='dark'] .bg-gray-100, :root[data-theme='dark'] .bg-gray-100,
:root[data-theme='dark'] .bg-gray-100\/50 { :root[data-theme='dark'] .bg-gray-100\/50,
:root[data-theme='dark'] .bg-gray-100\/60,
:root[data-theme='dark'] .bg-gray-100\/70,
:root[data-theme='dark'] .bg-gray-100\/80 {
background-color: var(--app-hover) !important; background-color: var(--app-hover) !important;
} }
:root[data-theme='dark'] .bg-brand-50,
:root[data-theme='dark'] .bg-primary-400\/10,
:root[data-theme='dark'] .bg-primary-400\/15,
:root[data-theme='dark'] .bg-primary-400\/20,
:root[data-theme='dark'] .bg-sand-50,
:root[data-theme='dark'] .bg-amber-50,
:root[data-theme='dark'] .bg-yellow-50,
:root[data-theme='dark'] .bg-emerald-50,
:root[data-theme='dark'] .bg-green-50,
:root[data-theme='dark'] .bg-red-50 {
background-color: var(--app-control-bg) !important;
}
:root[data-theme='dark'] .bg-\[\#f9fafb\] { :root[data-theme='dark'] .bg-\[\#f9fafb\] {
background-color: var(--app-bg) !important; background-color: var(--app-bg) !important;
} }
:root[data-theme='dark'] .hover\:bg-gray-50:hover,
:root[data-theme='dark'] .hover\:bg-gray-50\/50:hover,
:root[data-theme='dark'] .hover\:bg-gray-100:hover,
:root[data-theme='dark'] .hover\:bg-brand-50:hover,
:root[data-theme='dark'] .hover\:bg-primary-400\/10:hover,
:root[data-theme='dark'] .hover\:bg-red-100:hover {
background-color: var(--app-hover) !important;
}
:root[data-theme='dark'] .text-gray-900, :root[data-theme='dark'] .text-gray-900,
:root[data-theme='dark'] .text-gray-950, :root[data-theme='dark'] .text-gray-950,
:root[data-theme='dark'] .text-\[\#111827\], :root[data-theme='dark'] .text-\[\#111827\],
+1 -4
View File
@@ -3,7 +3,7 @@ import { AdminLibraryTable } from './AdminLibraryTable'
import { useAdminLibraryPanel } from './useAdminLibraryPanel' import { useAdminLibraryPanel } from './useAdminLibraryPanel'
export function AdminLibraryPanel() { export function AdminLibraryPanel() {
const { libs, createForm, newRoots, editableRoots, rootActions, libraryActions } = useAdminLibraryPanel() const { libs, createForm, editableRoots, rootActions, libraryActions } = useAdminLibraryPanel()
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -20,11 +20,8 @@ export function AdminLibraryPanel() {
/> />
<AdminLibraryTable <AdminLibraryTable
libs={libs} libs={libs}
newRootDraft={newRoots.newRootDraft}
editableRootDraft={editableRoots.editableRootDraft} editableRootDraft={editableRoots.editableRootDraft}
onNewRootChange={newRoots.setNewRootDraft}
onEditableRootChange={editableRoots.setEditableRootDraft} onEditableRootChange={editableRoots.setEditableRootDraft}
onAddRoot={newRoots.addLibraryRoot}
onSaveRoot={rootActions.saveLibraryRoot} onSaveRoot={rootActions.saveLibraryRoot}
onScanRoot={rootActions.scanLibraryRoot} onScanRoot={rootActions.scanLibraryRoot}
onToggleRoot={rootActions.toggleLibraryRoot} onToggleRoot={rootActions.toggleLibraryRoot}
+4 -4
View File
@@ -58,11 +58,11 @@ export function AdminLibraryCreateForm({
</button> </button>
</div> </div>
<p className="md:col-span-4 -mt-2 text-xs text-sand-500"> <p className="md:col-span-4 -mt-2 text-xs text-sand-500">
Docker 部署时请优先填写容器内路径,例如 /media/电影、/media/电视剧/国产剧;如果误填 NAS 名称和类型与现有媒体库一致时,会自动把这里填写的路径追加到该媒体库;Docker
宿主机路径,系统会尝试按 compose 挂载自动转换。 部署请优先填写容器内路径,例如 /media/电影、/media/电视剧/国产剧。
</p> </p>
<button type="submit" className="neon-button md:col-span-4"> <button type="submit" className="neon-button md:col-span-4">
新建媒体库 新建 / 追加路径
</button> </button>
</form> </form>
) )
@@ -78,7 +78,7 @@ type CreateRootRowProps = {
function CreateRootRow({ root, index, canRemove, onChange, onRemove }: CreateRootRowProps) { function CreateRootRow({ root, index, canRemove, onChange, onRemove }: CreateRootRowProps) {
return ( return (
<div className="grid gap-2 md:grid-cols-[minmax(0,1fr)_minmax(0,2fr)_auto]"> <div className="grid gap-2 rounded-xl border border-gray-200/80 bg-white/60 p-2 md:grid-cols-[minmax(0,1fr)_minmax(0,2fr)_auto]">
<input <input
className="input-base" className="input-base"
placeholder="路径名称" placeholder="路径名称"
+131 -92
View File
@@ -1,16 +1,14 @@
import { Plus, RefreshCw, Save, Trash2 } from 'lucide-react' import type { MouseEvent, ReactNode } from 'react'
import { MoreVertical, Power, PowerOff, RefreshCw, Save, Trash2 } from 'lucide-react'
import type { Library, LibraryRoot } from '../types' import type { Library, LibraryRoot } from '../types'
import type { RootDraft } from './adminLibraryPanelModel' import type { RootDraft } from './adminLibraryPanelModel'
import { fallbackLibraryRoot } from './adminLibraryPanelModel' import { displayLibraryRootName, displayLibraryRootPath, fallbackLibraryRoot } from './adminLibraryPanelModel'
type LibraryTableProps = { type LibraryTableProps = {
libs: Library[] libs: Library[]
newRootDraft: (libraryID: string) => RootDraft
editableRootDraft: (libraryID: string, root: LibraryRoot) => RootDraft editableRootDraft: (libraryID: string, root: LibraryRoot) => RootDraft
onNewRootChange: (libraryID: string, patch: Partial<RootDraft>) => void
onEditableRootChange: (libraryID: string, root: LibraryRoot, patch: Partial<RootDraft>) => void onEditableRootChange: (libraryID: string, root: LibraryRoot, patch: Partial<RootDraft>) => void
onAddRoot: (libraryID: string) => void
onSaveRoot: (libraryID: string, root: LibraryRoot) => void onSaveRoot: (libraryID: string, root: LibraryRoot) => void
onScanRoot: (libraryID: string, root: LibraryRoot) => void onScanRoot: (libraryID: string, root: LibraryRoot) => void
onToggleRoot: (libraryID: string, root: LibraryRoot) => void onToggleRoot: (libraryID: string, root: LibraryRoot) => void
@@ -21,14 +19,14 @@ type LibraryTableProps = {
export function AdminLibraryTable({ libs, ...actions }: LibraryTableProps) { export function AdminLibraryTable({ libs, ...actions }: LibraryTableProps) {
return ( return (
<div className="glass-panel"> <div className="glass-panel overflow-x-auto !p-3">
<table className="w-full text-left text-sm"> <table className="w-full min-w-[900px] text-left text-sm">
<thead className="text-xs uppercase tracking-wider text-sand-500"> <thead className="text-xs uppercase tracking-wider text-sand-500">
<tr> <tr>
<th className="py-2">名称</th> <th className="w-28 py-2">名称</th>
<th>路径</th> <th>路径</th>
<th>类型</th> <th className="w-20">类型</th>
<th className="text-right">操作</th> <th className="w-12 text-right">操作</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -48,12 +46,12 @@ type LibraryTableRowProps = Omit<LibraryTableProps, 'libs'> & {
function LibraryTableRow({ library, ...actions }: LibraryTableRowProps) { function LibraryTableRow({ library, ...actions }: LibraryTableRowProps) {
return ( return (
<tr className="border-t border-gray-200"> <tr className="border-t border-gray-200">
<td className="py-2 text-ink-600">{library.name}</td> <td className="py-2 pr-3 font-medium text-ink-600">{library.name}</td>
<td className="text-ink-100"> <td className="py-1.5 text-ink-100">
<LibraryRootsCell library={library} {...actions} /> <LibraryRootsCell library={library} {...actions} />
</td> </td>
<td className="text-ink-100">{library.type}</td> <td className="px-3 text-ink-100">{library.type}</td>
<td className="space-x-2 py-2 text-right"> <td className="py-2 text-right">
<LibraryActionsCell library={library} {...actions} /> <LibraryActionsCell library={library} {...actions} />
</td> </td>
</tr> </tr>
@@ -63,11 +61,10 @@ function LibraryTableRow({ library, ...actions }: LibraryTableRowProps) {
function LibraryRootsCell({ library, ...actions }: LibraryTableRowProps) { function LibraryRootsCell({ library, ...actions }: LibraryTableRowProps) {
const roots = library.roots?.length ? library.roots : [fallbackLibraryRoot(library)] const roots = library.roots?.length ? library.roots : [fallbackLibraryRoot(library)]
return ( return (
<div className="space-y-2"> <div className="min-w-[520px] space-y-1">
{roots.map((root) => ( {roots.map((root) => (
<ExistingRootEditor key={root.id || root.path} library={library} root={root} {...actions} /> <ExistingRootEditor key={root.id || root.path} library={library} root={root} {...actions} />
))} ))}
<AddRootRow library={library} {...actions} />
</div> </div>
) )
} }
@@ -80,30 +77,36 @@ type RootEditorProps = Omit<LibraryTableRowProps, 'library'> & {
function ExistingRootEditor({ library, root, ...actions }: RootEditorProps) { function ExistingRootEditor({ library, root, ...actions }: RootEditorProps) {
const draft = actions.editableRootDraft(library.id, root) const draft = actions.editableRootDraft(library.id, root)
return ( return (
<div className="rounded border border-gray-200/70 p-2"> <div className="grid items-center gap-1.5 rounded-lg border border-gray-200/80 bg-gray-50/60 p-1.5 xl:grid-cols-[minmax(92px,0.65fr)_minmax(240px,2fr)_auto_auto]">
<div className="grid gap-2 xl:grid-cols-[minmax(120px,0.8fr)_minmax(220px,2fr)_auto]"> {root.id ? <EditableRootFields library={library} root={root} draft={draft} {...actions} /> : <ReadonlyRootFields root={root} />}
{root.id ? ( <RootStatus enabled={draft.enabled ?? root.enabled} />
<EditableRootFields library={library} root={root} draft={draft} {...actions} /> <RootActionButtons library={library} root={root} draft={draft} {...actions} />
) : (
<span className="min-w-0 break-all xl:col-span-2">{root.name ? `${root.name}:${root.path}` : root.path}</span>
)}
<RootActionButtons library={library} root={root} draft={draft} {...actions} />
</div>
</div> </div>
) )
} }
function ReadonlyRootFields({ root }: { root: LibraryRoot }) {
return (
<>
<span className="truncate rounded-md bg-white/80 px-2.5 py-1.5 text-xs text-ink-600">{displayLibraryRootName(root.name, root.path)}</span>
<span className="min-w-0 truncate rounded-md bg-white/80 px-2.5 py-1.5 text-xs text-ink-100" title={displayLibraryRootPath(root.path)}>
{displayLibraryRootPath(root.path)}
</span>
</>
)
}
function EditableRootFields({ library, root, draft, onEditableRootChange }: RootEditorProps & { draft: RootDraft }) { function EditableRootFields({ library, root, draft, onEditableRootChange }: RootEditorProps & { draft: RootDraft }) {
return ( return (
<> <>
<input <input
className="input-base" className="h-9 w-full rounded-lg border border-gray-200 bg-white/80 px-3 text-xs text-gray-900 outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-100/60"
placeholder="路径名称" placeholder="路径名称"
value={draft.name ?? ''} value={draft.name ?? ''}
onChange={(e) => onEditableRootChange(library.id, root, { name: e.target.value })} onChange={(e) => onEditableRootChange(library.id, root, { name: e.target.value })}
/> />
<input <input
className="input-base" className="h-9 w-full rounded-lg border border-gray-200 bg-white/80 px-3 text-xs text-gray-900 outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-100/60"
placeholder="真实路径" placeholder="真实路径"
value={draft.path} value={draft.path}
onChange={(e) => onEditableRootChange(library.id, root, { path: e.target.value })} onChange={(e) => onEditableRootChange(library.id, root, { path: e.target.value })}
@@ -112,81 +115,117 @@ function EditableRootFields({ library, root, draft, onEditableRootChange }: Root
) )
} }
function RootActionButtons({ library, root, draft, ...actions }: RootEditorProps & { draft: RootDraft }) { function RootStatus({ enabled }: { enabled: boolean }) {
return ( return (
<div className="flex flex-wrap items-center gap-2"> <span
{root.id && ( className={`whitespace-nowrap rounded-md border px-2 py-1 text-xs ${
<button enabled ? 'border-emerald-300/60 text-emerald-600' : 'border-gray-300 text-ink-50'
className="rounded border border-primary-400/40 p-1 text-brand-500 hover:bg-primary-400/10" }`}
title="保存路径" >
onClick={() => actions.onSaveRoot(library.id, root)} {enabled ? '启用' : '禁用'}
> </span>
<Save size={14} />
</button>
)}
<button
className="rounded border border-primary-400/40 p-1 text-brand-500 hover:bg-primary-400/10"
title="扫描路径"
onClick={() => actions.onScanRoot(library.id, root)}
>
<RefreshCw size={14} />
</button>
{root.id && (
<button className="rounded border border-gray-300 px-2 py-1 text-xs" onClick={() => actions.onToggleRoot(library.id, root)}>
{draft.enabled ? '启用' : '禁用'}
</button>
)}
{root.id && (
<button
className="rounded border border-red-400/40 p-1 text-red-400 hover:bg-red-400/10"
title="删除路径"
onClick={() => actions.onRemoveRoot(library, root)}
>
<Trash2 size={14} />
</button>
)}
</div>
) )
} }
function AddRootRow({ library, ...actions }: LibraryTableRowProps) { function RootActionButtons({ library, root, draft, ...actions }: RootEditorProps & { draft: RootDraft }) {
const draft = actions.newRootDraft(library.id) const enabled = draft.enabled ?? root.enabled
return ( return (
<div className="grid gap-2 md:grid-cols-[minmax(0,1fr)_minmax(0,2fr)_auto]"> <ActionMenu label="路径操作">
<input {root.id && (
className="input-base" <MenuButton
placeholder="路径名称" icon={<Save size={14} />}
value={draft.name ?? ''} label="保存"
onChange={(e) => actions.onNewRootChange(library.id, { name: e.target.value })} onClick={() => actions.onSaveRoot(library.id, root)}
/> >
<input 保存
className="input-base" </MenuButton>
placeholder="新增路径" )}
value={draft.path} <MenuButton
onChange={(e) => actions.onNewRootChange(library.id, { path: e.target.value })} icon={<RefreshCw size={14} />}
/> label="扫描"
<button className="rounded-lg border px-3 py-2 text-sm" onClick={() => actions.onAddRoot(library.id)}> onClick={() => actions.onScanRoot(library.id, root)}
<Plus size={14} /> >
</button> 扫描
</div> </MenuButton>
{root.id && (
<MenuButton
icon={enabled ? <PowerOff size={14} /> : <Power size={14} />}
label={enabled ? '禁用' : '启用'}
onClick={() => actions.onToggleRoot(library.id, root)}
>
{enabled ? '禁用' : '启用'}
</MenuButton>
)}
{root.id && (
<MenuButton
danger
icon={<Trash2 size={14} />}
label="删除"
onClick={() => actions.onRemoveRoot(library, root)}
>
删除
</MenuButton>
)}
</ActionMenu>
) )
} }
function LibraryActionsCell({ library, onScanLibrary, onRemoveLibrary }: LibraryTableRowProps) { function LibraryActionsCell({ library, onScanLibrary, onRemoveLibrary }: LibraryTableRowProps) {
return ( return (
<> <ActionMenu label="媒体库操作">
<button <MenuButton icon={<RefreshCw size={14} />} label="扫描" onClick={() => onScanLibrary(library)}>
className="rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10"
onClick={() => onScanLibrary(library)}
>
扫描 扫描
</button> </MenuButton>
<button <MenuButton danger icon={<Trash2 size={14} />} label="删除" onClick={() => onRemoveLibrary(library)}>
className="rounded-lg border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10" 删除
onClick={() => onRemoveLibrary(library)} </MenuButton>
> </ActionMenu>
<Trash2 size={12} /> )
</button> }
</>
function ActionMenu({ label, children }: { label: string; children: ReactNode }) {
return (
<details className="group relative inline-flex justify-end">
<summary
className="flex h-8 w-8 cursor-pointer list-none items-center justify-center rounded-lg border border-gray-200 bg-white text-ink-50 transition hover:border-primary-400/50 hover:text-brand-500 [&::-webkit-details-marker]:hidden"
title={label}
>
<MoreVertical size={16} />
</summary>
<div className="absolute right-0 top-9 z-30 min-w-28 rounded-lg border border-gray-200 bg-white p-1 shadow-lg">
{children}
</div>
</details>
)
}
function MenuButton({
icon,
label,
danger,
onClick,
children,
}: {
icon: ReactNode
label: string
danger?: boolean
onClick: () => void
children: ReactNode
}) {
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
event.currentTarget.closest('details')?.removeAttribute('open')
onClick()
}
return (
<button
className={`flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-xs transition ${
danger ? 'text-red-500 hover:bg-red-50' : 'text-ink-100 hover:bg-gray-50 hover:text-brand-500'
}`}
title={label}
onClick={handleClick}
>
{icon}
<span>{children}</span>
</button>
) )
} }
+2
View File
@@ -42,6 +42,8 @@ export function AdminPage() {
{ to: '/download-clients', title: '下载器管理', description: '配置 qBittorrent 等下载器连接', badge: '下载', group: '站点与下载' }, { to: '/download-clients', title: '下载器管理', description: '配置 qBittorrent 等下载器连接', badge: '下载', group: '站点与下载' },
{ to: '/files', title: '手动整理', description: '从下载目录选择文件夹并整理入库', group: '文件与入库' }, { to: '/files', title: '手动整理', description: '从下载目录选择文件夹并整理入库', group: '文件与入库' },
{ to: '/storage', title: '存储与文件', description: '查看占用、清理重复项和管理文件', group: '文件与入库' }, { to: '/storage', title: '存储与文件', description: '查看占用、清理重复项和管理文件', group: '文件与入库' },
{ to: '/notify-channels', title: '通知渠道', description: '配置 Bot、Webhook、邮件等通知出口', group: '系统运维' },
{ to: '/assistant', title: 'AI 会话', description: '查看和接管后台 AI 操作记录', group: '系统运维' },
]} ]}
/> />
<div className="flex flex-wrap gap-2 border-b border-gray-200"> <div className="flex flex-wrap gap-2 border-b border-gray-200">
+2
View File
@@ -16,6 +16,7 @@ const TYPE_ICONS: Record<string, ReactNode> = {
anime: <PlayCircle size={18} />, anime: <PlayCircle size={18} />,
variety: <Tv size={18} />, variety: <Tv size={18} />,
music: <Music size={18} />, music: <Music size={18} />,
adult: <Film size={18} />,
} }
const TYPE_LABELS: Record<string, string> = { const TYPE_LABELS: Record<string, string> = {
@@ -24,6 +25,7 @@ const TYPE_LABELS: Record<string, string> = {
anime: '动漫', anime: '动漫',
variety: '综艺', variety: '综艺',
music: '音乐', music: '音乐',
adult: '成人',
} }
export function LibrariesHeader({ export function LibrariesHeader({
+45
View File
@@ -0,0 +1,45 @@
import { Loader2 } from 'lucide-react'
import {
RecognitionWordsActions,
RecognitionWordsEditors,
RecognitionWordsHeader,
RecognitionWordsTester,
} from './RecognitionWordsPanelSections'
import { useRecognitionWordsPanel } from './useRecognitionWordsPanel'
export function RecognitionWordsPanel() {
const state = useRecognitionWordsPanel()
if (state.loading) {
return (
<div className="flex justify-center py-12 text-ink-50">
<Loader2 className="animate-spin" />
</div>
)
}
return (
<div className="glass-panel space-y-4">
<RecognitionWordsHeader config={state.config} onEnabledChange={state.updateEnabled} />
<RecognitionWordsEditors
config={state.config}
onLocalTextChange={state.updateLocalText}
onSharedURLsChange={state.updateSharedURLs}
/>
<RecognitionWordsTester
input={state.testInput}
result={state.testResult}
testing={state.testing}
onInputChange={state.setTestInput}
onTest={state.test}
/>
<RecognitionWordsActions
saving={state.saving}
syncing={state.syncing}
onSave={state.save}
onSync={state.sync}
/>
</div>
)
}
@@ -0,0 +1,149 @@
import { Loader2, Play, RefreshCw, Save } from 'lucide-react'
import type { RecognitionWordsConfig, RecognitionWordsTestResult } from '../api/recognitionWords'
export function RecognitionWordsHeader({
config,
onEnabledChange,
}: {
config: RecognitionWordsConfig
onEnabledChange: (enabled: boolean) => void
}) {
return (
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold text-ink-600">自定义识别词</div>
<div className="text-xs text-sand-500">
当前已加载 {config.rule_count} 条规则{config.synced_at ? ` · 上次同步 ${new Date(config.synced_at).toLocaleString()}` : ''}
</div>
</div>
<label className="flex cursor-pointer items-center gap-2 text-sm text-ink-100">
<input
type="checkbox"
className="h-4 w-4 accent-primary-400"
checked={config.enabled}
onChange={(event) => onEnabledChange(event.target.checked)}
/>
启用
</label>
</div>
)
}
export function RecognitionWordsEditors({
config,
onLocalTextChange,
onSharedURLsChange,
}: {
config: RecognitionWordsConfig
onLocalTextChange: (value: string) => void
onSharedURLsChange: (value: string) => void
}) {
return (
<div className="grid gap-4 lg:grid-cols-2">
<label className="space-y-2">
<div className="text-sm font-medium text-ink-100">共享识别词源</div>
<textarea
rows={7}
className="input-base font-mono text-xs"
value={config.shared_urls.join('\n')}
onChange={(event) => onSharedURLsChange(event.target.value)}
/>
</label>
<label className="space-y-2">
<div className="text-sm font-medium text-ink-100">本地识别词</div>
<textarea
rows={7}
className="input-base font-mono text-xs"
placeholder={'屏蔽词\n错误标题 => 正确标题\nS01E <> . >> EP-10'}
value={config.local_text}
onChange={(event) => onLocalTextChange(event.target.value)}
/>
</label>
</div>
)
}
export function RecognitionWordsTester({
input,
result,
testing,
onInputChange,
onTest,
}: {
input: string
result: RecognitionWordsTestResult | null
testing: boolean
onInputChange: (value: string) => void
onTest: () => void
}) {
return (
<div className="rounded-lg border border-white/10 bg-black/10 p-3">
<div className="grid gap-3 md:grid-cols-[1fr_auto]">
<input
className="input-base"
placeholder="输入文件名或种子标题测试识别效果"
value={input}
onChange={(event) => onInputChange(event.target.value)}
/>
<button type="button" className="neon-button" onClick={onTest} disabled={testing || !input.trim()}>
{testing ? <Loader2 size={16} className="animate-spin" /> : <Play size={16} />}
测试
</button>
</div>
{result && <RecognitionWordsTestResultGrid result={result} />}
</div>
)
}
function RecognitionWordsTestResultGrid({ result }: { result: RecognitionWordsTestResult }) {
return (
<div className="mt-3 grid gap-2 text-xs text-ink-100 md:grid-cols-3">
<RecognitionWordsResultCard label="识别后" value={result.output || '-'} mono />
<RecognitionWordsResultCard label="标题" value={result.title || '-'} />
<RecognitionWordsResultCard label="年份" value={result.year || '-'} />
</div>
)
}
function RecognitionWordsResultCard({
label,
mono,
value,
}: {
label: string
mono?: boolean
value: string | number
}) {
return (
<div className="rounded-md bg-white/5 p-2">
<div className="text-sand-500">{label}</div>
<div className={mono ? 'break-all font-mono' : ''}>{value}</div>
</div>
)
}
export function RecognitionWordsActions({
saving,
syncing,
onSave,
onSync,
}: {
saving: boolean
syncing: boolean
onSave: () => void
onSync: () => void
}) {
return (
<div className="flex flex-wrap justify-end gap-2">
<button type="button" className="btn-secondary" onClick={onSync} disabled={syncing}>
{syncing ? <Loader2 size={16} className="animate-spin" /> : <RefreshCw size={16} />}
同步共享词
</button>
<button type="button" className="neon-button" onClick={onSave} disabled={saving}>
{saving ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
保存
</button>
</div>
)
}
+29 -25
View File
@@ -8,6 +8,7 @@ import type { Library, Setting } from '../types'
import { SettingRow } from './SettingsRow' import { SettingRow } from './SettingsRow'
import { ALL_KEYS, GROUPS } from './settingsGroups' import { ALL_KEYS, GROUPS } from './settingsGroups'
import { SystemUpdatePanel } from './SystemUpdatePanel' import { SystemUpdatePanel } from './SystemUpdatePanel'
import { RecognitionWordsPanel } from './RecognitionWordsPanel'
export function SettingsPage() { export function SettingsPage() {
const [activeGroup, setActiveGroup] = useState(GROUPS[0].key) const [activeGroup, setActiveGroup] = useState(GROUPS[0].key)
@@ -107,31 +108,34 @@ export function SettingsPage() {
{!loading && ( {!loading && (
<div className="space-y-4"> <div className="space-y-4">
{group.key === 'system-update' && <SystemUpdatePanel />} {group.key === 'system-update' && <SystemUpdatePanel />}
<form onSubmit={onSave} className="glass-panel space-y-4"> {group.key === 'recognition-words' && <RecognitionWordsPanel />}
{group.description && <p className="text-xs text-sand-500">{group.description}</p>} {group.items.length > 0 && (
{group.items.map((it) => ( <form onSubmit={onSave} className="glass-panel space-y-4">
<SettingRow {group.description && <p className="text-xs text-sand-500">{group.description}</p>}
key={it.key} {group.items.map((it) => (
def={it} <SettingRow
value={values[it.key] ?? it.defaultValue ?? ''} key={it.key}
onChange={(v) => onChange(it.key, v)} def={it}
libraries={libraries} value={values[it.key] ?? it.defaultValue ?? ''}
/> onChange={(v) => onChange(it.key, v)}
))} libraries={libraries}
<div className="flex items-center justify-between pt-2"> />
<span className="text-xs text-sand-500"> ))}
{dirty.size > 0 ? `有 ${dirty.size} 项未保存` : '所有更改已保存'} <div className="flex items-center justify-between pt-2">
</span> <span className="text-xs text-sand-500">
<button {dirty.size > 0 ? `有 ${dirty.size} 项未保存` : '所有更改已保存'}
type="submit" </span>
disabled={saving || dirty.size === 0} <button
className="neon-button disabled:opacity-50" type="submit"
> disabled={saving || dirty.size === 0}
{saving ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />} className="neon-button disabled:opacity-50"
保存 >
</button> {saving ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
</div> 保存
</form> </button>
</div>
</form>
)}
</div> </div>
)} )}
</div> </div>
+6 -1
View File
@@ -124,13 +124,18 @@ export function SiteSearchPage() {
href={item.torrent_url} href={item.torrent_url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-white transition hover:text-brand-500" className="font-semibold text-ink-600 transition hover:text-brand-500"
title={item.title} title={item.title}
> >
{item.title.length > 80 {item.title.length > 80
? item.title.substring(0, 80) + '…' ? item.title.substring(0, 80) + '…'
: item.title} : item.title}
</a> </a>
{item.subtitle && (
<div className="mt-1 line-clamp-2 text-xs text-ink-50" title={item.subtitle}>
{item.subtitle}
</div>
)}
</td> </td>
<td className="whitespace-nowrap text-ink-100"> <td className="whitespace-nowrap text-ink-100">
{fmtBytes(item.size)} {fmtBytes(item.size)}
+2 -1
View File
@@ -67,10 +67,11 @@ export function StoragePage() {
<summary className="cursor-pointer list-none font-display text-lg font-semibold text-ink-600"> <summary className="cursor-pointer list-none font-display text-lg font-semibold text-ink-600">
低频维护入口 <span className="text-xs font-normal text-sand-500">(点击展开)</span> 低频维护入口 <span className="text-xs font-normal text-sand-500">(点击展开)</span>
</summary> </summary>
<div className="mt-3 grid gap-2 text-sm sm:grid-cols-2 lg:grid-cols-5"> <div className="mt-3 grid gap-2 text-sm sm:grid-cols-2 lg:grid-cols-6">
<MaintenanceLink to="/strm" title="STRM 生成" /> <MaintenanceLink to="/strm" title="STRM 生成" />
<MaintenanceLink to="/scheduler" title="定时任务" /> <MaintenanceLink to="/scheduler" title="定时任务" />
<MaintenanceLink to="/tasks" title="任务队列" /> <MaintenanceLink to="/tasks" title="任务队列" />
<MaintenanceLink to="/stats" title="运行状态" />
<MaintenanceLink to="/notify-channels" title="通知渠道" /> <MaintenanceLink to="/notify-channels" title="通知渠道" />
<MaintenanceLink to="/assistant" title="AI 对话台" /> <MaintenanceLink to="/assistant" title="AI 对话台" />
</div> </div>
+42 -10
View File
@@ -1,4 +1,4 @@
import { Archive, Film, Play, RotateCcw } from 'lucide-react' import { AlertTriangle, Archive, Film, Play, RefreshCw, RotateCcw } from 'lucide-react'
import { imageURL } from '../api/client' import { imageURL } from '../api/client'
import type { Subscription } from '../types' import type { Subscription } from '../types'
@@ -6,20 +6,51 @@ import { subscriptionProgressLabel } from './subscriptionPageModel'
interface SubscriptionHistorySectionProps { interface SubscriptionHistorySectionProps {
subscriptions: Subscription[] subscriptions: Subscription[]
loading?: boolean
error?: string
onRefresh?: () => Promise<void>
onRestore: (subscription: Subscription, runAfterRestore?: boolean) => void onRestore: (subscription: Subscription, runAfterRestore?: boolean) => void
} }
export function SubscriptionHistorySection({ subscriptions, onRestore }: SubscriptionHistorySectionProps) { export function SubscriptionHistorySection({
if (subscriptions.length === 0) return null subscriptions,
loading = false,
error = '',
onRefresh,
onRestore,
}: SubscriptionHistorySectionProps) {
return ( return (
<section className="space-y-3"> <section className="space-y-3">
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center justify-between gap-3">
<Archive size={18} className="text-brand-500" /> <div className="flex items-center gap-2">
<h2 className="font-display text-xl font-semibold text-ink-600">订阅历史</h2> <Archive size={18} className="text-brand-500" />
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-ink-50">{subscriptions.length} 条</span> <h2 className="font-display text-xl font-semibold text-ink-600">订阅历史</h2>
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-ink-50">{subscriptions.length} 条</span>
</div>
{onRefresh && (
<button
type="button"
className="inline-flex items-center gap-2 rounded-xl border border-gray-300 bg-white px-3 py-1.5 text-xs font-semibold text-ink-100 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => onRefresh().catch(() => undefined)}
disabled={loading}
>
<RefreshCw size={13} className={loading ? 'animate-spin' : ''} />
刷新历史
</button>
)}
</div> </div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{loading && <p className="text-sm text-sand-500">历史加载中…</p>}
{!loading && error && (
<div className="flex items-center gap-2 rounded-2xl border border-red-300/70 bg-red-50 px-4 py-3 text-sm text-red-700">
<AlertTriangle size={16} className="shrink-0" />
<span className="break-words">订阅历史加载失败:{error}</span>
</div>
)}
{!loading && !error && subscriptions.length === 0 && <p className="text-sm text-ink-50">暂无历史订阅。</p>}
{!error && subscriptions.length > 0 && (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{subscriptions.map((subscription) => ( {subscriptions.map((subscription) => (
<article key={subscription.id} className="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm"> <article key={subscription.id} className="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm">
<div className="flex gap-3"> <div className="flex gap-3">
@@ -61,7 +92,8 @@ export function SubscriptionHistorySection({ subscriptions, onRestore }: Subscri
</div> </div>
</article> </article>
))} ))}
</div> </div>
)}
</section> </section>
) )
} }
+93 -17
View File
@@ -1,4 +1,5 @@
import { FormEvent, useEffect, useState } from 'react' import { FormEvent, useEffect, useState } from 'react'
import { AlertTriangle, RefreshCw } from 'lucide-react'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { subscriptionsAPI } from '../api/subscriptions' import { subscriptionsAPI } from '../api/subscriptions'
@@ -15,22 +16,40 @@ export function SubscriptionsPage() {
const [formValues, setFormValues] = useState<SubscriptionFormValues>(defaultSubscriptionFormValues) const [formValues, setFormValues] = useState<SubscriptionFormValues>(defaultSubscriptionFormValues)
const [editingId, setEditingId] = useState('') const [editingId, setEditingId] = useState('')
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [historyLoading, setHistoryLoading] = useState(true)
const [listError, setListError] = useState('')
const [historyError, setHistoryError] = useState('')
const refresh = async () => { const refresh = async () => {
setLoading(true) setLoading(true)
void subscriptionsAPI setHistoryLoading(true)
setListError('')
setHistoryError('')
const historyPromise = subscriptionsAPI
.history() .history()
.then(setHistoryItems) .then((history) => {
.catch(() => toast.error('订阅历史加载失败')) setHistoryItems(history)
})
.catch((err: unknown) => {
const msg = apiErrorMessage(err, '订阅历史加载失败')
setHistoryError(msg)
toast.error(msg)
})
.finally(() => setHistoryLoading(false))
try { try {
const active = await subscriptionsAPI.list() const active = await subscriptionsAPI.list()
setItems(active) setItems(active)
} catch { } catch (err: unknown) {
toast.error('订阅列表加载失败') const msg = apiErrorMessage(err, '订阅列表加载失败')
setListError(msg)
setItems([])
toast.error(msg)
} finally { } finally {
setLoading(false) setLoading(false)
} }
await historyPromise
} }
useEffect(() => { useEffect(() => {
@@ -69,8 +88,7 @@ export function SubscriptionsPage() {
resetForm() resetForm()
await refresh() await refresh()
} catch (err: unknown) { } catch (err: unknown) {
const msg = const msg = apiErrorMessage(err, '创建失败')
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '创建失败'
toast.error(msg) toast.error(msg)
} }
} }
@@ -117,21 +135,30 @@ export function SubscriptionsPage() {
} }
await refresh() await refresh()
} catch (err: unknown) { } catch (err: unknown) {
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error || '恢复订阅失败' const msg = apiErrorMessage(err, '恢复订阅失败')
toast.error(msg) toast.error(msg)
} }
} }
const runSubscriptionNow = async (subscription: Subscription) => { const runSubscriptionNow = async (subscription: Subscription) => {
const result = await subscriptionsAPI.runNow(subscription.id) try {
toast.success(`已加入 ${result.queued} 项`) const result = await subscriptionsAPI.runNow(subscription.id)
toast.success(`已加入 ${result.queued} 项`)
await refresh()
} catch (err: unknown) {
toast.error(apiErrorMessage(err, '运行订阅失败'))
}
} }
const removeSubscription = async (subscription: Subscription) => { const removeSubscription = async (subscription: Subscription) => {
if (!(await confirmAction({ title: '删除订阅', message: `删除订阅「${subscription.name}」?`, confirmText: '删除' }))) return if (!(await confirmAction({ title: '删除订阅', message: `删除订阅「${subscription.name}」?`, confirmText: '删除' }))) return
await subscriptionsAPI.remove(subscription.id) try {
toast.success('已删除') await subscriptionsAPI.remove(subscription.id)
await refresh() toast.success('已删除')
await refresh()
} catch (err: unknown) {
toast.error(apiErrorMessage(err, '删除失败'))
}
} }
return ( return (
@@ -149,10 +176,24 @@ export function SubscriptionsPage() {
onChange={updateFormValue} onChange={updateFormValue}
/> />
{loading && <p className="text-sand-500">加载中…</p>} <div className="flex items-center justify-between gap-3">
{!loading && items.length === 0 && <p className="text-ink-50">暂无订阅。</p>} <h2 className="font-display text-xl font-semibold text-ink-600">正在订阅</h2>
<button
type="button"
className="inline-flex items-center gap-2 rounded-xl border border-primary-400/40 bg-white px-3 py-2 text-xs font-semibold text-brand-500 hover:bg-primary-400/10 disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => refresh().catch(() => undefined)}
disabled={loading || historyLoading}
>
<RefreshCw size={14} className={loading || historyLoading ? 'animate-spin' : ''} />
刷新
</button>
</div>
{items.length > 0 && ( {loading && <p className="text-sand-500">加载中…</p>}
{!loading && listError && <SubscriptionLoadError message={listError} onRetry={refresh} />}
{!loading && !listError && items.length === 0 && <p className="text-ink-50">暂无订阅。</p>}
{!listError && items.length > 0 && (
<div className="grid gap-5 sm:grid-cols-2 xl:grid-cols-3"> <div className="grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
{items.map((subscription) => ( {items.map((subscription) => (
<SubscriptionCard <SubscriptionCard
@@ -166,7 +207,42 @@ export function SubscriptionsPage() {
</div> </div>
)} )}
<SubscriptionHistorySection subscriptions={historyItems} onRestore={restoreHistorySubscription} /> <SubscriptionHistorySection
subscriptions={historyItems}
loading={historyLoading}
error={historyError}
onRefresh={refresh}
onRestore={restoreHistorySubscription}
/>
</div> </div>
) )
} }
function SubscriptionLoadError({ message, onRetry }: { message: string; onRetry: () => Promise<void> }) {
return (
<div className="rounded-2xl border border-red-300/70 bg-red-50 px-4 py-3 text-sm text-red-700">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<AlertTriangle size={16} className="shrink-0" />
<span className="break-words">订阅列表加载失败:{message}</span>
</div>
<button
type="button"
className="rounded-xl border border-red-300 bg-white px-3 py-1.5 text-xs font-semibold text-red-600 hover:bg-red-100"
onClick={() => onRetry().catch(() => undefined)}
>
重试
</button>
</div>
</div>
)
}
function apiErrorMessage(err: unknown, fallback: string): string {
const data = (err as { response?: { data?: { error?: string; message?: string }; status?: number } })?.response
if (data?.data?.error) return data.data.error
if (data?.data?.message) return data.data.message
if (data?.status) return `${fallback} (${data.status})`
if ((err as { code?: string })?.code === 'ECONNABORTED') return '请求超时,请检查服务或网络'
return fallback
}
+21
View File
@@ -7,6 +7,27 @@ export const emptyRootDraft = (): RootDraft => ({ name: '', path: '', enabled: t
export const rootDraftKey = (libraryID: string, rootID: string) => `${libraryID}:${rootID}` export const rootDraftKey = (libraryID: string, rootID: string) => `${libraryID}:${rootID}`
export function displayLibraryRootPath(path: string) {
if (!path.toLowerCase().startsWith('cloud://')) return path
return decodePercentEscapes(path)
}
export function displayLibraryRootName(name: string | undefined, path: string) {
const value = name?.trim() || '默认路径'
if (!path.toLowerCase().startsWith('cloud://') && !/%[0-9a-f]{2}/i.test(value)) return value
return decodePercentEscapes(value)
}
function decodePercentEscapes(value: string) {
return value.replace(/%[0-9a-f]{2}/gi, (token) => {
try {
return decodeURIComponent(token)
} catch {
return token
}
})
}
export function createRootPayload(roots: RootDraft[]) { export function createRootPayload(roots: RootDraft[]) {
return roots return roots
.map((root, index) => ({ .map((root, index) => ({
@@ -0,0 +1,7 @@
import type { SettingGroup } from './settingsGroupTypes'
export const recognitionWordsSettingsGroup: SettingGroup = {
key: 'recognition-words',
label: '识别词',
items: [],
}
+2
View File
@@ -1,6 +1,7 @@
import { adultSettingsGroup } from './settingsGroupAccess' import { adultSettingsGroup } from './settingsGroupAccess'
import { cloudUploadSettingsGroup } from './settingsGroupCloud' import { cloudUploadSettingsGroup } from './settingsGroupCloud'
import { generalSettingsGroup, licenseSettingsGroup } from './settingsGroupGeneral' import { generalSettingsGroup, licenseSettingsGroup } from './settingsGroupGeneral'
import { recognitionWordsSettingsGroup } from './settingsGroupRecognitionWords'
import { subscriptionSettingsGroup } from './settingsGroupSubscriptions' import { subscriptionSettingsGroup } from './settingsGroupSubscriptions'
import { systemUpdateSettingsGroup } from './settingsGroupSystemUpdate' import { systemUpdateSettingsGroup } from './settingsGroupSystemUpdate'
import type { SettingGroup } from './settingsGroupTypes' import type { SettingGroup } from './settingsGroupTypes'
@@ -11,6 +12,7 @@ export const GROUPS: SettingGroup[] = [
generalSettingsGroup, generalSettingsGroup,
licenseSettingsGroup, licenseSettingsGroup,
systemUpdateSettingsGroup, systemUpdateSettingsGroup,
recognitionWordsSettingsGroup,
subscriptionSettingsGroup, subscriptionSettingsGroup,
cloudUploadSettingsGroup, cloudUploadSettingsGroup,
adultSettingsGroup, adultSettingsGroup,
+45 -4
View File
@@ -42,6 +42,9 @@ export function suggestedSTRMOutputDir(root: string, library: Library) {
const base = trimPath(root) const base = trimPath(root)
const subdir = strmLibraryOutputSubdir(library) const subdir = strmLibraryOutputSubdir(library)
if (!subdir) return base if (!subdir) return base
if (pathEndsWith(base, subdir) || pathEndsWith(base, pathBase(subdir))) return base
const tail = missingCategoryTail(base, subdir)
if (tail) return joinPath(base, tail)
return joinPath(base || 'data/strm', subdir) return joinPath(base || 'data/strm', subdir)
} }
@@ -77,7 +80,7 @@ function categoryPartsFromPath(parts: string[]) {
const root = canonicalRoot(part) const root = canonicalRoot(part)
if (root) return [root, ...parts.slice(index + 1)] if (root) return [root, ...parts.slice(index + 1)]
const categoryRoot = categoryRootFor(part) const categoryRoot = categoryRootFor(part)
if (categoryRoot) return [categoryRoot, part] if (categoryRoot) return [categoryRoot, canonicalCategory(part)]
} }
return null return null
} }
@@ -124,13 +127,34 @@ function canonicalRoot(part: string) {
function categoryRootFor(part: string) { function categoryRootFor(part: string) {
const key = part.trim().toLowerCase() const key = part.trim().toLowerCase()
if (['动画电影', '动漫电影', '华语电影', '国产电影', '外语电影', '欧美电影', '日韩电影'].includes(key)) return '电影' if (['演唱会', '音乐会', '纪录片', '纪录', '动画电影', '动漫电影', '华语电影', '国产电影', '外语电影', '外国电影', '欧美电影', '日韩电影', '日本电影', '韩国电影'].includes(key)) return '电影'
if (['国产剧', '欧美剧', '日韩剧', '日剧', '韩剧', '综艺', '真人秀', '纪录片', '纪录', '未分类'].includes(key)) return '电视剧' if (['国产剧', '欧美剧', '日韩剧', '日剧', '韩剧', '综艺', '真人秀', '儿童', '少儿', '未分类'].includes(key)) return '电视剧'
if (['国漫', '国产动漫', '日番', '番剧', '日漫', '日本动漫', '日本动画', '欧美动漫', '欧美动画', '西方动画', '儿童', '少儿'].includes(key)) return '动漫' if (['国漫', '国产动漫', '日番', '番剧', '日漫', '日本动漫', '日本动画', '韩漫', '韩国动漫', '韩国动画', '美漫', '欧美动漫', '欧美动画', '西方动画', '其他', '其他动漫', '其它动漫'].includes(key)) return '动漫'
if (key === '番号') return '成人' if (key === '番号') return '成人'
return '' return ''
} }
function canonicalCategory(part: string) {
const key = part.trim().toLowerCase()
if (key === '音乐会') return '演唱会'
if (key === '纪录') return '纪录片'
if (key === '动漫电影') return '动画电影'
if (key === '国产电影') return '华语电影'
if (key === '外语电影' || key === '外国电影') return '欧美电影'
if (key === '日本电影' || key === '韩国电影') return '日韩电影'
if (key === '日剧' || key === '韩剧') return '日韩剧'
if (key === '真人秀') return '综艺'
if (key === '少儿') return '儿童'
if (key === '未分类') return '欧美剧'
if (key === '国产动漫') return '国漫'
if (['番剧', '日漫', '日本动漫', '日本动画'].includes(key)) return '日番'
if (key === '韩国动漫' || key === '韩国动画') return '韩漫'
if (['欧美动漫', '欧美动画', '西方动画'].includes(key)) return '美漫'
if (key === '其他动漫' || key === '其它动漫') return '其他'
if (key === '番号') return '成人'
return part.trim()
}
function splitPath(raw: string) { function splitPath(raw: string) {
return raw.split('/').map((part) => part.trim()).filter((part) => part && part !== '.') return raw.split('/').map((part) => part.trim()).filter((part) => part && part !== '.')
} }
@@ -153,6 +177,23 @@ function joinPath(root: string, ...parts: string[]) {
return [trimPath(root), ...parts.map(trimPath)].filter(Boolean).join('/') return [trimPath(root), ...parts.map(trimPath)].filter(Boolean).join('/')
} }
function missingCategoryTail(base: string, subdir: string) {
const parts = splitPath(subdir)
if (parts.length < 2 || !pathEndsWith(base, parts[0])) return ''
return joinPath('', ...parts.slice(1))
}
function pathEndsWith(pathValue: string, suffix: string) {
const path = trimPath(pathValue).toLowerCase()
const tail = trimPath(suffix).toLowerCase()
return !!path && !!tail && (path === tail || path.endsWith(`/${tail}`))
}
function pathBase(pathValue: string) {
const parts = splitPath(pathValue)
return parts[parts.length - 1] || ''
}
function trimPath(raw: string) { function trimPath(raw: string) {
return toSlash(raw).replace(/\/+$/g, '').trim() return toSlash(raw).replace(/\/+$/g, '').trim()
} }
+6 -30
View File
@@ -4,17 +4,16 @@ import toast from 'react-hot-toast'
import { libraryAPI } from '../api/library' import { libraryAPI } from '../api/library'
import type { Library, LibraryRoot } from '../types' import type { Library, LibraryRoot } from '../types'
import { confirmAction } from '../components/confirmAction' import { confirmAction } from '../components/confirmAction'
import { apiErrorMessage, createRootPayload, emptyRootDraft, rootDraftKey, type RootDraft } from './adminLibraryPanelModel' import { apiErrorMessage, createRootPayload, displayLibraryRootName, displayLibraryRootPath, emptyRootDraft, rootDraftKey, type RootDraft } from './adminLibraryPanelModel'
export function useAdminLibraryPanel() { export function useAdminLibraryPanel() {
const { libs, refresh } = useAdminLibraryList() const { libs, refresh } = useAdminLibraryList()
const createForm = useCreateLibraryForm(refresh) const createForm = useCreateLibraryForm(refresh)
const newRoots = useNewLibraryRootDrafts(refresh)
const editableRoots = useEditableRootDrafts() const editableRoots = useEditableRootDrafts()
const rootActions = useEditableLibraryRootActions(refresh, editableRoots) const rootActions = useEditableLibraryRootActions(refresh, editableRoots)
const libraryActions = useLibraryActions(refresh) const libraryActions = useLibraryActions(refresh)
return { libs, createForm, newRoots, editableRoots, rootActions, libraryActions } return { libs, createForm, editableRoots, rootActions, libraryActions }
} }
function useAdminLibraryList() { function useAdminLibraryList() {
@@ -42,7 +41,7 @@ function useCreateLibraryForm(refresh: () => Promise<void>) {
return return
} }
await libraryAPI.createWithRoots(name, type, payload) await libraryAPI.createWithRoots(name, type, payload)
toast.success('媒体库已创建') toast.success('媒体库已保存')
setName('') setName('')
setRoots([emptyRootDraft()]) setRoots([emptyRootDraft()])
await refresh() await refresh()
@@ -68,37 +67,14 @@ function useCreateLibraryForm(refresh: () => Promise<void>) {
} }
} }
function useNewLibraryRootDrafts(refresh: () => Promise<void>) {
const [newRootByLibrary, setNewRootByLibrary] = useState<Record<string, RootDraft>>({})
const newRootDraft = (libraryID: string) => newRootByLibrary[libraryID] ?? emptyRootDraft()
const setNewRootDraft = (libraryID: string, patch: Partial<RootDraft>) => {
setNewRootByLibrary((prev) => ({ ...prev, [libraryID]: { ...newRootDraft(libraryID), ...patch } }))
}
const addLibraryRoot = async (libraryID: string) => {
const draft = newRootDraft(libraryID)
if (!draft.path?.trim()) {
toast.error('请填写路径')
return
}
await libraryAPI.addRoot(libraryID, { ...draft, path: draft.path.trim(), name: draft.name?.trim() })
setNewRootByLibrary((prev) => ({ ...prev, [libraryID]: emptyRootDraft() }))
toast.success('路径已添加')
await refresh()
}
return { newRootDraft, setNewRootDraft, addLibraryRoot }
}
function useEditableRootDrafts() { function useEditableRootDrafts() {
const [rootDrafts, setRootDrafts] = useState<Record<string, RootDraft>>({}) const [rootDrafts, setRootDrafts] = useState<Record<string, RootDraft>>({})
const editableRootDraft = (libraryID: string, root: LibraryRoot): RootDraft => { const editableRootDraft = (libraryID: string, root: LibraryRoot): RootDraft => {
const key = rootDraftKey(libraryID, root.id) const key = rootDraftKey(libraryID, root.id)
return rootDrafts[key] ?? { return rootDrafts[key] ?? {
name: root.name ?? '', name: displayLibraryRootName(root.name, root.path),
path: root.path, path: displayLibraryRootPath(root.path),
enabled: root.enabled, enabled: root.enabled,
sort_order: root.sort_order, sort_order: root.sort_order,
} }
@@ -154,7 +130,7 @@ function useEditableLibraryRootActions(refresh: () => Promise<void>, drafts: Edi
} }
const removeLibraryRoot = async (library: Library, root: LibraryRoot) => { const removeLibraryRoot = async (library: Library, root: LibraryRoot) => {
if (!(await confirmAction({ title: '删除媒体库路径', message: `确定删除「${root.path}」?`, confirmText: '删除' }))) return if (!(await confirmAction({ title: '删除媒体库路径', message: `确定删除「${displayLibraryRootPath(root.path)}」?`, confirmText: '删除' }))) return
await libraryAPI.removeRoot(library.id, root.id) await libraryAPI.removeRoot(library.id, root.id)
toast.success('路径已删除') toast.success('路径已删除')
await refresh() await refresh()
+112
View File
@@ -0,0 +1,112 @@
import { useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import { recognitionWordsAPI, type RecognitionWordsConfig, type RecognitionWordsTestResult } from '../api/recognitionWords'
const DEFAULT_CONFIG: RecognitionWordsConfig = {
enabled: true,
local_text: '',
shared_urls: [
'https://raw.githubusercontent.com/Putarku/MoviePilot-Help/main/Words/general.txt',
'https://raw.githubusercontent.com/Putarku/MoviePilot-Help/main/Words/TV.txt',
'https://raw.githubusercontent.com/Putarku/MoviePilot-Help/main/Words/anime.txt',
],
rule_count: 0,
}
export function useRecognitionWordsPanel() {
const [config, setConfig] = useState<RecognitionWordsConfig>(DEFAULT_CONFIG)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [syncing, setSyncing] = useState(false)
const [testing, setTesting] = useState(false)
const [testInput, setTestInput] = useState('')
const [testResult, setTestResult] = useState<RecognitionWordsTestResult | null>(null)
const refresh = async () => {
setLoading(true)
try {
setConfig(await recognitionWordsAPI.get())
} finally {
setLoading(false)
}
}
useEffect(() => {
refresh().catch(() => undefined)
}, [])
const updateEnabled = (enabled: boolean) => {
setConfig((prev) => ({ ...prev, enabled }))
}
const updateSharedURLs = (value: string) => {
setConfig((prev) => ({
...prev,
shared_urls: value.split(/\r?\n/).map((item) => item.trim()).filter(Boolean),
}))
}
const updateLocalText = (localText: string) => {
setConfig((prev) => ({ ...prev, local_text: localText }))
}
const save = async () => {
setSaving(true)
try {
const saved = await recognitionWordsAPI.save(config)
setConfig(saved)
toast.success('识别词配置已保存')
} catch (error: unknown) {
toast.error(apiErrorMessage(error, '保存失败'))
} finally {
setSaving(false)
}
}
const sync = async () => {
setSyncing(true)
try {
const synced = await recognitionWordsAPI.sync()
setConfig(synced)
toast.success(`已同步 ${synced.rule_count} 条识别词`)
} catch (error: unknown) {
toast.error(apiErrorMessage(error, '同步失败'))
} finally {
setSyncing(false)
}
}
const test = async () => {
if (!testInput.trim()) return
setTesting(true)
try {
setTestResult(await recognitionWordsAPI.test(testInput))
} catch (error: unknown) {
toast.error(apiErrorMessage(error, '测试失败'))
} finally {
setTesting(false)
}
}
return {
config,
loading,
saving,
syncing,
testing,
testInput,
testResult,
save,
setTestInput,
sync,
test,
updateEnabled,
updateLocalText,
updateSharedURLs,
}
}
function apiErrorMessage(error: unknown, fallback: string): string {
return (error as { response?: { data?: { error?: string } } })?.response?.data?.error ?? fallback
}

Some files were not shown because too many files have changed in this diff Show More