fix: stabilize library paths and subscription matching

This commit is contained in:
ShukeBta
2026-06-28 12:15:10 +08:00
parent e4abb03ca5
commit a97dc35790
102 changed files with 4582 additions and 1519 deletions
+12 -3
View File
@@ -72,9 +72,10 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("organize.scrape_after", true)
v.SetDefault("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)
+1 -2
View File
@@ -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
+27 -3
View File
@@ -95,7 +95,7 @@ func TestListLibrariesIncludeHiddenNormalizesCloudDisplayNames(t *testing.T) {
}
}
func TestListLibrariesIncludeHiddenHidesInternalAutoCategoryLibraries(t *testing.T) {
func TestListLibrariesShowsAutoCategoryLibraries(t *testing.T) {
gin.SetMode(gin.TestMode)
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)
}
}
+84
View File
@@ -0,0 +1,84 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
func recognitionWordsService(svc *service.Container) *service.RecognitionWordsService {
if svc == nil {
return nil
}
if svc.RecognitionWords != nil {
return svc.RecognitionWords
}
return service.NewRecognitionWordsService(svc.Log, svc.Repo)
}
func getRecognitionWordsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
rw := recognitionWordsService(svc)
if rw == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "recognition words service unavailable"})
return
}
c.JSON(http.StatusOK, rw.Config(c.Request.Context()))
}
}
func saveRecognitionWordsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
rw := recognitionWordsService(svc)
if rw == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "recognition words service unavailable"})
return
}
var req service.RecognitionWordsConfig
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := rw.SaveConfig(c.Request.Context(), req); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, rw.Config(c.Request.Context()))
}
}
func syncRecognitionWordsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
rw := recognitionWordsService(svc)
if rw == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "recognition words service unavailable"})
return
}
cfg, err := rw.SyncShared(c.Request.Context())
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, cfg)
}
}
func testRecognitionWordsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
rw := recognitionWordsService(svc)
if rw == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "recognition words service unavailable"})
return
}
var req struct {
Input string `json:"input"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, rw.Test(c.Request.Context(), req.Input))
}
}
+8
View File
@@ -25,6 +25,7 @@ func registerAdminRoutes(api *gin.RouterGroup, cfg *config.Config, svc *service.
registerAdminRepairRoutes(admin, svc)
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))
}
+4 -9
View File
@@ -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)})
}
}
+17
View File
@@ -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
+54
View File
@@ -0,0 +1,54 @@
package handler
import (
"net/url"
"strings"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/middleware"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
func subscriptionRequestUserID(c *gin.Context) string {
if c == nil {
return ""
}
if uid, ok := c.Get(middleware.CtxUserID); ok {
if userID, ok := uid.(string); ok {
return userID
}
}
return ""
}
func subscriptionFeedKind(feedURL string) string {
raw := strings.TrimSpace(feedURL)
if raw == "" {
return "empty"
}
lower := strings.ToLower(raw)
if strings.HasPrefix(lower, "site-search://") {
return "site-search"
}
parsed, err := url.Parse(raw)
if err == nil && parsed.Scheme != "" {
return parsed.Scheme
}
return "unknown"
}
func logSubscriptionInfo(svc *service.Container, msg string, fields ...zap.Field) {
if svc == nil || svc.Log == nil {
return
}
svc.Log.Info(msg, fields...)
}
func logSubscriptionWarn(svc *service.Container, msg string, fields ...zap.Field) {
if svc == nil || svc.Log == nil {
return
}
svc.Log.Warn(msg, fields...)
}
+53
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"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])
+3 -3
View File
@@ -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)
}
}
+139 -31
View File
@@ -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 = &copy
continue
}
return &lib
if lib.ID == rootLibraryID || CloudLibraryAutoCategory(lib) || !lib.Enabled || targetKey == "" {
continue
}
key, ok := CloudLibraryMergeKey(lib)
if target == nil && ok && key == targetKey {
copy := lib
target = &copy
}
}
return target, existingAuto
}
func (s *ScannerService) ensureCloudLibraryRoot(ctx context.Context, libraryID, name, pathValue string) (*model.LibraryRoot, error) {
if s == nil || s.repo == nil || s.repo.Library == nil {
return nil, nil
}
roots, err := s.repo.Library.ListRoots(ctx, libraryID)
if err != nil {
return nil, err
}
targetKey := libraryRootPathKey(pathValue)
for i := range roots {
if libraryRootPathKey(roots[i].Path) == targetKey {
if strings.TrimSpace(roots[i].Name) == "" && strings.TrimSpace(name) != "" {
_ = s.repo.Library.UpdateRoot(ctx, &roots[i], map[string]any{"name": strings.TrimSpace(name)})
roots[i].Name = strings.TrimSpace(name)
}
return &roots[i], nil
}
}
root := &model.LibraryRoot{
LibraryID: libraryID,
Name: strings.TrimSpace(name),
Path: pathValue,
Enabled: true,
SortOrder: len(roots),
}
if err := s.repo.Library.CreateRoot(ctx, root); err != nil {
return nil, err
}
return root, nil
}
func (s *ScannerService) migrateCloudAutoCategoryLibrary(ctx context.Context, source, target *model.Library, root *model.LibraryRoot) {
if s == nil || s.repo == nil || s.repo.DB == nil || source == nil || target == nil || source.ID == "" || target.ID == "" {
return
}
updates := map[string]any{"library_id": target.ID}
if rootID := libraryRootID(root); rootID != "" {
updates["library_root_id"] = rootID
}
if err := s.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("library_id = ?", source.ID).Updates(updates).Error; err != nil {
if s.log != nil {
s.log.Warn("migrate cloud auto category media failed",
zap.String("from_library_id", source.ID),
zap.String("to_library_id", target.ID),
zap.Error(err))
}
return
}
_ = hardDeleteLibraryRoots(ctx, s.repo.DB, source.ID)
if err := s.repo.Library.Delete(ctx, source.ID); err != nil && s.log != nil {
s.log.Warn("remove migrated cloud auto category library failed",
zap.String("library_id", source.ID),
zap.Error(err))
}
return nil
}
func (s *ScannerService) cloudScanLibraryScopeIDs(ctx context.Context, lib *model.Library, mount CloudMountInfo) []string {
+38
View File
@@ -0,0 +1,38 @@
package service
import (
"context"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
func cloudLibraryMediaCounts(ctx context.Context, repo *repository.Container, libs []model.Library) map[string]int64 {
counts := make(map[string]int64, len(libs))
if repo == nil || repo.DB == nil || len(libs) == 0 {
return counts
}
ids := make([]string, 0, len(libs))
for _, lib := range libs {
ids = append(ids, lib.ID)
}
if len(ids) == 0 {
return counts
}
var rows []struct {
LibraryID string
Count int64
}
if err := repo.DB.WithContext(ctx).
Model(&model.Media{}).
Select("library_id, COUNT(*) AS count").
Where("library_id IN ? AND deleted_at IS NULL", ids).
Group("library_id").
Scan(&rows).Error; err != nil {
return counts
}
for _, row := range rows {
counts[row.LibraryID] = row.Count
}
return counts
}
+122
View File
@@ -0,0 +1,122 @@
package service
import (
"strings"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
func betterDisplayCloudLibrary(candidate, current model.Library, counts map[string]int64) bool {
candidateCount := counts[candidate.ID]
currentCount := counts[current.ID]
if (candidateCount > 0) != (currentCount > 0) {
return candidateCount > 0
}
if candidate.Enabled != current.Enabled {
return candidate.Enabled
}
candidateCanonical := cloudLibraryPathIsCanonical(candidate)
currentCanonical := cloudLibraryPathIsCanonical(current)
if candidateCanonical != currentCanonical {
return candidateCanonical
}
if !candidate.CreatedAt.Equal(current.CreatedAt) {
return candidate.CreatedAt.After(current.CreatedAt)
}
return candidate.ID > current.ID
}
func mergeDisplayCloudLibraries(libs []model.Library) []model.Library {
if len(libs) == 0 {
return libs
}
localByKey := make(map[string]struct{}, len(libs))
for _, lib := range libs {
if _, ok := ParseCloudLibraryMount(lib.Path); ok || !lib.Enabled {
continue
}
if key, ok := CloudLibraryMergeKey(lib); ok {
localByKey[key] = struct{}{}
}
}
out := make([]model.Library, 0, len(libs))
for _, lib := range libs {
if displayName, ok := CloudLibraryDisplayName(lib); ok && displayName != "" {
lib.Name = displayName
if key, ok := CloudLibraryMergeKey(lib); ok {
if _, exists := localByKey[key]; exists && !CloudLibraryAutoCategory(lib) {
continue
}
}
} else if displayName := CanonicalLibraryDisplayName(lib); displayName != "" {
lib.Name = displayName
}
out = append(out, lib)
}
return out
}
func dedupeDisplayLibrariesByMergeKey(libs []model.Library, counts map[string]int64) []model.Library {
if len(libs) == 0 {
return libs
}
out := make([]model.Library, 0, len(libs))
byKey := make(map[string]int, len(libs))
for _, lib := range libs {
if displayName := CanonicalLibraryDisplayName(lib); displayName != "" {
lib.Name = displayName
}
if CloudLibraryAutoCategory(lib) {
out = append(out, lib)
continue
}
key, ok := CloudLibraryMergeKey(lib)
if !ok {
out = append(out, lib)
continue
}
if prev, exists := byKey[key]; exists {
if betterCanonicalDisplayLibrary(lib, out[prev], counts) {
out[prev] = lib
}
continue
}
byKey[key] = len(out)
out = append(out, lib)
}
return out
}
func betterCanonicalDisplayLibrary(candidate, current model.Library, counts map[string]int64) bool {
candidateScore := canonicalDisplayLibraryScore(candidate)
currentScore := canonicalDisplayLibraryScore(current)
if candidateScore != currentScore {
return candidateScore > currentScore
}
candidateCount := counts[candidate.ID]
currentCount := counts[current.ID]
if (candidateCount > 0) != (currentCount > 0) {
return candidateCount > 0
}
if candidate.Enabled != current.Enabled {
return candidate.Enabled
}
if !candidate.CreatedAt.Equal(current.CreatedAt) {
return candidate.CreatedAt.After(current.CreatedAt)
}
return candidate.ID > current.ID
}
func canonicalDisplayLibraryScore(lib model.Library) int {
score := 0
if canonical := CanonicalLibraryDisplayName(lib); canonical == "" || strings.EqualFold(strings.TrimSpace(lib.Name), canonical) {
score += 4
}
if canonical := canonicalLibraryCategoryName(lib.Type, pathBaseSlash(lib.Path)); canonical == "" {
score += 2
}
if _, ok := ParseCloudLibraryMount(lib.Path); !ok {
score++
}
return score
}
+234
View File
@@ -0,0 +1,234 @@
package service
import (
"strings"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
func NormalizeCloudLibraryDisplayNames(libs []model.Library) []model.Library {
out := make([]model.Library, 0, len(libs))
for _, lib := range libs {
if displayName, ok := CloudLibraryDisplayName(lib); ok && displayName != "" {
lib.Name = displayName
} else if displayName := CanonicalLibraryDisplayName(lib); displayName != "" {
lib.Name = displayName
}
out = append(out, lib)
}
return out
}
func NormalizeCloudLibraryDisplay(libs []model.Library) []model.Library {
return normalizeDisplayLibraries(libs)
}
func normalizeDisplayLibraries(libs []model.Library) []model.Library {
out := make([]model.Library, 0, len(libs))
for _, lib := range libs {
if displayName, ok := CloudLibraryDisplayName(lib); ok && displayName != "" {
lib.Name = displayName
} else if displayName := CanonicalLibraryDisplayName(lib); displayName != "" {
lib.Name = displayName
}
if displayType := CanonicalLibraryDisplayType(lib); displayType != "" {
lib.Type = displayType
}
if displayPath := CanonicalLibraryDisplayPath(lib); displayPath != "" {
lib.Path = displayPath
}
out = append(out, lib)
}
return out
}
func CloudLibraryDisplayName(lib model.Library) (string, bool) {
info, ok := ParseCloudLibraryMount(lib.Path)
if !ok {
return "", false
}
name := stripCloudProviderDisplayPrefix(strings.TrimSpace(lib.Name), info.Provider)
dir := firstNonEmpty(info.DisplayDir, info.ScanDir)
if name == "" || strings.EqualFold(name, CloudMountProviderLabel(info.Provider)) {
if base := cloudMountDirBase(dir); base != "" {
name = base
}
}
if name == "" {
name = CloudMountProviderLabel(info.Provider)
}
if canonical := canonicalLibraryCategoryName(lib.Type, name); canonical != "" {
name = canonical
} else if canonical := canonicalLibraryCategoryNameAny(name); canonical != "" {
name = canonical
}
return name, true
}
func CanonicalLibraryDisplayName(lib model.Library) string {
if canonical := canonicalLibraryCategoryName(lib.Type, lib.Name); canonical != "" {
return canonical
}
return canonicalLibraryCategoryNameAny(lib.Name)
}
func CanonicalLibraryDisplayType(lib model.Library) string {
if displayName, ok := CloudLibraryDisplayName(lib); ok {
if typ := canonicalLibraryCategoryDisplayType(displayName); typ != "" {
return typ
}
}
if typ := canonicalLibraryCategoryDisplayType(lib.Name); typ != "" {
return typ
}
return canonicalLibraryCategoryDisplayType(pathBaseSlash(lib.Path))
}
func CanonicalLibraryDisplayPath(lib model.Library) string {
raw := strings.TrimSpace(lib.Path)
if raw == "" {
return ""
}
if info, ok := ParseCloudLibraryMount(raw); ok {
dir := firstNonEmpty(info.DisplayDir, info.ScanDir)
displayDir := canonicalLibraryDisplayDir(dir)
if displayDir == "" {
return raw
}
if CloudLibraryAutoCategory(lib) {
return BuildCloudAutoCategoryLibraryPathWithScanDir(info.Provider, info.ScanDir, displayDir)
}
return BuildCloudLibraryPath(info.Provider, info.ScanDir, displayDir)
}
return canonicalLocalLibraryDisplayPath(raw)
}
func canonicalLibraryCategoryName(libraryType, name string) string {
typeKey := cloudLibraryMergeTypeKey(libraryType)
name = normalizeLibraryMergeName(name)
switch typeKey {
case "movie":
switch name {
case "国产电影", "大陆电影":
return "华语电影"
case "外语电影", "外国电影":
return "欧美电影"
case "日本电影", "韩国电影":
return "日韩电影"
case "音乐会", "concert":
return "演唱会"
case "纪录":
return "纪录片"
case "动漫电影":
return "动画电影"
}
case "tvshows":
switch name {
case "国剧", "大陆剧", "国产电视剧", "华语剧":
return "国产剧"
case "欧美电视剧", "美剧", "英剧", "未分类", "uncategorized":
return "欧美剧"
case "日剧", "韩剧":
return "日韩剧"
case "真人秀":
return "综艺"
case "纪录":
return "纪录片"
case "少儿":
return "儿童"
case "国产动漫", "国产动画":
return "国漫"
case "日漫", "番剧", "日本动漫", "日本动画":
return "日番"
case "韩国动漫", "韩国动画":
return "韩漫"
case "欧美动漫", "欧美动画", "西方动画":
return "美漫"
case "其他动漫", "其它动漫", "other":
return "其他"
}
case "adult":
switch name {
case "9kg", "番号", "jav", "nsfw", "adult":
return "成人"
}
}
return ""
}
func canonicalLibraryCategoryNameAny(name string) string {
for _, libraryType := range []string{"movie", "tv", "anime", "adult"} {
if canonical := canonicalLibraryCategoryName(libraryType, name); canonical != "" {
return canonical
}
}
return ""
}
func canonicalLibraryDisplayDir(raw string) string {
parts := strmSlashParts(raw)
if len(parts) == 0 {
return ""
}
return strings.Join(canonicalLibraryDisplayParts(parts), "/")
}
func canonicalLocalLibraryDisplayPath(raw string) string {
value := strings.TrimSpace(raw)
if value == "" {
return ""
}
sep := "/"
if strings.Contains(value, "\\") {
sep = "\\"
}
slash := strings.ReplaceAll(value, "\\", "/")
prefix := ""
for strings.HasPrefix(slash, "/") {
prefix += "/"
slash = strings.TrimPrefix(slash, "/")
}
parts := strings.Split(slash, "/")
canonical := canonicalLibraryDisplayParts(parts)
if len(canonical) == 0 {
return raw
}
out := prefix + strings.Join(canonical, "/")
if sep == "\\" {
out = strings.ReplaceAll(out, "/", "\\")
}
return out
}
func canonicalLibraryDisplayParts(parts []string) []string {
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" || part == "." {
continue
}
if canonical := canonicalLibraryCategoryNameAny(part); canonical != "" {
part = canonical
}
if len(out) > 0 && normalizeLibraryMergeName(out[len(out)-1]) == normalizeLibraryMergeName(part) {
continue
}
out = append(out, part)
}
return out
}
func canonicalLibraryCategoryDisplayType(name string) string {
switch normalizeLibraryMergeName(name) {
case "演唱会", "音乐会", "动画电影", "动漫电影", "华语电影", "国产电影", "大陆电影", "欧美电影", "外语电影", "外国电影", "日韩电影", "日本电影", "韩国电影":
return "movie"
case "国产剧", "国剧", "大陆剧", "国产电视剧", "华语剧", "欧美剧", "欧美电视剧", "美剧", "英剧", "未分类", "uncategorized", "日韩剧", "日剧", "韩剧", "综艺", "真人秀", "儿童", "少儿":
return "tv"
case "国漫", "国产动漫", "国产动画", "日番", "日漫", "番剧", "日本动漫", "日本动画", "韩漫", "韩国动漫", "韩国动画", "美漫", "欧美动漫", "欧美动画", "西方动画", "其他", "其他动漫", "其它动漫", "other":
return "anime"
case "成人", "9kg", "番号", "jav", "nsfw", "adult":
return "adult"
default:
return ""
}
}
+29 -187
View File
@@ -2,7 +2,6 @@ package service
import (
"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
}
+107 -17
View File
@@ -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)
}
}
+2 -2
View File
@@ -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"
+64 -141
View File
@@ -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
}
+152
View File
@@ -0,0 +1,152 @@
package service
import (
"context"
"strings"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
func MergedLibraryIDsForLibrary(ctx context.Context, repo *repository.Container, libraryID string) ([]string, error) {
libraryID = strings.TrimSpace(libraryID)
if libraryID == "" || repo == nil || repo.Library == nil {
return []string{libraryID}, nil
}
lib, err := repo.Library.FindByID(ctx, libraryID)
if err != nil {
return nil, err
}
if lib == nil {
return []string{libraryID}, nil
}
libs, err := repo.Library.List(ctx)
if err != nil {
return nil, err
}
return MergedLibraryIDs(libs, *lib), nil
}
func MergedLibraryIDs(libs []model.Library, target model.Library) []string {
ids := appendUniqueLibraryIDs(nil, target.ID)
if rootAutoIDs := cloudRootAutoCategoryLibraryIDs(libs, target); len(rootAutoIDs) > 0 {
ids = appendUniqueLibraryIDs(ids, rootAutoIDs...)
}
targetKey, hasTargetKey := CloudLibraryMergeKey(target)
if !hasTargetKey {
return ids
}
_, targetIsCloud := ParseCloudLibraryMount(target.Path)
for _, candidate := range libs {
if candidate.ID == target.ID || strings.TrimSpace(candidate.ID) == "" || !candidate.Enabled {
continue
}
key, ok := CloudLibraryMergeKey(candidate)
if ok && key == targetKey {
_, candidateIsCloud := ParseCloudLibraryMount(candidate.Path)
if !targetIsCloud && !candidateIsCloud {
continue
}
ids = appendUniqueLibraryIDs(ids, candidate.ID)
}
}
return ids
}
func cloudRootAutoCategoryLibraryIDs(libs []model.Library, lib model.Library) []string {
mount, ok := ParseCloudLibraryMount(lib.Path)
if !ok || !cloudRootMountNeedsAutoCategory(mount) {
return nil
}
ids := make([]string, 0)
for _, candidate := range libs {
if candidate.ID == lib.ID || !candidate.Enabled || !CloudLibraryAutoCategory(candidate) {
continue
}
info, ok := ParseCloudLibraryMount(candidate.Path)
if ok && info.Provider == mount.Provider {
ids = appendUniqueLibraryIDs(ids, candidate.ID)
}
}
return ids
}
func ExpandMediaVisibilityForMergedCloudLibraries(ctx context.Context, repo *repository.Container, visibility MediaVisibility) MediaVisibility {
if repo == nil || repo.Library == nil {
return visibility
}
libs, err := repo.Library.List(ctx)
if err != nil {
return visibility
}
if len(visibility.AllowedLibraryIDs) > 0 {
visibility.AllowedLibraryIDs = expandMergedLibraryIDsFromLibraries(libs, visibility.AllowedLibraryIDs)
}
if len(visibility.HiddenLibraryIDs) > 0 {
visibility.HiddenLibraryIDs = expandMergedLibraryIDsFromLibraries(libs, visibility.HiddenLibraryIDs)
}
visibility.HiddenLibraryIDs = appendUniqueLibraryIDs(visibility.HiddenLibraryIDs, DeprecatedNativeCloudLibraryIDs(libs)...)
return visibility
}
func expandMergedLibraryIDs(ctx context.Context, repo *repository.Container, ids []string) []string {
if len(ids) == 0 || repo == nil || repo.Library == nil {
return ids
}
libs, err := repo.Library.List(ctx)
if err != nil {
return ids
}
return expandMergedLibraryIDsFromLibraries(libs, ids)
}
func expandMergedLibraryIDsFromLibraries(libs []model.Library, ids []string) []string {
byID := make(map[string]model.Library, len(libs))
for _, lib := range libs {
byID[lib.ID] = lib
}
out := make([]string, 0, len(ids))
for _, id := range ids {
id = strings.TrimSpace(id)
if id == "" {
continue
}
if lib, ok := byID[id]; ok {
out = appendUniqueLibraryIDs(out, MergedLibraryIDs(libs, lib)...)
continue
}
out = appendUniqueLibraryIDs(out, id)
}
return out
}
func DeprecatedNativeCloudLibraryIDs(libs []model.Library) []string {
ids := make([]string, 0)
for _, lib := range libs {
info, ok := ParseCloudLibraryMount(lib.Path)
if ok && IsDeprecatedNativeCloudProvider(info.Provider) {
ids = appendUniqueLibraryIDs(ids, lib.ID)
}
}
return ids
}
func appendUniqueLibraryIDs(ids []string, values ...string) []string {
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
exists := false
for _, id := range ids {
if id == value {
exists = true
break
}
}
if !exists {
ids = append(ids, value)
}
}
return ids
}
+5 -5
View File
@@ -51,7 +51,7 @@ func TestSyncDownloadTaskProgressMatchesSeasonFolderTorrentName(t *testing.T) {
Source: "qbittorrent",
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,
}
+6 -6
View File
@@ -13,7 +13,7 @@ func (s *ScraperService) ManualSearch(ctx context.Context, media *model.Media, q
return nil, errors.New("media required")
}
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)
}
}
+41 -27
View File
@@ -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)
}
+55 -14
View File
@@ -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{
+4 -4
View File
@@ -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
}
}
+99 -4
View File
@@ -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)
}
}
+1 -1
View File
@@ -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(), &currentLib); err != nil {
t.Fatal(err)
}
org := NewOrganizerService(&config.Config{}, zap.NewNop(), repos)
res, err := org.OrganizeDirectory(t.Context(), OrganizeOptions{
SourcePath: src,
DestPath: dest,
MediaType: "movie",
MediaCategory: "外语电影",
TransferMode: TransferCopy,
})
if err != nil {
t.Fatalf("organize legacy category: %v", err)
}
if res.Organized != 1 || len(res.Items) != 1 {
t.Fatalf("result = %+v, want one organized item", res)
}
if !pathWithin(res.Items[0].Target, currentRoot) {
t.Fatalf("target = %q, want current category root %q", res.Items[0].Target, currentRoot)
}
if pathWithin(res.Items[0].Target, legacyRoot) {
t.Fatalf("target must not use legacy category root %q", res.Items[0].Target)
}
}
func TestOrganizeDirectoryTreatsCategoryDestAsCollectionRoot(t *testing.T) {
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)
+27 -5
View File
@@ -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
}
+3 -1
View File
@@ -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 := ""
+71 -8
View File
@@ -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)
}
+197
View File
@@ -0,0 +1,197 @@
package service
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
const (
RecognitionWordsEnabledKey = "recognition_words.enabled"
RecognitionWordsLocalTextKey = "recognition_words.local_text"
RecognitionWordsSharedURLsKey = "recognition_words.shared_urls"
RecognitionWordsSharedTextKey = "recognition_words.shared_text"
RecognitionWordsSyncedAtKey = "recognition_words.synced_at"
)
var DefaultRecognitionWordURLs = []string{
"https://raw.githubusercontent.com/Putarku/MoviePilot-Help/main/Words/general.txt",
"https://raw.githubusercontent.com/Putarku/MoviePilot-Help/main/Words/TV.txt",
"https://raw.githubusercontent.com/Putarku/MoviePilot-Help/main/Words/anime.txt",
}
type RecognitionWordsService struct {
log *zap.Logger
repo *repository.Container
client *http.Client
}
type RecognitionWordsConfig struct {
Enabled bool `json:"enabled"`
LocalText string `json:"local_text"`
SharedURLs []string `json:"shared_urls"`
SharedText string `json:"shared_text,omitempty"`
SyncedAt string `json:"synced_at,omitempty"`
RuleCount int `json:"rule_count"`
}
type RecognitionWordsTestResult struct {
Input string `json:"input"`
Output string `json:"output"`
Title string `json:"title"`
Year int `json:"year"`
Changed bool `json:"changed"`
}
func NewRecognitionWordsService(log *zap.Logger, repo *repository.Container) *RecognitionWordsService {
return &RecognitionWordsService{
log: log,
repo: repo,
client: recognitionWordHTTPClient(),
}
}
func (s *RecognitionWordsService) Config(ctx context.Context) RecognitionWordsConfig {
cfg := recognitionWordsConfig(ctx, s.repo)
cfg.RuleCount = len(parseRecognitionWordRules(recognitionWordsCombinedText(cfg)))
return cfg
}
func (s *RecognitionWordsService) SaveConfig(ctx context.Context, cfg RecognitionWordsConfig) error {
if s == nil || s.repo == nil || s.repo.Setting == nil {
return fmt.Errorf("setting repository unavailable")
}
if err := s.repo.Setting.Set(ctx, RecognitionWordsEnabledKey, strconv.FormatBool(cfg.Enabled)); err != nil {
return err
}
if err := s.repo.Setting.Set(ctx, RecognitionWordsLocalTextKey, cfg.LocalText); err != nil {
return err
}
rawURLs, err := json.Marshal(normalizeRecognitionWordURLs(cfg.SharedURLs))
if err != nil {
return err
}
return s.repo.Setting.Set(ctx, RecognitionWordsSharedURLsKey, string(rawURLs))
}
func (s *RecognitionWordsService) SyncShared(ctx context.Context) (RecognitionWordsConfig, error) {
cfg := s.Config(ctx)
urls := cfg.SharedURLs
if len(urls) == 0 {
urls = DefaultRecognitionWordURLs
}
var combined []string
for _, rawURL := range urls {
text, err := s.fetchSharedWords(ctx, rawURL)
if err != nil {
return cfg, err
}
combined = append(combined, "# "+rawURL, text)
}
now := time.Now().Format(time.RFC3339)
if err := s.repo.Setting.Set(ctx, RecognitionWordsSharedTextKey, strings.Join(combined, "\n")); err != nil {
return cfg, err
}
if err := s.repo.Setting.Set(ctx, RecognitionWordsSyncedAtKey, now); err != nil {
return cfg, err
}
return s.Config(ctx), nil
}
func (s *RecognitionWordsService) Test(ctx context.Context, input string) RecognitionWordsTestResult {
output := ApplyRecognitionWords(ctx, s.repo, input)
title, year := CleanQuery(output)
return RecognitionWordsTestResult{
Input: input,
Output: output,
Title: title,
Year: year,
Changed: strings.TrimSpace(input) != strings.TrimSpace(output),
}
}
func ApplyRecognitionWords(ctx context.Context, repo *repository.Container, raw string) string {
cfg := recognitionWordsConfig(ctx, repo)
if !cfg.Enabled {
return raw
}
rules := parseRecognitionWordRules(recognitionWordsCombinedText(cfg))
return applyRecognitionWordRules(raw, rules)
}
func CleanQueryWithRecognition(ctx context.Context, repo *repository.Container, raw string) (string, int) {
return CleanQuery(ApplyRecognitionWords(ctx, repo, raw))
}
func recognitionWordsConfig(ctx context.Context, repo *repository.Container) RecognitionWordsConfig {
cfg := RecognitionWordsConfig{Enabled: true, SharedURLs: DefaultRecognitionWordURLs}
if repo == nil || repo.DB == nil || repo.Setting == nil || !repo.DB.Migrator().HasTable(&model.Setting{}) {
return cfg
}
if value, err := repo.Setting.Get(ctx, RecognitionWordsEnabledKey); err == nil && strings.TrimSpace(value) != "" {
cfg.Enabled = parseBoolSetting(value, true)
}
if value, err := repo.Setting.Get(ctx, RecognitionWordsLocalTextKey); err == nil {
cfg.LocalText = value
}
if value, err := repo.Setting.Get(ctx, RecognitionWordsSharedURLsKey); err == nil && strings.TrimSpace(value) != "" {
cfg.SharedURLs = parseRecognitionWordURLs(value)
}
if value, err := repo.Setting.Get(ctx, RecognitionWordsSharedTextKey); err == nil {
cfg.SharedText = value
}
if value, err := repo.Setting.Get(ctx, RecognitionWordsSyncedAtKey); err == nil {
cfg.SyncedAt = value
}
return cfg
}
func recognitionWordsCombinedText(cfg RecognitionWordsConfig) string {
return strings.TrimSpace(cfg.LocalText + "\n" + cfg.SharedText)
}
func parseRecognitionWordURLs(raw string) []string {
var values []string
if err := json.Unmarshal([]byte(raw), &values); err == nil {
return normalizeRecognitionWordURLs(values)
}
return normalizeRecognitionWordURLs(strings.FieldsFunc(raw, func(r rune) bool {
return r == '\n' || r == '\r' || r == ',' || r == ';'
}))
}
func normalizeRecognitionWordURLs(values []string) []string {
seen := map[string]struct{}{}
out := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
return out
}
type recognitionWordRule struct {
raw string
block string
replaceFrom string
replaceTo string
offsetLeft string
offsetRight string
offsetExpr string
}
+155
View File
@@ -0,0 +1,155 @@
package service
import (
"fmt"
"regexp"
"strconv"
"strings"
)
func parseRecognitionWordRules(raw string) []recognitionWordRule {
var out []recognitionWordRule
for _, line := range strings.Split(raw, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "//") {
continue
}
out = append(out, parseRecognitionWordRule(line))
}
return out
}
func parseRecognitionWordRule(line string) recognitionWordRule {
rule := recognitionWordRule{raw: line}
for _, part := range strings.Split(line, "&&") {
part = strings.TrimSpace(part)
switch {
case strings.Contains(part, "=>"):
pieces := strings.SplitN(part, "=>", 2)
rule.replaceFrom = strings.TrimSpace(pieces[0])
rule.replaceTo = normalizeRecognitionReplacement(strings.TrimSpace(pieces[1]))
case strings.Contains(part, "<>") && strings.Contains(part, ">>"):
beforeAfter := strings.SplitN(part, ">>", 2)
bounds := strings.SplitN(beforeAfter[0], "<>", 2)
rule.offsetLeft = strings.TrimSpace(bounds[0])
rule.offsetRight = strings.TrimSpace(bounds[1])
rule.offsetExpr = strings.TrimSpace(beforeAfter[1])
default:
rule.block = part
}
}
return rule
}
func normalizeRecognitionReplacement(value string) string {
re := regexp.MustCompile(`\\([0-9]+)`)
return re.ReplaceAllString(value, "$$$1")
}
func applyRecognitionWordRules(raw string, rules []recognitionWordRule) string {
out := strings.TrimSpace(raw)
for _, rule := range rules {
if rule.block != "" {
out = applyRecognitionBlock(out, rule.block)
}
if rule.replaceFrom != "" {
out = applyRecognitionReplace(out, rule.replaceFrom, rule.replaceTo)
}
if rule.offsetLeft != "" || rule.offsetRight != "" {
out = applyRecognitionOffset(out, rule.offsetLeft, rule.offsetRight, rule.offsetExpr)
}
}
return strings.Join(strings.Fields(out), " ")
}
func applyRecognitionBlock(raw, block string) string {
if re, err := regexp.Compile(block); err == nil {
return re.ReplaceAllString(raw, " ")
}
return strings.ReplaceAll(raw, block, " ")
}
func applyRecognitionReplace(raw, from, to string) string {
if re, err := regexp.Compile(from); err == nil {
return re.ReplaceAllString(raw, to)
}
return strings.ReplaceAll(raw, from, to)
}
func applyRecognitionOffset(raw, left, right, expr string) string {
if strings.TrimSpace(expr) == "" {
return raw
}
leftPattern := firstNonEmpty(left, `^`)
rightPattern := firstNonEmpty(right, `$`)
re, err := regexp.Compile(`(?i)(` + leftPattern + `)(\d{1,5})(` + rightPattern + `)`)
if err != nil {
return raw
}
return re.ReplaceAllStringFunc(raw, func(match string) string {
return applyRecognitionOffsetMatch(re, match, expr)
})
}
func applyRecognitionOffsetMatch(re *regexp.Regexp, match, expr string) string {
parts := re.FindStringSubmatch(match)
if len(parts) < 4 {
return match
}
ep, err := strconv.Atoi(parts[2])
if err != nil {
return match
}
next, ok := evalRecognitionEpisodeExpr(expr, ep)
if !ok || next < 0 {
return match
}
format := "%d"
if width := len(parts[2]); width > 1 && width <= 2 {
format = "%0" + strconv.Itoa(width) + "d"
}
return parts[1] + fmt.Sprintf(format, next) + parts[3]
}
func evalRecognitionEpisodeExpr(expr string, ep int) (int, bool) {
value := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(expr), " ", ""))
if value == "" || value == "EP" {
return ep, true
}
if n, err := strconv.Atoi(value); err == nil {
return n, true
}
if out, ok := evalRecognitionEpisodeAddSub(value, ep); ok {
return out, true
}
return evalRecognitionEpisodeMul(value, ep)
}
func evalRecognitionEpisodeAddSub(value string, ep int) (int, bool) {
for _, op := range []string{"+", "-"} {
if !strings.HasPrefix(value, "EP"+op) {
continue
}
n, err := strconv.Atoi(strings.TrimPrefix(value, "EP"+op))
if err != nil {
return 0, false
}
if op == "+" {
return ep + n, true
}
return ep - n, true
}
return 0, false
}
func evalRecognitionEpisodeMul(value string, ep int) (int, bool) {
if strings.HasPrefix(value, "EP*") {
n, err := strconv.Atoi(strings.TrimPrefix(value, "EP*"))
return ep * n, err == nil
}
if strings.HasSuffix(value, "*EP") {
n, err := strconv.Atoi(strings.TrimSuffix(value, "*EP"))
return ep * n, err == nil
}
return 0, false
}
@@ -0,0 +1,139 @@
package service
import (
"context"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"time"
)
func recognitionWordHTTPClient() *http.Client {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.DialContext = dialRecognitionWordContext
return &http.Client{
Timeout: 20 * time.Second,
Transport: transport,
CheckRedirect: func(req *http.Request, _ []*http.Request) error {
return validateRecognitionWordURL(req.Context(), req.URL.String())
},
}
}
func dialRecognitionWordContext(ctx context.Context, network, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, err
}
if isLocalHostname(host) {
return nil, fmt.Errorf("recognition word URL host is not allowed: %s", host)
}
dialer := &net.Dialer{Timeout: 20 * time.Second}
if ip := net.ParseIP(host); ip != nil {
if err := validateRecognitionWordIP(host, ip); err != nil {
return nil, err
}
return dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
}
addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, fmt.Errorf("resolve recognition word URL host %s: %w", host, err)
}
if len(addrs) == 0 {
return nil, fmt.Errorf("resolve recognition word URL host %s: no addresses", host)
}
for _, addr := range addrs {
if err := validateRecognitionWordIP(host, addr.IP); err != nil {
return nil, err
}
}
var lastErr error
for _, addr := range addrs {
conn, err := dialer.DialContext(ctx, network, net.JoinHostPort(addr.IP.String(), port))
if err == nil {
return conn, nil
}
lastErr = err
}
return nil, lastErr
}
func (s *RecognitionWordsService) fetchSharedWords(ctx context.Context, rawURL string) (string, error) {
rawURL = strings.TrimSpace(rawURL)
if err := validateRecognitionWordURL(ctx, rawURL); err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return "", err
}
resp, err := s.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("fetch %s failed: %s", rawURL, resp.Status)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return "", err
}
return string(body), nil
}
func validateRecognitionWordURL(ctx context.Context, rawURL string) error {
parsed, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil {
return fmt.Errorf("invalid recognition word URL %q: %w", rawURL, err)
}
if parsed.User != nil {
return fmt.Errorf("recognition word URL must not include userinfo: %s", rawURL)
}
switch strings.ToLower(parsed.Scheme) {
case "http", "https":
default:
return fmt.Errorf("recognition word URL must use http or https: %s", rawURL)
}
host := strings.TrimSpace(parsed.Hostname())
if host == "" {
return fmt.Errorf("recognition word URL host is required: %s", rawURL)
}
if isLocalHostname(host) {
return fmt.Errorf("recognition word URL host is not allowed: %s", host)
}
if ip := net.ParseIP(host); ip != nil {
return validateRecognitionWordIP(host, ip)
}
addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return fmt.Errorf("resolve recognition word URL host %s: %w", host, err)
}
if len(addrs) == 0 {
return fmt.Errorf("resolve recognition word URL host %s: no addresses", host)
}
for _, addr := range addrs {
if err := validateRecognitionWordIP(host, addr.IP); err != nil {
return err
}
}
return nil
}
func isLocalHostname(host string) bool {
host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")
return host == "localhost" || host == "localhost.localdomain"
}
func validateRecognitionWordIP(host string, ip net.IP) error {
if ip == nil {
return fmt.Errorf("recognition word URL host %s resolved to an invalid address", host)
}
if ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsMulticast() {
return fmt.Errorf("recognition word URL host %s resolved to a restricted address: %s", host, ip.String())
}
return nil
}
@@ -0,0 +1,60 @@
package service
import (
"strings"
"testing"
)
func TestApplyRecognitionWordRules(t *testing.T) {
rules := parseRecognitionWordRules(`
BADWORD
Wrong.Title => 正确标题
One\.Piece\.S01E(89[2-9]|9\d{2}|10\d{2})\.1999 => 海贼王.S21E\1.1999 && S21E <> \. >> EP-892
`)
got := applyRecognitionWordRules("BADWORD Wrong.Title One.Piece.S01E1076.1999.1080p", rules)
want := "正确标题 海贼王.S21E184.1999.1080p"
if got != want {
t.Fatalf("recognized = %q, want %q", got, want)
}
}
func TestCleanQueryWithRecognitionDisabledByDefaultRepoNil(t *testing.T) {
title, year := CleanQueryWithRecognition(t.Context(), nil, "Dune.2021.2160p.WEB-DL.mkv")
if title != "dune" || year != 2021 {
t.Fatalf("CleanQueryWithRecognition = %q/%d, want dune/2021", title, year)
}
}
func TestValidateRecognitionWordURLRejectsUnsafeTargets(t *testing.T) {
tests := []string{
"file:///etc/passwd",
"https://user:pass@example.com/words.txt",
"http://localhost/words.txt",
"http://127.0.0.1/words.txt",
"http://10.0.0.1/words.txt",
"http://172.16.0.1/words.txt",
"http://192.168.1.1/words.txt",
"http://[::1]/words.txt",
}
for _, rawURL := range tests {
if err := validateRecognitionWordURL(t.Context(), rawURL); err == nil {
t.Fatalf("validateRecognitionWordURL(%q) succeeded, want rejection", rawURL)
}
}
}
func TestValidateRecognitionWordURLAllowsPublicHTTPTargets(t *testing.T) {
if err := validateRecognitionWordURL(t.Context(), "https://1.1.1.1/words.txt"); err != nil {
t.Fatalf("public IP should be allowed: %v", err)
}
if err := validateRecognitionWordURL(t.Context(), "http://8.8.8.8/words.txt"); err != nil {
t.Fatalf("public HTTP IP should be allowed: %v", err)
}
}
func TestRecognitionWordDialRejectsUnsafeTargets(t *testing.T) {
_, err := dialRecognitionWordContext(t.Context(), "tcp", "127.0.0.1:80")
if err == nil || !strings.Contains(err.Error(), "restricted address") {
t.Fatalf("dialRecognitionWordContext err = %v, want restricted address", err)
}
}
@@ -120,7 +120,7 @@ func TestScanRootCloudLibraryCreatesAutoCategoryLibraries(t *testing.T) {
wantLibraries := map[string]string{
"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
}
+28 -7
View File
@@ -171,24 +171,45 @@ func (c *cloudScanCandidateCollector) addFileCandidate(displayDir string, entry
c.req.progress.publish(c.scanner, c.lib.ID, c.req.result, "listing", c.req.progress.markFileDiscovered())
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()
+13 -12
View File
@@ -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 != "" {
+36 -10
View File
@@ -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
}
+2 -2
View File
@@ -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 {
+1 -1
View File
@@ -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)
}
+25
View File
@@ -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
+3
View File
@@ -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)
}
+1 -1
View File
@@ -61,7 +61,7 @@ func (s *ScraperService) EnrichOneWithOptions(ctx context.Context, m *model.Medi
return s.applyProviderMatchWithOptions(ctx, m, lib, match, options)
}
candidates := scrapeQueryCandidates(m, lib)
candidates := scrapeQueryCandidatesWithRecognition(ctx, s.repo, m, lib)
var query string
match := (*Match)(nil)
for _, candidate := range candidates {
+15 -1
View File
@@ -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)
}
+2 -2
View File
@@ -132,9 +132,9 @@ func isGenericMediaCategoryFolder(name string) bool {
"欧美剧", "欧美电视剧",
"日韩剧", "日剧", "韩剧",
"华语电影", "国产电影", "大陆电影",
"外语电影", "欧美电影", "日韩电影",
"外语电影", "外国电影", "欧美电影", "日韩电影", "演唱会", "音乐会",
"动画电影", "动漫电影",
"国漫", "国产动漫", "日番", "日漫", "日本动漫", "日本动画", "欧美动漫", "欧美动画", "西方动画",
"国漫", "国产动漫", "日番", "日漫", "日本动漫", "日本动画", "韩漫", "韩国动漫", "韩国动画", "美漫", "欧美动漫", "欧美动画", "西方动画", "其他动漫", "其它动漫",
"综艺", "真人秀",
"纪录片", "纪录",
"儿童", "少儿",
+1
View File
@@ -74,6 +74,7 @@ type Container struct {
Device *DeviceService
Cache *RuntimeCacheService
Sessions *SessionTrackerService
RecognitionWords *RecognitionWordsService
stopCtx context.Context
stopCancel context.CancelFunc
+1
View File
@@ -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(
+10 -260
View File
@@ -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()
}
+19
View File
@@ -212,6 +212,25 @@ func TestNexusPHPSearchUsesSearchstr(t *testing.T) {
}
}
func TestNexusPHPSearchReportsExpiredCookieLoginPage(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`<html><form id="loginform" action="takelogin.php"><input type="password" /></form></html>`))
}))
defer server.Close()
adapter := NewNexusPHPAdapter()
_, err := adapter.Search(t.Context(), SiteConfig{
Name: "Nexus",
URL: server.URL,
AuthType: "cookie",
Cookie: "uid=1; pass=expired",
Timeout: 5 * time.Second,
}, "测试", 1)
if err == nil || !strings.Contains(err.Error(), "cookie expired") {
t.Fatalf("Search error = %v, want cookie expired hint", err)
}
}
func TestParseNexusPHPHTMLModernRows(t *testing.T) {
page := `
<table class="torrents">
+84 -22
View File
@@ -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
}
+36
View File
@@ -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)
}
}
+1
View File
@@ -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
+50
View File
@@ -0,0 +1,50 @@
package service
import (
"slices"
"testing"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
"go.uber.org/zap"
)
func TestStorageBreakdownUsesCanonicalLibraryDisplay(t *testing.T) {
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
repos := repository.New(db)
libs := []model.Library{
{Name: "外语电影", Path: "/media/电影/外语电影", Type: "movie", Enabled: true},
{Name: "欧美动漫", Path: "/media/动漫/欧美动漫", Type: "tv", Enabled: true},
{Name: "9KG", Path: "/media/成人/9KG", Type: "movie", Enabled: true},
}
for i := range libs {
if err := repos.Library.Create(t.Context(), &libs[i]); err != nil {
t.Fatal(err)
}
if err := repos.Media.Upsert(t.Context(), &model.Media{
LibraryID: libs[i].ID,
Title: libs[i].Name,
Path: libs[i].Path + "/item.mkv",
SizeBytes: 1024,
}); err != nil {
t.Fatal(err)
}
}
breakdown, err := NewStorageService(zap.NewNop(), repos).Compute(t.Context())
if err != nil {
t.Fatal(err)
}
gotNames := make([]string, 0, len(breakdown.ByLibrary))
gotTypes := make([]string, 0, len(breakdown.ByLibrary))
for _, row := range breakdown.ByLibrary {
gotNames = append(gotNames, row.Name)
gotTypes = append(gotTypes, row.Type)
}
if want := []string{"欧美电影", "美漫", "成人"}; !slices.Equal(gotNames, want) {
t.Fatalf("library names = %#v, want %#v", gotNames, want)
}
if want := []string{"movie", "anime", "adult"}; !slices.Equal(gotTypes, want) {
t.Fatalf("library types = %#v, want %#v", gotTypes, want)
}
}
+44 -4
View File
@@ -108,7 +108,7 @@ func strmCategoryPartsFromPath(parts []string) []string {
return append([]string{root}, strmSanitizedTail(parts[i+1:])...)
}
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)
}
}
+10 -1
View File
@@ -166,10 +166,16 @@ func (s *SubscriptionService) Delete(ctx context.Context, id string) error {
// by the admin UI's "test now" button.
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
+1 -1
View File
@@ -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'
}
+54
View File
@@ -0,0 +1,54 @@
package service
import (
"net/url"
"strings"
"time"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
func subscriptionRunLogFields(sub *model.Subscription) []zap.Field {
fields := []zap.Field{}
if sub == nil {
return fields
}
return append(fields,
zap.String("subscription_id", sub.ID),
zap.String("subscription", sub.Name),
zap.String("feed_kind", subscriptionFeedKind(sub.FeedURL)),
zap.String("filter", sub.Filter),
zap.String("media_type", sub.MediaType),
zap.String("media_category", sub.MediaCategory),
zap.String("search_mode", sub.SearchMode),
zap.Bool("enabled", sub.Enabled),
zap.Bool("wash_enabled", sub.WashEnabled),
zap.String("wash_priority", sub.WashPriority),
zap.Int("total_episodes", sub.TotalEpisodes),
)
}
func appendSubscriptionRunResultFields(fields []zap.Field, queued int, started time.Time) []zap.Field {
return append(fields,
zap.Int("queued", queued),
zap.Int64("duration_ms", time.Since(started).Milliseconds()),
)
}
func subscriptionFeedKind(feedURL string) string {
raw := strings.TrimSpace(feedURL)
if raw == "" {
return "empty"
}
lower := strings.ToLower(raw)
if strings.HasPrefix(lower, "site-search://") {
return "site-search"
}
parsed, err := url.Parse(raw)
if err == nil && parsed.Scheme != "" {
return parsed.Scheme
}
return "unknown"
}
+23
View File
@@ -0,0 +1,23 @@
package service
import (
"regexp"
"strings"
)
var (
seriesPackRE = regexp.MustCompile(`(?i)(complete|batch|合集|全集|全\s*\d+\s*[集话話期]|整季|全季|s\d{1,2}\s*(?:complete|batch|pack)|season\s*\d{1,2}\s*(?:complete|batch|pack)|s\d{1,2}e\d{1,3}\s*[-~–—]\s*(?:s\d{1,2})?e?\d{1,3}|第\s*\d+\s*[-~–—]\s*\d+\s*[集话話期])`)
seasonOnlyRE = regexp.MustCompile(`(?i)(?:^|[\s._-])(?:s|season)\s*\d{1,2}(?:[\s._-]|$)|第\s*\d+\s*季`)
)
func isSeriesPackTitle(title string) bool {
title = strings.TrimSpace(title)
if title == "" {
return false
}
if seriesPackRE.MatchString(title) {
return true
}
_, episode := ParseEpisode(title)
return episode == 0 && seasonOnlyRE.MatchString(title)
}
+42 -5
View File
@@ -19,8 +19,21 @@ type rssSubscriptionRunState struct {
washOff bool
}
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 -237
View File
@@ -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{
+137
View File
@@ -0,0 +1,137 @@
package service
import (
"regexp"
"strings"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
func subscriptionCandidateScore(sub *model.Subscription, item SearchResult) int {
title := strings.ToLower(subscriptionSearchResultText(item))
score := item.Seeders
if sub == nil || !sub.WashEnabled {
if item.Free {
score += 25
}
return score
}
resolutionScore := detectResolutionScore(title)
qualityScore := detectQualityScore(title)
effectScore := detectEffectScore(title)
priority := "balanced"
if sub != nil && strings.TrimSpace(sub.WashPriority) != "" {
priority = strings.ToLower(strings.TrimSpace(sub.WashPriority))
}
switch priority {
case "resolution":
score += resolutionScore*1000 + qualityScore*100 + effectScore*50
case "quality":
score += qualityScore*1000 + resolutionScore*200 + effectScore*50
case "effects":
score += effectScore*1000 + resolutionScore*200 + qualityScore*100
case "seeders":
score += qualityScore*3 + resolutionScore*2 + effectScore
default:
score += resolutionScore*500 + qualityScore*300 + effectScore*150
}
if item.Free {
score += 25
}
return score
}
func containsAnyEffect(titleFold, csv string) bool {
for _, token := range strings.FieldsFunc(strings.ToLower(csv), func(r rune) bool {
return r == ',' || r == '/' || r == '|' || r == ';' || r == ','
}) {
token = strings.TrimSpace(token)
if token == "" {
continue
}
switch token {
case "dolby-vision", "dolby vision", "dv":
if strings.Contains(titleFold, "dolby vision") || strings.Contains(titleFold, "dovi") || regexp.MustCompile(`\bdv\b`).MatchString(titleFold) {
return true
}
default:
if strings.Contains(titleFold, token) {
return true
}
}
}
return false
}
func titleMatchesResolution(titleFold, resolution string) bool {
switch strings.ToLower(strings.TrimSpace(resolution)) {
case "2160p", "4k", "uhd":
return strings.Contains(titleFold, "2160p") || strings.Contains(titleFold, "4k") || strings.Contains(titleFold, "uhd")
case "1080p":
return strings.Contains(titleFold, "1080p") || strings.Contains(titleFold, "fhd")
case "720p":
return strings.Contains(titleFold, "720p")
default:
return strings.Contains(titleFold, strings.ToLower(strings.TrimSpace(resolution)))
}
}
func titleMatchesQuality(titleFold, quality string) bool {
switch strings.ToLower(strings.TrimSpace(quality)) {
case "webdl", "web-dl":
return strings.Contains(titleFold, "web-dl") || strings.Contains(titleFold, "webdl")
case "bluray", "blu-ray":
return strings.Contains(titleFold, "bluray") || strings.Contains(titleFold, "blu-ray") || strings.Contains(titleFold, "bdrip")
case "remux":
return strings.Contains(titleFold, "remux")
case "hdtv":
return strings.Contains(titleFold, "hdtv")
default:
return strings.Contains(titleFold, strings.ToLower(strings.TrimSpace(quality)))
}
}
func detectResolutionScore(titleFold string) int {
switch {
case titleMatchesResolution(titleFold, "2160p"):
return 4
case titleMatchesResolution(titleFold, "1080p"):
return 3
case titleMatchesResolution(titleFold, "720p"):
return 2
default:
return 1
}
}
func detectQualityScore(titleFold string) int {
switch {
case titleMatchesQuality(titleFold, "remux"):
return 5
case titleMatchesQuality(titleFold, "bluray"):
return 4
case titleMatchesQuality(titleFold, "web-dl"):
return 3
case titleMatchesQuality(titleFold, "hdtv"):
return 2
default:
return 1
}
}
func detectEffectScore(titleFold string) int {
score := 0
if containsAnyEffect(titleFold, "dolby-vision") {
score += 4
}
if strings.Contains(titleFold, "hdr10+") {
score += 3
} else if strings.Contains(titleFold, "hdr") {
score += 2
}
if strings.Contains(titleFold, "atmos") {
score += 2
}
return score
}
+36 -4
View File
@@ -69,15 +69,42 @@ func (s *SubscriptionService) runSiteSearch(ctx context.Context, sub *model.Subs
func (s *SubscriptionService) finishSiteSearchRun(ctx context.Context, sub *model.Subscription, guidKey string, state *siteSearchRunState) LocalAvailability {
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
}
+30
View File
@@ -0,0 +1,30 @@
import { api } from './client'
export interface RecognitionWordsConfig {
enabled: boolean
local_text: string
shared_urls: string[]
shared_text?: string
synced_at?: string
rule_count: number
}
export interface RecognitionWordsTestResult {
input: string
output: string
title: string
year: number
changed: boolean
}
export const recognitionWordsAPI = {
get: () => api.get<RecognitionWordsConfig>('/admin/recognition-words').then((r) => r.data),
save: (payload: RecognitionWordsConfig) =>
api.put<RecognitionWordsConfig>('/admin/recognition-words', payload).then((r) => r.data),
sync: () => api.post<RecognitionWordsConfig>('/admin/recognition-words/sync').then((r) => r.data),
test: (input: string) =>
api.post<RecognitionWordsTestResult>('/admin/recognition-words/test', { input }).then((r) => r.data),
}
+13 -2
View File
@@ -42,10 +42,14 @@ export function buildSubscriptionAliases(item: {
export const subscriptionsAPI = {
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() },
}
}
+204
View File
@@ -0,0 +1,204 @@
import { Link } from 'react-router-dom'
import { Menu, MessageSquareText, Search, Sparkles } from 'lucide-react'
import type { PlayProfile, User } from '../types'
import { LayoutSearchBox } from './LayoutSearchBox'
import { LayoutThemeToggle } from './LayoutThemeToggle'
import { LayoutUserMenu } from './LayoutUserMenu'
import type { useLayoutProfiles } from './useLayoutProfiles'
import type { useLayoutSearch } from './useLayoutSearch'
import type { ThemeMode, useThemeMode } from './useThemeMode'
type LayoutSearchState = ReturnType<typeof useLayoutSearch>
type LayoutProfileState = ReturnType<typeof useLayoutProfiles>
type LayoutThemeState = ReturnType<typeof useThemeMode>
type LayoutPermissionState = {
can: (key: string) => boolean
isAdmin: boolean
}
type LayoutHeaderProps = {
search: LayoutSearchState
permissions: LayoutPermissionState
theme: LayoutThemeState
onOpenMobileDrawer: () => void
user: User | null | undefined
activeProfileId: string | null
profile: LayoutProfileState
onLogout: () => void
}
export function LayoutHeader({
search,
permissions,
theme,
onOpenMobileDrawer,
user,
activeProfileId,
profile,
onLogout,
}: LayoutHeaderProps) {
return (
<header className="flex h-20 shrink-0 items-center justify-between border-b border-[var(--app-border)] bg-[var(--app-header-bg)] px-4 backdrop-blur-md z-30 md:px-8">
<LayoutHeaderSearch search={search} onOpenMobileDrawer={onOpenMobileDrawer} />
<LayoutHeaderActions
permissions={permissions}
themeMode={theme.mode}
onThemeChange={theme.setMode}
user={user}
isProfileOpen={profile.isProfileOpen}
profiles={profile.profiles}
activeProfileId={activeProfileId}
activeProfile={profile.activeProfile}
onToggleProfile={() => profile.setIsProfileOpen((open) => !open)}
onCloseProfile={() => profile.setIsProfileOpen(false)}
onUseDefaultProfile={profile.useDefaultProfile}
onSwitchProfile={profile.switchProfile}
onLogout={onLogout}
/>
</header>
)
}
function LayoutHeaderSearch({
search,
onOpenMobileDrawer,
}: {
search: LayoutSearchState
onOpenMobileDrawer: () => void
}) {
return (
<div className="flex items-center gap-3 flex-1 max-w-lg md:gap-4">
<button
onClick={onOpenMobileDrawer}
className="rounded-xl border border-[var(--app-border)] p-2.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-colors lg:hidden"
>
<Menu size={18} />
</button>
<LayoutSearchBox
query={search.query}
focused={search.focused}
loading={search.loading}
error={search.error}
cards={search.cards}
total={search.total}
onQueryChange={search.setQuery}
onFocusedChange={search.setFocused}
onSubmit={search.submit}
/>
</div>
)
}
type LayoutHeaderActionsProps = {
permissions: LayoutPermissionState
themeMode: ThemeMode
onThemeChange: (mode: ThemeMode) => void
user: User | null | undefined
isProfileOpen: boolean
profiles: PlayProfile[]
activeProfileId: string | null
activeProfile: PlayProfile | null
onToggleProfile: () => void
onCloseProfile: () => void
onUseDefaultProfile: () => void
onSwitchProfile: (profile: PlayProfile) => void
onLogout: () => void
}
function LayoutHeaderActions({
permissions,
themeMode,
onThemeChange,
user,
isProfileOpen,
profiles,
activeProfileId,
activeProfile,
onToggleProfile,
onCloseProfile,
onUseDefaultProfile,
onSwitchProfile,
onLogout,
}: LayoutHeaderActionsProps) {
return (
<div className="flex shrink-0 items-center gap-2 sm:gap-3 md:gap-4">
<LayoutQuickActions permissions={permissions} />
<LayoutThemeToggle mode={themeMode} onChange={onThemeChange} />
<span className="hidden h-6 w-px bg-[var(--app-border)] sm:block" />
<LayoutProfileMenu
user={user}
isProfileOpen={isProfileOpen}
profiles={profiles}
activeProfileId={activeProfileId}
activeProfile={activeProfile}
onToggleProfile={onToggleProfile}
onCloseProfile={onCloseProfile}
onUseDefaultProfile={onUseDefaultProfile}
onSwitchProfile={onSwitchProfile}
onLogout={onLogout}
/>
</div>
)
}
function LayoutQuickActions({ permissions }: { permissions: LayoutPermissionState }) {
return (
<>
<Link
to="/search"
className="rounded-xl border border-[var(--app-border)] p-2.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-colors sm:hidden"
>
<Search size={18} />
</Link>
{permissions.can('can_view_discover') && (
<Link
to="/discover"
className="hidden md:flex items-center gap-2 rounded-xl border border-[var(--app-border)] px-4 py-2.5 text-xs font-bold text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-all"
>
<Sparkles size={14} className="text-brand-500" />
<span>发现新片</span>
</Link>
)}
{permissions.isAdmin && (
<Link
to="/notify-channels"
title="通知配置"
aria-label="打开通知配置"
className="relative rounded-xl border border-[var(--app-border)] p-2.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-all"
>
<MessageSquareText size={18} />
</Link>
)}
</>
)
}
function LayoutProfileMenu({
user,
isProfileOpen,
profiles,
activeProfileId,
activeProfile,
onToggleProfile,
onCloseProfile,
onUseDefaultProfile,
onSwitchProfile,
onLogout,
}: Omit<LayoutHeaderActionsProps, 'permissions' | 'themeMode' | 'onThemeChange'>) {
return (
<LayoutUserMenu
user={user}
isOpen={isProfileOpen}
profiles={profiles}
activeProfileId={activeProfileId}
activeProfile={activeProfile}
onToggle={onToggleProfile}
onClose={onCloseProfile}
onUseDefaultProfile={onUseDefaultProfile}
onSwitchProfile={onSwitchProfile}
onLogout={onLogout}
/>
)
}
+3 -203
View File
@@ -1,29 +1,12 @@
import { Link, Outlet } from 'react-router-dom'
import { Outlet } from 'react-router-dom'
import { AnimatePresence, motion } from 'framer-motion'
import { 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}
/>
)
}
+3 -21
View File
@@ -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 },
],
},
]
+36 -2
View File
@@ -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\],
+1 -4
View File
@@ -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}
+4 -4
View File
@@ -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="路径名称"
+131 -92
View File
@@ -1,16 +1,14 @@
import { Plus, RefreshCw, Save, Trash2 } from 'lucide-react'
import type { MouseEvent, ReactNode } from 'react'
import { MoreVertical, Power, PowerOff, RefreshCw, Save, Trash2 } from 'lucide-react'
import type { Library, LibraryRoot } from '../types'
import type { 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>
)
}
+2
View File
@@ -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">
+2
View File
@@ -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({
+45
View File
@@ -0,0 +1,45 @@
import { Loader2 } from 'lucide-react'
import {
RecognitionWordsActions,
RecognitionWordsEditors,
RecognitionWordsHeader,
RecognitionWordsTester,
} from './RecognitionWordsPanelSections'
import { useRecognitionWordsPanel } from './useRecognitionWordsPanel'
export function RecognitionWordsPanel() {
const state = useRecognitionWordsPanel()
if (state.loading) {
return (
<div className="flex justify-center py-12 text-ink-50">
<Loader2 className="animate-spin" />
</div>
)
}
return (
<div className="glass-panel space-y-4">
<RecognitionWordsHeader config={state.config} onEnabledChange={state.updateEnabled} />
<RecognitionWordsEditors
config={state.config}
onLocalTextChange={state.updateLocalText}
onSharedURLsChange={state.updateSharedURLs}
/>
<RecognitionWordsTester
input={state.testInput}
result={state.testResult}
testing={state.testing}
onInputChange={state.setTestInput}
onTest={state.test}
/>
<RecognitionWordsActions
saving={state.saving}
syncing={state.syncing}
onSave={state.save}
onSync={state.sync}
/>
</div>
)
}
@@ -0,0 +1,149 @@
import { Loader2, Play, RefreshCw, Save } from 'lucide-react'
import type { RecognitionWordsConfig, RecognitionWordsTestResult } from '../api/recognitionWords'
export function RecognitionWordsHeader({
config,
onEnabledChange,
}: {
config: RecognitionWordsConfig
onEnabledChange: (enabled: boolean) => void
}) {
return (
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold text-ink-600">自定义识别词</div>
<div className="text-xs text-sand-500">
当前已加载 {config.rule_count} 条规则{config.synced_at ? ` · 上次同步 ${new Date(config.synced_at).toLocaleString()}` : ''}
</div>
</div>
<label className="flex cursor-pointer items-center gap-2 text-sm text-ink-100">
<input
type="checkbox"
className="h-4 w-4 accent-primary-400"
checked={config.enabled}
onChange={(event) => onEnabledChange(event.target.checked)}
/>
启用
</label>
</div>
)
}
export function RecognitionWordsEditors({
config,
onLocalTextChange,
onSharedURLsChange,
}: {
config: RecognitionWordsConfig
onLocalTextChange: (value: string) => void
onSharedURLsChange: (value: string) => void
}) {
return (
<div className="grid gap-4 lg:grid-cols-2">
<label className="space-y-2">
<div className="text-sm font-medium text-ink-100">共享识别词源</div>
<textarea
rows={7}
className="input-base font-mono text-xs"
value={config.shared_urls.join('\n')}
onChange={(event) => onSharedURLsChange(event.target.value)}
/>
</label>
<label className="space-y-2">
<div className="text-sm font-medium text-ink-100">本地识别词</div>
<textarea
rows={7}
className="input-base font-mono text-xs"
placeholder={'屏蔽词\n错误标题 => 正确标题\nS01E <> . >> EP-10'}
value={config.local_text}
onChange={(event) => onLocalTextChange(event.target.value)}
/>
</label>
</div>
)
}
export function RecognitionWordsTester({
input,
result,
testing,
onInputChange,
onTest,
}: {
input: string
result: RecognitionWordsTestResult | null
testing: boolean
onInputChange: (value: string) => void
onTest: () => void
}) {
return (
<div className="rounded-lg border border-white/10 bg-black/10 p-3">
<div className="grid gap-3 md:grid-cols-[1fr_auto]">
<input
className="input-base"
placeholder="输入文件名或种子标题测试识别效果"
value={input}
onChange={(event) => onInputChange(event.target.value)}
/>
<button type="button" className="neon-button" onClick={onTest} disabled={testing || !input.trim()}>
{testing ? <Loader2 size={16} className="animate-spin" /> : <Play size={16} />}
测试
</button>
</div>
{result && <RecognitionWordsTestResultGrid result={result} />}
</div>
)
}
function RecognitionWordsTestResultGrid({ result }: { result: RecognitionWordsTestResult }) {
return (
<div className="mt-3 grid gap-2 text-xs text-ink-100 md:grid-cols-3">
<RecognitionWordsResultCard label="识别后" value={result.output || '-'} mono />
<RecognitionWordsResultCard label="标题" value={result.title || '-'} />
<RecognitionWordsResultCard label="年份" value={result.year || '-'} />
</div>
)
}
function RecognitionWordsResultCard({
label,
mono,
value,
}: {
label: string
mono?: boolean
value: string | number
}) {
return (
<div className="rounded-md bg-white/5 p-2">
<div className="text-sand-500">{label}</div>
<div className={mono ? 'break-all font-mono' : ''}>{value}</div>
</div>
)
}
export function RecognitionWordsActions({
saving,
syncing,
onSave,
onSync,
}: {
saving: boolean
syncing: boolean
onSave: () => void
onSync: () => void
}) {
return (
<div className="flex flex-wrap justify-end gap-2">
<button type="button" className="btn-secondary" onClick={onSync} disabled={syncing}>
{syncing ? <Loader2 size={16} className="animate-spin" /> : <RefreshCw size={16} />}
同步共享词
</button>
<button type="button" className="neon-button" onClick={onSave} disabled={saving}>
{saving ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
保存
</button>
</div>
)
}
+29 -25
View File
@@ -8,6 +8,7 @@ import type { Library, Setting } from '../types'
import { SettingRow } from './SettingsRow'
import { 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>
+6 -1
View File
@@ -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)}
+2 -1
View File
@@ -67,10 +67,11 @@ export function StoragePage() {
<summary className="cursor-pointer list-none font-display text-lg font-semibold text-ink-600">
低频维护入口 <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>
+42 -10
View File
@@ -1,4 +1,4 @@
import { Archive, Film, Play, RotateCcw } from 'lucide-react'
import { AlertTriangle, Archive, Film, Play, RefreshCw, RotateCcw } from 'lucide-react'
import { imageURL } from '../api/client'
import 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>
)
}
+93 -17
View File
@@ -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
}
+21
View File
@@ -7,6 +7,27 @@ export const emptyRootDraft = (): RootDraft => ({ name: '', path: '', enabled: t
export const rootDraftKey = (libraryID: string, rootID: string) => `${libraryID}:${rootID}`
export 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: [],
}
+2
View File
@@ -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,
+45 -4
View File
@@ -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()
}
+6 -30
View File
@@ -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()
+112
View File
@@ -0,0 +1,112 @@
import { useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import { recognitionWordsAPI, type RecognitionWordsConfig, type RecognitionWordsTestResult } from '../api/recognitionWords'
const DEFAULT_CONFIG: RecognitionWordsConfig = {
enabled: true,
local_text: '',
shared_urls: [
'https://raw.githubusercontent.com/Putarku/MoviePilot-Help/main/Words/general.txt',
'https://raw.githubusercontent.com/Putarku/MoviePilot-Help/main/Words/TV.txt',
'https://raw.githubusercontent.com/Putarku/MoviePilot-Help/main/Words/anime.txt',
],
rule_count: 0,
}
export function useRecognitionWordsPanel() {
const [config, setConfig] = useState<RecognitionWordsConfig>(DEFAULT_CONFIG)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [syncing, setSyncing] = useState(false)
const [testing, setTesting] = useState(false)
const [testInput, setTestInput] = useState('')
const [testResult, setTestResult] = useState<RecognitionWordsTestResult | null>(null)
const refresh = async () => {
setLoading(true)
try {
setConfig(await recognitionWordsAPI.get())
} finally {
setLoading(false)
}
}
useEffect(() => {
refresh().catch(() => undefined)
}, [])
const updateEnabled = (enabled: boolean) => {
setConfig((prev) => ({ ...prev, enabled }))
}
const updateSharedURLs = (value: string) => {
setConfig((prev) => ({
...prev,
shared_urls: value.split(/\r?\n/).map((item) => item.trim()).filter(Boolean),
}))
}
const updateLocalText = (localText: string) => {
setConfig((prev) => ({ ...prev, local_text: localText }))
}
const save = async () => {
setSaving(true)
try {
const saved = await recognitionWordsAPI.save(config)
setConfig(saved)
toast.success('识别词配置已保存')
} catch (error: unknown) {
toast.error(apiErrorMessage(error, '保存失败'))
} finally {
setSaving(false)
}
}
const sync = async () => {
setSyncing(true)
try {
const synced = await recognitionWordsAPI.sync()
setConfig(synced)
toast.success(`已同步 ${synced.rule_count} 条识别词`)
} catch (error: unknown) {
toast.error(apiErrorMessage(error, '同步失败'))
} finally {
setSyncing(false)
}
}
const test = async () => {
if (!testInput.trim()) return
setTesting(true)
try {
setTestResult(await recognitionWordsAPI.test(testInput))
} catch (error: unknown) {
toast.error(apiErrorMessage(error, '测试失败'))
} finally {
setTesting(false)
}
}
return {
config,
loading,
saving,
syncing,
testing,
testInput,
testResult,
save,
setTestInput,
sync,
test,
updateEnabled,
updateLocalText,
updateSharedURLs,
}
}
function apiErrorMessage(error: unknown, fallback: string): string {
return (error as { response?: { data?: { error?: string } } })?.response?.data?.error ?? fallback
}

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