mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 03:06:38 +08:00
fix: stabilize library paths and subscription matching
This commit is contained in:
@@ -72,9 +72,10 @@ func setDefaults(v *viper.Viper) {
|
||||
v.SetDefault("organize.scrape_after", true)
|
||||
v.SetDefault("scrape.delay_min_ms", 250)
|
||||
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.animation_movie", "动画电影")
|
||||
v.SetDefault("organizer.categories.foreign_movie", "外语电影")
|
||||
v.SetDefault("organizer.categories.euus_movie", "欧美电影")
|
||||
v.SetDefault("organizer.categories.jk_movie", "日韩电影")
|
||||
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.jp_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.documentary", "纪录片")
|
||||
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.enabled", true)
|
||||
|
||||
@@ -31,7 +31,6 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
libs = service.FilterDeprecatedNativeCloudLibraries(libs)
|
||||
libs = service.FilterInternalCloudAutoCategoryLibraries(libs)
|
||||
role, _ := c.Get(middleware.CtxUserRole)
|
||||
includeHidden := role == "admin" && (c.Query("include_hidden") == "1" || c.Query("all") == "1")
|
||||
if !includeHidden {
|
||||
@@ -45,6 +44,7 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
libs = filtered
|
||||
} else {
|
||||
libs = service.FilterMergedCloudAutoCategoryLibraries(libs)
|
||||
libs = service.NormalizeCloudLibraryDisplayNames(libs)
|
||||
}
|
||||
c.JSON(http.StatusOK, libs)
|
||||
@@ -63,7 +63,6 @@ func getLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
libs := service.FilterDeprecatedNativeCloudLibraries([]model.Library{*lib})
|
||||
libs = service.FilterInternalCloudAutoCategoryLibraries(libs)
|
||||
if len(libs) == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
|
||||
@@ -95,7 +95,7 @@ func TestListLibrariesIncludeHiddenNormalizesCloudDisplayNames(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListLibrariesIncludeHiddenHidesInternalAutoCategoryLibraries(t *testing.T) {
|
||||
func TestListLibrariesShowsAutoCategoryLibraries(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
@@ -118,8 +118,32 @@ func TestListLibrariesIncludeHiddenHidesInternalAutoCategoryLibraries(t *testing
|
||||
}
|
||||
|
||||
all := requestLibraries(t, svc, "admin", "admin", "/api/libraries?include_hidden=1")
|
||||
if len(all) != 1 || all[0].ID != root.ID {
|
||||
t.Fatalf("include_hidden list = %#v, want only user-mounted cloud library", all)
|
||||
if len(all) != 2 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ func registerAdminRoutes(api *gin.RouterGroup, cfg *config.Config, svc *service.
|
||||
registerAdminRepairRoutes(admin, svc)
|
||||
registerAdminAPIConfigRoutes(admin, svc)
|
||||
registerAdminSchedulerRoutes(admin, svc)
|
||||
registerAdminRecognitionWordRoutes(admin, svc)
|
||||
}
|
||||
|
||||
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.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))
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"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"})
|
||||
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 {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
want := c.Param("id")
|
||||
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})
|
||||
c.JSON(http.StatusOK, gin.H{"items": items, "total": len(items)})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
@@ -56,9 +57,17 @@ func updateSubscriptionHandler(svc *service.Container) gin.HandlerFunc {
|
||||
Model(&model.Subscription{}).
|
||||
Where("id = ?", c.Param("id")).
|
||||
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()})
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -146,6 +155,14 @@ func subscriptionPatchUpdates(patch subscriptionPatchReq) map[string]any {
|
||||
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
|
||||
// configured tracker sites for the given subscription. We treat the
|
||||
// subscription's filter as the search term; this lets the UI preview
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/middleware"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
@@ -80,9 +81,24 @@ func createSubscriptionHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
enrichSubscriptionArtwork(c.Request.Context(), svc, s)
|
||||
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()})
|
||||
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}
|
||||
svc.Subscription.EnrichManagementProgress(c.Request.Context(), enriched)
|
||||
*s = enriched[0]
|
||||
@@ -94,11 +110,18 @@ func listSubscriptionsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
items, err := svc.Subscription.List(c.Request.Context())
|
||||
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()})
|
||||
return
|
||||
}
|
||||
svc.Subscription.EnrichManagementProgress(c.Request.Context(), 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})
|
||||
}
|
||||
}
|
||||
@@ -107,10 +130,17 @@ func listSubscriptionHistoryHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
items, err := svc.Subscription.History(c.Request.Context())
|
||||
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()})
|
||||
return
|
||||
}
|
||||
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})
|
||||
}
|
||||
}
|
||||
@@ -118,9 +148,16 @@ func listSubscriptionHistoryHandler(svc *service.Container) gin.HandlerFunc {
|
||||
func deleteSubscriptionHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
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()})
|
||||
return
|
||||
}
|
||||
logSubscriptionInfo(svc, "subscription deleted",
|
||||
zap.String("user_id", subscriptionRequestUserID(c)),
|
||||
zap.String("subscription_id", c.Param("id")))
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
@@ -129,9 +166,17 @@ func runSubscriptionHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
n, err := svc.Subscription.RunNow(c.Request.Context(), c.Param("id"))
|
||||
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()})
|
||||
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})
|
||||
}
|
||||
}
|
||||
@@ -140,9 +185,17 @@ func restoreSubscriptionHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
sub, err := svc.Subscription.Restore(c.Request.Context(), c.Param("id"))
|
||||
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()})
|
||||
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}
|
||||
svc.Subscription.EnrichManagementProgress(c.Request.Context(), enriched)
|
||||
c.JSON(http.StatusOK, enriched[0])
|
||||
|
||||
@@ -152,7 +152,7 @@ func TestCloudDrive2MutableProviderUsesWebDAV(t *testing.T) {
|
||||
if _, err := mutable.Rename(context.Background(), "/TV", "电视剧"); err != nil {
|
||||
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 {
|
||||
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" {
|
||||
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])
|
||||
}
|
||||
if moved.ID != "/电影/外语电影/Movie (2026)/Movie (2026).mkv" {
|
||||
if moved.ID != "/电影/欧美电影/Movie (2026)/Movie (2026).mkv" {
|
||||
t.Fatalf("moved entry = %#v", moved)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,11 @@ import (
|
||||
const cloudAutoCategoryQueryKey = "auto_category"
|
||||
|
||||
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) == "" {
|
||||
return ""
|
||||
}
|
||||
@@ -42,40 +46,45 @@ func cloudRootMountNeedsAutoCategory(mount CloudMountInfo) bool {
|
||||
}
|
||||
|
||||
func cloudAutoCategoryDisplayDirForMediaPath(path string) string {
|
||||
displayDir, _ := cloudAutoCategoryDirsForMediaPath(path)
|
||||
return displayDir
|
||||
}
|
||||
|
||||
func cloudAutoCategoryDirsForMediaPath(path string) (string, string) {
|
||||
info, ok := ParseCloudLibraryMount(path)
|
||||
if !ok {
|
||||
return ""
|
||||
return "", ""
|
||||
}
|
||||
parts := strmSlashParts(info.DisplayDir)
|
||||
if len(parts) <= 1 {
|
||||
return ""
|
||||
return "", ""
|
||||
}
|
||||
parts = parts[:len(parts)-1]
|
||||
categoryParts := cloudAutoCategoryParts(parts)
|
||||
categoryParts, scanParts := cloudAutoCategoryParts(parts)
|
||||
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 {
|
||||
root := strmCanonicalRoot(part)
|
||||
if root != "" {
|
||||
if i+1 >= len(parts) {
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
category := strings.TrimSpace(parts[i+1])
|
||||
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 != "" {
|
||||
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 {
|
||||
@@ -92,33 +101,61 @@ func cloudAutoCategoryRootMatches(root, category string) bool {
|
||||
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)
|
||||
scanDir = normalizeCloudMountDir(provider, firstNonEmpty(scanDir, 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 {
|
||||
return existing, nil
|
||||
}
|
||||
path := BuildCloudAutoCategoryLibraryPath(provider, displayDir)
|
||||
path := BuildCloudAutoCategoryLibraryPathWithScanDir(provider, scanDir, displayDir)
|
||||
if path == "" {
|
||||
return rootLib, nil
|
||||
return cloudAutoCategoryTarget{Library: rootLib}, nil
|
||||
}
|
||||
name := cloudMountDirBase(displayDir)
|
||||
if name == "" {
|
||||
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{
|
||||
Name: name,
|
||||
Path: path,
|
||||
Type: InferCloudMountMediaType(displayDir, name),
|
||||
Type: kind,
|
||||
Enabled: true,
|
||||
}
|
||||
if err := s.repo.Library.Create(ctx, lib); err != nil {
|
||||
if existing := s.findCloudLibraryByDisplayDir(ctx, provider, displayDir); existing != nil {
|
||||
return existing, nil
|
||||
root := model.LibraryRoot{Name: name, Path: path, Enabled: true}
|
||||
if err := s.repo.Library.CreateWithRoots(ctx, lib, []model.LibraryRoot{root}); err != 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 {
|
||||
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("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 {
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
libs, err := s.repo.Library.List(ctx)
|
||||
if err != nil {
|
||||
if s.log != nil {
|
||||
s.log.Warn("list libraries for cloud auto category failed", zap.Error(err))
|
||||
}
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
displayDir = normalizeCloudMountDir(provider, displayDir)
|
||||
targetKey, _ := CloudLibraryMergeKey(model.Library{Name: name, Type: kind})
|
||||
var target *model.Library
|
||||
var existingAuto *model.Library
|
||||
for _, lib := range libs {
|
||||
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 = ©
|
||||
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 = ©
|
||||
}
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||
@@ -13,7 +12,7 @@ func FilterDisplayCloudLibraries(ctx context.Context, repo *repository.Container
|
||||
return libs
|
||||
}
|
||||
libs = FilterDeprecatedNativeCloudLibraries(libs)
|
||||
libs = FilterInternalCloudAutoCategoryLibraries(libs)
|
||||
libs = FilterMergedCloudAutoCategoryLibraries(libs)
|
||||
counts := cloudLibraryMediaCounts(ctx, repo, libs)
|
||||
collapsed := make([]model.Library, 0, 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 = FilterShadowedCloudLibraries(collapsed)
|
||||
return mergeDisplayCloudLibraries(collapsed)
|
||||
return normalizeDisplayLibraries(dedupeDisplayLibrariesByMergeKey(mergeDisplayCloudLibraries(collapsed), counts))
|
||||
}
|
||||
|
||||
func FilterInternalCloudAutoCategoryLibraries(libs []model.Library) []model.Library {
|
||||
@@ -50,6 +49,33 @@ func FilterInternalCloudAutoCategoryLibraries(libs []model.Library) []model.Libr
|
||||
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 {
|
||||
if len(libs) == 0 {
|
||||
return libs
|
||||
@@ -95,187 +121,3 @@ func FilterDeprecatedNativeCloudLibraries(libs []model.Library) []model.Library
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -125,13 +126,13 @@ func TestFilterDisplayCloudLibrariesMergesCategoryNameAliases(t *testing.T) {
|
||||
}
|
||||
|
||||
filtered := FilterDisplayCloudLibraries(t.Context(), repos, []model.Library{foreignMovie, westernMovie, eastAsianMovie, jpAnime, jpAnimeCloud})
|
||||
if got := libraryNames(filtered); !slices.Equal(got, []string{"外语电影", "日番"}) {
|
||||
t.Fatalf("filtered names = %#v, want user-facing alias libraries only", got)
|
||||
if got := libraryNames(filtered); !slices.Equal(got, []string{"欧美电影", "日韩电影", "日番"}) {
|
||||
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)
|
||||
if !slices.Equal(movieMerged, []string{foreignMovie.ID, westernMovie.ID, eastAsianMovie.ID}) {
|
||||
t.Fatalf("movie merged ids = %#v, want foreign movie aliases", movieMerged)
|
||||
if !slices.Equal(movieMerged, []string{foreignMovie.ID, westernMovie.ID}) {
|
||||
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)
|
||||
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) {
|
||||
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
|
||||
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{})
|
||||
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}
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -303,12 +393,12 @@ func TestAutoCategoryCloudLibrariesDoNotShadowRootOrScan(t *testing.T) {
|
||||
t.Fatalf("auto category should not shadow root scan: %#v", shadow)
|
||||
}
|
||||
display := FilterDisplayCloudLibraries(t.Context(), repos, libs)
|
||||
if len(display) != 1 || display[0].ID != root.ID {
|
||||
t.Fatalf("display libraries = %#v, want only user-mounted root", display)
|
||||
if got := libraryNames(display); !slices.Equal(got, []string{"欧美剧", "OpenList"}) {
|
||||
t.Fatalf("display libraries = %#v, want local library and user-mounted root only", got)
|
||||
}
|
||||
scannable := FilterScannableCloudLibraries(t.Context(), repos, libs)
|
||||
if len(scannable) != 1 || scannable[0].ID != root.ID {
|
||||
t.Fatalf("scannable libraries = %#v, want only root", scannable)
|
||||
if got := libraryNames(scannable); !slices.Equal(got, []string{"欧美剧", "OpenList"}) {
|
||||
t.Fatalf("scannable libraries = %#v, want local library and root only", got)
|
||||
}
|
||||
|
||||
scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil)
|
||||
@@ -317,11 +407,11 @@ func TestAutoCategoryCloudLibrariesDoNotShadowRootOrScan(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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{})
|
||||
repos := repository.New(db)
|
||||
root := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "movie", Enabled: true}
|
||||
@@ -345,13 +435,13 @@ func TestRootCloudLibraryIncludesHiddenAutoCategoryMedia(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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 {
|
||||
t.Fatalf("media library metadata = (%q, %q), want user-mounted root", items[0].LibraryName, items[0].LibraryPath)
|
||||
if items[0].LibraryName != auto.Name || items[0].LibraryPath != auto.Path {
|
||||
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 {
|
||||
t.Fatalf("display library = (%q, %q), want user-mounted root", items[0].DisplayLibraryID, items[0].DisplayLibraryPath)
|
||||
if items[0].DisplayLibraryID != auto.ID || items[0].DisplayLibraryPath != auto.Path {
|
||||
t.Fatalf("display library = (%q, %q), want auto category", items[0].DisplayLibraryID, items[0].DisplayLibraryPath)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -85,11 +85,11 @@ func InferCloudMountMediaType(dir, name string) string {
|
||||
switch {
|
||||
case strings.Contains(text, "成人") || strings.Contains(text, "adult") || strings.Contains(text, "jav") || strings.Contains(text, "9kg"):
|
||||
return "adult"
|
||||
case containsAny(text, "动画电影", "华语电影", "外语电影", "欧美电影", "日韩电影", "韩国电影", "日本电影", "港台电影", "香港电影", "台湾电影", "大陆电影", "国产电影", "纪录片", "演唱会", "电影", "movie", "movies", "film", "films", "documentary", "concert"):
|
||||
case containsAny(text, "动画电影", "华语电影", "外语电影", "外国电影", "欧美电影", "日韩电影", "韩国电影", "日本电影", "港台电影", "香港电影", "台湾电影", "大陆电影", "国产电影", "纪录片", "演唱会", "音乐会", "电影", "movie", "movies", "film", "films", "documentary", "concert"):
|
||||
return "movie"
|
||||
case containsAny(text, "综艺", "真人秀", "脱口秀", "晚会", "variety"):
|
||||
return "variety"
|
||||
case containsAny(text, "国漫", "日漫", "日番", "番剧", "动漫", "欧美动漫", "动画剧集", "anime"):
|
||||
case containsAny(text, "国漫", "日漫", "日番", "韩漫", "美漫", "番剧", "动漫", "欧美动漫", "动画剧集", "anime"):
|
||||
return "anime"
|
||||
case containsAny(text, "国产剧", "大陆剧", "华语剧", "欧美剧", "日韩剧", "韩剧", "日剧", "港剧", "台剧", "泰剧", "英剧", "美剧", "短剧", "电视剧", "剧集", "连续剧", "series", "tv", "shows"):
|
||||
return "tv"
|
||||
|
||||
@@ -1,167 +1,90 @@
|
||||
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, 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)
|
||||
func cloudLibraryDisplayKey(lib model.Library) (string, bool) {
|
||||
info, ok := ParseCloudLibraryMount(lib.Path)
|
||||
if !ok {
|
||||
return ids
|
||||
return "", false
|
||||
}
|
||||
_, libIsCloud := ParseCloudLibraryMount(lib.Path)
|
||||
for _, candidate := range libs {
|
||||
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
|
||||
dir := firstNonEmpty(info.DisplayDir, info.ScanDir)
|
||||
return info.Provider + "\x00" + dir, true
|
||||
}
|
||||
|
||||
func cloudRootAutoCategoryLibraryIDs(libs []model.Library, lib model.Library) []string {
|
||||
mount, ok := ParseCloudLibraryMount(lib.Path)
|
||||
if !ok || !cloudRootMountNeedsAutoCategory(mount) {
|
||||
return nil
|
||||
func cloudLibraryPathIsCanonical(lib model.Library) bool {
|
||||
info, ok := ParseCloudLibraryMount(lib.Path)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
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
|
||||
return BuildCloudLibraryPath(info.Provider, info.ScanDir, info.DisplayDir) == strings.TrimSpace(lib.Path)
|
||||
}
|
||||
|
||||
func ExpandMediaVisibilityForMergedCloudLibraries(ctx context.Context, repo *repository.Container, visibility MediaVisibility) MediaVisibility {
|
||||
if repo == nil || repo.Library == nil {
|
||||
return visibility
|
||||
func CloudLibraryMergeKey(lib model.Library) (string, bool) {
|
||||
name := strings.TrimSpace(lib.Name)
|
||||
if displayName, ok := CloudLibraryDisplayName(lib); ok {
|
||||
name = displayName
|
||||
}
|
||||
libs, err := repo.Library.List(ctx)
|
||||
if err != nil {
|
||||
return visibility
|
||||
name = normalizeLibraryMergeName(name)
|
||||
if name == "" {
|
||||
return "", false
|
||||
}
|
||||
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
|
||||
typeKey := cloudLibraryMergeTypeKey(lib.Type)
|
||||
return typeKey + "\x00" + cloudLibraryMergeNameKey(typeKey, name), true
|
||||
}
|
||||
|
||||
func expandMergedLibraryIDs(ctx context.Context, repo *repository.Container, ids []string) []string {
|
||||
if len(ids) == 0 {
|
||||
return ids
|
||||
func cloudLibraryMergeTypeKey(libraryType string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(libraryType)) {
|
||||
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 {
|
||||
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 {
|
||||
lib, ok := byID[id]
|
||||
if !ok {
|
||||
out = appendUniqueLibraryIDs(out, id)
|
||||
continue
|
||||
func cloudLibraryMergeNameKey(typeKey, name string) string {
|
||||
switch typeKey {
|
||||
case "movie":
|
||||
switch name {
|
||||
case "国产电影", "大陆电影":
|
||||
return "华语电影"
|
||||
case "华语电影":
|
||||
return "华语电影"
|
||||
case "外语电影", "外国电影", "欧美电影":
|
||||
return "欧美电影"
|
||||
case "日韩电影", "日本电影", "韩国电影":
|
||||
return "日韩电影"
|
||||
case "纪录", "纪录片":
|
||||
return "纪录片"
|
||||
case "演唱会", "concert":
|
||||
return "演唱会"
|
||||
case "动画电影", "动漫电影":
|
||||
return "动画电影"
|
||||
}
|
||||
for _, mergedID := range MergedLibraryIDs(libs, lib) {
|
||||
out = appendUniqueLibraryIDs(out, mergedID)
|
||||
case "tvshows":
|
||||
switch name {
|
||||
case "国产剧", "大陆剧", "华语剧", "国剧":
|
||||
return "国产剧"
|
||||
case "欧美剧", "美剧", "英剧":
|
||||
return "欧美剧"
|
||||
case "日韩剧", "日剧", "韩剧":
|
||||
return "日韩剧"
|
||||
case "国漫", "国产动漫", "国产动画":
|
||||
return "国漫"
|
||||
case "日番", "日漫", "番剧", "日本动漫", "日本动画":
|
||||
return "日番"
|
||||
case "韩漫", "韩国动漫", "韩国动画":
|
||||
return "韩漫"
|
||||
case "美漫", "欧美动漫", "欧美动画", "西方动画":
|
||||
return "美漫"
|
||||
case "其他", "其他动漫", "其它动漫", "other":
|
||||
return "其他"
|
||||
case "纪录", "纪录片":
|
||||
return "纪录片"
|
||||
}
|
||||
}
|
||||
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, 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
|
||||
return name
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -51,7 +51,7 @@ func TestSyncDownloadTaskProgressMatchesSeasonFolderTorrentName(t *testing.T) {
|
||||
Source: "qbittorrent",
|
||||
URL: "magnet:?xt=urn:btih:test",
|
||||
Title: "The First Jasmine S01E01 1080p TX WEB-DL AAC2.0 H.264-MWeb",
|
||||
SavePath: "/downloads/未分类",
|
||||
SavePath: "/downloads/欧美剧",
|
||||
Status: "queued",
|
||||
Progress: 0.5,
|
||||
}
|
||||
@@ -82,7 +82,7 @@ func TestProcessDownloadSnapshotQueuesCompletedPendingTaskOnFirstSnapshot(t *tes
|
||||
Source: "qbittorrent",
|
||||
URL: "magnet:?xt=urn:btih:test",
|
||||
Title: "Blades of the Guardians S02E01 1080p TX WEB-DL AAC2.0 H.264-MWeb",
|
||||
SavePath: "/downloads/未分类",
|
||||
SavePath: "/downloads/欧美剧",
|
||||
Status: "queued",
|
||||
Progress: 0,
|
||||
}
|
||||
@@ -113,7 +113,7 @@ func TestProcessDownloadSnapshotDoesNotQueueActiveDownloadAtFullProgress(t *test
|
||||
Source: "qbittorrent",
|
||||
URL: "magnet:?xt=urn:btih:test",
|
||||
Title: "Still Downloading S01E01",
|
||||
SavePath: "/downloads/未分类",
|
||||
SavePath: "/downloads/欧美剧",
|
||||
Status: "downloading",
|
||||
Progress: 0.99,
|
||||
}
|
||||
@@ -151,7 +151,7 @@ func TestProcessDownloadSnapshotDoesNotQueueFullProgressWithoutQBitState(t *test
|
||||
Source: "qbittorrent",
|
||||
URL: "magnet:?xt=urn:btih:test",
|
||||
Title: "Missing State S01E01",
|
||||
SavePath: "/downloads/未分类",
|
||||
SavePath: "/downloads/欧美剧",
|
||||
Status: "downloading",
|
||||
Progress: 0.99,
|
||||
}
|
||||
@@ -188,7 +188,7 @@ func TestProcessDownloadSnapshotDoesNotTrustCompletionOnForActiveDownload(t *tes
|
||||
Source: "qbittorrent",
|
||||
URL: "magnet:?xt=urn:btih:test",
|
||||
Title: "Still Downloading With Completion Timestamp S01E01",
|
||||
SavePath: "/downloads/未分类",
|
||||
SavePath: "/downloads/欧美剧",
|
||||
Status: "downloading",
|
||||
Progress: 0.5,
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ func (s *ScraperService) ManualSearch(ctx context.Context, media *model.Media, q
|
||||
return nil, errors.New("media required")
|
||||
}
|
||||
lib, _ := s.repo.Library.FindByID(ctx, media.LibraryID)
|
||||
queries := manualSearchQueries(media, lib, query)
|
||||
queries := s.manualSearchQueries(ctx, media, lib, query)
|
||||
if len(queries) == 0 {
|
||||
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)
|
||||
year := mediaYearHint(media)
|
||||
if year <= 0 {
|
||||
_, year = CleanQuery(queries[0])
|
||||
_, year = CleanQueryWithRecognition(ctx, s.repo, queries[0])
|
||||
}
|
||||
|
||||
out := make([]ExternalMediaResult, 0, 6)
|
||||
@@ -124,7 +124,7 @@ func (p manualSearchProviders) want(provider string) bool {
|
||||
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{}{}
|
||||
out := make([]string, 0, 4)
|
||||
add := func(value string) {
|
||||
@@ -140,16 +140,16 @@ func manualSearchQueries(media *model.Media, lib *model.Library, query string) [
|
||||
out = append(out, value)
|
||||
}
|
||||
|
||||
add(query)
|
||||
add(ApplyRecognitionWords(ctx, s.repo, query))
|
||||
if strings.TrimSpace(query) == "" && media != nil {
|
||||
add(firstText(media.Title, media.OriginalName))
|
||||
}
|
||||
if media != nil {
|
||||
for _, candidate := range scrapeQueryCandidates(media, lib) {
|
||||
for _, candidate := range scrapeQueryCandidatesWithRecognition(ctx, s.repo, media, lib) {
|
||||
add(candidate)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
title, _ := CleanQuery(media.Path)
|
||||
title, _ := CleanQueryWithRecognition(ctx, s.repo, media.Path)
|
||||
add(title)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,12 +44,9 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin
|
||||
isChineseByText := containsHan(rawTitleText) || containsAnyText(strings.ToLower(rawTitleText), "华语", "国产", "国剧", "国漫")
|
||||
isChineseByCategory := containsAnyText(categoryText, "华语", "国产", "国剧", "大陆剧", "国产电视剧", "国产电影", "国漫", "国产动漫", "国产动画")
|
||||
isChinese := isChineseByMetadata || (!hasMetadata && isChineseByText)
|
||||
// 动漫的中文译名几乎都是纯汉字(如日本动画「葬送的芙莉莲」),用 containsHan
|
||||
// 判中文会把日本动画误判成国漫。动漫只在有元数据或显式中文标记时才算国漫,
|
||||
// 否则默认日番(日本动画占绝大多数;未刮削的国漫刮出 origin_country=CN 后仍正确)。
|
||||
isChineseAnime := isChineseByMetadata || (!hasMetadata && containsAnyText(text, "华语", "国产", "国漫", "國漫", "国创", "国产动漫", "国产动画"))
|
||||
isJapanese := hasAny(languages, "JA", "JP") || hasAny(countries, "JP") || containsJapaneseKana(rawText) || strings.Contains(text, "日番")
|
||||
isKorean := hasAny(languages, "KO", "KR") || hasAny(countries, "KR", "KP") || containsKoreanHangul(rawText)
|
||||
isJapanese := hasAny(languages, "JA", "JP") || hasAny(countries, "JP") || containsJapaneseKana(rawTitleText) || (!hasMetadata && strings.Contains(text, "日番"))
|
||||
isKorean := hasAny(languages, "KO", "KR") || hasAny(countries, "KR", "KP") || containsKoreanHangul(rawTitleText) || (!hasMetadata && containsAnyText(categoryText, "韩漫", "韩国动漫", "韩国动画"))
|
||||
isEastAsianByCategory := containsAnyText(categoryText, "日韩剧", "日剧", "韩剧", "日韩电影")
|
||||
isEastAsian := isJapanese || isKorean || hasAny(countries, "TH", "IN", "SG") || (!hasMetadata && isEastAsianByCategory)
|
||||
isWesternByMetadata := hasAny(countries,
|
||||
@@ -58,9 +55,11 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin
|
||||
)
|
||||
isWesternByCategory := containsAnyText(categoryText, "欧美剧", "欧美电视剧", "美剧", "英剧", "欧美电影", "外语电影")
|
||||
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")
|
||||
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))
|
||||
|
||||
hasGenre := func(values ...string) bool {
|
||||
@@ -77,12 +76,33 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin
|
||||
}
|
||||
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 {
|
||||
case "movie":
|
||||
if isAdultText {
|
||||
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 {
|
||||
return categoryName(categories, "animation_movie", "动画电影")
|
||||
}
|
||||
@@ -92,41 +112,35 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin
|
||||
if isChinese {
|
||||
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":
|
||||
if isAdultText {
|
||||
return categoryName(categories, "adult", "成人")
|
||||
}
|
||||
if !hasMetadata && sourceHint != "" {
|
||||
return sourceHint
|
||||
}
|
||||
if isChineseAnime {
|
||||
return categoryName(categories, "cn_anime", "国漫")
|
||||
}
|
||||
if isWesternByMetadata || (!hasMetadata && containsAnyText(categoryText, "欧美动漫", "欧美动画", "西方动画")) {
|
||||
return categoryName(categories, "euus_anime", "欧美动漫")
|
||||
}
|
||||
return categoryName(categories, "jp_anime", "日番")
|
||||
return animeCategory()
|
||||
case "variety":
|
||||
return categoryName(categories, "variety", "综艺")
|
||||
case "tv":
|
||||
if isAdultText {
|
||||
return categoryName(categories, "adult", "成人")
|
||||
}
|
||||
if hasGenre("10764", "10767", "REALITY", "TALK", "综艺", "真人秀", "脱口秀") || hasVarietyText {
|
||||
return categoryName(categories, "variety", "综艺")
|
||||
}
|
||||
if hasGenre("99", "DOCUMENTARY", "纪录", "纪录片") || hasDocumentaryText {
|
||||
return categoryName(categories, "documentary", "纪录片")
|
||||
}
|
||||
if hasGenre("10762", "KIDS", "儿童") {
|
||||
return categoryName(categories, "children", "儿童")
|
||||
}
|
||||
if hasGenre("10764", "10767", "REALITY", "TALK", "综艺", "真人秀", "脱口秀") || hasVarietyText {
|
||||
return categoryName(categories, "variety", "综艺")
|
||||
}
|
||||
if hasGenre("16", "ANIMATION", "动画", "动漫") || hasAnimeText {
|
||||
if isChineseAnime {
|
||||
return categoryName(categories, "cn_anime", "国漫")
|
||||
}
|
||||
if isWesternByMetadata || (!hasMetadata && containsAnyText(categoryText, "欧美动漫", "欧美动画", "西方动画")) {
|
||||
return categoryName(categories, "euus_anime", "欧美动漫")
|
||||
}
|
||||
return categoryName(categories, "jp_anime", "日番")
|
||||
return animeCategory()
|
||||
}
|
||||
if !hasMetadata && sourceHint != "" {
|
||||
return sourceHint
|
||||
@@ -140,7 +154,7 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin
|
||||
if isWestern {
|
||||
return categoryName(categories, "euus_tv", "欧美剧")
|
||||
}
|
||||
return categoryName(categories, "uncategorized_tv", "未分类")
|
||||
return categoryName(categories, "euus_tv", "欧美剧")
|
||||
case "adult":
|
||||
return categoryName(categories, "adult", "成人")
|
||||
}
|
||||
@@ -166,11 +180,11 @@ func normalizeMediaType(mediaType, title, category string) string {
|
||||
return "adult"
|
||||
case containsAnyText(raw, "综艺", "真人秀"):
|
||||
return "variety"
|
||||
case (containsAnyText(raw, "国漫", "日漫", "日番", "动漫", "动画") || classifierAnimeRE.MatchString(raw)) && !containsAnyText(raw, "动画电影"):
|
||||
case (containsAnyText(raw, "国漫", "日漫", "日番", "韩漫", "美漫", "欧美动漫", "其他动漫", "动漫", "动画") || classifierAnimeRE.MatchString(raw)) && !containsAnyText(raw, "动画电影"):
|
||||
return "anime"
|
||||
case containsAnyText(raw, "电视剧", "国产剧", "欧美剧", "日韩剧", "日剧", "韩剧", "剧集") || classifierTVRE.MatchString(raw):
|
||||
return "tv"
|
||||
case containsAnyText(raw, "电影") || classifierMovieRE.MatchString(raw):
|
||||
case containsAnyText(raw, "电影", "演唱会") || classifierMovieRE.MatchString(raw):
|
||||
return "movie"
|
||||
}
|
||||
text := strings.ToLower(title + " " + category)
|
||||
|
||||
@@ -6,26 +6,29 @@ type sourceCategoryHintDef struct {
|
||||
Key string
|
||||
Fallback string
|
||||
MediaType string
|
||||
Aliases []string
|
||||
}
|
||||
|
||||
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: "chinese_movie", Fallback: "华语电影", MediaType: "movie"},
|
||||
{Key: "jk_movie", Fallback: "日韩电影", MediaType: "movie"},
|
||||
{Key: "euus_movie", Fallback: "欧美电影", MediaType: "movie"},
|
||||
{Key: "foreign_movie", Fallback: "外语电影", MediaType: "movie"},
|
||||
{Key: "euus_movie", Fallback: "欧美电影", MediaType: "movie", Aliases: []string{"外语电影", "外国电影", "western movie", "foreign movie"}},
|
||||
{Key: "domestic_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: "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: "documentary", Fallback: "纪录片", MediaType: "tv"},
|
||||
{Key: "children", Fallback: "儿童", MediaType: "tv"},
|
||||
{Key: "adult", Fallback: "成人", MediaType: "adult"},
|
||||
{Key: "adult_9kg", Fallback: "9KG", MediaType: "adult"},
|
||||
{Key: "adult_jav", Fallback: "番号", MediaType: "adult"},
|
||||
{Key: "euus_tv", Fallback: "欧美剧", MediaType: "tv", Aliases: []string{"未分类", "uncategorized"}},
|
||||
{Key: "adult", Fallback: "成人", MediaType: "adult", Aliases: []string{"9KG", "番号", "JAV", "adult", "nsfw"}},
|
||||
}
|
||||
|
||||
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) {
|
||||
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 {
|
||||
return categoryName(categories, hint.Key, hint.Fallback)
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ func TestClassifyMediaCategoryMatchesSmartRules(t *testing.T) {
|
||||
Countries: []string{"NL"},
|
||||
Genres: []string{"Comedy"},
|
||||
},
|
||||
want: "外语电影",
|
||||
want: "欧美电影",
|
||||
},
|
||||
{
|
||||
name: "movie animation source category fallback",
|
||||
@@ -99,7 +99,7 @@ func TestClassifyMediaCategoryMatchesSmartRules(t *testing.T) {
|
||||
MediaType: "movie",
|
||||
Title: "Dune 2021 2160p",
|
||||
},
|
||||
want: "外语电影",
|
||||
want: "欧美电影",
|
||||
},
|
||||
{
|
||||
name: "chinese tv title without metadata",
|
||||
@@ -110,12 +110,12 @@ func TestClassifyMediaCategoryMatchesSmartRules(t *testing.T) {
|
||||
want: "国产剧",
|
||||
},
|
||||
{
|
||||
name: "latin tv title without metadata stays uncategorized",
|
||||
name: "latin tv title without metadata falls back to western tv",
|
||||
input: mediaClassifyInput{
|
||||
MediaType: "tv",
|
||||
Title: "The Last of Us S01E01 1080p",
|
||||
},
|
||||
want: "未分类",
|
||||
want: "欧美剧",
|
||||
},
|
||||
{
|
||||
name: "latin tv keeps explicit western source category",
|
||||
@@ -133,7 +133,7 @@ func TestClassifyMediaCategoryMatchesSmartRules(t *testing.T) {
|
||||
Title: "The Last of Us S01E01 1080p",
|
||||
Category: "downloads 电视剧",
|
||||
},
|
||||
want: "未分类",
|
||||
want: "欧美剧",
|
||||
},
|
||||
{
|
||||
name: "gala title overrides wrong western source category",
|
||||
@@ -150,7 +150,7 @@ func TestClassifyMediaCategoryMatchesSmartRules(t *testing.T) {
|
||||
MediaType: "tv",
|
||||
Title: "Motherhood.of.Taihang.S01E01.2026.1080p.iQIYI.WEB-DL",
|
||||
},
|
||||
want: "未分类",
|
||||
want: "欧美剧",
|
||||
},
|
||||
{
|
||||
name: "metadata classifies romanized chinese drama",
|
||||
@@ -170,12 +170,12 @@ func TestClassifyMediaCategoryMatchesSmartRules(t *testing.T) {
|
||||
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{
|
||||
MediaType: "anime",
|
||||
Title: "葬送的芙莉莲",
|
||||
},
|
||||
want: "日番",
|
||||
want: "其他",
|
||||
},
|
||||
{
|
||||
name: "chinese anime explicit marker without metadata",
|
||||
@@ -197,7 +197,7 @@ func TestClassifyMediaCategoryMatchesSmartRules(t *testing.T) {
|
||||
want: "日番",
|
||||
},
|
||||
{
|
||||
name: "western anime metadata uses western anime category",
|
||||
name: "western anime metadata uses us anime category",
|
||||
input: mediaClassifyInput{
|
||||
MediaType: "anime",
|
||||
Title: "Family Guy",
|
||||
@@ -205,26 +205,26 @@ func TestClassifyMediaCategoryMatchesSmartRules(t *testing.T) {
|
||||
Genres: []string{"16"},
|
||||
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{
|
||||
MediaType: "tv",
|
||||
Title: "The Simpsons",
|
||||
Countries: []string{"US"},
|
||||
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{
|
||||
MediaType: "anime",
|
||||
Title: "The Simpsons S01E01 1080p",
|
||||
Category: "downloads 欧美动漫",
|
||||
},
|
||||
want: "欧美动漫",
|
||||
want: "美漫",
|
||||
},
|
||||
{
|
||||
name: "anime with CN country metadata is cn",
|
||||
@@ -245,6 +245,47 @@ func TestClassifyMediaCategoryMatchesSmartRules(t *testing.T) {
|
||||
},
|
||||
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",
|
||||
input: mediaClassifyInput{
|
||||
|
||||
@@ -80,13 +80,13 @@ func (r mediaDisplayLibraryResolver) DisplayLibraryForMedia(media model.Media) (
|
||||
}
|
||||
own, hasOwn := r.byID[media.LibraryID]
|
||||
if hasOwn {
|
||||
if CloudLibraryAutoCategory(own) {
|
||||
if lib, ok := r.rootCloudDisplayLibraryForAutoCategory(own); ok {
|
||||
if key, ok := CloudLibraryMergeKey(own); ok {
|
||||
if lib, exists := r.displayByMergeKey[key]; exists {
|
||||
return lib, true
|
||||
}
|
||||
}
|
||||
if key, ok := CloudLibraryMergeKey(own); ok {
|
||||
if lib, exists := r.displayByMergeKey[key]; exists {
|
||||
if CloudLibraryAutoCategory(own) {
|
||||
if lib, ok := r.rootCloudDisplayLibraryForAutoCategory(own); ok {
|
||||
return lib, true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,16 @@ func (s *MediaService) CreateLibraryWithRoots(ctx context.Context, name, kind st
|
||||
return nil, err
|
||||
}
|
||||
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}
|
||||
if err := s.repo.Library.CreateWithRoots(ctx, lib, roots); err != nil {
|
||||
return nil, err
|
||||
@@ -40,6 +50,53 @@ func (s *MediaService) CreateLibraryWithRoots(ctx context.Context, name, kind st
|
||||
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) {
|
||||
roots := make([]model.LibraryRoot, 0, len(inputs))
|
||||
seen := map[string]struct{}{}
|
||||
@@ -51,11 +108,11 @@ func normalizeLibraryRootInputs(inputs []LibraryRootInput, requirePath bool) ([]
|
||||
}
|
||||
continue
|
||||
}
|
||||
abs, err := resolveAccessibleLibraryPath(rawPath)
|
||||
abs, err := normalizeLibraryRootPath(rawPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key := strings.ToLower(filepath.Clean(abs))
|
||||
key := libraryRootPathKey(abs)
|
||||
if _, ok := seen[key]; ok {
|
||||
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{
|
||||
LibraryID: libraryID,
|
||||
Name: filepath.Base(filepath.Clean(lib.Path)),
|
||||
Name: libraryRootNameForPath(lib.Path),
|
||||
Path: lib.Path,
|
||||
Enabled: lib.Enabled,
|
||||
SortOrder: 0,
|
||||
@@ -208,13 +265,51 @@ func (s *MediaService) ensureLibraryRootPathUnique(ctx context.Context, libraryI
|
||||
return err
|
||||
}
|
||||
key := strings.ToLower(filepath.Clean(strings.TrimSpace(pathValue)))
|
||||
key = libraryRootPathKey(pathValue)
|
||||
for _, existing := range roots {
|
||||
if existing.ID == exceptRootID {
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"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 {
|
||||
return compactSeriesKey(mediaSeriesRawKey(media))
|
||||
|
||||
@@ -190,7 +190,7 @@ func TestOrganizeDirectoryMovieMetadataOverridesWrongTVFolder(t *testing.T) {
|
||||
if err != nil {
|
||||
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 {
|
||||
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" {
|
||||
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 != "外语电影" {
|
||||
t.Fatalf("organize item = %#v, want movie/外语电影", res.Items)
|
||||
if len(res.Items) != 1 || res.Items[0].MediaType != "movie" || res.Items[0].Category != "欧美电影" {
|
||||
t.Fatalf("organize item = %#v, want movie/欧美电影", res.Items)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,7 +238,7 @@ func TestOrganizeDirectoryReclassifiesMovieFromDirtyGeneratedEpisodePath(t *test
|
||||
root := t.TempDir()
|
||||
dest := filepath.Join(root, "media")
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -477,7 +477,7 @@ func TestOrganizeDirectoryEpisodeMarkerOverridesMovieSourceFolder(t *testing.T)
|
||||
root := t.TempDir()
|
||||
srcRoot := filepath.Join(root, "downloads")
|
||||
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")
|
||||
|
||||
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(), ¤tLib); 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) {
|
||||
root := t.TempDir()
|
||||
src := filepath.Join(root, "downloads", "Some.Show.S01E01.2026.1080p.mkv")
|
||||
@@ -172,7 +212,7 @@ func TestOrganizeDirectoryCreatesMissingCategoryLibraryForVisibility(t *testing.
|
||||
srcRoot := filepath.Join(root, "downloads")
|
||||
dest := filepath.Join(root, "media")
|
||||
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, target, "already-there")
|
||||
|
||||
@@ -182,7 +222,7 @@ func TestOrganizeDirectoryCreatesMissingCategoryLibraryForVisibility(t *testing.
|
||||
SourcePath: srcRoot,
|
||||
DestPath: dest,
|
||||
MediaType: "tv",
|
||||
MediaCategory: "未分类",
|
||||
MediaCategory: "欧美剧",
|
||||
TransferMode: TransferCopy,
|
||||
AllowReplaceExisting: false,
|
||||
})
|
||||
@@ -194,11 +234,11 @@ func TestOrganizeDirectoryCreatesMissingCategoryLibraryForVisibility(t *testing.
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
if lib.Name != "未分类" || lib.Type != "tv" || !lib.Enabled {
|
||||
t.Fatalf("auto-created library = %+v, want enabled tv 未分类", lib)
|
||||
if lib.Name != "欧美剧" || lib.Type != "tv" || !lib.Enabled {
|
||||
t.Fatalf("auto-created library = %+v, want enabled tv 欧美剧", lib)
|
||||
}
|
||||
|
||||
scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil)
|
||||
@@ -231,7 +271,7 @@ func TestOrganizeDirectoryCanDisableAutoAddLibrary(t *testing.T) {
|
||||
SourcePath: srcRoot,
|
||||
DestPath: dest,
|
||||
MediaType: "tv",
|
||||
MediaCategory: "未分类",
|
||||
MediaCategory: "欧美剧",
|
||||
TransferMode: TransferCopy,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -240,13 +280,13 @@ func TestOrganizeDirectoryCanDisableAutoAddLibrary(t *testing.T) {
|
||||
if res.Organized != 1 || len(res.Items) != 1 {
|
||||
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 {
|
||||
t.Fatalf("expected organized file at %q: %v", want, err)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
if count != 0 {
|
||||
@@ -282,9 +322,9 @@ func TestOrganizeDirectorySmartClassifiesUncategorizedSources(t *testing.T) {
|
||||
|
||||
for _, want := range []string{
|
||||
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, "电视剧", "未分类", "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 {
|
||||
t.Fatalf("expected smart classified file at %q: %v; items=%+v", want, err, res.Items)
|
||||
|
||||
@@ -106,24 +106,46 @@ func (o *OrganizerService) directoryCategoryTypes() map[string]organizeDirectory
|
||||
add(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("chinese_movie", "华语电影", "movie")
|
||||
addConfigured("jk_movie", "日韩电影", "movie")
|
||||
addConfigured("euus_movie", "欧美电影", "movie")
|
||||
addConfigured("foreign_movie", "外语电影", "movie")
|
||||
addAlias("外语电影", "euus_movie", "欧美电影", "movie")
|
||||
addAlias("外国电影", "euus_movie", "欧美电影", "movie")
|
||||
addConfigured("domestic_tv", "国产剧", "tv")
|
||||
addConfigured("euus_tv", "欧美剧", "tv")
|
||||
addConfigured("jk_tv", "日韩剧", "tv")
|
||||
addConfigured("cn_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("documentary", "纪录片", "tv")
|
||||
addConfigured("children", "儿童", "tv")
|
||||
addConfigured("uncategorized_tv", "未分类", "tv")
|
||||
addAlias("未分类", "euus_tv", "欧美剧", "tv")
|
||||
addAlias("uncategorized", "euus_tv", "欧美剧", "tv")
|
||||
addConfigured("adult", "成人", "adult")
|
||||
addConfigured("adult_9kg", "9KG", "adult")
|
||||
addConfigured("adult_jav", "番号", "adult")
|
||||
addAlias("9KG", "adult", "成人", "adult")
|
||||
addAlias("番号", "adult", "成人", "adult")
|
||||
addAlias("JAV", "adult", "成人", "adult")
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
@@ -115,7 +115,7 @@ func (o *OrganizerService) organizeLibraryMatchesExpectedPhysicalRoot(libPath, c
|
||||
if strings.TrimSpace(category) == "" {
|
||||
return true
|
||||
}
|
||||
physicalRoot := o.categoryPhysicalRootDir(category)
|
||||
physicalRoot := o.mediaTypeRootDirForCategory(mediaType, category)
|
||||
if physicalRoot == "" {
|
||||
physicalRoot = mediaTypeRootDir(mediaType)
|
||||
}
|
||||
@@ -126,7 +126,7 @@ func (o *OrganizerService) organizeLibraryMatchesExpectedPhysicalRoot(libPath, c
|
||||
}
|
||||
|
||||
func (o *OrganizerService) organizeLibraryPhysicalRootScore(libPath, collectionRoot, mediaType, category string) int {
|
||||
physicalRoot := o.categoryPhysicalRootDir(category)
|
||||
physicalRoot := o.mediaTypeRootDirForCategory(mediaType, category)
|
||||
if physicalRoot == "" {
|
||||
physicalRoot = mediaTypeRootDir(mediaType)
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ func (o *OrganizerService) lookupOrganizeMetadata(ctx context.Context, src, sour
|
||||
SeasonNum: lookupSeason,
|
||||
EpisodeNum: lookupEpisode,
|
||||
}
|
||||
for _, candidate := range scrapeQueryCandidates(media, lib) {
|
||||
for _, candidate := range scrapeQueryCandidatesWithRecognition(ctx, o.repo, media, lib) {
|
||||
key := organizeMetadataCacheKey(lib.Type, candidate, year)
|
||||
if cache != nil {
|
||||
if cached, ok := cache[key]; ok {
|
||||
|
||||
@@ -56,7 +56,7 @@ func (o *OrganizerService) resolveOrganizeSourceIdentity(ctx context.Context, re
|
||||
src := req.Source
|
||||
ext := filepath.Ext(src)
|
||||
season, episode := ParseEpisode(src)
|
||||
title, year := CleanQuery(src)
|
||||
title, year := CleanQueryWithRecognition(ctx, o.repo, src)
|
||||
if organizeWeakFileTitle(title) {
|
||||
if folderTitle, folderYear := organizeTitleFromParentFolder(src, req.SourceRoot, season > 0 || episode > 0); folderTitle != "" {
|
||||
title = folderTitle
|
||||
@@ -133,7 +133,7 @@ func organizeStandaloneMovieSourceHint(src string, identity organizeSourceIdenti
|
||||
if identity.Season > 0 || identity.Episode > 0 {
|
||||
return organizeEpisodeLooksSourcedFromMovieYear(src, identity)
|
||||
}
|
||||
_, year := CleanQuery(filepath.Base(src))
|
||||
_, year := CleanQueryWithRecognition(context.Background(), nil, filepath.Base(src))
|
||||
if year <= 0 {
|
||||
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 != "" {
|
||||
layout.Category = category
|
||||
}
|
||||
if forcedType == "" {
|
||||
if impliedType, normalizedCategory := o.mediaTypeForDirectoryCategory(layout.Category); impliedType != "" {
|
||||
layout.Category = normalizedCategory
|
||||
if impliedType, normalizedCategory := o.mediaTypeForDirectoryCategory(layout.Category); impliedType != "" {
|
||||
layout.Category = normalizedCategory
|
||||
if forcedType == "" {
|
||||
if layout.MediaType == "" || layout.MediaType == "tv" || layout.MediaType == "anime" || pathLayout.Category != layout.Category {
|
||||
layout.MediaType = impliedType
|
||||
}
|
||||
|
||||
@@ -45,38 +45,41 @@ func (o *OrganizerService) organizeCategoryAliases(mediaType, category string) m
|
||||
}
|
||||
}
|
||||
categories := o.categoryMap()
|
||||
add(category)
|
||||
switch normalizeOrganizeCategoryKey(category) {
|
||||
case normalizeOrganizeCategoryKey(categoryName(categories, "jp_anime", "日番")), "日番", "日漫", "日本动漫", "日本動畫", "日本动画":
|
||||
add("日番", "日漫", "日本动漫", "日本动画")
|
||||
add("日番", categoryName(categories, "jp_anime", "日番"))
|
||||
case normalizeOrganizeCategoryKey(categoryName(categories, "cn_anime", "国漫")), "国漫", "国产动漫", "國漫":
|
||||
add("国漫", "国产动漫")
|
||||
case normalizeOrganizeCategoryKey(categoryName(categories, "euus_anime", "欧美动漫")), "欧美动漫", "欧美动画", "西方动画":
|
||||
add("欧美动漫", "欧美动画", "西方动画")
|
||||
add("国漫", categoryName(categories, "cn_anime", "国漫"))
|
||||
case normalizeOrganizeCategoryKey(categoryName(categories, "kr_anime", "韩漫")), "韩漫", "韩国动漫", "韩国动画":
|
||||
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", "国产剧")), "国产剧", "国剧", "大陆剧", "国产电视剧":
|
||||
add("国产剧", "国剧", "大陆剧", "国产电视剧")
|
||||
add("国产剧", categoryName(categories, "domestic_tv", "国产剧"))
|
||||
case normalizeOrganizeCategoryKey(categoryName(categories, "euus_tv", "欧美剧")), "欧美剧", "欧美电视剧":
|
||||
add("欧美剧", "欧美电视剧")
|
||||
add("欧美剧", categoryName(categories, "euus_tv", "欧美剧"))
|
||||
case normalizeOrganizeCategoryKey(categoryName(categories, "jk_tv", "日韩剧")), "日韩剧", "日剧", "韩剧":
|
||||
add("日韩剧", "日剧", "韩剧")
|
||||
add("日韩剧", categoryName(categories, "jk_tv", "日韩剧"))
|
||||
case normalizeOrganizeCategoryKey(categoryName(categories, "variety", "综艺")), "综艺", "真人秀":
|
||||
add("综艺", "真人秀")
|
||||
add("综艺", categoryName(categories, "variety", "综艺"))
|
||||
case normalizeOrganizeCategoryKey(categoryName(categories, "documentary", "纪录片")), "纪录片", "纪录":
|
||||
add("纪录片", "纪录")
|
||||
add("纪录片", categoryName(categories, "documentary", "纪录片"))
|
||||
case normalizeOrganizeCategoryKey(categoryName(categories, "children", "儿童")), "儿童", "少儿":
|
||||
add("儿童", "少儿")
|
||||
add("儿童", categoryName(categories, "children", "儿童"))
|
||||
case normalizeOrganizeCategoryKey(categoryName(categories, "chinese_movie", "华语电影")), "华语电影", "国产电影", "大陆电影":
|
||||
add("华语电影", "国产电影", "大陆电影")
|
||||
case normalizeOrganizeCategoryKey(categoryName(categories, "foreign_movie", "外语电影")), "外语电影":
|
||||
add("外语电影")
|
||||
add("华语电影", categoryName(categories, "chinese_movie", "华语电影"))
|
||||
case normalizeOrganizeCategoryKey(categoryName(categories, "euus_movie", "欧美电影")), "欧美电影", "外语电影", "外国电影":
|
||||
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", "动画电影")), "动画电影", "动漫电影":
|
||||
add("动画电影", "动漫电影")
|
||||
case normalizeOrganizeCategoryKey(categoryName(categories, "adult", "成人")), "成人":
|
||||
add("成人")
|
||||
case normalizeOrganizeCategoryKey(categoryName(categories, "adult_9kg", "9KG")), "9kg":
|
||||
add("9KG")
|
||||
case normalizeOrganizeCategoryKey(categoryName(categories, "adult_jav", "番号")), "番号", "jav":
|
||||
add("番号", "JAV")
|
||||
add("动画电影", categoryName(categories, "animation_movie", "动画电影"))
|
||||
case normalizeOrganizeCategoryKey(categoryName(categories, "adult", "成人")), "成人", "9kg", "番号", "jav":
|
||||
add("成人", categoryName(categories, "adult", "成人"))
|
||||
}
|
||||
return aliases
|
||||
}
|
||||
|
||||
@@ -83,8 +83,10 @@ func (o *OrganizerService) buildOrganizeMediaDestination(ctx context.Context, re
|
||||
category = o.classifyMedia(ctx, m, mediaType)
|
||||
}
|
||||
if impliedType, normalizedCategory := o.mediaTypeForDirectoryCategory(category); impliedType != "" {
|
||||
mediaType = impliedType
|
||||
category = normalizedCategory
|
||||
if normalizeOrganizeMediaType(req.mediaType) == "" {
|
||||
mediaType = impliedType
|
||||
}
|
||||
}
|
||||
root := o.organizeRoot(req.baseRoot, mediaType, category)
|
||||
targetLibraryID := ""
|
||||
|
||||
@@ -26,12 +26,73 @@ func (o *OrganizerService) organizeRoot(libraryPath, mediaType, category string)
|
||||
}
|
||||
|
||||
func (o *OrganizerService) mediaTypeRootDirForCategory(mediaType, category string) string {
|
||||
if root := o.categoryPhysicalRootDirForType(mediaType, category); root != "" {
|
||||
return root
|
||||
}
|
||||
if root := o.categoryPhysicalRootDir(category); root != "" {
|
||||
return root
|
||||
}
|
||||
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 {
|
||||
key := normalizeOrganizeCategoryKey(category)
|
||||
if key == "" {
|
||||
@@ -50,9 +111,10 @@ func (o *OrganizerService) categoryPhysicalRootDir(category string) string {
|
||||
case match(
|
||||
categoryName(categories, "cn_anime", "国漫"),
|
||||
categoryName(categories, "jp_anime", "日番"),
|
||||
categoryName(categories, "euus_anime", "欧美动漫"),
|
||||
categoryName(categories, "children", "儿童"),
|
||||
"国漫", "国产动漫", "日番", "番剧", "日漫", "日本动漫", "日本动画", "欧美动漫", "欧美动画", "西方动画", "儿童", "少儿",
|
||||
categoryName(categories, "kr_anime", "韩漫"),
|
||||
categoryName(categories, "us_anime", "美漫"),
|
||||
categoryName(categories, "other_anime", "其他"),
|
||||
"国漫", "国产动漫", "日番", "番剧", "日漫", "日本动漫", "日本动画", "韩漫", "韩国动漫", "美漫", "欧美动漫", "欧美动画", "西方动画", "其他", "其他动漫", "其它动漫",
|
||||
):
|
||||
return "动漫"
|
||||
case match(
|
||||
@@ -61,20 +123,21 @@ func (o *OrganizerService) categoryPhysicalRootDir(category string) string {
|
||||
categoryName(categories, "jk_tv", "日韩剧"),
|
||||
categoryName(categories, "variety", "综艺"),
|
||||
categoryName(categories, "documentary", "纪录片"),
|
||||
categoryName(categories, "uncategorized_tv", "未分类"),
|
||||
"国产剧", "欧美剧", "日韩剧", "日剧", "韩剧", "综艺", "真人秀", "纪录片", "纪录", "未分类",
|
||||
categoryName(categories, "children", "儿童"),
|
||||
"国产剧", "欧美剧", "日韩剧", "日剧", "韩剧", "综艺", "真人秀", "纪录片", "纪录", "儿童", "少儿", "未分类",
|
||||
):
|
||||
return "电视剧"
|
||||
case match(
|
||||
categoryName(categories, "concert_movie", "演唱会"),
|
||||
categoryName(categories, "documentary_movie", "纪录片"),
|
||||
categoryName(categories, "animation_movie", "动画电影"),
|
||||
categoryName(categories, "chinese_movie", "华语电影"),
|
||||
categoryName(categories, "foreign_movie", "外语电影"),
|
||||
categoryName(categories, "euus_movie", "欧美电影"),
|
||||
categoryName(categories, "jk_movie", "日韩电影"),
|
||||
"动画电影", "动漫电影", "华语电影", "国产电影", "外语电影", "欧美电影", "日韩电影",
|
||||
"演唱会", "音乐会", "动画电影", "动漫电影", "华语电影", "国产电影", "外语电影", "外国电影", "欧美电影", "日韩电影",
|
||||
):
|
||||
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 "成人"
|
||||
default:
|
||||
return ""
|
||||
|
||||
@@ -194,7 +194,7 @@ func TestReclassifyMisclassifiedMediaMovesWesternAnimationToWesternAnimeLibrary(
|
||||
root := t.TempDir()
|
||||
dest := filepath.Join(root, "media")
|
||||
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} {
|
||||
if err := repos.Library.Create(t.Context(), lib); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -271,7 +271,7 @@ func TestReclassifyMisclassifiedMediaHonorsManualMovieHint(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
dest := filepath.Join(root, "media")
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{
|
||||
"cloud://openlist/电视剧/欧美剧/The Show/The.Show.S01E01.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 {
|
||||
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) {
|
||||
var active int32
|
||||
var maxActive int32
|
||||
@@ -242,3 +439,19 @@ func TestScanCloudLibraryListsChildDirectoriesConcurrently(t *testing.T) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
displayPath := joinCloudDisplayPath(displayDir, entry.Name)
|
||||
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{
|
||||
ref: ref,
|
||||
name: entry.Name,
|
||||
size: entry.Size,
|
||||
path: path,
|
||||
localMeta: localMeta,
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
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 {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
@@ -10,10 +10,10 @@ import (
|
||||
"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++
|
||||
ext := strings.ToLower(filepath.Ext(name))
|
||||
title, year := CleanQuery(name)
|
||||
title, year := CleanQueryWithRecognition(ctx, s.repo, name)
|
||||
if title == "" {
|
||||
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)
|
||||
m := &model.Media{
|
||||
LibraryID: lib.ID,
|
||||
Title: title,
|
||||
Year: year,
|
||||
Path: path,
|
||||
SizeBytes: size,
|
||||
Container: strings.TrimPrefix(ext, "."),
|
||||
STRMURL: expectedSTRMURL,
|
||||
ScrapeStatus: "pending",
|
||||
SeasonNum: parsedSeason,
|
||||
EpisodeNum: parsedEpisode,
|
||||
LibraryID: lib.ID,
|
||||
LibraryRootID: strings.TrimSpace(rootID),
|
||||
Title: title,
|
||||
Year: year,
|
||||
Path: path,
|
||||
SizeBytes: size,
|
||||
Container: strings.TrimPrefix(ext, "."),
|
||||
STRMURL: expectedSTRMURL,
|
||||
ScrapeStatus: "pending",
|
||||
SeasonNum: parsedSeason,
|
||||
EpisodeNum: parsedEpisode,
|
||||
}
|
||||
if ext == ".strm" {
|
||||
if targetURL, err := s.resolveCloudSTRMTarget(ctx, typ, ref); err == nil && targetURL != "" {
|
||||
|
||||
@@ -15,6 +15,7 @@ type cloudScanImportRequest struct {
|
||||
existingMedia map[string]existingCloudMedia
|
||||
writeBatch *localMediaWriteBatch
|
||||
probeBudget *int
|
||||
defaultRootID string
|
||||
progress *cloudScanProgressState
|
||||
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) {
|
||||
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}
|
||||
if s.storage == nil {
|
||||
return res, fmt.Errorf("cloud storage service unavailable")
|
||||
@@ -78,6 +87,7 @@ func (s *ScannerService) scanCloudLibrary(ctx context.Context, lib *model.Librar
|
||||
existingMedia: existingMedia,
|
||||
writeBatch: writeBatch,
|
||||
probeBudget: &probeBudget,
|
||||
defaultRootID: defaultRootID,
|
||||
progress: progress,
|
||||
result: res,
|
||||
})
|
||||
@@ -86,7 +96,12 @@ func (s *ScannerService) scanCloudLibrary(ctx context.Context, lib *model.Librar
|
||||
}
|
||||
scopeIDs = appendUniqueLibraryIDs(scopeIDs, imported.scopeLibraryIDs...)
|
||||
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 {
|
||||
s.log.Warn("prune missing cloud media failed", zap.String("library_id", lib.ID), zap.Error(err))
|
||||
} else {
|
||||
@@ -102,38 +117,49 @@ func (s *ScannerService) scanCloudLibrary(ctx context.Context, lib *model.Librar
|
||||
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) {
|
||||
imported := cloudScanImportResult{
|
||||
seen: make(map[string]struct{}),
|
||||
touchedLibraryIDs: []string{},
|
||||
scopeLibraryIDs: []string{},
|
||||
}
|
||||
targetLibs := map[string]*model.Library{"": rootLib}
|
||||
targetLibs := map[string]cloudScanTarget{"": {lib: rootLib, rootID: req.defaultRootID}}
|
||||
for _, candidate := range req.candidates {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return imported, ctx.Err()
|
||||
default:
|
||||
}
|
||||
targetLib := rootLib
|
||||
target := targetLibs[""]
|
||||
if candidate.categoryDisplayDir != "" {
|
||||
if cached, ok := targetLibs[candidate.categoryDisplayDir]; ok {
|
||||
targetLib = cached
|
||||
} else if categoryLib, err := s.ensureCloudAutoCategoryLibrary(ctx, rootLib, req.provider, candidate.categoryDisplayDir); err == nil && categoryLib != nil {
|
||||
targetLib = categoryLib
|
||||
targetLibs[candidate.categoryDisplayDir] = categoryLib
|
||||
imported.scopeLibraryIDs = appendUniqueLibraryIDs(imported.scopeLibraryIDs, categoryLib.ID)
|
||||
categoryKey := candidate.categoryDisplayDir + "\x00" + candidate.categoryScanDir
|
||||
if cached, ok := targetLibs[categoryKey]; ok {
|
||||
target = cached
|
||||
} else if categoryTarget, err := s.ensureCloudAutoCategoryTarget(ctx, rootLib, req.provider, candidate.categoryDisplayDir, candidate.categoryScanDir); err == nil && categoryTarget.Library != nil {
|
||||
target = cloudScanTarget{lib: categoryTarget.Library, rootID: categoryTarget.RootID}
|
||||
targetLibs[categoryKey] = target
|
||||
imported.scopeLibraryIDs = appendUniqueLibraryIDs(imported.scopeLibraryIDs, categoryTarget.Library.ID)
|
||||
} else if err != nil {
|
||||
s.log.Warn("ensure cloud auto category library failed",
|
||||
zap.String("library_id", rootLib.ID),
|
||||
zap.String("provider", req.provider),
|
||||
zap.String("category", candidate.categoryDisplayDir),
|
||||
zap.String("scan_dir", candidate.categoryScanDir),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
targetLib := target.lib
|
||||
if targetLib == nil {
|
||||
targetLib = rootLib
|
||||
}
|
||||
imported.touchedLibraryIDs = appendUniqueLibraryIDs(imported.touchedLibraryIDs, targetLib.ID)
|
||||
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)
|
||||
}
|
||||
return imported, nil
|
||||
|
||||
@@ -12,6 +12,7 @@ type cloudCandidate struct {
|
||||
size int64
|
||||
path string
|
||||
categoryDisplayDir string
|
||||
categoryScanDir string
|
||||
localMeta *LocalMetadata
|
||||
}
|
||||
|
||||
|
||||
@@ -88,13 +88,13 @@ func TestInferCloudMountMediaType(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"/日漫": "anime",
|
||||
"/国漫": "anime",
|
||||
"/欧美动漫": "anime",
|
||||
"/美漫": "anime",
|
||||
"/电视剧/国产剧": "tv",
|
||||
"/电视剧/欧美剧": "tv",
|
||||
"/电视剧/日韩剧": "tv",
|
||||
"/电影/动画电影": "movie",
|
||||
"/电影/华语电影": "movie",
|
||||
"/电影/外语电影": "movie",
|
||||
"/电影/欧美电影": "movie",
|
||||
"/综艺": "variety",
|
||||
}
|
||||
for dir, want := range cases {
|
||||
|
||||
@@ -136,7 +136,7 @@ type localScanMediaInput struct {
|
||||
}
|
||||
|
||||
func (s *ScannerService) buildLocalScanMedia(in localScanMediaInput) *model.Media {
|
||||
title, year := CleanQuery(in.path)
|
||||
title, year := CleanQueryWithRecognition(context.Background(), s.repo, in.path)
|
||||
if title == "" {
|
||||
title = strings.TrimSuffix(filepath.Base(in.path), in.ext)
|
||||
}
|
||||
|
||||
@@ -131,6 +131,31 @@ func (s *ScannerService) pruneMissingCloudMedia(ctx context.Context, libraryID s
|
||||
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) {
|
||||
if len(libraryIDs) == 0 {
|
||||
return 0, nil
|
||||
|
||||
@@ -32,6 +32,9 @@ func (s *ScannerService) ScanLibraryRoot(ctx context.Context, libraryID, rootID
|
||||
if root == nil {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ func (s *ScraperService) EnrichOneWithOptions(ctx context.Context, m *model.Medi
|
||||
return s.applyProviderMatchWithOptions(ctx, m, lib, match, options)
|
||||
}
|
||||
|
||||
candidates := scrapeQueryCandidates(m, lib)
|
||||
candidates := scrapeQueryCandidatesWithRecognition(ctx, s.repo, m, lib)
|
||||
var query string
|
||||
match := (*Match)(nil)
|
||||
for _, candidate := range candidates {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -16,10 +18,22 @@ var (
|
||||
)
|
||||
|
||||
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{}{}
|
||||
var out []string
|
||||
add := func(raw string) {
|
||||
cleaned, _ := CleanQuery(raw)
|
||||
cleaned, _ := clean(raw)
|
||||
if cleaned == "" {
|
||||
cleaned = strings.TrimSpace(raw)
|
||||
}
|
||||
|
||||
@@ -132,9 +132,9 @@ func isGenericMediaCategoryFolder(name string) bool {
|
||||
"欧美剧", "欧美电视剧",
|
||||
"日韩剧", "日剧", "韩剧",
|
||||
"华语电影", "国产电影", "大陆电影",
|
||||
"外语电影", "欧美电影", "日韩电影",
|
||||
"外语电影", "外国电影", "欧美电影", "日韩电影", "演唱会", "音乐会",
|
||||
"动画电影", "动漫电影",
|
||||
"国漫", "国产动漫", "日番", "日漫", "日本动漫", "日本动画", "欧美动漫", "欧美动画", "西方动画",
|
||||
"国漫", "国产动漫", "日番", "日漫", "日本动漫", "日本动画", "韩漫", "韩国动漫", "韩国动画", "美漫", "欧美动漫", "欧美动画", "西方动画", "其他动漫", "其它动漫",
|
||||
"综艺", "真人秀",
|
||||
"纪录片", "纪录",
|
||||
"儿童", "少儿",
|
||||
|
||||
@@ -74,6 +74,7 @@ type Container struct {
|
||||
Device *DeviceService
|
||||
Cache *RuntimeCacheService
|
||||
Sessions *SessionTrackerService
|
||||
RecognitionWords *RecognitionWordsService
|
||||
|
||||
stopCtx context.Context
|
||||
stopCancel context.CancelFunc
|
||||
|
||||
@@ -63,6 +63,7 @@ func (b *serviceContainerBuilder) initProviderServices() {
|
||||
b.c.TheTVDB = NewTheTVDBProvider(b.cfg, b.log)
|
||||
b.c.Douban = NewDoubanProvider(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)
|
||||
b.c.Scraper = NewScraperService(
|
||||
|
||||
@@ -4,10 +4,8 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"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 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) {
|
||||
@@ -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 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) {
|
||||
@@ -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) {
|
||||
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()
|
||||
}
|
||||
@@ -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) {
|
||||
page := `
|
||||
<table class="torrents">
|
||||
|
||||
@@ -102,28 +102,10 @@ func (s *SiteService) Search(ctx context.Context, keyword string) ([]SearchResul
|
||||
if result == nil {
|
||||
return
|
||||
}
|
||||
items := result.Items
|
||||
if items == nil {
|
||||
items = []TorrentItem{}
|
||||
}
|
||||
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()
|
||||
}
|
||||
siteResults := siteSearchResultsFromItems(site, result, keyword)
|
||||
mu.Lock()
|
||||
results = append(results, siteResults...)
|
||||
mu.Unlock()
|
||||
}(sites[i])
|
||||
}
|
||||
wg.Wait()
|
||||
@@ -152,3 +134,83 @@ func (s *SiteService) Search(ctx context.Context, keyword string) ([]SearchResul
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -146,3 +146,39 @@ func TestSiteSearchReturnsErrorWhenAllEnabledSitesFail(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ func (s *StorageService) Compute(ctx context.Context) (*Breakdown, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
libs = NormalizeCloudLibraryDisplay(libs)
|
||||
out := &Breakdown{ByLibrary: make([]LibraryUsage, 0, len(libs))}
|
||||
for _, l := range libs {
|
||||
var usage LibraryUsage
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -108,7 +108,7 @@ func strmCategoryPartsFromPath(parts []string) []string {
|
||||
return append([]string{root}, strmSanitizedTail(parts[i+1:])...)
|
||||
}
|
||||
if root := strmCategoryRoot(part); root != "" {
|
||||
return []string{root, part}
|
||||
return []string{root, strmCanonicalCategory(part)}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -143,11 +143,11 @@ func strmCanonicalRoot(part string) string {
|
||||
func strmCategoryRoot(part string) string {
|
||||
key := strings.ToLower(strings.TrimSpace(part))
|
||||
switch key {
|
||||
case "动画电影", "动漫电影", "华语电影", "国产电影", "外语电影", "欧美电影", "日韩电影":
|
||||
case "演唱会", "音乐会", "纪录片", "纪录", "动画电影", "动漫电影", "华语电影", "国产电影", "外语电影", "外国电影", "欧美电影", "日韩电影", "日本电影", "韩国电影":
|
||||
return "电影"
|
||||
case "国产剧", "欧美剧", "日韩剧", "日剧", "韩剧", "综艺", "真人秀", "纪录片", "纪录", "未分类":
|
||||
case "国产剧", "欧美剧", "日韩剧", "日剧", "韩剧", "综艺", "真人秀", "儿童", "少儿", "未分类":
|
||||
return "电视剧"
|
||||
case "国漫", "国产动漫", "日番", "番剧", "日漫", "日本动漫", "日本动画", "欧美动漫", "欧美动画", "西方动画", "儿童", "少儿":
|
||||
case "国漫", "国产动漫", "日番", "番剧", "日漫", "日本动漫", "日本动画", "韩漫", "韩国动漫", "韩国动画", "美漫", "欧美动漫", "欧美动画", "西方动画", "其他", "其他动漫", "其它动漫":
|
||||
return "动漫"
|
||||
case "番号":
|
||||
return "成人"
|
||||
@@ -155,3 +155,43 @@ func strmCategoryRoot(part string) string {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,10 +166,16 @@ func (s *SubscriptionService) Delete(ctx context.Context, id string) error {
|
||||
// by the admin UI's "test now" button.
|
||||
func (s *SubscriptionService) RunNow(ctx context.Context, id string) (int, error) {
|
||||
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
|
||||
}
|
||||
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 s.runOne(ctx, &sub)
|
||||
@@ -236,6 +242,9 @@ func (s *SubscriptionService) runAll(ctx context.Context) {
|
||||
s.log.Warn("subscription list failed", zap.Error(err))
|
||||
return
|
||||
}
|
||||
if s.log != nil {
|
||||
s.log.Info("subscription sweep started", zap.Int("count", len(subs)))
|
||||
}
|
||||
for i := range subs {
|
||||
if !subs[i].Enabled {
|
||||
continue
|
||||
|
||||
@@ -56,7 +56,7 @@ func (s *SubscriptionService) lookupSubscriptionMetadata(ctx context.Context, me
|
||||
for _, libType := range subscriptionMetadataLibraryTypes(mediaType, title) {
|
||||
lib := &model.Library{Type: libType, Enabled: true}
|
||||
for _, query := range queries {
|
||||
cleaned, year := CleanQuery(query)
|
||||
cleaned, year := CleanQueryWithRecognition(ctx, s.repo, query)
|
||||
if cleaned == "" {
|
||||
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'
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -19,8 +19,21 @@ type rssSubscriptionRunState struct {
|
||||
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)
|
||||
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://") {
|
||||
return s.runSiteSearch(ctx, sub)
|
||||
}
|
||||
@@ -50,7 +63,7 @@ func (s *SubscriptionService) runOne(ctx context.Context, sub *model.Subscriptio
|
||||
washOff: !sub.WashEnabled,
|
||||
}
|
||||
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)
|
||||
return queued, nil
|
||||
}
|
||||
@@ -105,6 +118,15 @@ func (s *SubscriptionService) enqueueRSSSubscriptionCandidate(ctx context.Contex
|
||||
zap.Error(err))
|
||||
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.markSeen(candidate.GUID)
|
||||
return true
|
||||
@@ -116,11 +138,26 @@ func (s *SubscriptionService) finishRSSSubscriptionRun(ctx context.Context, sub
|
||||
if 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()
|
||||
_ = s.repo.DB.Model(sub).Updates(map[string]any{"last_run_at": &now}).Error
|
||||
_ = s.archiveCompletedSubscription(ctx, sub, state.availability)
|
||||
if err := s.repo.DB.Model(sub).Updates(map[string]any{"last_run_at": &now}).Error; err != nil && s.log != nil {
|
||||
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 {
|
||||
s.hub.Publish("subscription", map[string]any{
|
||||
"id": sub.ID,
|
||||
|
||||
@@ -1,28 +1,11 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"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 {
|
||||
titleFold := strings.ToLower(title)
|
||||
if containsAnyExcludeToken(titleFold, defaultExcludeWords) {
|
||||
@@ -58,214 +41,7 @@ func shouldApplyDefaultCompatibilityExcludes(excludeWords string) bool {
|
||||
}
|
||||
|
||||
func normalizeExcludeWords(csv string) string {
|
||||
parts := make([]string, 0)
|
||||
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
|
||||
return strings.Join(excludeWordTokens(csv), ",")
|
||||
}
|
||||
|
||||
func isSubscriptionSeriesType(mediaType string) bool {
|
||||
@@ -276,15 +52,3 @@ func isSubscriptionSeriesType(mediaType string) bool {
|
||||
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) {
|
||||
sub := &model.Subscription{}
|
||||
for _, title := range []string{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
availability := s.finalizePendingAvailability(sub, state.Availability)
|
||||
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()
|
||||
_ = s.repo.DB.Model(sub).Updates(map[string]any{"last_run_at": &now}).Error
|
||||
_ = s.archiveCompletedSubscription(ctx, sub, availability)
|
||||
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))
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
func (s *SubscriptionService) handleSiteSearchQueueResult(sub *model.Subscription, keyword string, queueResult siteSearchQueueResult, selectionStats siteSearchSelectionStats, availability LocalAvailability) (int, error) {
|
||||
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{
|
||||
"id": sub.ID,
|
||||
"name": sub.Name,
|
||||
@@ -186,7 +213,12 @@ func (s *SubscriptionService) finishSiteSearchNoResults(sub *model.Subscription,
|
||||
s.log.Info("site-search subscription no results", fields...)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -42,10 +42,14 @@ export function buildSubscriptionAliases(item: {
|
||||
|
||||
export const subscriptionsAPI = {
|
||||
list: () =>
|
||||
api.get<{ items: Subscription[] }>('/subscriptions').then((r) => r.data.items),
|
||||
api
|
||||
.get<{ items: Subscription[] }>('/subscriptions', subscriptionListRequestConfig())
|
||||
.then((r) => r.data.items),
|
||||
|
||||
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: {
|
||||
name: string
|
||||
@@ -86,3 +90,10 @@ export const subscriptionsAPI = {
|
||||
runNow: (id: string) =>
|
||||
api.post<{ queued: number }>(`/subscriptions/${id}/run`).then((r) => r.data),
|
||||
}
|
||||
|
||||
function subscriptionListRequestConfig() {
|
||||
return {
|
||||
headers: { 'Cache-Control': 'no-cache' },
|
||||
params: { _ts: Date.now() },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,29 +1,12 @@
|
||||
import { Link, Outlet } from 'react-router-dom'
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { Menu, MessageSquareText, Search, Sparkles } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import type { PlayProfile, User } from '../types'
|
||||
import { AppFooter } from './AppFooter'
|
||||
import { LayoutSearchBox } from './LayoutSearchBox'
|
||||
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 { useThemeMode } from './useThemeMode'
|
||||
|
||||
type LayoutSearchState = ReturnType<typeof useLayoutSearch>
|
||||
type LayoutProfileState = ReturnType<typeof useLayoutProfiles>
|
||||
type LayoutSidebarState = ReturnType<typeof useLayoutSidebar>
|
||||
type LayoutThemeState = ReturnType<typeof useThemeMode>
|
||||
|
||||
type LayoutPermissionState = {
|
||||
can: (key: string) => boolean
|
||||
isAdmin: boolean
|
||||
}
|
||||
|
||||
type LayoutSidebarProps = {
|
||||
children: React.ReactNode
|
||||
@@ -36,17 +19,6 @@ type LayoutMobileSidebarProps = {
|
||||
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<
|
||||
LayoutSidebarContentProps,
|
||||
'isSidebarOpen' | 'isMobileDrawerOpen' | 'openGroups' | 'isRouteIn' | 'onToggleGroup' | 'onToggleSidebar' | 'onCloseMobileDrawer'
|
||||
@@ -58,6 +30,8 @@ type LayoutWorkspaceProps = {
|
||||
routeKey: string
|
||||
}
|
||||
|
||||
export { LayoutHeader } from './LayoutHeaderSections'
|
||||
|
||||
export function LayoutDesktopSidebar({ children, isSidebarOpen }: LayoutSidebarProps) {
|
||||
return (
|
||||
<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) {
|
||||
return (
|
||||
<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 }
|
||||
|
||||
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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import {
|
||||
Activity,
|
||||
Cast,
|
||||
Clock,
|
||||
CloudDownload,
|
||||
@@ -13,15 +12,12 @@ import {
|
||||
KeySquare,
|
||||
Library,
|
||||
ListMusic,
|
||||
MessageSquareText,
|
||||
Rss,
|
||||
Search,
|
||||
Settings,
|
||||
Sliders,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
User,
|
||||
UserCog,
|
||||
} from 'lucide-react'
|
||||
|
||||
export type LayoutNavGroupID = 'media' | 'personal' | 'downloads' | 'tools' | 'system'
|
||||
@@ -69,20 +65,17 @@ export const LAYOUT_NAV_GROUPS: LayoutNavGroup[] = [
|
||||
{ to: '/favourites', label: '我的收藏', icon: Heart },
|
||||
{ to: '/playlists', label: '播放列表', icon: ListMusic },
|
||||
{ to: '/history', label: '观看历史', icon: Clock },
|
||||
{ to: '/profile', label: '账号信息', icon: User },
|
||||
{ to: '/play-profiles', label: '观影 Profile', icon: UserCog },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'downloads',
|
||||
label: '下载与订阅',
|
||||
icon: CloudDownload,
|
||||
activePaths: ['/downloads', '/download-clients', '/subscriptions', '/site-search'],
|
||||
activePaths: ['/downloads', '/download-clients', '/subscriptions', '/site-search', '/sites'],
|
||||
items: [
|
||||
{ to: '/downloads', label: '下载中心', icon: CloudDownload, permission: 'can_manage_downloads' },
|
||||
{ to: '/subscriptions', label: '订阅管理', icon: Rss, permission: 'can_manage_subscriptions' },
|
||||
{ to: '/site-search', label: '站点检索', icon: Search, permission: 'can_manage_sites' },
|
||||
{ to: '/download-clients', label: '下载器管理', icon: Sliders, adminOnly: true },
|
||||
{ to: '/sites', label: '站点管理', icon: Globe, permission: 'can_manage_sites' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -93,14 +86,6 @@ export const LAYOUT_NAV_GROUPS: LayoutNavGroup[] = [
|
||||
adminOnly: true,
|
||||
items: [
|
||||
{ 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,
|
||||
items: [
|
||||
{ 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: '/license', label: '授权许可', icon: KeySquare },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
Vendored
+36
-2
@@ -98,22 +98,56 @@ body {
|
||||
:root[data-theme='dark'] .bg-white,
|
||||
:root[data-theme='dark'] .bg-gray-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\/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\/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;
|
||||
}
|
||||
|
||||
: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;
|
||||
}
|
||||
|
||||
: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\] {
|
||||
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-950,
|
||||
:root[data-theme='dark'] .text-\[\#111827\],
|
||||
|
||||
@@ -3,7 +3,7 @@ import { AdminLibraryTable } from './AdminLibraryTable'
|
||||
import { useAdminLibraryPanel } from './useAdminLibraryPanel'
|
||||
|
||||
export function AdminLibraryPanel() {
|
||||
const { libs, createForm, newRoots, editableRoots, rootActions, libraryActions } = useAdminLibraryPanel()
|
||||
const { libs, createForm, editableRoots, rootActions, libraryActions } = useAdminLibraryPanel()
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -20,11 +20,8 @@ export function AdminLibraryPanel() {
|
||||
/>
|
||||
<AdminLibraryTable
|
||||
libs={libs}
|
||||
newRootDraft={newRoots.newRootDraft}
|
||||
editableRootDraft={editableRoots.editableRootDraft}
|
||||
onNewRootChange={newRoots.setNewRootDraft}
|
||||
onEditableRootChange={editableRoots.setEditableRootDraft}
|
||||
onAddRoot={newRoots.addLibraryRoot}
|
||||
onSaveRoot={rootActions.saveLibraryRoot}
|
||||
onScanRoot={rootActions.scanLibraryRoot}
|
||||
onToggleRoot={rootActions.toggleLibraryRoot}
|
||||
|
||||
@@ -58,11 +58,11 @@ export function AdminLibraryCreateForm({
|
||||
</button>
|
||||
</div>
|
||||
<p className="md:col-span-4 -mt-2 text-xs text-sand-500">
|
||||
Docker 部署时请优先填写容器内路径,例如 /media/电影、/media/电视剧/国产剧;如果误填 NAS
|
||||
宿主机路径,系统会尝试按 compose 挂载自动转换。
|
||||
名称和类型与现有媒体库一致时,会自动把这里填写的路径追加到该媒体库;Docker
|
||||
部署请优先填写容器内路径,例如 /media/电影、/media/电视剧/国产剧。
|
||||
</p>
|
||||
<button type="submit" className="neon-button md:col-span-4">
|
||||
新建媒体库
|
||||
新建 / 追加路径
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
@@ -78,7 +78,7 @@ type CreateRootRowProps = {
|
||||
|
||||
function CreateRootRow({ root, index, canRemove, onChange, onRemove }: CreateRootRowProps) {
|
||||
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
|
||||
className="input-base"
|
||||
placeholder="路径名称"
|
||||
|
||||
@@ -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 { RootDraft } from './adminLibraryPanelModel'
|
||||
import { fallbackLibraryRoot } from './adminLibraryPanelModel'
|
||||
import { displayLibraryRootName, displayLibraryRootPath, fallbackLibraryRoot } from './adminLibraryPanelModel'
|
||||
|
||||
type LibraryTableProps = {
|
||||
libs: Library[]
|
||||
newRootDraft: (libraryID: string) => RootDraft
|
||||
editableRootDraft: (libraryID: string, root: LibraryRoot) => RootDraft
|
||||
onNewRootChange: (libraryID: string, patch: Partial<RootDraft>) => void
|
||||
onEditableRootChange: (libraryID: string, root: LibraryRoot, patch: Partial<RootDraft>) => void
|
||||
onAddRoot: (libraryID: string) => void
|
||||
onSaveRoot: (libraryID: string, root: LibraryRoot) => void
|
||||
onScanRoot: (libraryID: string, root: LibraryRoot) => void
|
||||
onToggleRoot: (libraryID: string, root: LibraryRoot) => void
|
||||
@@ -21,14 +19,14 @@ type LibraryTableProps = {
|
||||
|
||||
export function AdminLibraryTable({ libs, ...actions }: LibraryTableProps) {
|
||||
return (
|
||||
<div className="glass-panel">
|
||||
<table className="w-full text-left text-sm">
|
||||
<div className="glass-panel overflow-x-auto !p-3">
|
||||
<table className="w-full min-w-[900px] text-left text-sm">
|
||||
<thead className="text-xs uppercase tracking-wider text-sand-500">
|
||||
<tr>
|
||||
<th className="py-2">名称</th>
|
||||
<th className="w-28 py-2">名称</th>
|
||||
<th>路径</th>
|
||||
<th>类型</th>
|
||||
<th className="text-right">操作</th>
|
||||
<th className="w-20">类型</th>
|
||||
<th className="w-12 text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -48,12 +46,12 @@ type LibraryTableRowProps = Omit<LibraryTableProps, 'libs'> & {
|
||||
function LibraryTableRow({ library, ...actions }: LibraryTableRowProps) {
|
||||
return (
|
||||
<tr className="border-t border-gray-200">
|
||||
<td className="py-2 text-ink-600">{library.name}</td>
|
||||
<td className="text-ink-100">
|
||||
<td className="py-2 pr-3 font-medium text-ink-600">{library.name}</td>
|
||||
<td className="py-1.5 text-ink-100">
|
||||
<LibraryRootsCell library={library} {...actions} />
|
||||
</td>
|
||||
<td className="text-ink-100">{library.type}</td>
|
||||
<td className="space-x-2 py-2 text-right">
|
||||
<td className="px-3 text-ink-100">{library.type}</td>
|
||||
<td className="py-2 text-right">
|
||||
<LibraryActionsCell library={library} {...actions} />
|
||||
</td>
|
||||
</tr>
|
||||
@@ -63,11 +61,10 @@ function LibraryTableRow({ library, ...actions }: LibraryTableRowProps) {
|
||||
function LibraryRootsCell({ library, ...actions }: LibraryTableRowProps) {
|
||||
const roots = library.roots?.length ? library.roots : [fallbackLibraryRoot(library)]
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="min-w-[520px] space-y-1">
|
||||
{roots.map((root) => (
|
||||
<ExistingRootEditor key={root.id || root.path} library={library} root={root} {...actions} />
|
||||
))}
|
||||
<AddRootRow library={library} {...actions} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -80,30 +77,36 @@ type RootEditorProps = Omit<LibraryTableRowProps, 'library'> & {
|
||||
function ExistingRootEditor({ library, root, ...actions }: RootEditorProps) {
|
||||
const draft = actions.editableRootDraft(library.id, root)
|
||||
return (
|
||||
<div className="rounded border border-gray-200/70 p-2">
|
||||
<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} />
|
||||
) : (
|
||||
<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 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]">
|
||||
{root.id ? <EditableRootFields library={library} root={root} draft={draft} {...actions} /> : <ReadonlyRootFields root={root} />}
|
||||
<RootStatus enabled={draft.enabled ?? root.enabled} />
|
||||
<RootActionButtons library={library} root={root} draft={draft} {...actions} />
|
||||
</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 }) {
|
||||
return (
|
||||
<>
|
||||
<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="路径名称"
|
||||
value={draft.name ?? ''}
|
||||
onChange={(e) => onEditableRootChange(library.id, root, { name: e.target.value })}
|
||||
/>
|
||||
<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="真实路径"
|
||||
value={draft.path}
|
||||
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 (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{root.id && (
|
||||
<button
|
||||
className="rounded border border-primary-400/40 p-1 text-brand-500 hover:bg-primary-400/10"
|
||||
title="保存路径"
|
||||
onClick={() => actions.onSaveRoot(library.id, root)}
|
||||
>
|
||||
<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>
|
||||
<span
|
||||
className={`whitespace-nowrap rounded-md border px-2 py-1 text-xs ${
|
||||
enabled ? 'border-emerald-300/60 text-emerald-600' : 'border-gray-300 text-ink-50'
|
||||
}`}
|
||||
>
|
||||
{enabled ? '启用' : '禁用'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function AddRootRow({ library, ...actions }: LibraryTableRowProps) {
|
||||
const draft = actions.newRootDraft(library.id)
|
||||
function RootActionButtons({ library, root, draft, ...actions }: RootEditorProps & { draft: RootDraft }) {
|
||||
const enabled = draft.enabled ?? root.enabled
|
||||
return (
|
||||
<div className="grid gap-2 md:grid-cols-[minmax(0,1fr)_minmax(0,2fr)_auto]">
|
||||
<input
|
||||
className="input-base"
|
||||
placeholder="路径名称"
|
||||
value={draft.name ?? ''}
|
||||
onChange={(e) => actions.onNewRootChange(library.id, { name: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
className="input-base"
|
||||
placeholder="新增路径"
|
||||
value={draft.path}
|
||||
onChange={(e) => actions.onNewRootChange(library.id, { path: e.target.value })}
|
||||
/>
|
||||
<button className="rounded-lg border px-3 py-2 text-sm" onClick={() => actions.onAddRoot(library.id)}>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<ActionMenu label="路径操作">
|
||||
{root.id && (
|
||||
<MenuButton
|
||||
icon={<Save size={14} />}
|
||||
label="保存"
|
||||
onClick={() => actions.onSaveRoot(library.id, root)}
|
||||
>
|
||||
保存
|
||||
</MenuButton>
|
||||
)}
|
||||
<MenuButton
|
||||
icon={<RefreshCw size={14} />}
|
||||
label="扫描"
|
||||
onClick={() => actions.onScanRoot(library.id, root)}
|
||||
>
|
||||
扫描
|
||||
</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) {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
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)}
|
||||
>
|
||||
<ActionMenu label="媒体库操作">
|
||||
<MenuButton icon={<RefreshCw size={14} />} label="扫描" onClick={() => onScanLibrary(library)}>
|
||||
扫描
|
||||
</button>
|
||||
<button
|
||||
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)}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</>
|
||||
</MenuButton>
|
||||
<MenuButton danger icon={<Trash2 size={14} />} label="删除" onClick={() => onRemoveLibrary(library)}>
|
||||
删除
|
||||
</MenuButton>
|
||||
</ActionMenu>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@ export function AdminPage() {
|
||||
{ to: '/download-clients', title: '下载器管理', description: '配置 qBittorrent 等下载器连接', badge: '下载', group: '站点与下载' },
|
||||
{ to: '/files', 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">
|
||||
|
||||
@@ -16,6 +16,7 @@ const TYPE_ICONS: Record<string, ReactNode> = {
|
||||
anime: <PlayCircle size={18} />,
|
||||
variety: <Tv size={18} />,
|
||||
music: <Music size={18} />,
|
||||
adult: <Film size={18} />,
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
@@ -24,6 +25,7 @@ const TYPE_LABELS: Record<string, string> = {
|
||||
anime: '动漫',
|
||||
variety: '综艺',
|
||||
music: '音乐',
|
||||
adult: '成人',
|
||||
}
|
||||
|
||||
export function LibrariesHeader({
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import type { Library, Setting } from '../types'
|
||||
import { SettingRow } from './SettingsRow'
|
||||
import { ALL_KEYS, GROUPS } from './settingsGroups'
|
||||
import { SystemUpdatePanel } from './SystemUpdatePanel'
|
||||
import { RecognitionWordsPanel } from './RecognitionWordsPanel'
|
||||
|
||||
export function SettingsPage() {
|
||||
const [activeGroup, setActiveGroup] = useState(GROUPS[0].key)
|
||||
@@ -107,31 +108,34 @@ export function SettingsPage() {
|
||||
{!loading && (
|
||||
<div className="space-y-4">
|
||||
{group.key === 'system-update' && <SystemUpdatePanel />}
|
||||
<form onSubmit={onSave} className="glass-panel space-y-4">
|
||||
{group.description && <p className="text-xs text-sand-500">{group.description}</p>}
|
||||
{group.items.map((it) => (
|
||||
<SettingRow
|
||||
key={it.key}
|
||||
def={it}
|
||||
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} 项未保存` : '所有更改已保存'}
|
||||
</span>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving || dirty.size === 0}
|
||||
className="neon-button disabled:opacity-50"
|
||||
>
|
||||
{saving ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{group.key === 'recognition-words' && <RecognitionWordsPanel />}
|
||||
{group.items.length > 0 && (
|
||||
<form onSubmit={onSave} className="glass-panel space-y-4">
|
||||
{group.description && <p className="text-xs text-sand-500">{group.description}</p>}
|
||||
{group.items.map((it) => (
|
||||
<SettingRow
|
||||
key={it.key}
|
||||
def={it}
|
||||
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} 项未保存` : '所有更改已保存'}
|
||||
</span>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving || dirty.size === 0}
|
||||
className="neon-button disabled:opacity-50"
|
||||
>
|
||||
{saving ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -124,13 +124,18 @@ export function SiteSearchPage() {
|
||||
href={item.torrent_url}
|
||||
target="_blank"
|
||||
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}
|
||||
>
|
||||
{item.title.length > 80
|
||||
? item.title.substring(0, 80) + '…'
|
||||
: item.title}
|
||||
</a>
|
||||
{item.subtitle && (
|
||||
<div className="mt-1 line-clamp-2 text-xs text-ink-50" title={item.subtitle}>
|
||||
{item.subtitle}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap text-ink-100">
|
||||
{fmtBytes(item.size)}
|
||||
|
||||
@@ -67,10 +67,11 @@ export function StoragePage() {
|
||||
<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>
|
||||
</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="/scheduler" title="定时任务" />
|
||||
<MaintenanceLink to="/tasks" title="任务队列" />
|
||||
<MaintenanceLink to="/stats" title="运行状态" />
|
||||
<MaintenanceLink to="/notify-channels" title="通知渠道" />
|
||||
<MaintenanceLink to="/assistant" title="AI 对话台" />
|
||||
</div>
|
||||
|
||||
@@ -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 type { Subscription } from '../types'
|
||||
@@ -6,20 +6,51 @@ import { subscriptionProgressLabel } from './subscriptionPageModel'
|
||||
|
||||
interface SubscriptionHistorySectionProps {
|
||||
subscriptions: Subscription[]
|
||||
loading?: boolean
|
||||
error?: string
|
||||
onRefresh?: () => Promise<void>
|
||||
onRestore: (subscription: Subscription, runAfterRestore?: boolean) => void
|
||||
}
|
||||
|
||||
export function SubscriptionHistorySection({ subscriptions, onRestore }: SubscriptionHistorySectionProps) {
|
||||
if (subscriptions.length === 0) return null
|
||||
|
||||
export function SubscriptionHistorySection({
|
||||
subscriptions,
|
||||
loading = false,
|
||||
error = '',
|
||||
onRefresh,
|
||||
onRestore,
|
||||
}: SubscriptionHistorySectionProps) {
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Archive size={18} className="text-brand-500" />
|
||||
<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 className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Archive size={18} className="text-brand-500" />
|
||||
<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 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) => (
|
||||
<article key={subscription.id} className="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm">
|
||||
<div className="flex gap-3">
|
||||
@@ -61,7 +92,8 @@ export function SubscriptionHistorySection({ subscriptions, onRestore }: Subscri
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import { AlertTriangle, RefreshCw } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { subscriptionsAPI } from '../api/subscriptions'
|
||||
@@ -15,22 +16,40 @@ export function SubscriptionsPage() {
|
||||
const [formValues, setFormValues] = useState<SubscriptionFormValues>(defaultSubscriptionFormValues)
|
||||
const [editingId, setEditingId] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [historyLoading, setHistoryLoading] = useState(true)
|
||||
const [listError, setListError] = useState('')
|
||||
const [historyError, setHistoryError] = useState('')
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true)
|
||||
void subscriptionsAPI
|
||||
setHistoryLoading(true)
|
||||
setListError('')
|
||||
setHistoryError('')
|
||||
|
||||
const historyPromise = subscriptionsAPI
|
||||
.history()
|
||||
.then(setHistoryItems)
|
||||
.catch(() => toast.error('订阅历史加载失败'))
|
||||
.then((history) => {
|
||||
setHistoryItems(history)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const msg = apiErrorMessage(err, '订阅历史加载失败')
|
||||
setHistoryError(msg)
|
||||
toast.error(msg)
|
||||
})
|
||||
.finally(() => setHistoryLoading(false))
|
||||
|
||||
try {
|
||||
const active = await subscriptionsAPI.list()
|
||||
setItems(active)
|
||||
} catch {
|
||||
toast.error('订阅列表加载失败')
|
||||
} catch (err: unknown) {
|
||||
const msg = apiErrorMessage(err, '订阅列表加载失败')
|
||||
setListError(msg)
|
||||
setItems([])
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
await historyPromise
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -69,8 +88,7 @@ export function SubscriptionsPage() {
|
||||
resetForm()
|
||||
await refresh()
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '创建失败'
|
||||
const msg = apiErrorMessage(err, '创建失败')
|
||||
toast.error(msg)
|
||||
}
|
||||
}
|
||||
@@ -117,21 +135,30 @@ export function SubscriptionsPage() {
|
||||
}
|
||||
await refresh()
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error || '恢复订阅失败'
|
||||
const msg = apiErrorMessage(err, '恢复订阅失败')
|
||||
toast.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
const runSubscriptionNow = async (subscription: Subscription) => {
|
||||
const result = await subscriptionsAPI.runNow(subscription.id)
|
||||
toast.success(`已加入 ${result.queued} 项`)
|
||||
try {
|
||||
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) => {
|
||||
if (!(await confirmAction({ title: '删除订阅', message: `删除订阅「${subscription.name}」?`, confirmText: '删除' }))) return
|
||||
await subscriptionsAPI.remove(subscription.id)
|
||||
toast.success('已删除')
|
||||
await refresh()
|
||||
try {
|
||||
await subscriptionsAPI.remove(subscription.id)
|
||||
toast.success('已删除')
|
||||
await refresh()
|
||||
} catch (err: unknown) {
|
||||
toast.error(apiErrorMessage(err, '删除失败'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -149,10 +176,24 @@ export function SubscriptionsPage() {
|
||||
onChange={updateFormValue}
|
||||
/>
|
||||
|
||||
{loading && <p className="text-sand-500">加载中…</p>}
|
||||
{!loading && items.length === 0 && <p className="text-ink-50">暂无订阅。</p>}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<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">
|
||||
{items.map((subscription) => (
|
||||
<SubscriptionCard
|
||||
@@ -166,7 +207,42 @@ export function SubscriptionsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SubscriptionHistorySection subscriptions={historyItems} onRestore={restoreHistorySubscription} />
|
||||
<SubscriptionHistorySection
|
||||
subscriptions={historyItems}
|
||||
loading={historyLoading}
|
||||
error={historyError}
|
||||
onRefresh={refresh}
|
||||
onRestore={restoreHistorySubscription}
|
||||
/>
|
||||
</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
|
||||
}
|
||||
|
||||
@@ -7,6 +7,27 @@ export const emptyRootDraft = (): RootDraft => ({ name: '', path: '', enabled: t
|
||||
|
||||
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[]) {
|
||||
return roots
|
||||
.map((root, index) => ({
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { SettingGroup } from './settingsGroupTypes'
|
||||
|
||||
export const recognitionWordsSettingsGroup: SettingGroup = {
|
||||
key: 'recognition-words',
|
||||
label: '识别词',
|
||||
items: [],
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { adultSettingsGroup } from './settingsGroupAccess'
|
||||
import { cloudUploadSettingsGroup } from './settingsGroupCloud'
|
||||
import { generalSettingsGroup, licenseSettingsGroup } from './settingsGroupGeneral'
|
||||
import { recognitionWordsSettingsGroup } from './settingsGroupRecognitionWords'
|
||||
import { subscriptionSettingsGroup } from './settingsGroupSubscriptions'
|
||||
import { systemUpdateSettingsGroup } from './settingsGroupSystemUpdate'
|
||||
import type { SettingGroup } from './settingsGroupTypes'
|
||||
@@ -11,6 +12,7 @@ export const GROUPS: SettingGroup[] = [
|
||||
generalSettingsGroup,
|
||||
licenseSettingsGroup,
|
||||
systemUpdateSettingsGroup,
|
||||
recognitionWordsSettingsGroup,
|
||||
subscriptionSettingsGroup,
|
||||
cloudUploadSettingsGroup,
|
||||
adultSettingsGroup,
|
||||
|
||||
@@ -42,6 +42,9 @@ export function suggestedSTRMOutputDir(root: string, library: Library) {
|
||||
const base = trimPath(root)
|
||||
const subdir = strmLibraryOutputSubdir(library)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -77,7 +80,7 @@ function categoryPartsFromPath(parts: string[]) {
|
||||
const root = canonicalRoot(part)
|
||||
if (root) return [root, ...parts.slice(index + 1)]
|
||||
const categoryRoot = categoryRootFor(part)
|
||||
if (categoryRoot) return [categoryRoot, part]
|
||||
if (categoryRoot) return [categoryRoot, canonicalCategory(part)]
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -124,13 +127,34 @@ function canonicalRoot(part: string) {
|
||||
|
||||
function categoryRootFor(part: string) {
|
||||
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 '成人'
|
||||
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) {
|
||||
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('/')
|
||||
}
|
||||
|
||||
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) {
|
||||
return toSlash(raw).replace(/\/+$/g, '').trim()
|
||||
}
|
||||
|
||||
@@ -4,17 +4,16 @@ import toast from 'react-hot-toast'
|
||||
import { libraryAPI } from '../api/library'
|
||||
import type { Library, LibraryRoot } from '../types'
|
||||
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() {
|
||||
const { libs, refresh } = useAdminLibraryList()
|
||||
const createForm = useCreateLibraryForm(refresh)
|
||||
const newRoots = useNewLibraryRootDrafts(refresh)
|
||||
const editableRoots = useEditableRootDrafts()
|
||||
const rootActions = useEditableLibraryRootActions(refresh, editableRoots)
|
||||
const libraryActions = useLibraryActions(refresh)
|
||||
|
||||
return { libs, createForm, newRoots, editableRoots, rootActions, libraryActions }
|
||||
return { libs, createForm, editableRoots, rootActions, libraryActions }
|
||||
}
|
||||
|
||||
function useAdminLibraryList() {
|
||||
@@ -42,7 +41,7 @@ function useCreateLibraryForm(refresh: () => Promise<void>) {
|
||||
return
|
||||
}
|
||||
await libraryAPI.createWithRoots(name, type, payload)
|
||||
toast.success('媒体库已创建')
|
||||
toast.success('媒体库已保存')
|
||||
setName('')
|
||||
setRoots([emptyRootDraft()])
|
||||
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() {
|
||||
const [rootDrafts, setRootDrafts] = useState<Record<string, RootDraft>>({})
|
||||
|
||||
const editableRootDraft = (libraryID: string, root: LibraryRoot): RootDraft => {
|
||||
const key = rootDraftKey(libraryID, root.id)
|
||||
return rootDrafts[key] ?? {
|
||||
name: root.name ?? '',
|
||||
path: root.path,
|
||||
name: displayLibraryRootName(root.name, root.path),
|
||||
path: displayLibraryRootPath(root.path),
|
||||
enabled: root.enabled,
|
||||
sort_order: root.sort_order,
|
||||
}
|
||||
@@ -154,7 +130,7 @@ function useEditableLibraryRootActions(refresh: () => Promise<void>, drafts: Edi
|
||||
}
|
||||
|
||||
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)
|
||||
toast.success('路径已删除')
|
||||
await refresh()
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user