From 0bf983cec8e59b7b4bcb29363436592cab008726 Mon Sep 17 00:00:00 2001 From: ShukeBta Date: Wed, 10 Jun 2026 18:46:30 +0800 Subject: [PATCH] fix: improve cloud mount scanning feedback --- internal/handler/cloud.go | 128 ++++++++---- internal/handler/media.go | 56 ++++- internal/repository/repository.go | 6 +- internal/service/cloud_metadata.go | 266 ++++++++++++++++++++++++ internal/service/cloud_mount.go | 203 ++++++++++++++++++ internal/service/emby_compat.go | 4 +- internal/service/scanner.go | 275 +++++++++++++++++++++---- internal/service/scanner_cloud_test.go | 264 +++++++++++++++++++++++- internal/service/scheduler.go | 2 +- internal/service/scheduler_test.go | 2 +- internal/service/scraper.go | 8 +- internal/service/scraper_test.go | 2 + internal/service/service.go | 31 +++ web/src/api/storage_config.ts | 4 +- web/src/components/GlobalEvents.tsx | 34 ++- web/src/pages/AdminPage.tsx | 3 +- web/src/pages/LibraryPage.tsx | 108 ++++++++-- web/src/pages/StorageConfigPage.tsx | 131 +++++++++++- web/src/types/index.ts | 6 + 19 files changed, 1414 insertions(+), 119 deletions(-) create mode 100644 internal/service/cloud_metadata.go create mode 100644 internal/service/cloud_mount.go diff --git a/internal/handler/cloud.go b/internal/handler/cloud.go index 5467c7b..dc9bcf6 100644 --- a/internal/handler/cloud.go +++ b/internal/handler/cloud.go @@ -3,12 +3,14 @@ package handler import ( + "context" "io" "net/http" - "net/url" "strings" + "time" "github.com/gin-gonic/gin" + "go.uber.org/zap" "github.com/ShukeBta/MediaStationGo/internal/model" "github.com/ShukeBta/MediaStationGo/internal/service" @@ -52,13 +54,14 @@ func cloudImportHandler(svc *service.Container) gin.HandlerFunc { } // cloudMountHandler creates or reuses a cloud:// media library for a cloud -// directory, then scans it recursively so cloud files become playable STRM/302 -// media rows without copying bytes to local disk. +// directory, then queues a recursive import scan. The scan runs outside the +// request so large 115/OpenList folders do not make the UI report a timeout. func cloudMountHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { typ := c.Param("type") var in struct { Dir string `json:"dir"` + DirPath string `json:"dir_path"` Name string `json:"name"` MediaType string `json:"media_type"` } @@ -71,17 +74,18 @@ func cloudMountHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - path := "cloud://" + typ - if dir := strings.TrimSpace(in.Dir); dir != "" { - path += "/" + url.PathEscape(dir) - } + path := service.BuildCloudLibraryPath(typ, in.Dir, in.DirPath) name := strings.TrimSpace(in.Name) if name == "" { name = cloudMountLibraryName(typ, strings.TrimSpace(in.Dir)) } mediaType := strings.TrimSpace(in.MediaType) if mediaType == "" || strings.EqualFold(mediaType, "auto") { - mediaType = cloudMountMediaType(strings.TrimSpace(in.Dir), name) + displayDir := strings.TrimSpace(in.DirPath) + if displayDir == "" { + displayDir = strings.TrimSpace(in.Dir) + } + mediaType = service.InferCloudMountMediaType(displayDir, name) } libs, err := svc.Repo.Library.List(c.Request.Context()) if err != nil { @@ -89,10 +93,18 @@ func cloudMountHandler(svc *service.Container) gin.HandlerFunc { return } var lib *model.Library - for i := range libs { - if libs[i].Path == path { - lib = &libs[i] - break + alreadyMounted := false + if conflict := service.FindCloudMountConflict(libs, typ, in.Dir, in.DirPath); conflict != nil { + lib = &conflict.Library + alreadyMounted = conflict.Exact + if conflict.Nested { + c.JSON(http.StatusOK, gin.H{ + "library": lib, + "skipped": true, + "reason": "cloud mount overlaps an existing mounted parent/child directory", + "conflict_library": conflict.Library, + }) + return } } if lib == nil { @@ -101,17 +113,75 @@ func cloudMountHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - } - var scan any - if svc.Scan != nil { - res, err := svc.Scan.ScanLibrary(c.Request.Context(), lib.ID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error(), "library": lib}) - return + } else if alreadyMounted { + updates := map[string]any{} + if path != "" && path != lib.Path { + updates["path"] = path + lib.Path = path + } + if mediaType != "" && mediaType != lib.Type { + updates["type"] = mediaType + lib.Type = mediaType + } + if name != "" && name != lib.Name && !strings.Contains(lib.Name, " · ") { + updates["name"] = name + lib.Name = name + } + if len(updates) > 0 { + if err := svc.Repo.DB.WithContext(c.Request.Context()).Model(&model.Library{}).Where("id = ?", lib.ID).Updates(updates).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } } - scan = res } - c.JSON(http.StatusOK, gin.H{"library": lib, "scan": scan}) + if svc.Scan != nil { + libID := lib.ID + if svc.WSHub != nil { + svc.WSHub.Publish("scan", gin.H{ + "library_id": libID, + "cloud": true, + "queued": true, + "stage": "queued", + "message": "云盘扫描已加入后台队列,会递归扫描并自动加入媒体库", + "estimate_message": "小目录通常几十秒;几万文件的大目录可能需要数分钟到数小时,取决于网盘接口速度", + }) + } + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 6*time.Hour) + defer cancel() + res, err := svc.Scan.ScanLibraryWithoutAutoScrape(ctx, libID) + if err != nil { + if svc.Log != nil { + svc.Log.Warn("cloud mount background scan failed", zap.String("library_id", libID), zap.Error(err)) + } + if svc.WSHub != nil { + svc.WSHub.Publish("scan", gin.H{ + "library_id": libID, + "cloud": true, + "finished": true, + "error": err.Error(), + }) + } + return + } + if svc.Log != nil { + svc.Log.Info("cloud mount background scan finished", + zap.String("library_id", libID), + zap.Int("visited", res.Visited), + zap.Int("added", res.Added), + zap.Int("updated", res.Updated), + zap.Int("skipped", res.Skipped), + zap.Int64("removed", res.Removed)) + } + }() + } + c.JSON(http.StatusAccepted, gin.H{ + "library": lib, + "already_mounted": alreadyMounted, + "scan_queued": svc.Scan != nil, + "message": "挂载后会后台递归扫描,发现的媒体会自动加入当前媒体库", + "estimate_message": "小目录通常几十秒;几万文件的大目录可能需要数分钟到数小时,取决于网盘接口速度", + }) } } @@ -133,22 +203,6 @@ func cloudMountLibraryName(typ, dir string) string { return base + " · " + dir } -func cloudMountMediaType(dir, name string) string { - text := strings.ToLower(dir + " " + name) - switch { - case strings.Contains(text, "成人") || strings.Contains(text, "adult") || strings.Contains(text, "jav") || strings.Contains(text, "9kg"): - return "adult" - case strings.Contains(text, "动漫") || strings.Contains(text, "动画") || strings.Contains(text, "国漫") || strings.Contains(text, "日番") || strings.Contains(text, "anime"): - return "anime" - case strings.Contains(text, "综艺") || strings.Contains(text, "variety") || strings.Contains(text, "show"): - return "variety" - case strings.Contains(text, "剧") || strings.Contains(text, "series") || strings.Contains(text, "tv"): - return "tv" - default: - return "movie" - } -} - // cloud115QRStartHandler begins a 115 QR-code login and returns the session + // QR image URL for the frontend to render. func cloud115QRStartHandler(svc *service.Container) gin.HandlerFunc { diff --git a/internal/handler/media.go b/internal/handler/media.go index 4da8128..9a84b6c 100644 --- a/internal/handler/media.go +++ b/internal/handler/media.go @@ -6,8 +6,10 @@ import ( "errors" "net/http" "strconv" + "time" "github.com/gin-gonic/gin" + "go.uber.org/zap" "github.com/ShukeBta/MediaStationGo/internal/middleware" "github.com/ShukeBta/MediaStationGo/internal/service" @@ -29,6 +31,7 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc { role, _ := c.Get(middleware.CtxUserRole) includeHidden := role == "admin" && (c.Query("include_hidden") == "1" || c.Query("all") == "1") if !includeHidden { + libs = service.FilterShadowedCloudLibraries(libs) visibility := mediaVisibilityForRequest(c, svc) filtered := libs[:0] for _, lib := range libs { @@ -79,9 +82,56 @@ func deleteLibraryHandler(svc *service.Container) gin.HandlerFunc { func scanLibraryHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { id := c.Param("id") - // Run synchronously: small libraries return immediately, big ones can - // hit the (configurable) HTTP timeout. A future task queue can move - // this to a background worker. + lib, err := svc.Repo.Library.FindByID(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if lib == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "library not found"}) + return + } + if _, ok := service.ParseCloudLibraryMount(lib.Path); ok { + if svc.WSHub != nil { + svc.WSHub.Publish("scan", gin.H{ + "library_id": id, + "cloud": true, + "queued": true, + "stage": "queued", + "message": "云盘扫描已加入后台队列,会递归扫描并自动加入媒体库", + "estimate_message": "小目录通常几十秒;几万文件的大目录可能需要数分钟到数小时,取决于网盘接口速度", + }) + } + go func(libraryID string) { + ctx, cancel := context.WithTimeout(context.Background(), 6*time.Hour) + defer cancel() + if _, err := svc.Scan.ScanLibraryWithoutAutoScrape(ctx, libraryID); err != nil { + if svc.Log != nil { + svc.Log.Warn("cloud library async scan failed", zap.String("library_id", libraryID), zap.Error(err)) + } + if svc.WSHub != nil { + svc.WSHub.Publish("scan", gin.H{ + "library_id": libraryID, + "cloud": true, + "finished": true, + "error": err.Error(), + }) + } + } + }(id) + c.JSON(http.StatusAccepted, gin.H{ + "library_id": id, + "visited": 0, + "added": 0, + "updated": 0, + "probed": 0, + "queued": true, + "cloud": true, + "message": "云盘扫描已在后台运行,发现的媒体会自动加入当前媒体库", + "estimate_message": "小目录通常几十秒;几万文件的大目录可能需要数分钟到数小时,取决于网盘接口速度", + }) + return + } res, err := svc.Scan.ScanLibrary(c.Request.Context(), id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) diff --git a/internal/repository/repository.go b/internal/repository/repository.go index 6a266c1..271c4dc 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -299,7 +299,11 @@ func (r *MediaRepository) Upsert(ctx context.Context, m *model.Media) error { if m.ScrapeStatus == "" { m.ScrapeStatus = "pending" } - return r.db.WithContext(ctx).Create(m).Error + if createErr := r.db.WithContext(ctx).Create(m).Error; createErr == nil { + return nil + } else if retryErr := r.db.WithContext(ctx).Unscoped().Where("path = ?", m.Path).First(&existing).Error; retryErr != nil { + return createErr + } } if err != nil { return err diff --git a/internal/service/cloud_metadata.go b/internal/service/cloud_metadata.go new file mode 100644 index 0000000..54eff1f --- /dev/null +++ b/internal/service/cloud_metadata.go @@ -0,0 +1,266 @@ +package service + +import ( + "context" + "encoding/xml" + "net/url" + "path/filepath" + "strings" + + "github.com/ShukeBta/MediaStationGo/internal/service/cloud" +) + +type cloudSidecarSet struct { + nfoByName map[string]string + nfoByBase map[string]string + imageByBase map[string]string +} + +func newCloudSidecarSet(typ string, entries []cloud.FileEntry) cloudSidecarSet { + set := cloudSidecarSet{ + nfoByName: make(map[string]string), + nfoByBase: make(map[string]string), + imageByBase: make(map[string]string), + } + for _, entry := range entries { + if entry.IsDir { + continue + } + ref := cloudEntryRef(typ, entry.ID, entry.PickCode) + if ref == "" { + continue + } + name := strings.TrimSpace(entry.Name) + ext := strings.ToLower(filepath.Ext(name)) + base := strings.ToLower(strings.TrimSpace(strings.TrimSuffix(name, ext))) + if name == "" || base == "" { + continue + } + switch ext { + case ".nfo": + set.nfoByName[strings.ToLower(name)] = ref + set.nfoByBase[base] = ref + case ".jpg", ".jpeg", ".png", ".webp": + set.imageByBase[base] = ref + } + } + return set +} + +func (s *ScannerService) cloudDirectoryMetadata(ctx context.Context, typ, displayDir string, sidecars cloudSidecarSet, inherited *LocalMetadata) *LocalMetadata { + meta := cloneLocalMetadata(inherited) + for _, name := range cloudShowNFOCandidates(displayDir) { + ref := sidecars.nfoByName[strings.ToLower(name)] + if ref == "" { + ref = sidecars.nfoByBase[strings.ToLower(strings.TrimSuffix(name, filepath.Ext(name)))] + } + if ref == "" { + continue + } + if local, _, err := s.readCloudNFO(ctx, typ, ref, true); err == nil && local != nil { + meta = mergeCloudMetadata(meta, local) + break + } + } + meta = applyCloudDirectoryArtwork(typ, sidecars, meta) + if !cloudMetadataUseful(meta) { + return nil + } + return meta +} + +func (s *ScannerService) cloudFileMetadata(ctx context.Context, typ, displayPath, fileName string, sidecars cloudSidecarSet, inherited *LocalMetadata, seriesLike bool) *LocalMetadata { + season, episode := ParseEpisode(displayPath) + seriesLike = seriesLike || season > 0 || episode > 0 + meta := cloneLocalMetadata(inherited) + base := strings.ToLower(strings.TrimSpace(strings.TrimSuffix(fileName, filepath.Ext(fileName)))) + if ref := sidecars.nfoByBase[base]; ref != "" { + if local, doc, err := s.readCloudNFO(ctx, typ, ref, seriesLike); err == nil && local != nil { + if seriesLike && doc != nil { + if meta == nil { + meta = &LocalMetadata{} + } + mergeEpisodeMetadata(meta, local, doc) + meta.HasNFO = true + } else { + meta = mergeCloudMetadata(meta, local) + } + } + } + meta = applyCloudFileArtwork(typ, sidecars, base, meta) + if !cloudMetadataUseful(meta) { + return nil + } + return meta +} + +func (s *ScannerService) readCloudNFO(ctx context.Context, typ, ref string, seriesLike bool) (*LocalMetadata, *nfoDocument, error) { + if s.storage == nil { + return nil, nil, nil + } + body, err := s.storage.CloudReadText(ctx, typ, ref, 512<<10) + if err != nil { + return nil, nil, err + } + var doc nfoDocument + if err := xml.Unmarshal([]byte(body), &doc); err != nil { + return nil, nil, err + } + meta := metadataFromDoc(&doc, "", seriesLike) + return meta, &doc, nil +} + +func applyCloudDirectoryArtwork(typ string, sidecars cloudSidecarSet, meta *LocalMetadata) *LocalMetadata { + if meta == nil { + meta = &LocalMetadata{} + } + if meta.PosterURL == "" { + if ref := firstCloudImageRef(sidecars, "poster", "folder", "cover", "show", "tvshow"); ref != "" { + meta.PosterURL = cloudPlaybackURL(typ, ref) + meta.HasArtwork = true + } + } + if meta.BackdropURL == "" { + if ref := firstCloudImageRef(sidecars, "fanart", "backdrop", "background", "landscape"); ref != "" { + meta.BackdropURL = cloudPlaybackURL(typ, ref) + meta.HasArtwork = true + } + } + return meta +} + +func applyCloudFileArtwork(typ string, sidecars cloudSidecarSet, base string, meta *LocalMetadata) *LocalMetadata { + if meta == nil { + meta = &LocalMetadata{} + } + if meta.PosterURL == "" { + if ref := firstCloudImageRef(sidecars, + base+"-poster", base+".poster", base+"-cover", base+".cover", base+"-thumb", base+".thumb", + "poster", "folder", "cover", "movie", "show", "thumb", + ); ref != "" { + meta.PosterURL = cloudPlaybackURL(typ, ref) + meta.HasArtwork = true + } + } + if meta.BackdropURL == "" { + if ref := firstCloudImageRef(sidecars, + base+"-fanart", base+".fanart", base+"-backdrop", base+".backdrop", base+"-background", base+".background", + "fanart", "backdrop", "background", "landscape", + ); ref != "" { + meta.BackdropURL = cloudPlaybackURL(typ, ref) + meta.HasArtwork = true + } + } + return meta +} + +func firstCloudImageRef(sidecars cloudSidecarSet, names ...string) string { + for _, name := range names { + if ref := sidecars.imageByBase[strings.ToLower(strings.TrimSpace(name))]; ref != "" { + return ref + } + } + return "" +} + +func cloudShowNFOCandidates(displayDir string) []string { + names := []string{"tvshow.nfo", "series.nfo", "show.nfo"} + base := strings.TrimSpace(pathBaseSlash(displayDir)) + if base != "" { + names = append(names, base+".nfo") + } + return names +} + +func mergeCloudMetadata(dst, src *LocalMetadata) *LocalMetadata { + if src == nil { + return dst + } + if dst == nil { + return cloneLocalMetadata(src) + } + if src.Title != "" { + dst.Title = src.Title + } + if src.OriginalName != "" { + dst.OriginalName = src.OriginalName + } + if src.AdultCode != "" { + dst.AdultCode = src.AdultCode + } + if src.Year > 0 { + dst.Year = src.Year + } + if src.Overview != "" { + dst.Overview = src.Overview + } + if src.Rating > 0 { + dst.Rating = src.Rating + } + if src.PosterURL != "" { + dst.PosterURL = src.PosterURL + } + if src.BackdropURL != "" { + dst.BackdropURL = src.BackdropURL + } + if src.TMDbID > 0 { + dst.TMDbID = src.TMDbID + } + if src.SeasonNum > 0 { + dst.SeasonNum = src.SeasonNum + } + if src.EpisodeNum > 0 { + dst.EpisodeNum = src.EpisodeNum + } + if src.Genres != "" { + dst.Genres = src.Genres + } + if src.Countries != "" { + dst.Countries = src.Countries + } + if src.Languages != "" { + dst.Languages = src.Languages + } + dst.NSFW = dst.NSFW || src.NSFW + dst.HasNFO = dst.HasNFO || src.HasNFO + dst.HasArtwork = dst.HasArtwork || src.HasArtwork + return dst +} + +func cloneLocalMetadata(src *LocalMetadata) *LocalMetadata { + if src == nil { + return nil + } + cp := *src + return &cp +} + +func cloudMetadataUseful(meta *LocalMetadata) bool { + return meta != nil && (meta.HasNFO || meta.HasArtwork || localHasDescriptiveMetadata(meta)) +} + +func cloudPlaybackURL(typ, ref string) string { + return "/api/cloud/play/" + typ + "?ref=" + url.QueryEscape(ref) +} + +func joinCloudDisplayPath(parent, child string) string { + parent = strings.Trim(strings.ReplaceAll(strings.TrimSpace(parent), "\\", "/"), "/") + child = strings.Trim(strings.ReplaceAll(strings.TrimSpace(child), "\\", "/"), "/") + switch { + case parent == "": + return child + case child == "": + return parent + default: + return parent + "/" + child + } +} + +func pathBaseSlash(value string) string { + value = strings.Trim(strings.ReplaceAll(strings.TrimSpace(value), "\\", "/"), "/") + if value == "" { + return "" + } + parts := strings.Split(value, "/") + return parts[len(parts)-1] +} diff --git a/internal/service/cloud_mount.go b/internal/service/cloud_mount.go new file mode 100644 index 0000000..825061b --- /dev/null +++ b/internal/service/cloud_mount.go @@ -0,0 +1,203 @@ +package service + +import ( + "net/url" + "strings" + + "github.com/ShukeBta/MediaStationGo/internal/model" + "github.com/ShukeBta/MediaStationGo/internal/service/cloud" +) + +// CloudMountInfo is the canonical identity of a mounted cloud library. ScanDir +// is the provider id/path used for listing. DisplayDir is a hierarchical path +// used to prevent mounting both a parent and its child as separate libraries. +type CloudMountInfo struct { + Provider string + DisplayDir string + ScanDir string + Path string +} + +type CloudMountConflict struct { + Library model.Library `json:"library"` + Exact bool `json:"exact"` + Nested bool `json:"nested"` + ExistingIsAncestor bool `json:"existing_is_ancestor"` +} + +func BuildCloudLibraryPath(provider, scanDir, displayDir string) string { + provider = strings.TrimSpace(provider) + scanDir = normalizeCloudMountDir(provider, scanDir) + displayDir = normalizeCloudMountDir(provider, firstNonEmpty(displayDir, scanDir)) + if provider == "" { + return "" + } + base := "cloud://" + provider + if displayDir == "" { + if scanDir != "" { + return base + "?dir=" + url.QueryEscape(scanDir) + } + return base + } + path := base + "/" + url.PathEscape(displayDir) + if scanDir != "" && scanDir != displayDir { + path += "?dir=" + url.QueryEscape(scanDir) + } + return path +} + +func ParseCloudLibraryMount(raw string) (CloudMountInfo, bool) { + raw = strings.TrimSpace(raw) + if !strings.HasPrefix(strings.ToLower(raw), "cloud://") { + return CloudMountInfo{}, false + } + u, err := url.Parse(raw) + if err != nil || strings.ToLower(u.Scheme) != "cloud" { + return CloudMountInfo{}, false + } + provider := strings.TrimSpace(u.Host) + if provider == "" { + return CloudMountInfo{}, false + } + displayDir := strings.Trim(strings.TrimSpace(u.Path), "/") + if decoded, err := url.PathUnescape(displayDir); err == nil { + displayDir = decoded + } + scanDir := displayDir + if qDir := strings.TrimSpace(u.Query().Get("dir")); qDir != "" { + if decoded, err := url.QueryUnescape(qDir); err == nil { + qDir = decoded + } + scanDir = qDir + } + displayDir = normalizeCloudMountDir(provider, displayDir) + scanDir = normalizeCloudMountDir(provider, scanDir) + return CloudMountInfo{ + Provider: provider, + DisplayDir: displayDir, + ScanDir: scanDir, + Path: raw, + }, true +} + +func FindCloudMountConflict(libs []model.Library, provider, scanDir, displayDir string) *CloudMountConflict { + candidate := CloudMountInfo{ + Provider: strings.TrimSpace(provider), + DisplayDir: normalizeCloudMountDir(provider, firstNonEmpty(displayDir, scanDir)), + ScanDir: normalizeCloudMountDir(provider, scanDir), + } + for _, lib := range libs { + existing, ok := ParseCloudLibraryMount(lib.Path) + if !ok || existing.Provider != candidate.Provider { + continue + } + if existing.DisplayDir == candidate.DisplayDir { + return &CloudMountConflict{Library: lib, Exact: true} + } + if existing.ScanDir != "" && candidate.ScanDir != "" && existing.ScanDir == candidate.ScanDir { + return &CloudMountConflict{Library: lib, Exact: true} + } + if cloudMountAncestor(candidate.DisplayDir, existing.DisplayDir) { + return &CloudMountConflict{Library: lib, Nested: true} + } + } + return nil +} + +func CloudLibraryShadowed(libs []model.Library, lib model.Library) *CloudMountConflict { + current, ok := ParseCloudLibraryMount(lib.Path) + if !ok { + return nil + } + for _, existing := range libs { + if existing.ID == lib.ID || !existing.Enabled { + continue + } + info, ok := ParseCloudLibraryMount(existing.Path) + if !ok || info.Provider != current.Provider { + continue + } + if info.DisplayDir == current.DisplayDir && existing.CreatedAt.Before(lib.CreatedAt) { + return &CloudMountConflict{Library: existing, Exact: true} + } + if cloudMountAncestor(current.DisplayDir, info.DisplayDir) { + return &CloudMountConflict{Library: existing, Nested: true} + } + } + return nil +} + +func FilterShadowedCloudLibraries(libs []model.Library) []model.Library { + out := make([]model.Library, 0, len(libs)) + for _, lib := range libs { + if CloudLibraryShadowed(libs, lib) == nil { + out = append(out, lib) + } + } + return out +} + +func ShadowedCloudLibraryIDSet(libs []model.Library) map[string]bool { + out := make(map[string]bool) + for _, lib := range libs { + if CloudLibraryShadowed(libs, lib) != nil { + out[lib.ID] = true + } + } + return out +} + +func InferCloudMountMediaType(dir, name string) string { + text := strings.ToLower(dir + " " + name) + 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"): + return "movie" + case containsAny(text, "综艺", "真人秀", "脱口秀", "晚会", "variety"): + return "variety" + case containsAny(text, "国漫", "日漫", "日番", "番剧", "动漫", "欧美动漫", "动画剧集", "anime"): + return "anime" + case containsAny(text, "国产剧", "大陆剧", "华语剧", "欧美剧", "日韩剧", "韩剧", "日剧", "港剧", "台剧", "泰剧", "英剧", "美剧", "短剧", "电视剧", "剧集", "连续剧", "series", "tv", "shows"): + return "tv" + default: + return "movie" + } +} + +func containsAny(text string, values ...string) bool { + for _, value := range values { + if strings.Contains(text, value) { + return true + } + } + return false +} + +func cloudMountAncestor(parent, child string) bool { + parent = strings.Trim(parent, "/") + child = strings.Trim(child, "/") + if parent == child { + return false + } + if parent == "" { + return child != "" + } + return strings.HasPrefix(child, parent+"/") +} + +func normalizeCloudMountDir(provider, value string) string { + value = strings.TrimSpace(value) + if decoded, err := url.PathUnescape(value); err == nil { + value = decoded + } + if decoded, err := url.QueryUnescape(value); err == nil { + value = decoded + } + value = strings.ReplaceAll(value, "\\", "/") + value = strings.Trim(strings.TrimSpace(value), "/") + if value == "." || ((provider == cloud.Type115 || provider == cloud.TypeQuark) && value == "0") { + return "" + } + return value +} diff --git a/internal/service/emby_compat.go b/internal/service/emby_compat.go index 7ac9d81..b150743 100644 --- a/internal/service/emby_compat.go +++ b/internal/service/emby_compat.go @@ -186,6 +186,7 @@ func (e *EmbyService) Views(ctx context.Context, userID string) (map[string]any, if err != nil { return nil, err } + libs = FilterShadowedCloudLibraries(libs) visibility := UserDefaultMediaVisibility(ctx, e.repo, userID) items := make([]map[string]any, 0, len(libs)) for _, l := range libs { @@ -1149,9 +1150,10 @@ func (e *EmbyService) hiddenLibraryIDs(ctx context.Context, visibility MediaVisi if err != nil { return nil } + shadowed := ShadowedCloudLibraryIDSet(libs) ids := make([]string, 0) for _, lib := range libs { - if !LibraryVisibleForUser(ctx, e.repo, lib, visibility) { + if shadowed[lib.ID] || !LibraryVisibleForUser(ctx, e.repo, lib, visibility) { ids = append(ids, lib.ID) } } diff --git a/internal/service/scanner.go b/internal/service/scanner.go index 9defd31..e8a0ecc 100644 --- a/internal/service/scanner.go +++ b/internal/service/scanner.go @@ -17,6 +17,7 @@ import ( "os" "path/filepath" "strings" + "time" "go.uber.org/zap" @@ -93,13 +94,35 @@ func (s *ScannerService) ScanLibrary(ctx context.Context, libraryID string) (*Sc return s.scanLibrary(ctx, libraryID, true) } +// ScanLibraryWithoutAutoScrape walks a library without kicking off online +// metadata enrichment. Cloud mounts can contain very large trees; keeping mount +// scans import-only prevents scraper bursts from overwhelming small NAS boxes. +func (s *ScannerService) ScanLibraryWithoutAutoScrape(ctx context.Context, libraryID string) (*ScanResult, error) { + return s.scanLibrary(ctx, libraryID, false) +} + func (s *ScannerService) scanLibrary(ctx context.Context, libraryID string, autoScrape bool) (*ScanResult, error) { lib, err := s.repo.Library.FindByID(ctx, libraryID) if err != nil || lib == nil { return nil, err } - if typ, dirID, ok := parseCloudLibraryPath(lib.Path); ok { - return s.scanCloudLibrary(ctx, lib, typ, dirID, autoScrape) + if mount, ok := ParseCloudLibraryMount(lib.Path); ok { + if shadow := s.shadowedCloudLibrary(ctx, lib); shadow != nil { + res := &ScanResult{LibraryID: lib.ID, Skipped: 1} + s.log.Warn("skip shadowed cloud library scan", + zap.String("library_id", lib.ID), + zap.String("shadowed_by", shadow.Library.ID), + zap.String("provider", mount.Provider)) + s.hub.Publish("scan", map[string]any{ + "library_id": lib.ID, + "finished": true, + "skipped": res.Skipped, + "cloud": true, + "shadowed": true, + }) + return res, nil + } + return s.scanCloudLibrary(ctx, lib, mount, autoScrape) } res := &ScanResult{LibraryID: lib.ID} seen := make(map[string]struct{}) @@ -174,23 +197,86 @@ func (s *ScannerService) IngestPath(ctx context.Context, libraryID, path string) return res.Added+res.Updated > 0, nil } -func (s *ScannerService) scanCloudLibrary(ctx context.Context, lib *model.Library, typ, rootDir string, autoScrape bool) (*ScanResult, error) { +func (s *ScannerService) scanCloudLibrary(ctx context.Context, lib *model.Library, mount CloudMountInfo, autoScrape bool) (*ScanResult, error) { res := &ScanResult{LibraryID: lib.ID} if s.storage == nil { return res, fmt.Errorf("cloud storage service unavailable") } + typ := mount.Provider + rootDir := mount.ScanDir + rootDisplayDir := mount.DisplayDir + type cloudCandidate struct { + ref string + name string + size int64 + path string + localMeta *LocalMetadata + } seen := make(map[string]struct{}) + seenRefs := make(map[string]struct{}) + candidates := make([]cloudCandidate, 0, 256) + candidateByKey := make(map[string]int) visitedDirs := map[string]struct{}{} - var walkCloud func(string) error - walkCloud = func(dirID string) error { + startedAt := time.Now() + lastProgress := time.Time{} + dirsVisited := 0 + filesDiscovered := 0 + publishProgress := func(stage string, force bool) { + if s.hub == nil { + return + } + if !force && time.Since(lastProgress) < 2*time.Second { + return + } + lastProgress = time.Now() + elapsed := time.Since(startedAt) + filesPerSecond := 0.0 + processed := filesDiscovered + if res.Visited > processed { + processed = res.Visited + } + if elapsed.Seconds() > 0 { + filesPerSecond = float64(processed) / elapsed.Seconds() + } + s.hub.Publish("scan", map[string]any{ + "library_id": lib.ID, + "cloud": true, + "stage": stage, + "dirs": dirsVisited, + "discovered": filesDiscovered, + "visited": res.Visited, + "added": res.Added, + "updated": res.Updated, + "skipped": res.Skipped, + "elapsed_seconds": int(elapsed.Seconds()), + "files_per_second": filesPerSecond, + "estimate_message": "云盘接口不提供总文件数,剩余时间会随目录大小和网盘响应速度变化", + }) + } + publishProgress("listing", true) + var walkCloud func(dirID, displayDir string, inheritedMeta *LocalMetadata) error + walkCloud = func(dirID, displayDir string, inheritedMeta *LocalMetadata) error { if _, ok := visitedDirs[dirID]; ok { return nil } visitedDirs[dirID] = struct{}{} entries, err := s.storage.CloudList(ctx, typ, dirID) if err != nil { + if dirID != rootDir { + res.Skipped++ + s.log.Warn("skip inaccessible cloud directory", + zap.String("library_id", lib.ID), + zap.String("provider", typ), + zap.String("dir", dirID), + zap.Error(err)) + return nil + } return err } + dirsVisited++ + publishProgress("listing", dirsVisited == 1 || dirsVisited%20 == 0) + sidecars := newCloudSidecarSet(typ, entries) + dirMeta := s.cloudDirectoryMetadata(ctx, typ, displayDir, sidecars, inheritedMeta) for _, entry := range entries { select { case <-ctx.Done(): @@ -199,7 +285,7 @@ func (s *ScannerService) scanCloudLibrary(ctx context.Context, lib *model.Librar } if entry.IsDir { if strings.TrimSpace(entry.ID) != "" { - if err := walkCloud(entry.ID); err != nil { + if err := walkCloud(entry.ID, joinCloudDisplayPath(displayDir, entry.Name), dirMeta); err != nil { return err } } @@ -214,15 +300,45 @@ func (s *ScannerService) scanCloudLibrary(ctx context.Context, lib *model.Librar res.Skipped++ continue } - path := cloudMediaPath(typ, ref) - seen[path] = struct{}{} - s.ingestCloudFile(ctx, lib, typ, ref, entry.Name, entry.Size, res) + if _, ok := seenRefs[ref]; ok { + res.Skipped++ + continue + } + seenRefs[ref] = struct{}{} + filesDiscovered++ + publishProgress("listing", filesDiscovered%100 == 0) + displayPath := joinCloudDisplayPath(displayDir, entry.Name) + path := cloudMediaPath(typ, displayPath) + candidate := cloudCandidate{ + ref: ref, + name: entry.Name, + size: entry.Size, + path: path, + localMeta: s.cloudFileMetadata(ctx, typ, displayPath, entry.Name, sidecars, dirMeta, librarySupportsSeasons(lib)), + } + key := cloudMediaDedupeKey(lib, displayDir, entry.Name, entry.Size) + if key != "" { + if prevIndex, ok := candidateByKey[key]; ok { + res.Skipped++ + if candidate.size > candidates[prevIndex].size { + candidates[prevIndex] = candidate + } + continue + } + candidateByKey[key] = len(candidates) + } + candidates = append(candidates, candidate) } return nil } - if err := walkCloud(rootDir); err != nil { + if err := walkCloud(rootDir, rootDisplayDir, nil); err != nil { return res, err } + for _, candidate := range candidates { + seen[candidate.path] = struct{}{} + s.ingestCloudFile(ctx, lib, typ, candidate.ref, candidate.path, candidate.name, candidate.size, candidate.localMeta, res) + publishProgress("importing", res.Visited == 1 || res.Visited%100 == 0) + } removed, err := s.pruneMissingCloudMedia(ctx, lib.ID, seen) if err != nil { s.log.Warn("prune missing cloud media failed", zap.String("library_id", lib.ID), zap.Error(err)) @@ -230,13 +346,16 @@ func (s *ScannerService) scanCloudLibrary(ctx context.Context, lib *model.Librar res.Removed = removed } s.hub.Publish("scan", map[string]any{ - "library_id": lib.ID, - "finished": true, - "visited": res.Visited, - "added": res.Added, - "updated": res.Updated, - "removed": res.Removed, - "cloud": true, + "library_id": lib.ID, + "finished": true, + "visited": res.Visited, + "added": res.Added, + "updated": res.Updated, + "removed": res.Removed, + "discovered": filesDiscovered, + "dirs": dirsVisited, + "elapsed_seconds": int(time.Since(startedAt).Seconds()), + "cloud": true, }) if autoScrape && s.scraper != nil && s.scraper.AnyEnabled() && s.autoScrapeEnabled(ctx) { go func(libID string) { @@ -248,7 +367,16 @@ func (s *ScannerService) scanCloudLibrary(ctx context.Context, lib *model.Librar return res, nil } -func (s *ScannerService) ingestCloudFile(ctx context.Context, lib *model.Library, typ, ref, name string, size int64, res *ScanResult) { +func (s *ScannerService) shadowedCloudLibrary(ctx context.Context, lib *model.Library) *CloudMountConflict { + libs, err := s.repo.Library.List(ctx) + if err != nil { + s.log.Warn("list libraries for cloud shadow check failed", zap.String("library_id", lib.ID), zap.Error(err)) + return nil + } + return CloudLibraryShadowed(libs, *lib) +} + +func (s *ScannerService) ingestCloudFile(ctx context.Context, lib *model.Library, typ, ref, path, name string, size int64, localMeta *LocalMetadata, res *ScanResult) { res.Visited++ ext := strings.ToLower(filepath.Ext(name)) title, year := CleanQuery(name) @@ -258,7 +386,15 @@ func (s *ScannerService) ingestCloudFile(ctx context.Context, lib *model.Library if title == "" { title = ref } - path := cloudMediaPath(typ, ref) + parsedSeason, parsedEpisode := ParseEpisode(path) + if librarySupportsSeasons(lib) || parsedSeason > 0 || parsedEpisode > 0 { + if seriesTitle, seriesYear := cloudSeriesTitleFromMediaPath(path); seriesTitle != "" { + title = seriesTitle + if seriesYear > 0 { + year = seriesYear + } + } + } isNewMedia := !s.mediaPathExists(ctx, path) m := &model.Media{ LibraryID: lib.ID, @@ -277,9 +413,12 @@ func (s *ScannerService) ingestCloudFile(ctx context.Context, lib *model.Library s.log.Debug("read cloud strm failed", zap.String("ref", ref), zap.Error(err)) } } - parsedSeason, parsedEpisode := ParseEpisode(name) m.SeasonNum = parsedSeason m.EpisodeNum = parsedEpisode + if localMeta != nil { + applyLocalMetadata(m, localMeta) + res.LocalMetadata++ + } if err := s.repo.Media.Upsert(ctx, m); err != nil { s.log.Warn("upsert cloud media failed", zap.String("path", path), zap.Error(err)) return @@ -299,6 +438,48 @@ func (s *ScannerService) ingestCloudFile(ctx context.Context, lib *model.Library }) } +func cloudSeriesTitleFromMediaPath(mediaPath string) (string, int) { + displayPath := strings.TrimSpace(mediaPath) + if strings.HasPrefix(strings.ToLower(displayPath), "cloud://") { + rest := strings.TrimPrefix(displayPath, "cloud://") + if idx := strings.Index(rest, "/"); idx >= 0 { + displayPath = rest[idx+1:] + } else { + return "", 0 + } + } + displayPath = strings.Trim(strings.ReplaceAll(displayPath, "\\", "/"), "/") + if displayPath == "" { + return "", 0 + } + parts := strings.Split(displayPath, "/") + if len(parts) < 2 { + return "", 0 + } + dirs := parts[:len(parts)-1] + if len(dirs) == 0 { + return "", 0 + } + base := strings.TrimSpace(dirs[len(dirs)-1]) + usedSeasonFolder := false + if seasonFromDir(base) > 0 { + usedSeasonFolder = true + dirs = dirs[:len(dirs)-1] + if len(dirs) == 0 { + return "", 0 + } + base = strings.TrimSpace(dirs[len(dirs)-1]) + } + if base == "" || (!usedSeasonFolder && len(dirs) < 2) { + return "", 0 + } + title, year := CleanQuery(base) + if title == "" { + title = base + } + return strings.TrimSpace(title), year +} + // RemovePath deletes the media row for a path that has disappeared from disk // (incremental delete used by the watcher on Remove/Rename events). func (s *ScannerService) RemovePath(ctx context.Context, path string) (int64, error) { @@ -488,26 +669,11 @@ func (s *ScannerService) pruneMissingCloudMedia(ctx context.Context, libraryID s } func parseCloudLibraryPath(raw string) (typ, dirID string, ok bool) { - raw = strings.TrimSpace(raw) - if !strings.HasPrefix(strings.ToLower(raw), "cloud://") { + info, ok := ParseCloudLibraryMount(raw) + if !ok { return "", "", false } - u, err := url.Parse(raw) - if err != nil || strings.ToLower(u.Scheme) != "cloud" { - return "", "", false - } - typ = strings.TrimSpace(u.Host) - if typ == "" { - return "", "", false - } - dirID = strings.Trim(strings.TrimSpace(u.Path), "/") - if qDir := strings.TrimSpace(u.Query().Get("dir")); qDir != "" { - dirID = qDir - } - if decoded, err := url.PathUnescape(dirID); err == nil { - dirID = decoded - } - return typ, dirID, true + return info.Provider, info.ScanDir, true } func cloudEntryRef(typ, id, pickCode string) string { @@ -521,6 +687,39 @@ func cloudMediaPath(typ, ref string) string { return "cloud://" + strings.TrimSpace(typ) + "/" + strings.TrimLeft(strings.TrimSpace(ref), "/") } +func cloudMediaDedupeKey(lib *model.Library, dirID, name string, size int64) string { + base := strings.TrimSpace(strings.TrimSuffix(filepath.Base(name), filepath.Ext(name))) + if base == "" { + return "" + } + season, episode := ParseEpisode(name) + title, year := CleanQuery(name) + title = normalizeCloudDedupeText(title) + if (season > 0 || episode > 0) && title != "" { + return fmt.Sprintf("episode:%s:%s:%d:%d:%d", strings.ToLower(strings.TrimSpace(lib.Type)), title, year, season, episode) + } + if (season > 0 || episode > 0) && title == "" { + return fmt.Sprintf("episode-dir:%s:%s:%d:%d:%d", strings.ToLower(strings.TrimSpace(lib.Type)), normalizeCloudDedupeText(dirID), season, episode, size) + } + return fmt.Sprintf("file:%s:%d", normalizeCloudDedupeText(base), size) +} + +func normalizeCloudDedupeText(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + return "" + } + fields := strings.FieldsFunc(value, func(r rune) bool { + switch r { + case '.', '_', '-', ' ', '\t', '/', '\\', '[', ']', '(', ')': + return true + default: + return false + } + }) + return strings.Join(fields, " ") +} + func (s *ScannerService) resolveCloudSTRMTarget(ctx context.Context, typ, ref string) (string, error) { if s.storage == nil { return "", nil diff --git a/internal/service/scanner_cloud_test.go b/internal/service/scanner_cloud_test.go index 8e1087e..c39bc02 100644 --- a/internal/service/scanner_cloud_test.go +++ b/internal/service/scanner_cloud_test.go @@ -82,11 +82,14 @@ func TestScanCloudLibraryImportsRecursivePlayableMedia(t *testing.T) { if len(rows) != 2 { t.Fatalf("media rows = %d, want 2: %#v", len(rows), rows) } - if rows[0].Path != "cloud://quark/f1" || rows[0].STRMURL != "/api/cloud/play/quark?ref=f1" { - t.Fatalf("root media path/strm wrong: path=%q strm=%q", rows[0].Path, rows[0].STRMURL) + if rows[0].Path != "cloud://quark/Movies/Nested.Show.S01E02.mp4" || !strings.Contains(rows[0].STRMURL, "ref=f2") { + t.Fatalf("nested media path/strm wrong: path=%q strm=%q", rows[0].Path, rows[0].STRMURL) } - if rows[1].SeasonNum != 1 || rows[1].EpisodeNum != 2 || !strings.Contains(rows[1].STRMURL, "ref=f2") { - t.Fatalf("nested episode metadata wrong: %#v", rows[1]) + if rows[0].SeasonNum != 1 || rows[0].EpisodeNum != 2 { + t.Fatalf("nested episode metadata wrong: %#v", rows[0]) + } + if rows[1].Path != "cloud://quark/Root.Movie.2024.mkv" || rows[1].STRMURL != "/api/cloud/play/quark?ref=f1" { + t.Fatalf("root media path/strm wrong: path=%q strm=%q", rows[0].Path, rows[0].STRMURL) } empty = true @@ -108,7 +111,7 @@ func TestCloudLibraryPathParsing(t *testing.T) { t.Fatalf("parse path got typ=%q dir=%q ok=%v", typ, dir, ok) } typ, dir, ok = parseCloudLibraryPath("cloud://quark?dir=0") - if !ok || typ != "quark" || dir != "0" { + if !ok || typ != "quark" || dir != "" { t.Fatalf("parse query got typ=%q dir=%q ok=%v", typ, dir, ok) } if ref := cloudEntryRef("cloud115", "fid", "pick"); ref != "pick" { @@ -116,6 +119,167 @@ func TestCloudLibraryPathParsing(t *testing.T) { } } +func TestCloudMountConflictDetectsNestedMounts(t *testing.T) { + root := model.Library{Base: model.Base{ID: "root"}, Name: "115", Path: "cloud://cloud115", Enabled: true} + childPath := BuildCloudLibraryPath("cloud115", "child-id", "parent-id/child-id") + info, ok := ParseCloudLibraryMount(childPath) + if !ok || info.ScanDir != "child-id" || info.DisplayDir != "parent-id/child-id" { + t.Fatalf("parse child mount = %#v ok=%v", info, ok) + } + + conflict := FindCloudMountConflict([]model.Library{root}, "cloud115", "child-id", "parent-id/child-id") + if conflict != nil { + t.Fatalf("child mount under existing root should be allowed, got conflict %#v", conflict) + } + + sibling := model.Library{Base: model.Base{ID: "sibling"}, Name: "Sibling", Path: BuildCloudLibraryPath("cloud115", "sibling-id", "parent-id/sibling-id"), Enabled: true} + conflict = FindCloudMountConflict([]model.Library{sibling}, "cloud115", "child-id", "parent-id/child-id") + if conflict != nil { + t.Fatalf("sibling conflict = %#v, want nil", conflict) + } + + conflict = FindCloudMountConflict([]model.Library{sibling}, "cloud115", "parent-id", "parent-id") + if conflict == nil || !conflict.Nested { + t.Fatalf("parent mount over existing child = %#v, want nested conflict", conflict) + } + oldIDPath := BuildCloudLibraryPath("cloud115", "child-id", "old-parent-id/child-id") + conflict = FindCloudMountConflict([]model.Library{{Base: model.Base{ID: "old"}, Name: "Old", Path: oldIDPath, Enabled: true}}, "cloud115", "child-id", "父目录/子目录") + if conflict == nil || !conflict.Exact { + t.Fatalf("same scan dir with renamed display path = %#v, want exact conflict", conflict) + } + + root.CreatedAt = root.CreatedAt.Add(-1) + child := model.Library{Base: model.Base{ID: "child"}, Name: "Child", Path: childPath, Enabled: true} + if shadow := CloudLibraryShadowed([]model.Library{root, child}, child); shadow != nil { + t.Fatalf("child should not be shadowed by root: %#v", shadow) + } + if shadow := CloudLibraryShadowed([]model.Library{root, child}, root); shadow == nil || !shadow.Nested { + t.Fatalf("root should be shadowed by child, got %#v", shadow) + } +} + +func TestInferCloudMountMediaType(t *testing.T) { + cases := map[string]string{ + "/日漫": "anime", + "/国漫": "anime", + "/欧美动漫": "anime", + "/电视剧/国产剧": "tv", + "/电视剧/欧美剧": "tv", + "/电视剧/日韩剧": "tv", + "/电影/动画电影": "movie", + "/电影/华语电影": "movie", + "/电影/外语电影": "movie", + "/综艺": "variety", + } + for dir, want := range cases { + if got := InferCloudMountMediaType(dir, "OpenList · "+dir); got != want { + t.Fatalf("%s type = %s, want %s", dir, got, want) + } + } +} + +func TestScan115CloudLibraryKeepsDisplayHierarchyAndSeasonCounts(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/files" { + t.Fatalf("unexpected path %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + switch r.URL.Query().Get("cid") { + case "100": + _, _ = w.Write([]byte(`{"state":true,"data":[ + {"cid":"s1","n":"Season 1","s":0}, + {"cid":"s2","n":"Season 2","s":0} + ]}`)) + case "s1": + _, _ = w.Write([]byte(`{"state":true,"data":[ + {"fid":"f101","n":"剑来 - S01E01.mkv","s":1001,"pc":"pick101"}, + {"fid":"f125","n":"剑来 - S01E25.mkv","s":1025,"pc":"pick125"} + ]}`)) + case "s2": + _, _ = w.Write([]byte(`{"state":true,"data":[ + {"fid":"f201","n":"剑来 - S02E01.mkv","s":2001,"pc":"pick201"}, + {"fid":"f204","n":"剑来 - S02E04.mkv","s":2004,"pc":"pick204"} + ]}`)) + default: + t.Fatalf("unexpected cid %q", r.URL.Query().Get("cid")) + } + })) + defer upstream.Close() + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}); err != nil { + t.Fatal(err) + } + repos := repository.New(db) + log := zap.NewNop() + storage := NewStorageConfigService(log, repos, NewCryptoService("", log)) + if _, err := storage.Save(t.Context(), StorageInput{ + Type: "cloud115", + Config: map[string]any{ + "cookie": "UID=test", + "base": upstream.URL, + }, + }); err != nil { + t.Fatal(err) + } + lib := model.Library{Name: "115 · 国漫 · 剑来", Path: BuildCloudLibraryPath("cloud115", "100", "动漫/国漫/剑来"), Type: "anime", Enabled: true} + if err := repos.Library.Create(t.Context(), &lib); err != nil { + t.Fatal(err) + } + scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil) + scanner.SetStorageConfig(storage) + + res, err := scanner.ScanLibrary(t.Context(), lib.ID) + if err != nil { + t.Fatalf("scan cloud115: %v", err) + } + if res.Added != 4 { + t.Fatalf("scan result = %#v, want added=4", res) + } + var rows []model.Media + if err := repos.DB.Order("path").Find(&rows).Error; err != nil { + t.Fatal(err) + } + if len(rows) != 4 { + t.Fatalf("media rows = %d, want 4", len(rows)) + } + want := map[string][2]int{ + "cloud://cloud115/动漫/国漫/剑来/Season 1/剑来 - S01E01.mkv": {1, 1}, + "cloud://cloud115/动漫/国漫/剑来/Season 1/剑来 - S01E25.mkv": {1, 25}, + "cloud://cloud115/动漫/国漫/剑来/Season 2/剑来 - S02E01.mkv": {2, 1}, + "cloud://cloud115/动漫/国漫/剑来/Season 2/剑来 - S02E04.mkv": {2, 4}, + } + for _, row := range rows { + seasonEpisode, ok := want[row.Path] + if !ok { + t.Fatalf("unexpected path %q", row.Path) + } + if row.Title != "剑来" { + t.Fatalf("title = %q, want 剑来", row.Title) + } + if row.SeasonNum != seasonEpisode[0] || row.EpisodeNum != seasonEpisode[1] { + t.Fatalf("%s season/episode = %d/%d, want %d/%d", row.Path, row.SeasonNum, row.EpisodeNum, seasonEpisode[0], seasonEpisode[1]) + } + if !strings.Contains(row.STRMURL, "ref=pick") { + t.Fatalf("115 playback should keep pickcode ref, got %q", row.STRMURL) + } + } +} + +func TestCloudSeriesTitlePrefersShowFolder(t *testing.T) { + title, year := cloudSeriesTitleFromMediaPath("cloud://openlist/国产剧/紫川 (2024) {tmdb-247590}/Season 2/紫川.2024.S02E24.第24集.2160p.WEB-DL.H.265-ColorTV.mkv") + if title != "紫川" || year != 2024 { + t.Fatalf("cloud series title = %q/%d, want 紫川/2024", title, year) + } + title, year = cloudSeriesTitleFromMediaPath("cloud://openlist/国产剧/紫川.2024.S02E24.mkv") + if title != "" || year != 0 { + t.Fatalf("single category folder should not override title, got %q/%d", title, year) + } +} + func TestScanCloudLibraryReadsRemoteSTRMTarget(t *testing.T) { upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method { @@ -190,3 +354,93 @@ func TestScanCloudLibraryReadsRemoteSTRMTarget(t *testing.T) { t.Fatalf("strm target = %q", media.STRMURL) } } + +func TestScanCloudLibraryReadsRemoteNFOAndArtwork(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "PROPFIND": + w.Header().Set("Content-Type", "application/xml") + w.WriteHeader(http.StatusMultiStatus) + switch r.URL.Path { + case "/dav/Anime/JianLai": + _, _ = w.Write([]byte(` + + /dav/Anime/JianLai/ + /dav/Anime/JianLai/tvshow.nfotvshow.nfo64 + /dav/Anime/JianLai/poster.jpgposter.jpg1024 + /dav/Anime/JianLai/Season1/Season1 +`)) + case "/dav/Anime/JianLai/Season1": + _, _ = w.Write([]byte(` + + /dav/Anime/JianLai/Season1/ + /dav/Anime/JianLai/Season1/JianLai.S01E01.mkvJianLai.S01E01.mkv2048 + /dav/Anime/JianLai/Season1/JianLai.S01E01.nfoJianLai.S01E01.nfo128 +`)) + default: + t.Fatalf("unexpected propfind path %s", r.URL.Path) + } + case http.MethodGet: + switch r.URL.Path { + case "/dav/Anime/JianLai/tvshow.nfo": + _, _ = w.Write([]byte(`剑来2024天地有剑气`)) + case "/dav/Anime/JianLai/Season1/JianLai.S01E01.nfo": + _, _ = w.Write([]byte(`剑来第一集11`)) + default: + t.Fatalf("unexpected get path %s", r.URL.Path) + } + default: + t.Fatalf("unexpected method %s", r.Method) + } + })) + defer upstream.Close() + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}); err != nil { + t.Fatal(err) + } + repos := repository.New(db) + log := zap.NewNop() + storage := NewStorageConfigService(log, repos, NewCryptoService("", log)) + if _, err := storage.Save(t.Context(), StorageInput{ + Type: "openlist", + Config: map[string]any{ + "url": upstream.URL, + }, + }); err != nil { + t.Fatal(err) + } + lib := model.Library{Name: "OpenList · 国漫 · 剑来", Path: "cloud://openlist/Anime/JianLai", Type: "anime", Enabled: true} + if err := repos.Library.Create(t.Context(), &lib); err != nil { + t.Fatal(err) + } + scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil) + scanner.SetStorageConfig(storage) + + res, err := scanner.ScanLibrary(t.Context(), lib.ID) + if err != nil { + t.Fatalf("scan cloud: %v", err) + } + if res.Added != 1 || res.LocalMetadata != 1 { + t.Fatalf("scan result = %#v, want added=1 local_metadata=1", res) + } + var media model.Media + if err := repos.DB.First(&media).Error; err != nil { + t.Fatal(err) + } + if media.Title != "剑来" || media.OriginalName != "第一集" || media.Year != 2024 { + t.Fatalf("metadata not applied: %#v", media) + } + if media.SeasonNum != 1 || media.EpisodeNum != 1 { + t.Fatalf("episode numbers = %d/%d", media.SeasonNum, media.EpisodeNum) + } + if media.PosterURL != "/api/cloud/play/openlist?ref=%2FAnime%2FJianLai%2Fposter.jpg" { + t.Fatalf("poster url = %q", media.PosterURL) + } + if media.ScrapeStatus != "matched" { + t.Fatalf("scrape status = %q", media.ScrapeStatus) + } +} diff --git a/internal/service/scheduler.go b/internal/service/scheduler.go index 3f76655..ef28e55 100644 --- a/internal/service/scheduler.go +++ b/internal/service/scheduler.go @@ -334,7 +334,7 @@ func (s *SchedulerService) jobSyncCloudLibraries(ctx context.Context) error { if _, _, ok := parseCloudLibraryPath(l.Path); !ok { continue } - if _, err := s.scanner.ScanLibrary(ctx, l.ID); err != nil { + if _, err := s.scanner.ScanLibraryWithoutAutoScrape(ctx, l.ID); err != nil { s.log.Warn("cloud sync failed", zap.String("library", l.ID), zap.Error(err)) } } diff --git a/internal/service/scheduler_test.go b/internal/service/scheduler_test.go index d4e8c77..6c23922 100644 --- a/internal/service/scheduler_test.go +++ b/internal/service/scheduler_test.go @@ -154,7 +154,7 @@ func TestSchedulerCloudSyncImportsMountedCloudLibrary(t *testing.T) { t.Fatalf("cloud sync: %v", err) } var media model.Media - if err := repos.DB.First(&media, "path = ?", "cloud://quark/f1").Error; err != nil { + if err := repos.DB.First(&media, "path = ?", "cloud://quark/Cloud.Movie.2026.mkv").Error; err != nil { t.Fatalf("cloud media not imported: %v", err) } if media.STRMURL != "/api/cloud/play/quark?ref=f1" { diff --git a/internal/service/scraper.go b/internal/service/scraper.go index a297529..f0843b5 100644 --- a/internal/service/scraper.go +++ b/internal/service/scraper.go @@ -82,14 +82,14 @@ var noiseTokens = []string{ "netflix", "nf", "amzn", "hulu", "disney", "max", "hbo", "linetv", "ourtv", "iqiyi", "youku", "bilibili", "qiyi", "krj", "crunchyroll", "funimation", "anidb", "horriblesubs", "subsplease", - "erai-raws", "judas", "asw", "smcat", "leopard-raws", "ohys-raws", + "erai-raws", "judas", "asw", "smcat", "leopard-raws", "ohys-raws", "colortv", // 中文字幕标记 "zm", "zw", "ch", "chs", "cht", "cn", "tc", "sc", "中字", "繁字", "简中", "繁中", "国语", "粤语", "日语", // 季数前缀残留 — ParseEpisode 已抽取过 - "season", + "season", "264", "265", } var noiseTokenSet = func() map[string]struct{} { @@ -101,8 +101,8 @@ var noiseTokenSet = func() map[string]struct{} { return set }() -// bracketedTag matches "[anything]" or "(anything)" segments. -var bracketedTag = regexp.MustCompile(`[\[\(][^\]\)]*[\]\)]`) +// bracketedTag matches "[anything]", "(anything)" or "{anything}" segments. +var bracketedTag = regexp.MustCompile(`[\[\(\{][^\]\)\}]*[\]\)\}]`) var multiWordNoise = []*regexp.Regexp{ regexp.MustCompile(`(?i)\bweb[\s._-]*dl\b`), regexp.MustCompile(`(?i)\bblu[\s._-]*ray\b`), diff --git a/internal/service/scraper_test.go b/internal/service/scraper_test.go index 15b5ed9..864e7b8 100644 --- a/internal/service/scraper_test.go +++ b/internal/service/scraper_test.go @@ -31,6 +31,8 @@ func TestCleanQuery(t *testing.T) { {"My Movie 2022 [HDR] (1080p) [TGx].mp4", "my movie", 2022}, {"NoYearOrTags.mkv", "noyearortags", 0}, {"亏成首富从游戏开始 The Richest in Game - S01E11 - 4K.mp4", "亏成首富从游戏开始 the richest in game", 0}, + {"紫川.2024.S02E24.第24集.2160p.WEB-DL.H.265-ColorTV.mkv", "紫川", 2024}, + {"紫川 (2024) {tmdb-247590}", "紫川", 2024}, } for _, tc := range cases { t.Run(tc.in, func(t *testing.T) { diff --git a/internal/service/service.go b/internal/service/service.go index a27c80e..a285792 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -11,6 +11,7 @@ import ( "go.uber.org/zap" "github.com/ShukeBta/MediaStationGo/internal/config" + "github.com/ShukeBta/MediaStationGo/internal/model" "github.com/ShukeBta/MediaStationGo/internal/repository" ) @@ -232,6 +233,9 @@ func (c *Container) Boot() { if err := c.APIConfig.SeedDefaults(c.stopCtx); err != nil { c.Log.Warn("api config seed failed", zap.Error(err)) } + if err := c.NormalizeCloudLibraryTypes(c.stopCtx); err != nil { + c.Log.Warn("normalize cloud library types failed", zap.Error(err)) + } // 加载所有已配置的下载客户端 if err := c.DownloadMgr.LoadAll(c.stopCtx); err != nil { @@ -248,6 +252,33 @@ func (c *Container) Boot() { } } +func (c *Container) NormalizeCloudLibraryTypes(ctx context.Context) error { + if c == nil || c.Repo == nil || c.Repo.Library == nil || c.Repo.DB == nil { + return nil + } + libs, err := c.Repo.Library.List(ctx) + if err != nil { + return err + } + for _, lib := range libs { + info, ok := ParseCloudLibraryMount(lib.Path) + if !ok { + continue + } + want := InferCloudMountMediaType(info.DisplayDir, lib.Name) + if want == "" || want == lib.Type { + continue + } + if err := c.Repo.DB.WithContext(ctx). + Model(&model.Library{}). + Where("id = ?", lib.ID). + Update("type", want).Error; err != nil { + return err + } + } + return nil +} + // runInactivitySweeper periodically runs the account-cleanup policy. Kept with // the historical name to avoid churn in callers. func (c *Container) runInactivitySweeper(ctx context.Context) { diff --git a/web/src/api/storage_config.ts b/web/src/api/storage_config.ts index 7e61ffe..97e7b92 100644 --- a/web/src/api/storage_config.ts +++ b/web/src/api/storage_config.ts @@ -99,9 +99,9 @@ export const cloudAPI = { .post(`/admin/cloud/${type}/import`, { ref, name, size }) .then((r) => r.data), - mount: (type: StorageType, dir = '', name = '', media_type = 'movie') => + mount: (type: StorageType, dir = '', name = '', media_type = 'movie', dir_path = '') => api - .post(`/admin/cloud/${type}/mount`, { dir, name, media_type }) + .post(`/admin/cloud/${type}/mount`, { dir, dir_path, name, media_type }) .then((r) => r.data), qrStart: (type: StorageType) => diff --git a/web/src/components/GlobalEvents.tsx b/web/src/components/GlobalEvents.tsx index 7f57246..5a62a0e 100644 --- a/web/src/components/GlobalEvents.tsx +++ b/web/src/components/GlobalEvents.tsx @@ -10,8 +10,28 @@ export function GlobalEvents() { const onEvent = useCallback((topic: string, payload: unknown) => { if (!payload || typeof payload !== 'object') return const p = payload as Record - if (topic === 'scan' && p.finished) { - toast.success(`扫描完成:已添加 ${p.added ?? 0} 项,已探测 ${p.probed ?? 0} 项`) + if (topic === 'scan') { + const id = `scan-${String(p.library_id ?? 'global')}` + if (p.error) { + toast.error(`扫描失败:${String(p.error)}`, { id }) + return + } + if (p.finished) { + const elapsed = Number(p.elapsed_seconds ?? p.elapsed ?? 0) + const elapsedText = elapsed > 0 ? ` · 耗时 ${formatDuration(elapsed)}` : '' + toast.success(`扫描完成:发现 ${p.discovered ?? p.visited ?? 0} · 新增 ${p.added ?? 0} · 更新 ${p.updated ?? 0} · 跳过 ${p.skipped ?? 0}${elapsedText}`, { id }) + return + } + if (p.queued) { + toast.loading(String(p.message ?? '云盘扫描已加入后台队列,会自动入库'), { id }) + return + } + if (p.cloud && p.stage) { + const stage = p.stage === 'importing' ? '正在入库' : '正在遍历目录' + const speed = Number(p.files_per_second ?? 0) + const speedText = speed > 0 ? ` · ${speed.toFixed(speed >= 10 ? 0 : 1)} 个/秒` : '' + toast.loading(`${stage}:目录 ${p.dirs ?? 0} · 已发现 ${p.discovered ?? 0} · 已入库 ${p.visited ?? 0}${speedText}`, { id }) + } } if (topic === 'scrape' && p.finished) { toast.success(`刮削完成:成功匹配 ${p.matched ?? 0} 项`) @@ -25,3 +45,13 @@ export function GlobalEvents() { useWebSocket(onEvent) return null } + +function formatDuration(seconds: number): string { + if (!Number.isFinite(seconds) || seconds <= 0) return '' + if (seconds < 60) return `${Math.round(seconds)}秒` + const minutes = Math.floor(seconds / 60) + const rest = Math.round(seconds % 60) + if (minutes < 60) return `${minutes}分${rest}秒` + const hours = Math.floor(minutes / 60) + return `${hours}小时${minutes % 60}分` +} diff --git a/web/src/pages/AdminPage.tsx b/web/src/pages/AdminPage.tsx index 634f37f..ac0041c 100644 --- a/web/src/pages/AdminPage.tsx +++ b/web/src/pages/AdminPage.tsx @@ -155,7 +155,8 @@ function LibraryPanel() { className="rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10" onClick={async () => { const r = await libraryAPI.scan(l.id) - toast.success(`扫描完成,新增 ${r.added},更新 ${r.updated ?? 0}`) + if (r.queued) toast.success('云盘扫描已加入后台队列,会自动入库') + else toast.success(`扫描完成,新增 ${r.added},更新 ${r.updated ?? 0}`) }} > 扫描 diff --git a/web/src/pages/LibraryPage.tsx b/web/src/pages/LibraryPage.tsx index 50df70d..27cf16d 100644 --- a/web/src/pages/LibraryPage.tsx +++ b/web/src/pages/LibraryPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { Link, useParams, useSearchParams } from 'react-router-dom' import { motion, AnimatePresence } from 'framer-motion' import toast from 'react-hot-toast' @@ -11,6 +11,7 @@ import { ExternalPlayerButton } from '../components/ExternalPlayerButton' import { imageURL } from '../api/client' import { useAuthStore } from '../stores/auth' import { getSeriesKey, groupSeries, isEpisodeLike, seriesTitle, type SeriesCard } from '../utils/groupSeries' +import { useWebSocket } from '../hooks/useWebSocket' export function LibraryPage() { const { id = '' } = useParams() @@ -22,6 +23,7 @@ export function LibraryPage() { const [total, setTotal] = useState(0) const [loading, setLoading] = useState(true) const [scanning, setScanning] = useState(false) + const [scanProgress, setScanProgress] = useState('') const [scraping, setScraping] = useState(false) // 剧集模式:选中某个剧集后展开详情 @@ -71,18 +73,73 @@ export function LibraryPage() { useEffect(() => { if (!id || !library) return + let cancelled = false setLoading(true) - // 所有库都可能包含季集型内容(综艺/纪录片/课程等),统一拉取较大页数用于前端分组。 - const limit = 2000 - libraryAPI - .listMedia(id, 1, limit) - .then((d) => { - setItems(d.items) - setTotal(d.total) - }) - .finally(() => setLoading(false)) + setItems([]) + const loadAll = async () => { + const pageSize = 2000 + let page = 1 + let collected: Media[] = [] + try { + for (;;) { + const d = await libraryAPI.listMedia(id, page, pageSize) + if (cancelled) return + collected = collected.concat(d.items) + setItems(collected) + setTotal(d.total) + if (collected.length >= d.total || d.items.length < pageSize) break + page += 1 + } + } finally { + if (!cancelled) setLoading(false) + } + } + loadAll().catch(() => { + if (!cancelled) { + toast.error('媒体库加载失败') + setLoading(false) + } + }) + return () => { cancelled = true } }, [id, library]) + const reloadCurrentLibrary = useCallback(() => { + setLibrary((l) => (l ? { ...l } : l)) + }, []) + + const onRealtimeEvent = useCallback((topic: string, payload: unknown) => { + if (topic !== 'scan' || !payload || typeof payload !== 'object') return + const p = payload as Record + if (p.library_id !== id) return + if (p.error) { + setScanning(false) + setScanProgress(`扫描失败:${String(p.error)}`) + return + } + if (p.finished) { + setScanning(false) + const elapsed = Number(p.elapsed_seconds ?? p.elapsed ?? 0) + const elapsedText = elapsed > 0 ? ` · 耗时 ${formatDuration(elapsed)}` : '' + setScanProgress(`扫描完成:发现 ${p.discovered ?? p.visited ?? 0} · 新增 ${p.added ?? 0} · 更新 ${p.updated ?? 0} · 跳过 ${p.skipped ?? 0}${elapsedText}`) + reloadCurrentLibrary() + return + } + if (p.queued) { + setScanning(true) + setScanProgress(String(p.message ?? '扫描已排队,后台会自动入库')) + return + } + if (p.cloud && p.stage) { + const stage = p.stage === 'importing' ? '正在入库' : '正在遍历目录' + const speed = Number(p.files_per_second ?? 0) + const speedText = speed > 0 ? ` · ${speed.toFixed(speed >= 10 ? 0 : 1)} 个/秒` : '' + setScanning(true) + setScanProgress(`${stage}:目录 ${p.dirs ?? 0} · 已发现 ${p.discovered ?? 0} · 已入库 ${p.visited ?? 0}${speedText}`) + } + }, [id, reloadCurrentLibrary]) + + useWebSocket(onRealtimeEvent) + useEffect(() => { if (loading) return if (!isSeries) { @@ -113,13 +170,27 @@ export function LibraryPage() { const handleScan = async () => { setScanning(true) + setScanProgress('正在提交扫描任务…') + let keepScanning = false try { const r = await libraryAPI.scan(id) - toast.success(`扫描完成:新增 ${r.added} 项,更新 ${r.updated ?? 0} 项`) - setLibrary((l) => (l ? { ...l } : l)) + if (r.queued) { + keepScanning = true + setScanProgress(`${r.message ?? '云盘扫描已在后台运行,发现的媒体会自动加入当前媒体库'};${r.estimate_message ?? '大目录耗时取决于网盘接口速度'}`) + toast.success('云盘扫描已加入后台队列') + } else { + toast.success(`扫描完成:新增 ${r.added} 项,更新 ${r.updated ?? 0} 项`) + setScanProgress(`扫描完成:新增 ${r.added} · 更新 ${r.updated ?? 0}`) + reloadCurrentLibrary() + setScanning(false) + } } catch { toast.error('扫描失败') - } finally { setScanning(false) } + setScanProgress('扫描失败,请查看日志或稍后重试') + setScanning(false) + } finally { + if (!keepScanning) setScanning(false) + } } const handleScrape = async () => { @@ -168,6 +239,7 @@ export function LibraryPage() { ({isSeries ? seriesCards.length : total}) {library &&

{library.type} · {library.path}

} + {scanProgress &&

{scanProgress}

} {role === 'admin' && (
@@ -327,6 +399,16 @@ export function LibraryPage() { ) } +function formatDuration(seconds: number): string { + if (!Number.isFinite(seconds) || seconds <= 0) return '' + if (seconds < 60) return `${Math.round(seconds)}秒` + const minutes = Math.floor(seconds / 60) + const rest = Math.round(seconds % 60) + if (minutes < 60) return `${minutes}分${rest}秒` + const hours = Math.floor(minutes / 60) + return `${hours}小时${minutes % 60}分` +} + function formatSize(bytes: number): string { if (!bytes || bytes <= 0) return '—' const units = ['B', 'KB', 'MB', 'GB', 'TB'] diff --git a/web/src/pages/StorageConfigPage.tsx b/web/src/pages/StorageConfigPage.tsx index bdfff8d..aa99018 100644 --- a/web/src/pages/StorageConfigPage.tsx +++ b/web/src/pages/StorageConfigPage.tsx @@ -1,7 +1,8 @@ import { FormEvent, useEffect, useMemo, useState } from 'react' -import { Cloud, FileVideo, Folder, Loader2, QrCode, Save, Send, Upload } from 'lucide-react' +import { Cloud, FileVideo, Folder, Loader2, QrCode, Save, Send, Trash2, Upload } from 'lucide-react' import toast from 'react-hot-toast' +import { libraryAPI } from '../api/library' import { cloudAPI, storageAPI, @@ -9,6 +10,8 @@ import { type QRSession, type StorageType, } from '../api/storage_config' +import { confirmAction } from '../components/ConfirmDialog' +import type { Library } from '../types' const CLOUD_TYPES: StorageType[] = ['cloud115', 'quark', 'clouddrive2', 'openlist'] const isCloud = (t: StorageType) => CLOUD_TYPES.includes(t) @@ -21,6 +24,46 @@ const TYPE_LABEL: Record = { quark: '夸克网盘', clouddrive2: 'CloudDrive2', } +const PATH_BASED_CLOUD = new Set(['openlist', 'clouddrive2']) + +function normalizeCloudDisplayPath(value: string) { + let text = value.trim() + try { + text = decodeURIComponent(text) + } catch { + // Keep the original value if it is not URI-encoded. + } + return text.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '') +} + +function cloudLibraryProvider(path: string) { + if (!path.toLowerCase().startsWith('cloud://')) return '' + try { + return new URL(path).host + } catch { + return '' + } +} + +function cloudLibraryLabel(path: string) { + try { + const parsed = new URL(path) + const display = normalizeCloudDisplayPath(parsed.pathname) + const scanDir = normalizeCloudDisplayPath(parsed.searchParams.get('dir') ?? '') + return display || scanDir || '根目录' + } catch { + return path + } +} + +function cloudMountDisplayPath(type: StorageType, stack: { id: string; name: string }[], child?: CloudEntry) { + if (PATH_BASED_CLOUD.has(type)) { + return normalizeCloudDisplayPath(child?.id ?? stack[stack.length - 1]?.id ?? '') + } + const parts = stack.slice(1).map((item) => item.name).filter(Boolean) + if (child?.name) parts.push(child.name) + return parts.map(normalizeCloudDisplayPath).filter(Boolean).join('/') +} // StorageConfigPage manages the Alist / S3 / WebDAV adapters used by // the import / playback / STRM subsystems. Mirrors the Vue UI's @@ -433,6 +476,7 @@ function QRLoginPanel({ type, onCookie }: { type: StorageType; onCookie: (c: str function CloudBrowser({ type }: { type: StorageType }) { const [stack, setStack] = useState<{ id: string; name: string }[]>([{ id: '', name: '根目录' }]) const [items, setItems] = useState([]) + const [mounts, setMounts] = useState([]) const [loading, setLoading] = useState(false) const [mounting, setMounting] = useState(false) const [batchMounting, setBatchMounting] = useState(false) @@ -455,13 +499,39 @@ function CloudBrowser({ type }: { type: StorageType }) { } } + const loadMounts = async () => { + const libs = await libraryAPI.list({ includeHidden: true }) + setMounts(libs.filter((lib) => cloudLibraryProvider(lib.path) === type)) + } + useEffect(() => { load(cur.id).catch(() => undefined) // eslint-disable-next-line react-hooks/exhaustive-deps }, [stack.length, type]) + useEffect(() => { + loadMounts().catch(() => undefined) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [type]) + const enter = (e: CloudEntry) => setStack((s) => [...s, { id: e.id, name: e.name }]) const goTo = (i: number) => setStack((s) => s.slice(0, i + 1)) + const currentMountPath = () => cloudMountDisplayPath(type, stack) + const childMountPath = (child: CloudEntry) => cloudMountDisplayPath(type, stack, child) + + const handleMountResult = (res: unknown, label: string) => { + const out = res as { already_mounted?: boolean; skipped?: boolean; reason?: string; library?: Library; message?: string; estimate_message?: string } + if (out.skipped) { + toast(`已跳过「${label}」:和已挂载目录重叠`) + return 'skipped' + } + if (out.already_mounted) { + toast(`「${label}」已经挂载,后台会刷新扫描并自动入库`) + return 'mounted' + } + toast.success(`已挂载「${label}」,${out.message ?? '后台会递归扫描并自动加入媒体库'}。${out.estimate_message ?? ''}`) + return 'mounted' + } const doImport = async (e: CloudEntry) => { const ref = type === 'cloud115' ? e.pick_code || e.id : e.id @@ -478,9 +548,9 @@ function CloudBrowser({ type }: { type: StorageType }) { try { const label = TYPE_LABEL[type] ?? type const name = cur.id ? `${label} · ${cur.name}` : label - const res = await cloudAPI.mount(type, cur.id, name, mountMediaType) - const scan = (res as { scan?: { added?: number; updated?: number; removed?: number; visited?: number } }).scan - toast.success(`已挂载为媒体库并扫描:新增 ${scan?.added ?? 0} · 更新 ${scan?.updated ?? 0} · 访问 ${scan?.visited ?? 0}`) + const res = await cloudAPI.mount(type, cur.id, name, mountMediaType, currentMountPath()) + handleMountResult(res, cur.name) + await loadMounts() } catch (err: unknown) { toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '挂载失败') } finally { @@ -496,26 +566,64 @@ function CloudBrowser({ type }: { type: StorageType }) { } setBatchMounting(true) let ok = 0 + let skipped = 0 let failed = 0 const label = TYPE_LABEL[type] ?? type for (const dir of dirs) { try { - await cloudAPI.mount(type, dir.id, `${label} · ${dir.name}`, 'auto') - ok += 1 + const result = await cloudAPI.mount(type, dir.id, `${label} · ${dir.name}`, 'auto', childMountPath(dir)) + const state = handleMountResult(result, dir.name) + if (state === 'skipped') skipped += 1 + else ok += 1 } catch { failed += 1 } } if (failed > 0) { - toast.error(`已挂载 ${ok} 个目录,失败 ${failed} 个;请查看日志或逐个挂载定位`) + toast.error(`已挂载 ${ok} 个目录,跳过 ${skipped} 个重叠目录,失败 ${failed} 个`) } else { - toast.success(`已挂载 ${ok} 个目录为媒体库,后台会自动生成 302/STRM 播放入口`) + toast.success(`已挂载 ${ok} 个目录,跳过 ${skipped} 个重叠目录,后台会自动生成 302/STRM 播放入口`) } + await loadMounts() setBatchMounting(false) } + const removeMount = async (lib: Library) => { + const ok = await confirmAction({ + title: '移除网盘挂载', + message: `仅移除「${lib.name}」在本项目中的媒体库和媒体记录,不会删除网盘文件。`, + confirmText: '移除', + }) + if (!ok) return + await libraryAPI.remove(lib.id) + toast.success('已移除挂载') + await loadMounts() + } + return (
e.preventDefault()}> + {mounts.length > 0 && ( +
+
已挂载目录
+
+ {mounts.map((lib) => ( +
+ + {lib.name} · {cloudLibraryLabel(lib.path)} + + +
+ ))} +
+
+ )}
网盘资源: @@ -528,6 +636,9 @@ function CloudBrowser({ type }: { type: StorageType }) { ))}
+

+ 挂载后不会复制网盘文件;后台会递归读取该目录里的子文件夹和媒体文件,扫描到的影片会自动加入对应媒体库。小目录通常几十秒,大目录取决于网盘接口速度。 +