mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
fix: improve cloud mount scanning feedback
This commit is contained in:
+91
-37
@@ -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 {
|
||||
|
||||
@@ -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()})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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`),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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}分`
|
||||
}
|
||||
|
||||
@@ -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}`)
|
||||
}}
|
||||
>
|
||||
扫描
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user