fix: improve cloud mount scanning feedback

This commit is contained in:
ShukeBta
2026-06-10 18:46:30 +08:00
parent 6a892d6549
commit 0bf983cec8
19 changed files with 1414 additions and 119 deletions
+91 -37
View File
@@ -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 {
+53 -3
View File
@@ -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()})
+5 -1
View File
@@ -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
+266
View File
@@ -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]
}
+203
View File
@@ -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
}
+3 -1
View File
@@ -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)
}
}
+237 -38
View File
@@ -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
+259 -5
View File
@@ -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(`<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:">
<d:response><d:href>/dav/Anime/JianLai/</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response>
<d:response><d:href>/dav/Anime/JianLai/tvshow.nfo</d:href><d:propstat><d:prop><d:displayname>tvshow.nfo</d:displayname><d:getcontentlength>64</d:getcontentlength><d:resourcetype/></d:prop></d:propstat></d:response>
<d:response><d:href>/dav/Anime/JianLai/poster.jpg</d:href><d:propstat><d:prop><d:displayname>poster.jpg</d:displayname><d:getcontentlength>1024</d:getcontentlength><d:resourcetype/></d:prop></d:propstat></d:response>
<d:response><d:href>/dav/Anime/JianLai/Season1/</d:href><d:propstat><d:prop><d:displayname>Season1</d:displayname><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response>
</d:multistatus>`))
case "/dav/Anime/JianLai/Season1":
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:">
<d:response><d:href>/dav/Anime/JianLai/Season1/</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response>
<d:response><d:href>/dav/Anime/JianLai/Season1/JianLai.S01E01.mkv</d:href><d:propstat><d:prop><d:displayname>JianLai.S01E01.mkv</d:displayname><d:getcontentlength>2048</d:getcontentlength><d:resourcetype/></d:prop></d:propstat></d:response>
<d:response><d:href>/dav/Anime/JianLai/Season1/JianLai.S01E01.nfo</d:href><d:propstat><d:prop><d:displayname>JianLai.S01E01.nfo</d:displayname><d:getcontentlength>128</d:getcontentlength><d:resourcetype/></d:prop></d:propstat></d:response>
</d:multistatus>`))
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(`<tvshow><title>剑来</title><year>2024</year><plot>天地有剑气</plot></tvshow>`))
case "/dav/Anime/JianLai/Season1/JianLai.S01E01.nfo":
_, _ = w.Write([]byte(`<episodedetails><showtitle>剑来</showtitle><title>第一集</title><season>1</season><episode>1</episode></episodedetails>`))
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)
}
}
+1 -1
View File
@@ -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))
}
}
+1 -1
View File
@@ -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" {
+4 -4
View File
@@ -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`),
+2
View File
@@ -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) {
+31
View File
@@ -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) {
+2 -2
View File
@@ -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) =>
+32 -2
View File
@@ -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<string, unknown>
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}分`
}
+2 -1
View File
@@ -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}`)
}}
>
扫描
+95 -13
View File
@@ -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<string, unknown>
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() {
<span className="text-sand-500"> ({isSeries ? seriesCards.length : total})</span>
</h1>
{library && <p className="text-sm text-ink-50">{library.type} · {library.path}</p>}
{scanProgress && <p className="mt-1 text-xs text-brand-500">{scanProgress}</p>}
</div>
{role === 'admin' && (
<div className="flex flex-wrap gap-2">
@@ -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']
+121 -10
View File
@@ -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<string, string> = {
quark: '夸克网盘',
clouddrive2: 'CloudDrive2',
}
const PATH_BASED_CLOUD = new Set<StorageType>(['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<CloudEntry[]>([])
const [mounts, setMounts] = useState<Library[]>([])
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 (
<div className="mt-2 rounded-lg border border-gray-200 p-3" onClick={(e) => e.preventDefault()}>
{mounts.length > 0 && (
<div className="mb-3 rounded border border-blue-100 bg-blue-50/60 p-2">
<div className="mb-1 text-xs font-semibold text-ink-100">已挂载目录</div>
<div className="space-y-1">
{mounts.map((lib) => (
<div key={lib.id} className="flex items-center gap-2 rounded bg-white/80 px-2 py-1 text-xs">
<span className="min-w-0 flex-1 truncate text-ink-100">
{lib.name} · {cloudLibraryLabel(lib.path)}
</span>
<button
type="button"
className="rounded border border-red-200 px-1.5 py-0.5 text-red-500 hover:bg-red-50"
onClick={() => removeMount(lib)}
title="移除挂载"
>
<Trash2 size={13} />
</button>
</div>
))}
</div>
</div>
)}
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-1 text-xs text-ink-50">
<span className="text-ink-100">网盘资源:</span>
@@ -528,6 +636,9 @@ function CloudBrowser({ type }: { type: StorageType }) {
</span>
))}
</div>
<p className="basis-full text-xs text-ink-50">
挂载后不会复制网盘文件;后台会递归读取该目录里的子文件夹和媒体文件,扫描到的影片会自动加入对应媒体库。小目录通常几十秒,大目录取决于网盘接口速度。
</p>
<div className="flex flex-wrap items-center gap-2">
<select
className="rounded border border-gray-200 bg-white px-2 py-0.5 text-xs text-ink-100"
@@ -547,7 +658,7 @@ function CloudBrowser({ type }: { type: StorageType }) {
disabled={mounting || batchMounting || loading}
onClick={mountCurrent}
>
{mounting ? '挂载扫描中…' : '挂载当前目录为媒体库'}
{mounting ? '挂载中…' : '挂载当前目录为媒体库并递归扫描'}
</button>
<button
type="button"
@@ -555,7 +666,7 @@ function CloudBrowser({ type }: { type: StorageType }) {
disabled={mounting || batchMounting || loading || items.every((item) => !item.is_dir)}
onClick={mountVisibleDirectories}
>
{batchMounting ? '批量挂载中…' : '一键挂载当前目录下文件夹'}
{batchMounting ? '批量挂载中…' : '一键把当前目录下所有文件夹挂载为媒体库'}
</button>
</div>
</div>
+6
View File
@@ -152,6 +152,12 @@ export interface ScanResult {
probed: number
local_metadata?: number
removed?: number
skipped?: number
discovered?: number
queued?: boolean
cloud?: boolean
message?: string
estimate_message?: string
}
export interface Setting {