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 package handler
import ( import (
"context"
"io" "io"
"net/http" "net/http"
"net/url"
"strings" "strings"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/model" "github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/service" "github.com/ShukeBta/MediaStationGo/internal/service"
@@ -52,13 +54,14 @@ func cloudImportHandler(svc *service.Container) gin.HandlerFunc {
} }
// cloudMountHandler creates or reuses a cloud:// media library for a cloud // cloudMountHandler creates or reuses a cloud:// media library for a cloud
// directory, then scans it recursively so cloud files become playable STRM/302 // directory, then queues a recursive import scan. The scan runs outside the
// media rows without copying bytes to local disk. // request so large 115/OpenList folders do not make the UI report a timeout.
func cloudMountHandler(svc *service.Container) gin.HandlerFunc { func cloudMountHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
typ := c.Param("type") typ := c.Param("type")
var in struct { var in struct {
Dir string `json:"dir"` Dir string `json:"dir"`
DirPath string `json:"dir_path"`
Name string `json:"name"` Name string `json:"name"`
MediaType string `json:"media_type"` 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()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
path := "cloud://" + typ path := service.BuildCloudLibraryPath(typ, in.Dir, in.DirPath)
if dir := strings.TrimSpace(in.Dir); dir != "" {
path += "/" + url.PathEscape(dir)
}
name := strings.TrimSpace(in.Name) name := strings.TrimSpace(in.Name)
if name == "" { if name == "" {
name = cloudMountLibraryName(typ, strings.TrimSpace(in.Dir)) name = cloudMountLibraryName(typ, strings.TrimSpace(in.Dir))
} }
mediaType := strings.TrimSpace(in.MediaType) mediaType := strings.TrimSpace(in.MediaType)
if mediaType == "" || strings.EqualFold(mediaType, "auto") { 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()) libs, err := svc.Repo.Library.List(c.Request.Context())
if err != nil { if err != nil {
@@ -89,10 +93,18 @@ func cloudMountHandler(svc *service.Container) gin.HandlerFunc {
return return
} }
var lib *model.Library var lib *model.Library
for i := range libs { alreadyMounted := false
if libs[i].Path == path { if conflict := service.FindCloudMountConflict(libs, typ, in.Dir, in.DirPath); conflict != nil {
lib = &libs[i] lib = &conflict.Library
break 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 { if lib == nil {
@@ -101,17 +113,75 @@ func cloudMountHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
} } else if alreadyMounted {
var scan any updates := map[string]any{}
if svc.Scan != nil { if path != "" && path != lib.Path {
res, err := svc.Scan.ScanLibrary(c.Request.Context(), lib.ID) updates["path"] = path
if err != nil { lib.Path = path
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error(), "library": lib}) }
return 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 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 + // cloud115QRStartHandler begins a 115 QR-code login and returns the session +
// QR image URL for the frontend to render. // QR image URL for the frontend to render.
func cloud115QRStartHandler(svc *service.Container) gin.HandlerFunc { func cloud115QRStartHandler(svc *service.Container) gin.HandlerFunc {
+53 -3
View File
@@ -6,8 +6,10 @@ import (
"errors" "errors"
"net/http" "net/http"
"strconv" "strconv"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/middleware" "github.com/ShukeBta/MediaStationGo/internal/middleware"
"github.com/ShukeBta/MediaStationGo/internal/service" "github.com/ShukeBta/MediaStationGo/internal/service"
@@ -29,6 +31,7 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
role, _ := c.Get(middleware.CtxUserRole) role, _ := c.Get(middleware.CtxUserRole)
includeHidden := role == "admin" && (c.Query("include_hidden") == "1" || c.Query("all") == "1") includeHidden := role == "admin" && (c.Query("include_hidden") == "1" || c.Query("all") == "1")
if !includeHidden { if !includeHidden {
libs = service.FilterShadowedCloudLibraries(libs)
visibility := mediaVisibilityForRequest(c, svc) visibility := mediaVisibilityForRequest(c, svc)
filtered := libs[:0] filtered := libs[:0]
for _, lib := range libs { for _, lib := range libs {
@@ -79,9 +82,56 @@ func deleteLibraryHandler(svc *service.Container) gin.HandlerFunc {
func scanLibraryHandler(svc *service.Container) gin.HandlerFunc { func scanLibraryHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
// Run synchronously: small libraries return immediately, big ones can lib, err := svc.Repo.Library.FindByID(c.Request.Context(), id)
// hit the (configurable) HTTP timeout. A future task queue can move if err != nil {
// this to a background worker. 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) res, err := svc.Scan.ScanLibrary(c.Request.Context(), id)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) 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 == "" { if m.ScrapeStatus == "" {
m.ScrapeStatus = "pending" 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 { if err != nil {
return err 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 { if err != nil {
return nil, err return nil, err
} }
libs = FilterShadowedCloudLibraries(libs)
visibility := UserDefaultMediaVisibility(ctx, e.repo, userID) visibility := UserDefaultMediaVisibility(ctx, e.repo, userID)
items := make([]map[string]any, 0, len(libs)) items := make([]map[string]any, 0, len(libs))
for _, l := range libs { for _, l := range libs {
@@ -1149,9 +1150,10 @@ func (e *EmbyService) hiddenLibraryIDs(ctx context.Context, visibility MediaVisi
if err != nil { if err != nil {
return nil return nil
} }
shadowed := ShadowedCloudLibraryIDSet(libs)
ids := make([]string, 0) ids := make([]string, 0)
for _, lib := range libs { 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) ids = append(ids, lib.ID)
} }
} }
+237 -38
View File
@@ -17,6 +17,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
"go.uber.org/zap" "go.uber.org/zap"
@@ -93,13 +94,35 @@ func (s *ScannerService) ScanLibrary(ctx context.Context, libraryID string) (*Sc
return s.scanLibrary(ctx, libraryID, true) 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) { func (s *ScannerService) scanLibrary(ctx context.Context, libraryID string, autoScrape bool) (*ScanResult, error) {
lib, err := s.repo.Library.FindByID(ctx, libraryID) lib, err := s.repo.Library.FindByID(ctx, libraryID)
if err != nil || lib == nil { if err != nil || lib == nil {
return nil, err return nil, err
} }
if typ, dirID, ok := parseCloudLibraryPath(lib.Path); ok { if mount, ok := ParseCloudLibraryMount(lib.Path); ok {
return s.scanCloudLibrary(ctx, lib, typ, dirID, autoScrape) 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} res := &ScanResult{LibraryID: lib.ID}
seen := make(map[string]struct{}) 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 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} res := &ScanResult{LibraryID: lib.ID}
if s.storage == nil { if s.storage == nil {
return res, fmt.Errorf("cloud storage service unavailable") return res, fmt.Errorf("cloud storage service unavailable")
} }
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{}) seen := make(map[string]struct{})
seenRefs := make(map[string]struct{})
candidates := make([]cloudCandidate, 0, 256)
candidateByKey := make(map[string]int)
visitedDirs := map[string]struct{}{} visitedDirs := map[string]struct{}{}
var walkCloud func(string) error startedAt := time.Now()
walkCloud = func(dirID string) error { 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 { if _, ok := visitedDirs[dirID]; ok {
return nil return nil
} }
visitedDirs[dirID] = struct{}{} visitedDirs[dirID] = struct{}{}
entries, err := s.storage.CloudList(ctx, typ, dirID) entries, err := s.storage.CloudList(ctx, typ, dirID)
if err != nil { 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 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 { for _, entry := range entries {
select { select {
case <-ctx.Done(): case <-ctx.Done():
@@ -199,7 +285,7 @@ func (s *ScannerService) scanCloudLibrary(ctx context.Context, lib *model.Librar
} }
if entry.IsDir { if entry.IsDir {
if strings.TrimSpace(entry.ID) != "" { 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 return err
} }
} }
@@ -214,15 +300,45 @@ func (s *ScannerService) scanCloudLibrary(ctx context.Context, lib *model.Librar
res.Skipped++ res.Skipped++
continue continue
} }
path := cloudMediaPath(typ, ref) if _, ok := seenRefs[ref]; ok {
seen[path] = struct{}{} res.Skipped++
s.ingestCloudFile(ctx, lib, typ, ref, entry.Name, entry.Size, res) 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 return nil
} }
if err := walkCloud(rootDir); err != nil { if err := walkCloud(rootDir, rootDisplayDir, nil); err != nil {
return res, err 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) removed, err := s.pruneMissingCloudMedia(ctx, lib.ID, seen)
if err != nil { if err != nil {
s.log.Warn("prune missing cloud media failed", zap.String("library_id", lib.ID), zap.Error(err)) s.log.Warn("prune missing cloud media failed", zap.String("library_id", lib.ID), zap.Error(err))
@@ -230,13 +346,16 @@ func (s *ScannerService) scanCloudLibrary(ctx context.Context, lib *model.Librar
res.Removed = removed res.Removed = removed
} }
s.hub.Publish("scan", map[string]any{ s.hub.Publish("scan", map[string]any{
"library_id": lib.ID, "library_id": lib.ID,
"finished": true, "finished": true,
"visited": res.Visited, "visited": res.Visited,
"added": res.Added, "added": res.Added,
"updated": res.Updated, "updated": res.Updated,
"removed": res.Removed, "removed": res.Removed,
"cloud": true, "discovered": filesDiscovered,
"dirs": dirsVisited,
"elapsed_seconds": int(time.Since(startedAt).Seconds()),
"cloud": true,
}) })
if autoScrape && s.scraper != nil && s.scraper.AnyEnabled() && s.autoScrapeEnabled(ctx) { if autoScrape && s.scraper != nil && s.scraper.AnyEnabled() && s.autoScrapeEnabled(ctx) {
go func(libID string) { go func(libID string) {
@@ -248,7 +367,16 @@ func (s *ScannerService) scanCloudLibrary(ctx context.Context, lib *model.Librar
return res, nil 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++ res.Visited++
ext := strings.ToLower(filepath.Ext(name)) ext := strings.ToLower(filepath.Ext(name))
title, year := CleanQuery(name) title, year := CleanQuery(name)
@@ -258,7 +386,15 @@ func (s *ScannerService) ingestCloudFile(ctx context.Context, lib *model.Library
if title == "" { if title == "" {
title = ref 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) isNewMedia := !s.mediaPathExists(ctx, path)
m := &model.Media{ m := &model.Media{
LibraryID: lib.ID, 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)) s.log.Debug("read cloud strm failed", zap.String("ref", ref), zap.Error(err))
} }
} }
parsedSeason, parsedEpisode := ParseEpisode(name)
m.SeasonNum = parsedSeason m.SeasonNum = parsedSeason
m.EpisodeNum = parsedEpisode m.EpisodeNum = parsedEpisode
if localMeta != nil {
applyLocalMetadata(m, localMeta)
res.LocalMetadata++
}
if err := s.repo.Media.Upsert(ctx, m); err != nil { if err := s.repo.Media.Upsert(ctx, m); err != nil {
s.log.Warn("upsert cloud media failed", zap.String("path", path), zap.Error(err)) s.log.Warn("upsert cloud media failed", zap.String("path", path), zap.Error(err))
return 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 // RemovePath deletes the media row for a path that has disappeared from disk
// (incremental delete used by the watcher on Remove/Rename events). // (incremental delete used by the watcher on Remove/Rename events).
func (s *ScannerService) RemovePath(ctx context.Context, path string) (int64, error) { 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) { func parseCloudLibraryPath(raw string) (typ, dirID string, ok bool) {
raw = strings.TrimSpace(raw) info, ok := ParseCloudLibraryMount(raw)
if !strings.HasPrefix(strings.ToLower(raw), "cloud://") { if !ok {
return "", "", false return "", "", false
} }
u, err := url.Parse(raw) return info.Provider, info.ScanDir, true
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
} }
func cloudEntryRef(typ, id, pickCode string) string { 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), "/") 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) { func (s *ScannerService) resolveCloudSTRMTarget(ctx context.Context, typ, ref string) (string, error) {
if s.storage == nil { if s.storage == nil {
return "", nil return "", nil
+259 -5
View File
@@ -82,11 +82,14 @@ func TestScanCloudLibraryImportsRecursivePlayableMedia(t *testing.T) {
if len(rows) != 2 { if len(rows) != 2 {
t.Fatalf("media rows = %d, want 2: %#v", len(rows), rows) 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" { if rows[0].Path != "cloud://quark/Movies/Nested.Show.S01E02.mp4" || !strings.Contains(rows[0].STRMURL, "ref=f2") {
t.Fatalf("root media path/strm wrong: path=%q strm=%q", rows[0].Path, rows[0].STRMURL) 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") { if rows[0].SeasonNum != 1 || rows[0].EpisodeNum != 2 {
t.Fatalf("nested episode metadata wrong: %#v", rows[1]) 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 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) t.Fatalf("parse path got typ=%q dir=%q ok=%v", typ, dir, ok)
} }
typ, dir, ok = parseCloudLibraryPath("cloud://quark?dir=0") 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) t.Fatalf("parse query got typ=%q dir=%q ok=%v", typ, dir, ok)
} }
if ref := cloudEntryRef("cloud115", "fid", "pick"); ref != "pick" { 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) { func TestScanCloudLibraryReadsRemoteSTRMTarget(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method { switch r.Method {
@@ -190,3 +354,93 @@ func TestScanCloudLibraryReadsRemoteSTRMTarget(t *testing.T) {
t.Fatalf("strm target = %q", media.STRMURL) 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 { if _, _, ok := parseCloudLibraryPath(l.Path); !ok {
continue 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)) 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) t.Fatalf("cloud sync: %v", err)
} }
var media model.Media 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) t.Fatalf("cloud media not imported: %v", err)
} }
if media.STRMURL != "/api/cloud/play/quark?ref=f1" { 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", "netflix", "nf", "amzn", "hulu", "disney", "max", "hbo",
"linetv", "ourtv", "iqiyi", "youku", "bilibili", "qiyi", "krj", "linetv", "ourtv", "iqiyi", "youku", "bilibili", "qiyi", "krj",
"crunchyroll", "funimation", "anidb", "horriblesubs", "subsplease", "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", "zm", "zw", "ch", "chs", "cht", "cn", "tc", "sc",
"中字", "繁字", "简中", "繁中", "国语", "粤语", "日语", "中字", "繁字", "简中", "繁中", "国语", "粤语", "日语",
// 季数前缀残留 — ParseEpisode 已抽取过 // 季数前缀残留 — ParseEpisode 已抽取过
"season", "season", "264", "265",
} }
var noiseTokenSet = func() map[string]struct{} { var noiseTokenSet = func() map[string]struct{} {
@@ -101,8 +101,8 @@ var noiseTokenSet = func() map[string]struct{} {
return set return set
}() }()
// bracketedTag matches "[anything]" or "(anything)" segments. // bracketedTag matches "[anything]", "(anything)" or "{anything}" segments.
var bracketedTag = regexp.MustCompile(`[\[\(][^\]\)]*[\]\)]`) var bracketedTag = regexp.MustCompile(`[\[\(\{][^\]\)\}]*[\]\)\}]`)
var multiWordNoise = []*regexp.Regexp{ var multiWordNoise = []*regexp.Regexp{
regexp.MustCompile(`(?i)\bweb[\s._-]*dl\b`), regexp.MustCompile(`(?i)\bweb[\s._-]*dl\b`),
regexp.MustCompile(`(?i)\bblu[\s._-]*ray\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}, {"My Movie 2022 [HDR] (1080p) [TGx].mp4", "my movie", 2022},
{"NoYearOrTags.mkv", "noyearortags", 0}, {"NoYearOrTags.mkv", "noyearortags", 0},
{"亏成首富从游戏开始 The Richest in Game - S01E11 - 4K.mp4", "亏成首富从游戏开始 the richest in game", 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 { for _, tc := range cases {
t.Run(tc.in, func(t *testing.T) { t.Run(tc.in, func(t *testing.T) {
+31
View File
@@ -11,6 +11,7 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/config" "github.com/ShukeBta/MediaStationGo/internal/config"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository" "github.com/ShukeBta/MediaStationGo/internal/repository"
) )
@@ -232,6 +233,9 @@ func (c *Container) Boot() {
if err := c.APIConfig.SeedDefaults(c.stopCtx); err != nil { if err := c.APIConfig.SeedDefaults(c.stopCtx); err != nil {
c.Log.Warn("api config seed failed", zap.Error(err)) 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 { 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 // runInactivitySweeper periodically runs the account-cleanup policy. Kept with
// the historical name to avoid churn in callers. // the historical name to avoid churn in callers.
func (c *Container) runInactivitySweeper(ctx context.Context) { 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 }) .post(`/admin/cloud/${type}/import`, { ref, name, size })
.then((r) => r.data), .then((r) => r.data),
mount: (type: StorageType, dir = '', name = '', media_type = 'movie') => mount: (type: StorageType, dir = '', name = '', media_type = 'movie', dir_path = '') =>
api 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), .then((r) => r.data),
qrStart: (type: StorageType) => qrStart: (type: StorageType) =>
+32 -2
View File
@@ -10,8 +10,28 @@ export function GlobalEvents() {
const onEvent = useCallback((topic: string, payload: unknown) => { const onEvent = useCallback((topic: string, payload: unknown) => {
if (!payload || typeof payload !== 'object') return if (!payload || typeof payload !== 'object') return
const p = payload as Record<string, unknown> const p = payload as Record<string, unknown>
if (topic === 'scan' && p.finished) { if (topic === 'scan') {
toast.success(`扫描完成:已添加 ${p.added ?? 0} 项,已探测 ${p.probed ?? 0} 项`) 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) { if (topic === 'scrape' && p.finished) {
toast.success(`刮削完成:成功匹配 ${p.matched ?? 0} 项`) toast.success(`刮削完成:成功匹配 ${p.matched ?? 0} 项`)
@@ -25,3 +45,13 @@ export function GlobalEvents() {
useWebSocket(onEvent) useWebSocket(onEvent)
return null 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" className="rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10"
onClick={async () => { onClick={async () => {
const r = await libraryAPI.scan(l.id) 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 { Link, useParams, useSearchParams } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from 'framer-motion'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
@@ -11,6 +11,7 @@ import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
import { imageURL } from '../api/client' import { imageURL } from '../api/client'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { getSeriesKey, groupSeries, isEpisodeLike, seriesTitle, type SeriesCard } from '../utils/groupSeries' import { getSeriesKey, groupSeries, isEpisodeLike, seriesTitle, type SeriesCard } from '../utils/groupSeries'
import { useWebSocket } from '../hooks/useWebSocket'
export function LibraryPage() { export function LibraryPage() {
const { id = '' } = useParams() const { id = '' } = useParams()
@@ -22,6 +23,7 @@ export function LibraryPage() {
const [total, setTotal] = useState(0) const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [scanning, setScanning] = useState(false) const [scanning, setScanning] = useState(false)
const [scanProgress, setScanProgress] = useState('')
const [scraping, setScraping] = useState(false) const [scraping, setScraping] = useState(false)
// 剧集模式:选中某个剧集后展开详情 // 剧集模式:选中某个剧集后展开详情
@@ -71,18 +73,73 @@ export function LibraryPage() {
useEffect(() => { useEffect(() => {
if (!id || !library) return if (!id || !library) return
let cancelled = false
setLoading(true) setLoading(true)
// 所有库都可能包含季集型内容(综艺/纪录片/课程等),统一拉取较大页数用于前端分组。 setItems([])
const limit = 2000 const loadAll = async () => {
libraryAPI const pageSize = 2000
.listMedia(id, 1, limit) let page = 1
.then((d) => { let collected: Media[] = []
setItems(d.items) try {
setTotal(d.total) for (;;) {
}) const d = await libraryAPI.listMedia(id, page, pageSize)
.finally(() => setLoading(false)) 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]) }, [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(() => { useEffect(() => {
if (loading) return if (loading) return
if (!isSeries) { if (!isSeries) {
@@ -113,13 +170,27 @@ export function LibraryPage() {
const handleScan = async () => { const handleScan = async () => {
setScanning(true) setScanning(true)
setScanProgress('正在提交扫描任务…')
let keepScanning = false
try { try {
const r = await libraryAPI.scan(id) const r = await libraryAPI.scan(id)
toast.success(`扫描完成:新增 ${r.added} 项,更新 ${r.updated ?? 0} 项`) if (r.queued) {
setLibrary((l) => (l ? { ...l } : l)) 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 { } catch {
toast.error('扫描失败') toast.error('扫描失败')
} finally { setScanning(false) } setScanProgress('扫描失败,请查看日志或稍后重试')
setScanning(false)
} finally {
if (!keepScanning) setScanning(false)
}
} }
const handleScrape = async () => { const handleScrape = async () => {
@@ -168,6 +239,7 @@ export function LibraryPage() {
<span className="text-sand-500"> ({isSeries ? seriesCards.length : total})</span> <span className="text-sand-500"> ({isSeries ? seriesCards.length : total})</span>
</h1> </h1>
{library && <p className="text-sm text-ink-50">{library.type} · {library.path}</p>} {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> </div>
{role === 'admin' && ( {role === 'admin' && (
<div className="flex flex-wrap gap-2"> <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 { function formatSize(bytes: number): string {
if (!bytes || bytes <= 0) return '—' if (!bytes || bytes <= 0) return '—'
const units = ['B', 'KB', 'MB', 'GB', 'TB'] const units = ['B', 'KB', 'MB', 'GB', 'TB']
+121 -10
View File
@@ -1,7 +1,8 @@
import { FormEvent, useEffect, useMemo, useState } from 'react' 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 toast from 'react-hot-toast'
import { libraryAPI } from '../api/library'
import { import {
cloudAPI, cloudAPI,
storageAPI, storageAPI,
@@ -9,6 +10,8 @@ import {
type QRSession, type QRSession,
type StorageType, type StorageType,
} from '../api/storage_config' } from '../api/storage_config'
import { confirmAction } from '../components/ConfirmDialog'
import type { Library } from '../types'
const CLOUD_TYPES: StorageType[] = ['cloud115', 'quark', 'clouddrive2', 'openlist'] const CLOUD_TYPES: StorageType[] = ['cloud115', 'quark', 'clouddrive2', 'openlist']
const isCloud = (t: StorageType) => CLOUD_TYPES.includes(t) const isCloud = (t: StorageType) => CLOUD_TYPES.includes(t)
@@ -21,6 +24,46 @@ const TYPE_LABEL: Record<string, string> = {
quark: '夸克网盘', quark: '夸克网盘',
clouddrive2: 'CloudDrive2', 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 // StorageConfigPage manages the Alist / S3 / WebDAV adapters used by
// the import / playback / STRM subsystems. Mirrors the Vue UI's // 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 }) { function CloudBrowser({ type }: { type: StorageType }) {
const [stack, setStack] = useState<{ id: string; name: string }[]>([{ id: '', name: '根目录' }]) const [stack, setStack] = useState<{ id: string; name: string }[]>([{ id: '', name: '根目录' }])
const [items, setItems] = useState<CloudEntry[]>([]) const [items, setItems] = useState<CloudEntry[]>([])
const [mounts, setMounts] = useState<Library[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [mounting, setMounting] = useState(false) const [mounting, setMounting] = useState(false)
const [batchMounting, setBatchMounting] = 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(() => { useEffect(() => {
load(cur.id).catch(() => undefined) load(cur.id).catch(() => undefined)
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [stack.length, type]) }, [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 enter = (e: CloudEntry) => setStack((s) => [...s, { id: e.id, name: e.name }])
const goTo = (i: number) => setStack((s) => s.slice(0, i + 1)) 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 doImport = async (e: CloudEntry) => {
const ref = type === 'cloud115' ? e.pick_code || e.id : e.id const ref = type === 'cloud115' ? e.pick_code || e.id : e.id
@@ -478,9 +548,9 @@ function CloudBrowser({ type }: { type: StorageType }) {
try { try {
const label = TYPE_LABEL[type] ?? type const label = TYPE_LABEL[type] ?? type
const name = cur.id ? `${label} · ${cur.name}` : label const name = cur.id ? `${label} · ${cur.name}` : label
const res = await cloudAPI.mount(type, cur.id, name, mountMediaType) const res = await cloudAPI.mount(type, cur.id, name, mountMediaType, currentMountPath())
const scan = (res as { scan?: { added?: number; updated?: number; removed?: number; visited?: number } }).scan handleMountResult(res, cur.name)
toast.success(`已挂载为媒体库并扫描:新增 ${scan?.added ?? 0} · 更新 ${scan?.updated ?? 0} · 访问 ${scan?.visited ?? 0}`) await loadMounts()
} catch (err: unknown) { } catch (err: unknown) {
toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '挂载失败') toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '挂载失败')
} finally { } finally {
@@ -496,26 +566,64 @@ function CloudBrowser({ type }: { type: StorageType }) {
} }
setBatchMounting(true) setBatchMounting(true)
let ok = 0 let ok = 0
let skipped = 0
let failed = 0 let failed = 0
const label = TYPE_LABEL[type] ?? type const label = TYPE_LABEL[type] ?? type
for (const dir of dirs) { for (const dir of dirs) {
try { try {
await cloudAPI.mount(type, dir.id, `${label} · ${dir.name}`, 'auto') const result = await cloudAPI.mount(type, dir.id, `${label} · ${dir.name}`, 'auto', childMountPath(dir))
ok += 1 const state = handleMountResult(result, dir.name)
if (state === 'skipped') skipped += 1
else ok += 1
} catch { } catch {
failed += 1 failed += 1
} }
} }
if (failed > 0) { if (failed > 0) {
toast.error(`已挂载 ${ok} 个目录,失败 ${failed} 个;请查看日志或逐个挂载定位`) toast.error(`已挂载 ${ok} 个目录,跳过 ${skipped} 个重叠目录,失败 ${failed} 个`)
} else { } else {
toast.success(`已挂载 ${ok} 个目录为媒体库,后台会自动生成 302/STRM 播放入口`) toast.success(`已挂载 ${ok} 个目录,跳过 ${skipped} 个重叠目录,后台会自动生成 302/STRM 播放入口`)
} }
await loadMounts()
setBatchMounting(false) 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 ( return (
<div className="mt-2 rounded-lg border border-gray-200 p-3" onClick={(e) => e.preventDefault()}> <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="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"> <div className="flex flex-wrap items-center gap-1 text-xs text-ink-50">
<span className="text-ink-100">网盘资源:</span> <span className="text-ink-100">网盘资源:</span>
@@ -528,6 +636,9 @@ function CloudBrowser({ type }: { type: StorageType }) {
</span> </span>
))} ))}
</div> </div>
<p className="basis-full text-xs text-ink-50">
挂载后不会复制网盘文件;后台会递归读取该目录里的子文件夹和媒体文件,扫描到的影片会自动加入对应媒体库。小目录通常几十秒,大目录取决于网盘接口速度。
</p>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<select <select
className="rounded border border-gray-200 bg-white px-2 py-0.5 text-xs text-ink-100" 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} disabled={mounting || batchMounting || loading}
onClick={mountCurrent} onClick={mountCurrent}
> >
{mounting ? '挂载扫描中…' : '挂载当前目录为媒体库'} {mounting ? '挂载中…' : '挂载当前目录为媒体库并递归扫描'}
</button> </button>
<button <button
type="button" type="button"
@@ -555,7 +666,7 @@ function CloudBrowser({ type }: { type: StorageType }) {
disabled={mounting || batchMounting || loading || items.every((item) => !item.is_dir)} disabled={mounting || batchMounting || loading || items.every((item) => !item.is_dir)}
onClick={mountVisibleDirectories} onClick={mountVisibleDirectories}
> >
{batchMounting ? '批量挂载中…' : '一键挂载当前目录下文件夹'} {batchMounting ? '批量挂载中…' : '一键把当前目录下所有文件夹挂载为媒体库'}
</button> </button>
</div> </div>
</div> </div>
+6
View File
@@ -152,6 +152,12 @@ export interface ScanResult {
probed: number probed: number
local_metadata?: number local_metadata?: number
removed?: number removed?: number
skipped?: number
discovered?: number
queued?: boolean
cloud?: boolean
message?: string
estimate_message?: string
} }
export interface Setting { export interface Setting {