mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-09 06:46:37 +08:00
bug处理优化
bug处理优化
This commit is contained in:
@@ -76,14 +76,19 @@ func (w *rotatingFileWriter) Sync() error {
|
|||||||
if w.file == nil {
|
if w.file == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
err := w.file.Sync()
|
return w.file.Sync()
|
||||||
closeErr := w.file.Close()
|
}
|
||||||
|
|
||||||
|
func (w *rotatingFileWriter) Close() error {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
if w.file == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
err := w.file.Close()
|
||||||
w.file = nil
|
w.file = nil
|
||||||
w.size = 0
|
w.size = 0
|
||||||
if err != nil {
|
return err
|
||||||
return err
|
|
||||||
}
|
|
||||||
return closeErr
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *rotatingFileWriter) open() error {
|
func (w *rotatingFileWriter) open() error {
|
||||||
|
|||||||
+20
-5
@@ -13,8 +13,14 @@ import (
|
|||||||
|
|
||||||
// newLogger 根据 cfg.Logging 构建 Zap。
|
// newLogger 根据 cfg.Logging 构建 Zap。
|
||||||
func newLogger(cfg *config.Config) (*zap.Logger, error) {
|
func newLogger(cfg *config.Config) (*zap.Logger, error) {
|
||||||
|
log, _, err := newLoggerWithCloser(cfg)
|
||||||
|
return log, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func newLoggerWithCloser(cfg *config.Config) (*zap.Logger, func(), error) {
|
||||||
if cfg.App.Debug {
|
if cfg.App.Debug {
|
||||||
return zap.NewDevelopment()
|
log, err := zap.NewDevelopment()
|
||||||
|
return log, func() {}, err
|
||||||
}
|
}
|
||||||
level := configuredLogLevel(cfg.Logging.Level)
|
level := configuredLogLevel(cfg.Logging.Level)
|
||||||
encoderCfg := zap.NewProductionEncoderConfig()
|
encoderCfg := zap.NewProductionEncoderConfig()
|
||||||
@@ -28,33 +34,42 @@ func newLogger(cfg *config.Config) (*zap.Logger, error) {
|
|||||||
cores := []zapcore.Core{
|
cores := []zapcore.Core{
|
||||||
zapcore.NewCore(encoder, zapcore.Lock(os.Stdout), level),
|
zapcore.NewCore(encoder, zapcore.Lock(os.Stdout), level),
|
||||||
}
|
}
|
||||||
|
var closers []func() error
|
||||||
appPath, warnPath, errorPath := logFilePaths(cfg)
|
appPath, warnPath, errorPath := logFilePaths(cfg)
|
||||||
if appPath != "" {
|
if appPath != "" {
|
||||||
appWriter, err := newRotatingFileWriter(appPath, cfg.Logging)
|
appWriter, err := newRotatingFileWriter(appPath, cfg.Logging)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
cores = append(cores, zapcore.NewCore(encoder, appWriter, level))
|
cores = append(cores, zapcore.NewCore(encoder, appWriter, level))
|
||||||
|
closers = append(closers, appWriter.Close)
|
||||||
}
|
}
|
||||||
if warnPath != "" {
|
if warnPath != "" {
|
||||||
warnWriter, err := newRotatingFileWriter(warnPath, cfg.Logging)
|
warnWriter, err := newRotatingFileWriter(warnPath, cfg.Logging)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
cores = append(cores, zapcore.NewCore(encoder, warnWriter, zap.LevelEnablerFunc(func(lvl zapcore.Level) bool {
|
cores = append(cores, zapcore.NewCore(encoder, warnWriter, zap.LevelEnablerFunc(func(lvl zapcore.Level) bool {
|
||||||
return lvl == zapcore.WarnLevel && level.Enabled(lvl)
|
return lvl == zapcore.WarnLevel && level.Enabled(lvl)
|
||||||
})))
|
})))
|
||||||
|
closers = append(closers, warnWriter.Close)
|
||||||
}
|
}
|
||||||
if errorPath != "" {
|
if errorPath != "" {
|
||||||
errorWriter, err := newRotatingFileWriter(errorPath, cfg.Logging)
|
errorWriter, err := newRotatingFileWriter(errorPath, cfg.Logging)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
cores = append(cores, zapcore.NewCore(encoder, errorWriter, zap.LevelEnablerFunc(func(lvl zapcore.Level) bool {
|
cores = append(cores, zapcore.NewCore(encoder, errorWriter, zap.LevelEnablerFunc(func(lvl zapcore.Level) bool {
|
||||||
return lvl >= zapcore.ErrorLevel && level.Enabled(lvl)
|
return lvl >= zapcore.ErrorLevel && level.Enabled(lvl)
|
||||||
})))
|
})))
|
||||||
|
closers = append(closers, errorWriter.Close)
|
||||||
}
|
}
|
||||||
return zap.New(zapcore.NewTee(cores...), zap.AddCaller(), zap.AddStacktrace(zapcore.ErrorLevel), zap.ErrorOutput(zapcore.Lock(os.Stderr))), nil
|
closeFn := func() {
|
||||||
|
for _, c := range closers {
|
||||||
|
_ = c()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return zap.New(zapcore.NewTee(cores...), zap.AddCaller(), zap.AddStacktrace(zapcore.ErrorLevel), zap.ErrorOutput(zapcore.Lock(os.Stderr))), closeFn, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func configuredLogLevel(raw string) zapcore.Level {
|
func configuredLogLevel(raw string) zapcore.Level {
|
||||||
|
|||||||
@@ -22,10 +22,11 @@ func TestProductionLoggerWritesConfiguredInfoToAppLogAndSplitsWarnError(t *testi
|
|||||||
cfg.Logging.MaxSizeMB = 1
|
cfg.Logging.MaxSizeMB = 1
|
||||||
cfg.Logging.MaxBackups = 2
|
cfg.Logging.MaxBackups = 2
|
||||||
|
|
||||||
log, err := newLogger(cfg)
|
log, closeFn, err := newLoggerWithCloser(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
defer closeFn()
|
||||||
log.Info("info should be stored")
|
log.Info("info should be stored")
|
||||||
log.Warn("warning only", zap.String("kind", "warn"))
|
log.Warn("warning only", zap.String("kind", "warn"))
|
||||||
log.Error("error only", zap.String("kind", "error"))
|
log.Error("error only", zap.String("kind", "error"))
|
||||||
@@ -70,10 +71,11 @@ func TestProductionLoggerDefaultsToWarnInAppLog(t *testing.T) {
|
|||||||
cfg.Logging.OutputPath = filepath.Join(dir, "logs")
|
cfg.Logging.OutputPath = filepath.Join(dir, "logs")
|
||||||
cfg.Logging.EnableRotation = true
|
cfg.Logging.EnableRotation = true
|
||||||
|
|
||||||
log, err := newLogger(cfg)
|
log, closeFn, err := newLoggerWithCloser(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
defer closeFn()
|
||||||
log.Info("info should stay quiet by default")
|
log.Info("info should stay quiet by default")
|
||||||
log.Warn("warning should be stored")
|
log.Warn("warning should be stored")
|
||||||
_ = log.Sync()
|
_ = log.Sync()
|
||||||
@@ -101,6 +103,7 @@ func TestRotatingFileWriterCapsFileSize(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
defer writer.Close()
|
||||||
chunk := strings.Repeat("x", 700*1024)
|
chunk := strings.Repeat("x", 700*1024)
|
||||||
if _, err := writer.Write([]byte(chunk)); err != nil {
|
if _, err := writer.Write([]byte(chunk)); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -42,10 +44,12 @@ func getPublicIP(timeout time.Duration) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
buf := make([]byte, 64)
|
if resp.StatusCode != http.StatusOK {
|
||||||
n, err := resp.Body.Read(buf)
|
|
||||||
if err != nil || n == 0 || resp.StatusCode != http.StatusOK {
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return string(buf[:n])
|
data, err := io.ReadAll(io.LimitReader(resp.Body, 64))
|
||||||
|
if err != nil || len(data) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(data))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,8 +20,12 @@ func updateProfileHandler(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
|
||||||
}
|
}
|
||||||
uid, _ := c.Get(middleware.CtxUserID)
|
uid, exists := c.Get(middleware.CtxUserID)
|
||||||
userID := uid.(string)
|
userID, ok := uid.(string)
|
||||||
|
if !exists || !ok || userID == "" {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
hideAdultChanged, err := profileHideAdultChanged(c.Request.Context(), svc, userID, patch)
|
hideAdultChanged, err := profileHideAdultChanged(c.Request.Context(), svc, userID, patch)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ func (p *OrganizePipelineService) Run(ctx context.Context, req OrganizePipelineR
|
|||||||
Message: "整理/重命名完成,准备扫描入库",
|
Message: "整理/重命名完成,准备扫描入库",
|
||||||
Metrics: OrganizeTaskMetrics(res),
|
Metrics: OrganizeTaskMetrics(res),
|
||||||
Details: OrganizeTaskDetails(res, 8),
|
Details: OrganizeTaskDetails(res, 8),
|
||||||
|
Items: organizeItemsFromResult(res),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,6 +129,7 @@ func (p *OrganizePipelineService) Run(ctx context.Context, req OrganizePipelineR
|
|||||||
Message: "正在扫描入库并按设置刮削",
|
Message: "正在扫描入库并按设置刮削",
|
||||||
Metrics: OrganizeTaskMetrics(res),
|
Metrics: OrganizeTaskMetrics(res),
|
||||||
Details: OrganizeTaskDetails(res, 8),
|
Details: OrganizeTaskDetails(res, 8),
|
||||||
|
Items: combineOrganizeItems(res),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
scanRoot := organizeScanRoot(res, path)
|
scanRoot := organizeScanRoot(res, path)
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ func (p *OrganizePipelineService) finishTask(task *TaskHandle, err error, stage,
|
|||||||
Message: message,
|
Message: message,
|
||||||
Metrics: OrganizeTaskMetrics(res),
|
Metrics: OrganizeTaskMetrics(res),
|
||||||
Details: OrganizeTaskDetails(res, 8),
|
Details: OrganizeTaskDetails(res, 8),
|
||||||
|
Items: combineOrganizeItems(res),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -127,6 +127,10 @@ func (o *OrganizerService) writeOrganizedSourceFile(ctx context.Context, req org
|
|||||||
o.log.Warn("organize sidecar nfo failed",
|
o.log.Warn("organize sidecar nfo failed",
|
||||||
zap.String("from", req.Source), zap.String("to", plan.Target.Path), zap.Error(err))
|
zap.String("from", req.Source), zap.String("to", plan.Target.Path), zap.Error(err))
|
||||||
}
|
}
|
||||||
|
if err := transferSidecarArtwork(req.Source, plan.Target.Path, req.Mode); err != nil {
|
||||||
|
o.log.Warn("organize sidecar artwork failed",
|
||||||
|
zap.String("from", req.Source), zap.String("to", plan.Target.Path), zap.Error(err))
|
||||||
|
}
|
||||||
o.persistOrganizedSourceMetadata(ctx, plan)
|
o.persistOrganizedSourceMetadata(ctx, plan)
|
||||||
req.Result.Organized++
|
req.Result.Organized++
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
@@ -185,33 +186,109 @@ func (o *OrganizerService) existingByFolder(destDir, episodeTag string) []string
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// replaceVersions removes the existing lower-resolution files (and their NFO
|
// replaceVersions promotes a higher-resolution src into dst, then removes the
|
||||||
// sidecars + DB rows) and transfers src into dst.
|
// old lower-resolution files (and their NFO sidecars + DB rows). dst may
|
||||||
|
// coincide with one of the existing versions being superseded, so the new file
|
||||||
|
// is first transferred to a temporary staging name inside the destination
|
||||||
|
// directory. Only after the transfer succeeds are the old versions removed and
|
||||||
|
// the staged file renamed into place. This keeps the lower-res versions intact
|
||||||
|
// if the transfer fails (hardlink across mounts, disk full, etc.) instead of
|
||||||
|
// destroying them before the new file exists.
|
||||||
func (o *OrganizerService) replaceVersions(ctx context.Context, src string, existing []string, dst string, mode TransferMode) error {
|
func (o *OrganizerService) replaceVersions(ctx context.Context, src string, existing []string, dst string, mode TransferMode) error {
|
||||||
|
dstDir := filepath.Dir(dst)
|
||||||
|
if err := os.MkdirAll(dstDir, 0o755); err != nil { // #nosec G301 -- organized media directories must remain readable by NAS/player users.
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Unique staging name so dst (which may already exist as the lower-res
|
||||||
|
// version) is never the transfer target and never clobbered early.
|
||||||
|
stage := dst + ".replace" + randomSuffix()
|
||||||
|
cleanup := func() {
|
||||||
|
_ = os.Remove(stage)
|
||||||
|
_ = os.Remove(nfoPath(stage))
|
||||||
|
removeStagedArtwork(stage)
|
||||||
|
}
|
||||||
|
if err := transferFile(src, stage, mode); err != nil {
|
||||||
|
cleanup()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := transferSidecarNFO(src, stage, mode); err != nil {
|
||||||
|
o.log.Warn("organize replace sidecar nfo failed",
|
||||||
|
zap.String("from", src), zap.String("to", dst), zap.Error(err))
|
||||||
|
}
|
||||||
|
if err := transferSidecarArtwork(src, stage, mode); err != nil {
|
||||||
|
o.log.Warn("organize replace sidecar artwork failed",
|
||||||
|
zap.String("from", src), zap.String("to", dst), zap.Error(err))
|
||||||
|
}
|
||||||
|
// New file is safely staged; the transfer succeeded so it is now safe to
|
||||||
|
// supersede the existing lower-res versions.
|
||||||
for _, e := range existing {
|
for _, e := range existing {
|
||||||
if err := os.Remove(e); err != nil && !os.IsNotExist(err) {
|
|
||||||
return fmt.Errorf("remove existing %s: %w", e, err)
|
|
||||||
}
|
|
||||||
if nfo := nfoPath(e); nfo != "" {
|
if nfo := nfoPath(e); nfo != "" {
|
||||||
_ = os.Remove(nfo)
|
_ = os.Remove(nfo)
|
||||||
}
|
}
|
||||||
|
if err := os.Remove(e); err != nil && !os.IsNotExist(err) {
|
||||||
|
o.log.Warn("organize replace remove existing failed",
|
||||||
|
zap.String("path", e), zap.Error(err))
|
||||||
|
}
|
||||||
if o.repo != nil && o.repo.DB != nil {
|
if o.repo != nil && o.repo.DB != nil {
|
||||||
_ = o.repo.DB.WithContext(ctx).Where("path = ?", e).Delete(&model.Media{}).Error
|
_ = o.repo.DB.WithContext(ctx).Where("path = ?", e).Delete(&model.Media{}).Error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { // #nosec G301 -- organized media directories must remain readable by NAS/player users.
|
// Move staged file + sidecars into the final path.
|
||||||
|
if err := os.Rename(stage, dst); err != nil {
|
||||||
|
cleanup()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := transferFile(src, dst, mode); err != nil {
|
moveSidecarRename(nfoPath(stage), nfoPath(dst))
|
||||||
return err
|
moveStagedArtwork(stage, dst)
|
||||||
}
|
|
||||||
if err := transferSidecarNFO(src, dst, mode); err != nil {
|
|
||||||
o.log.Warn("organize sidecar nfo failed",
|
|
||||||
zap.String("from", src), zap.String("to", dst), zap.Error(err))
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// randomSuffix returns a short random suffix for staging filenames.
|
||||||
|
func randomSuffix() string {
|
||||||
|
return strconv.Itoa(int(time.Now().UnixNano()) & 0xffffff)
|
||||||
|
}
|
||||||
|
|
||||||
|
// moveStagedArtwork renames artwork sidecars staged alongside `stage` into
|
||||||
|
// their final names next to `dst`.
|
||||||
|
func moveStagedArtwork(stage, dst string) {
|
||||||
|
stageBase := strings.TrimSuffix(filepath.Base(stage), filepath.Ext(stage))
|
||||||
|
dstBase := strings.TrimSuffix(filepath.Base(dst), filepath.Ext(dst))
|
||||||
|
stageDir := filepath.Dir(stage)
|
||||||
|
for _, suffix := range artworkSidecarSuffixes {
|
||||||
|
for _, ext := range artworkSidecarExtensions {
|
||||||
|
srcPath := filepath.Join(stageDir, stageBase+suffix+ext)
|
||||||
|
if _, err := os.Stat(srcPath); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_ = os.Rename(srcPath, filepath.Join(stageDir, dstBase+suffix+ext))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// moveSidecarRename renames a staged sidecar to its final name if it exists.
|
||||||
|
func moveSidecarRename(from, to string) {
|
||||||
|
if from == "" || to == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(from); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = os.Rename(from, to)
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeStagedArtwork removes artwork sidecars that were staged alongside
|
||||||
|
// `stage`, used when a replace fails and its staged outputs must be cleaned up.
|
||||||
|
func removeStagedArtwork(stage string) {
|
||||||
|
stageBase := strings.TrimSuffix(filepath.Base(stage), filepath.Ext(stage))
|
||||||
|
stageDir := filepath.Dir(stage)
|
||||||
|
for _, suffix := range artworkSidecarSuffixes {
|
||||||
|
for _, ext := range artworkSidecarExtensions {
|
||||||
|
path := filepath.Join(stageDir, stageBase+suffix+ext)
|
||||||
|
_ = os.Remove(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// resolutionArea returns the pixel area (width*height) of a video file for 洗版
|
// resolutionArea returns the pixel area (width*height) of a video file for 洗版
|
||||||
// comparison. It prefers ffprobe; when unavailable it falls back to a
|
// comparison. It prefers ffprobe; when unavailable it falls back to a
|
||||||
// resolution token in the filename (2160p/1080p/720p). Returns 0 when the
|
// resolution token in the filename (2160p/1080p/720p). Returns 0 when the
|
||||||
|
|||||||
@@ -226,6 +226,13 @@ func (o *OrganizerService) applyOrganizeMedia(ctx context.Context, req organizeM
|
|||||||
zap.String("to", nfoPath(dst.path)),
|
zap.String("to", nfoPath(dst.path)),
|
||||||
zap.Error(err))
|
zap.Error(err))
|
||||||
}
|
}
|
||||||
|
if err := transferSidecarArtwork(m.Path, dst.path, req.transferMode); err != nil {
|
||||||
|
o.log.Warn("organize sidecar artwork failed",
|
||||||
|
zap.String("media", m.ID),
|
||||||
|
zap.String("from", m.Path),
|
||||||
|
zap.String("to", dst.path),
|
||||||
|
zap.Error(err))
|
||||||
|
}
|
||||||
o.log.Info("organized",
|
o.log.Info("organized",
|
||||||
zap.String("media", m.ID),
|
zap.String("media", m.ID),
|
||||||
zap.String("from", m.Path),
|
zap.String("from", m.Path),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package service
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// transferSidecarNFO moves/copies/links the .nfo sidecar alongside its media
|
// transferSidecarNFO moves/copies/links the .nfo sidecar alongside its media
|
||||||
@@ -27,3 +28,65 @@ func transferSidecarNFO(srcMedia, dstMedia string, mode TransferMode) error {
|
|||||||
}
|
}
|
||||||
return transferFile(src, dst, mode)
|
return transferFile(src, dst, mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// artworkSidecarSuffixes lists the poster/backdrop sidecar name suffixes that
|
||||||
|
// scrapers write next to a media file. It is deliberately conservative: we only
|
||||||
|
// follow the canonical "<base>-poster" / "<base>-backdrop" names and their
|
||||||
|
// Emby/Jellyfin variants, all scoped to the same base name as the media.
|
||||||
|
var artworkSidecarSuffixes = []string{
|
||||||
|
"-poster", ".poster",
|
||||||
|
"-backdrop", ".backdrop",
|
||||||
|
"-fanart", ".fanart", "-landscape",
|
||||||
|
"-cover", ".cover", "-thumb", ".thumb",
|
||||||
|
}
|
||||||
|
|
||||||
|
// artworkSidecarExtensions are the image extensions a sidecar may use. Both
|
||||||
|
// "base-poster.jpg" (bare separator) and "base.poster.jpg" (dotted separator)
|
||||||
|
// rely on suffix matching, so we probe the common image extensions.
|
||||||
|
var artworkSidecarExtensions = []string{".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tbn"}
|
||||||
|
|
||||||
|
// transferSidecarArtwork moves/copies/links the scraped poster/backdrop
|
||||||
|
// sidecar files alongside its media using the same transfer mode, mirroring
|
||||||
|
// transferSidecarNFO. Previously organize moved only the .nfo and left posters
|
||||||
|
// behind in the old folder; this keeps artwork with the organized file.
|
||||||
|
func transferSidecarArtwork(srcMedia, dstMedia string, mode TransferMode) error {
|
||||||
|
srcDir := filepath.Dir(srcMedia)
|
||||||
|
base := strings.TrimSuffix(filepath.Base(srcMedia), filepath.Ext(srcMedia))
|
||||||
|
if base == "" || base == "." {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Find every existing sidecar by probing suffix + extension combinations.
|
||||||
|
sources := make([]string, 0, len(artworkSidecarSuffixes)*len(artworkSidecarExtensions))
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for _, suffix := range artworkSidecarSuffixes {
|
||||||
|
for _, ext := range artworkSidecarExtensions {
|
||||||
|
path := filepath.Join(srcDir, base+suffix+ext)
|
||||||
|
key := strings.ToLower(filepath.Clean(path))
|
||||||
|
if _, ok := seen[key]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
if _, err := os.Stat(path); err == nil {
|
||||||
|
sources = append(sources, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(sources) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
dstDir := filepath.Dir(dstMedia)
|
||||||
|
if err := os.MkdirAll(dstDir, 0o755); err != nil { // #nosec G301 -- sidecar media directories must remain readable by NAS/player users.
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var firstErr error
|
||||||
|
for _, src := range sources {
|
||||||
|
dst := filepath.Join(dstDir, filepath.Base(src))
|
||||||
|
if _, err := os.Stat(dst); err == nil {
|
||||||
|
continue // never clobber an existing artwork at the destination
|
||||||
|
}
|
||||||
|
if err := transferFile(src, dst, mode); err != nil && firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return firstErr
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTransferSidecarArtworkMovesPosterAndBackdrop(t *testing.T) {
|
||||||
|
srcDir := t.TempDir()
|
||||||
|
dstDir := t.TempDir()
|
||||||
|
|
||||||
|
srcMedia := filepath.Join(srcDir, "ADN-188.mkv")
|
||||||
|
// Write a fake media + its scraped sidecar artwork.
|
||||||
|
if err := os.WriteFile(srcMedia, []byte("media"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
poster := filepath.Join(srcDir, "ADN-188-poster.jpg")
|
||||||
|
backdrop := filepath.Join(srcDir, "ADN-188-backdrop.jpg")
|
||||||
|
if err := os.WriteFile(poster, []byte("poster"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(backdrop, []byte("backdrop"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dstMedia := filepath.Join(dstDir, "出口, of a title (2018).mkv")
|
||||||
|
if err := transferSidecarArtwork(srcMedia, dstMedia, TransferCopy); err != nil {
|
||||||
|
t.Fatalf("transferSidecarArtwork: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poster + backdrop relocated with the same base file name.
|
||||||
|
dstPoster := filepath.Join(dstDir, "ADN-188-poster.jpg")
|
||||||
|
dstBackdrop := filepath.Join(dstDir, "ADN-188-backdrop.jpg")
|
||||||
|
for _, p := range []string{dstPoster, dstBackdrop} {
|
||||||
|
if _, err := os.Stat(p); err != nil {
|
||||||
|
t.Fatalf("expected %s to exist after transfer: %v", p, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTransferSidecarArtworkNoopWhenNoArtwork(t *testing.T) {
|
||||||
|
srcDir := t.TempDir()
|
||||||
|
dstDir := t.TempDir()
|
||||||
|
srcMedia := filepath.Join(srcDir, "A.mkv")
|
||||||
|
if err := os.WriteFile(srcMedia, []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := transferSidecarArtwork(srcMedia, filepath.Join(dstDir, "B.mkv"), TransferCopy); err != nil {
|
||||||
|
t.Fatalf("expected no error with no artwork, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTransferSidecarArtworkNeverOverwritesExisting(t *testing.T) {
|
||||||
|
srcDir := t.TempDir()
|
||||||
|
dstDir := t.TempDir()
|
||||||
|
srcMedia := filepath.Join(srcDir, "A.mkv")
|
||||||
|
poster := filepath.Join(srcDir, "A-poster.jpg")
|
||||||
|
if err := os.WriteFile(srcMedia, []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(poster, []byte("new"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Pre-existing poster at destination must be preserved.
|
||||||
|
dstMedia := filepath.Join(dstDir, "A.mkv")
|
||||||
|
dstPoster := filepath.Join(dstDir, "A-poster.jpg")
|
||||||
|
if err := os.WriteFile(dstPoster, []byte("keep"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := transferSidecarArtwork(srcMedia, dstMedia, TransferCopy); err != nil {
|
||||||
|
t.Fatalf("transferSidecarArtwork: %v", err)
|
||||||
|
}
|
||||||
|
data, _ := os.ReadFile(dstPoster)
|
||||||
|
if string(data) != "keep" {
|
||||||
|
t.Fatalf("destination poster overwritten, got %q", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -77,10 +77,15 @@ func mergeLocalMetadataIntoMatch(match *Match, local *LocalMetadata) {
|
|||||||
if local.Overview != "" {
|
if local.Overview != "" {
|
||||||
match.Overview = local.Overview
|
match.Overview = local.Overview
|
||||||
}
|
}
|
||||||
if local.PosterURL != "" {
|
// Artwork from local metadata (sidecar NFO / folder poster) should only be
|
||||||
|
// used when the online match did not already produce a usable HTTP artwork.
|
||||||
|
// Otherwise a stale local path (e.g. a poster left behind in the library root
|
||||||
|
// from a previous organize) would clobber the freshly-scraped cover and get
|
||||||
|
// re-applied on every rescrape.
|
||||||
|
if local.PosterURL != "" && !isHTTPish(match.PosterURL) {
|
||||||
match.PosterURL = local.PosterURL
|
match.PosterURL = local.PosterURL
|
||||||
}
|
}
|
||||||
if local.BackdropURL != "" {
|
if local.BackdropURL != "" && !isHTTPish(match.BackdropURL) {
|
||||||
match.BackdropURL = local.BackdropURL
|
match.BackdropURL = local.BackdropURL
|
||||||
}
|
}
|
||||||
if local.Rating > 0 {
|
if local.Rating > 0 {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ type BackgroundTask struct {
|
|||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
Details []string `json:"details,omitempty"`
|
Details []string `json:"details,omitempty"`
|
||||||
Metrics map[string]int64 `json:"metrics,omitempty"`
|
Metrics map[string]int64 `json:"metrics,omitempty"`
|
||||||
|
Items []TaskItemRecord `json:"items,omitempty"`
|
||||||
StartedAt time.Time `json:"started_at"`
|
StartedAt time.Time `json:"started_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
FinishedAt *time.Time `json:"finished_at,omitempty"`
|
FinishedAt *time.Time `json:"finished_at,omitempty"`
|
||||||
@@ -46,6 +47,7 @@ type TaskUpdate struct {
|
|||||||
Message string
|
Message string
|
||||||
Details []string
|
Details []string
|
||||||
Metrics map[string]int64
|
Metrics map[string]int64
|
||||||
|
Items []TaskItemRecord
|
||||||
}
|
}
|
||||||
|
|
||||||
type TaskSnapshot struct {
|
type TaskSnapshot struct {
|
||||||
@@ -214,6 +216,9 @@ func applyTaskUpdate(task *BackgroundTask, update TaskUpdate) {
|
|||||||
if update.Metrics != nil {
|
if update.Metrics != nil {
|
||||||
task.Metrics = cloneTaskMetrics(update.Metrics)
|
task.Metrics = cloneTaskMetrics(update.Metrics)
|
||||||
}
|
}
|
||||||
|
if update.Items != nil {
|
||||||
|
task.Items = append([]TaskItemRecord(nil), update.Items...)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func cloneBackgroundTask(task BackgroundTask) BackgroundTask {
|
func cloneBackgroundTask(task BackgroundTask) BackgroundTask {
|
||||||
@@ -221,6 +226,9 @@ func cloneBackgroundTask(task BackgroundTask) BackgroundTask {
|
|||||||
if task.Details != nil {
|
if task.Details != nil {
|
||||||
task.Details = append([]string(nil), task.Details...)
|
task.Details = append([]string(nil), task.Details...)
|
||||||
}
|
}
|
||||||
|
if task.Items != nil {
|
||||||
|
task.Items = append([]TaskItemRecord(nil), task.Items...)
|
||||||
|
}
|
||||||
if task.FinishedAt != nil {
|
if task.FinishedAt != nil {
|
||||||
finishedAt := *task.FinishedAt
|
finishedAt := *task.FinishedAt
|
||||||
task.FinishedAt = &finishedAt
|
task.FinishedAt = &finishedAt
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// Item-level task status. A single BackgroundTask (one organize → scan →
|
||||||
|
// scrape run) is broken down into per-file / per-library TaskItemRecord rows so
|
||||||
|
// the operator can watch exactly which file is being organized, renamed, ingested
|
||||||
|
// or scraped, and retry only the failed ones.
|
||||||
|
const (
|
||||||
|
ItemStatusPending = "pending" // 待进行
|
||||||
|
ItemStatusRunning = "running" // 进行中
|
||||||
|
ItemStatusSucceeded = "succeeded" // 成功
|
||||||
|
ItemStatusFailed = "failed" // 失败
|
||||||
|
)
|
||||||
|
|
||||||
|
// Item kind / phase. 整理与重命名 share the "organize" phase (per file);
|
||||||
|
// scan(入库)与 scrape(刮削)are reported per library.
|
||||||
|
const (
|
||||||
|
ItemKindOrganize = "organize"
|
||||||
|
ItemKindScan = "scan"
|
||||||
|
ItemKindScrape = "scrape"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TaskItemRecord is a single row on the live tasks board. Each record maps to
|
||||||
|
// exactly one file (organize) or one library (ingest / scrape).
|
||||||
|
type TaskItemRecord struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Kind string `json:"kind"` // organize / scan / scrape
|
||||||
|
Status string `json:"status"` // pending / running / succeeded / failed
|
||||||
|
Name string `json:"name"` // display name (file base name or library name)
|
||||||
|
Source string `json:"source,omitempty"` // source file path (organize)
|
||||||
|
DestPath string `json:"dest_path,omitempty"`
|
||||||
|
LibraryID string `json:"library_id,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// organizeItemsFromResult converts the final organize result items into
|
||||||
|
// per-file task records. The organize result is only complete after the whole
|
||||||
|
// directory walk finishes, so this is called once at stage boundaries rather
|
||||||
|
// than per file. Each item is keyed by its source path for stable identity.
|
||||||
|
func organizeItemsFromResult(res *OrganizeResult) []TaskItemRecord {
|
||||||
|
if res == nil || len(res.Items) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]TaskItemRecord, 0, len(res.Items))
|
||||||
|
for _, item := range res.Items {
|
||||||
|
rec := TaskItemRecord{
|
||||||
|
ID: "organize:" + item.Source,
|
||||||
|
Kind: ItemKindOrganize,
|
||||||
|
Name: itemBaseName(item.Source, item.Title),
|
||||||
|
Source: item.Source,
|
||||||
|
DestPath: item.Target,
|
||||||
|
}
|
||||||
|
switch item.Action {
|
||||||
|
case "error":
|
||||||
|
rec.Status = ItemStatusFailed
|
||||||
|
rec.Error = strings.TrimSpace(item.Reason)
|
||||||
|
default:
|
||||||
|
// organize / replace / reclassify / cleanup / skip all count as done.
|
||||||
|
rec.Status = ItemStatusSucceeded
|
||||||
|
}
|
||||||
|
out = append(out, rec)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanItemsFromResult converts per-library scan summaries into task records.
|
||||||
|
func scanItemsFromResult(res *OrganizeResult) []TaskItemRecord {
|
||||||
|
if res == nil || len(res.Scans) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]TaskItemRecord, 0, len(res.Scans))
|
||||||
|
for _, scan := range res.Scans {
|
||||||
|
rec := TaskItemRecord{
|
||||||
|
ID: "scan:" + scan.LibraryID,
|
||||||
|
Kind: ItemKindScan,
|
||||||
|
Name: scan.Name,
|
||||||
|
LibraryID: scan.LibraryID,
|
||||||
|
Status: ItemStatusSucceeded,
|
||||||
|
}
|
||||||
|
if scan.Error != "" {
|
||||||
|
rec.Status = ItemStatusFailed
|
||||||
|
rec.Error = scan.Error
|
||||||
|
}
|
||||||
|
out = append(out, rec)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// scrapeItemsFromResult converts per-library scrape summaries into task records.
|
||||||
|
func scrapeItemsFromResult(res *OrganizeResult) []TaskItemRecord {
|
||||||
|
if res == nil || len(res.Scrapes) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]TaskItemRecord, 0, len(res.Scrapes))
|
||||||
|
for _, scrape := range res.Scrapes {
|
||||||
|
rec := TaskItemRecord{
|
||||||
|
ID: "scrape:" + scrape.LibraryID,
|
||||||
|
Kind: ItemKindScrape,
|
||||||
|
Name: scrape.Name,
|
||||||
|
LibraryID: scrape.LibraryID,
|
||||||
|
Status: ItemStatusSucceeded,
|
||||||
|
}
|
||||||
|
if scrape.Error != "" {
|
||||||
|
rec.Status = ItemStatusFailed
|
||||||
|
rec.Error = scrape.Error
|
||||||
|
out = append(out, rec)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if scrape.Skipped {
|
||||||
|
rec.Status = ItemStatusSucceeded
|
||||||
|
}
|
||||||
|
out = append(out, rec)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// combineOrganizeItems merges organize + scan + scrape item rows into one flat
|
||||||
|
// list ordered by phase, deduping by ID (scan/scrape share the library ID and
|
||||||
|
// there is at most one of each per run).
|
||||||
|
func combineOrganizeItems(res *OrganizeResult) []TaskItemRecord {
|
||||||
|
items := organizeItemsFromResult(res)
|
||||||
|
items = append(items, scanItemsFromResult(res)...)
|
||||||
|
items = append(items, scrapeItemsFromResult(res)...)
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func itemBaseName(source, title string) string {
|
||||||
|
if strings.TrimSpace(title) != "" && !strings.Contains(title, ".") {
|
||||||
|
return strings.TrimSpace(title)
|
||||||
|
}
|
||||||
|
if source == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
base := source
|
||||||
|
if idx := strings.LastIndexAny(base, "/\\"); idx >= 0 {
|
||||||
|
base = base[idx+1:]
|
||||||
|
}
|
||||||
|
if idx := strings.LastIndex(base, "."); idx > 0 {
|
||||||
|
base = base[:idx]
|
||||||
|
}
|
||||||
|
if base == "" {
|
||||||
|
return source
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
@@ -27,3 +27,69 @@ func TestOrganizeTaskMetricsIncludesScrapeProcessed(t *testing.T) {
|
|||||||
t.Fatalf("scrape_skipped = %d, want 1", metrics["scrape_skipped"])
|
t.Fatalf("scrape_skipped = %d, want 1", metrics["scrape_skipped"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCombineOrganizeItemsBuildsPerItemRows(t *testing.T) {
|
||||||
|
res := &OrganizeResult{
|
||||||
|
Items: []OrganizePreviewItem{
|
||||||
|
{Source: `/downloads/Show.S01E01.mkv`, Target: `/media/Show/Show.S01E01.mkv`, Action: "organize", Title: "Show"},
|
||||||
|
{Source: `/downloads/dup.mkv`, Action: "skip", Reason: "duplicate in library"},
|
||||||
|
{Source: `/downloads/broken.mp4`, Action: "error", Reason: "unsupported codec"},
|
||||||
|
},
|
||||||
|
Scans: []OrganizeScanSummary{
|
||||||
|
{LibraryID: "lib1", Name: "电影库"},
|
||||||
|
{LibraryID: "lib2", Name: "剧集库", Error: "scan failed"},
|
||||||
|
},
|
||||||
|
Scrapes: []OrganizeScrapeSummary{
|
||||||
|
{LibraryID: "lib1", Name: "电影库", Matched: 2},
|
||||||
|
{LibraryID: "lib2", Name: "剧集库", Error: "scrape failed"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
items := combineOrganizeItems(res)
|
||||||
|
if len(items) != 7 {
|
||||||
|
t.Fatalf("len(items) = %d, want 7", len(items))
|
||||||
|
}
|
||||||
|
|
||||||
|
// organize items mapped by source path.
|
||||||
|
bySource := map[string]TaskItemRecord{}
|
||||||
|
for _, item := range items {
|
||||||
|
if item.Kind == ItemKindOrganize {
|
||||||
|
bySource[item.Source] = item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := bySource["/downloads/Show.S01E01.mkv"]; got.Status != ItemStatusSucceeded || got.Kind != ItemKindOrganize {
|
||||||
|
t.Fatalf("organized item = %#v, want succeeded/organize", got)
|
||||||
|
}
|
||||||
|
if got := bySource["/downloads/broken.mp4"]; got.Status != ItemStatusFailed || got.Error == "" {
|
||||||
|
t.Fatalf("error item = %#v, want failed with error", got)
|
||||||
|
}
|
||||||
|
if got := bySource["/downloads/dup.mkv"]; got.Status != ItemStatusSucceeded {
|
||||||
|
t.Fatalf("skip item = %#v, want succeeded", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// scan + scrape items carry library IDs.
|
||||||
|
var scanFailed, scrapeFailed bool
|
||||||
|
for _, item := range items {
|
||||||
|
if item.Kind == ItemKindScan && item.LibraryID == "lib2" {
|
||||||
|
scanFailed = item.Status == ItemStatusFailed
|
||||||
|
}
|
||||||
|
if item.Kind == ItemKindScrape && item.LibraryID == "lib2" {
|
||||||
|
scrapeFailed = item.Status == ItemStatusFailed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !scanFailed {
|
||||||
|
t.Fatal("scan lib2 should be failed")
|
||||||
|
}
|
||||||
|
if !scrapeFailed {
|
||||||
|
t.Fatal("scrape lib2 should be failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCombineOrganizeItemsHandlesNoItems(t *testing.T) {
|
||||||
|
if got := combineOrganizeItems(nil); got != nil {
|
||||||
|
t.Fatalf("combineOrganizeItems(nil) = %#v, want nil", got)
|
||||||
|
}
|
||||||
|
if got := combineOrganizeItems(&OrganizeResult{}); got != nil {
|
||||||
|
t.Fatalf("combineOrganizeItems(empty) = %#v, want nil", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -66,13 +66,19 @@ func (t *TranscoderService) runFFmpeg(ctx context.Context, job *hlsJob, source s
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (t *TranscoderService) resolveFFmpegPath() (string, error) {
|
func (t *TranscoderService) resolveFFmpegPath() (string, error) {
|
||||||
|
t.mu.Lock()
|
||||||
|
configuredPath := strings.TrimSpace(t.cfg.App.FFmpegPath)
|
||||||
|
t.mu.Unlock()
|
||||||
|
|
||||||
var lastErr error
|
var lastErr error
|
||||||
for _, bin := range executableCandidates(strings.TrimSpace(t.cfg.App.FFmpegPath), "ffmpeg") {
|
for _, bin := range executableCandidates(configuredPath, "ffmpeg") {
|
||||||
if err := validateFFmpegForTranscode(context.Background(), bin, t.effectiveEncoder()); err != nil {
|
if err := validateFFmpegForTranscode(context.Background(), bin, t.effectiveEncoder()); err != nil {
|
||||||
lastErr = err
|
lastErr = err
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
t.mu.Lock()
|
||||||
t.cfg.App.FFmpegPath = bin
|
t.cfg.App.FFmpegPath = bin
|
||||||
|
t.mu.Unlock()
|
||||||
return bin, nil
|
return bin, nil
|
||||||
}
|
}
|
||||||
if lastErr != nil {
|
if lastErr != nil {
|
||||||
|
|||||||
@@ -109,6 +109,9 @@ func TestTransferFileSymlinkKeepsSource(t *testing.T) {
|
|||||||
src := writeTemp(t, dir, "src.mkv", "payload")
|
src := writeTemp(t, dir, "src.mkv", "payload")
|
||||||
dst := filepath.Join(dir, "dst.mkv")
|
dst := filepath.Join(dir, "dst.mkv")
|
||||||
if err := transferFile(src, dst, TransferSymlink); err != nil {
|
if err := transferFile(src, dst, TransferSymlink); err != nil {
|
||||||
|
if errors.Is(err, os.ErrPermission) || strings.Contains(strings.ToLower(err.Error()), "privilege") {
|
||||||
|
t.Skipf("skipping symlink test due to permission: %v", err)
|
||||||
|
}
|
||||||
t.Fatalf("symlink: %v", err)
|
t.Fatalf("symlink: %v", err)
|
||||||
}
|
}
|
||||||
fi, err := os.Lstat(dst)
|
fi, err := os.Lstat(dst)
|
||||||
|
|||||||
@@ -93,41 +93,49 @@ func (a *TransmissionAdapter) rpcLocked(ctx context.Context, method string, args
|
|||||||
}
|
}
|
||||||
|
|
||||||
for attempt := 0; attempt < 2; attempt++ {
|
for attempt := 0; attempt < 2; attempt++ {
|
||||||
req, err := newDownloadClientHTTPRequest(ctx, http.MethodPost, rpcURL, bytes.NewReader(body))
|
res, retry, err := func() (*transmissionRPCResponse, bool, error) {
|
||||||
|
req, err := newDownloadClientHTTPRequest(ctx, http.MethodPost, rpcURL, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if a.sessionID != "" {
|
||||||
|
req.Header.Set("X-Transmission-Session-Id", a.sessionID)
|
||||||
|
}
|
||||||
|
if a.cfg.Username != "" {
|
||||||
|
req.SetBasicAuth(a.cfg.Username, a.cfg.Password)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := a.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == 409 {
|
||||||
|
a.sessionID = resp.Header.Get("X-Transmission-Session-Id")
|
||||||
|
return nil, true, nil
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
raw, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, false, fmt.Errorf("transmission rpc error: %d: %s", resp.StatusCode, string(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
var result transmissionRPCResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
if result.Result != "success" {
|
||||||
|
return nil, false, fmt.Errorf("transmission rpc result: %s", result.Result)
|
||||||
|
}
|
||||||
|
return &result, false, nil
|
||||||
|
}()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
if !retry {
|
||||||
if a.sessionID != "" {
|
return res, nil
|
||||||
req.Header.Set("X-Transmission-Session-Id", a.sessionID)
|
|
||||||
}
|
}
|
||||||
if a.cfg.Username != "" {
|
|
||||||
req.SetBasicAuth(a.cfg.Username, a.cfg.Password)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := a.client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode == 409 {
|
|
||||||
a.sessionID = resp.Header.Get("X-Transmission-Session-Id")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if resp.StatusCode >= 400 {
|
|
||||||
raw, _ := io.ReadAll(resp.Body)
|
|
||||||
return nil, fmt.Errorf("transmission rpc error: %d: %s", resp.StatusCode, string(raw))
|
|
||||||
}
|
|
||||||
|
|
||||||
var result transmissionRPCResponse
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if result.Result != "success" {
|
|
||||||
return nil, fmt.Errorf("transmission rpc result: %s", result.Result)
|
|
||||||
}
|
|
||||||
return &result, nil
|
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("transmission: failed after CSRF retry")
|
return nil, fmt.Errorf("transmission: failed after CSRF retry")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,19 @@ export interface ActiveTranscode {
|
|||||||
playlist_ok: boolean
|
playlist_ok: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TaskItemStatus = 'pending' | 'running' | 'succeeded' | 'failed'
|
||||||
|
|
||||||
|
export interface TaskItem {
|
||||||
|
id: string
|
||||||
|
kind: string // organize | scan | scrape
|
||||||
|
status: TaskItemStatus
|
||||||
|
name: string
|
||||||
|
source?: string
|
||||||
|
dest_path?: string
|
||||||
|
library_id?: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface BackgroundTask {
|
export interface BackgroundTask {
|
||||||
id: string
|
id: string
|
||||||
kind: string
|
kind: string
|
||||||
@@ -20,6 +33,7 @@ export interface BackgroundTask {
|
|||||||
error?: string
|
error?: string
|
||||||
details?: string[]
|
details?: string[]
|
||||||
metrics?: Record<string, number>
|
metrics?: Record<string, number>
|
||||||
|
items?: TaskItem[]
|
||||||
started_at: string
|
started_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
finished_at?: string
|
finished_at?: string
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ export function useSSE(
|
|||||||
options: { autoConnect?: boolean } = {}
|
options: { autoConnect?: boolean } = {}
|
||||||
) {
|
) {
|
||||||
const { autoConnect = true } = options
|
const { autoConnect = true } = options
|
||||||
|
const onEventRef = useRef(onEvent)
|
||||||
|
onEventRef.current = onEvent
|
||||||
|
|
||||||
const eventSourceRef = useRef<EventSource | null>(null)
|
const eventSourceRef = useRef<EventSource | null>(null)
|
||||||
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const isConnectedRef = useRef(false)
|
const isConnectedRef = useRef(false)
|
||||||
@@ -64,7 +67,7 @@ export function useSSE(
|
|||||||
eventSource.onmessage = (event) => {
|
eventSource.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data) as SSEEvent
|
const data = JSON.parse(event.data) as SSEEvent
|
||||||
onEvent(data)
|
onEventRef.current(data)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to parse SSE event:', err)
|
console.error('Failed to parse SSE event:', err)
|
||||||
}
|
}
|
||||||
@@ -84,7 +87,7 @@ export function useSSE(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}, [onEvent])
|
}, [])
|
||||||
|
|
||||||
const disconnect = useCallback(() => {
|
const disconnect = useCallback(() => {
|
||||||
if (reconnectTimeoutRef.current) {
|
if (reconnectTimeoutRef.current) {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ const MAX_RECONNECT_ATTEMPTS = 5
|
|||||||
export function useWebSocket(onEvent: (topic: string, payload: unknown) => void) {
|
export function useWebSocket(onEvent: (topic: string, payload: unknown) => void) {
|
||||||
const ref = useRef<WebSocket | null>(null)
|
const ref = useRef<WebSocket | null>(null)
|
||||||
const token = useAuthStore((s) => s.token)
|
const token = useAuthStore((s) => s.token)
|
||||||
|
const onEventRef = useRef(onEvent)
|
||||||
|
onEventRef.current = onEvent
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) return
|
if (!token) return
|
||||||
@@ -32,7 +34,7 @@ export function useWebSocket(onEvent: (topic: string, payload: unknown) => void)
|
|||||||
try {
|
try {
|
||||||
const msg = JSON.parse(ev.data)
|
const msg = JSON.parse(ev.data)
|
||||||
if (msg && typeof msg.topic === 'string') {
|
if (msg && typeof msg.topic === 'string') {
|
||||||
onEvent(msg.topic, msg.payload)
|
onEventRef.current(msg.topic, msg.payload)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore malformed frames
|
// ignore malformed frames
|
||||||
@@ -52,5 +54,5 @@ export function useWebSocket(onEvent: (topic: string, payload: unknown) => void)
|
|||||||
if (timer) window.clearTimeout(timer)
|
if (timer) window.clearTimeout(timer)
|
||||||
ref.current?.close()
|
ref.current?.close()
|
||||||
}
|
}
|
||||||
}, [token, onEvent])
|
}, [token])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
|
||||||
|
import { toolsAPI } from '../api/tools'
|
||||||
|
import { libraryAPI } from '../api/library'
|
||||||
|
import type { BackgroundTask, TaskItem } from '../api/tasks'
|
||||||
|
|
||||||
|
const kindLabels: Record<string, string> = {
|
||||||
|
organize: '整理 / 重命名',
|
||||||
|
scan: '入库扫描',
|
||||||
|
scrape: '刮削',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ItemRetryDialog lets the operator manually re-handle a single failed item.
|
||||||
|
// It pre-fills the values captured from the failed task so retrying is
|
||||||
|
// reproducible, while still allowing overrides before re-running.
|
||||||
|
export function ItemRetryDialog({
|
||||||
|
task,
|
||||||
|
item,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
task: BackgroundTask
|
||||||
|
item: TaskItem
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const [sourcePath, setSourcePath] = useState(item.source || task.source_path || '')
|
||||||
|
const [destPath, setDestPath] = useState(item.dest_path || task.dest_path || '')
|
||||||
|
const [transferMode, setTransferMode] = useState('hardlink')
|
||||||
|
const [scanAfter, setScanAfter] = useState(true)
|
||||||
|
const [scrapeAfter, setScrapeAfter] = useState(true)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
if (item.kind === 'organize') {
|
||||||
|
if (!sourcePath.trim()) {
|
||||||
|
toast.error('整理需要来源路径')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await toolsAPI.organizeDirectory({
|
||||||
|
source_path: sourcePath.trim(),
|
||||||
|
dest_path: destPath.trim() || undefined,
|
||||||
|
transfer_mode: transferMode,
|
||||||
|
scan_after: scanAfter,
|
||||||
|
scrape_after: scanAfter && scrapeAfter,
|
||||||
|
})
|
||||||
|
toast.success(`已重新整理:${item.name}`)
|
||||||
|
} else if (item.kind === 'scan' && item.library_id) {
|
||||||
|
await libraryAPI.scan(item.library_id)
|
||||||
|
toast.success(`已重新入库:${item.name}`)
|
||||||
|
} else if (item.kind === 'scrape' && item.library_id) {
|
||||||
|
await libraryAPI.scrape(item.library_id, {
|
||||||
|
episode_artwork: true,
|
||||||
|
refresh_matched: true,
|
||||||
|
})
|
||||||
|
toast.success(`已重新刮削:${item.name}`)
|
||||||
|
} else {
|
||||||
|
toast.error(`无法处理该类型条目(${item.kind})`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onClose()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '处理失败')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className="glass-card-elevated w-full max-w-lg space-y-4 rounded-2xl bg-white p-5 shadow-xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-display text-lg font-semibold text-ink-600">手动处理失败条目</h3>
|
||||||
|
<p className="text-xs text-sand-500">
|
||||||
|
{item.name} · {kindLabels[item.kind] ?? item.kind}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-lg p-1 text-sand-500 hover:bg-gray-100"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="关闭"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{item.error && (
|
||||||
|
<div className="rounded-lg border border-red-200 bg-red-50 p-2 text-xs text-red-600">{item.error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{item.kind === 'organize' && (
|
||||||
|
<>
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="mb-1 block text-xs font-medium text-sand-500">来源路径</span>
|
||||||
|
<input
|
||||||
|
className="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-ink-600 outline-none focus:border-primary-400"
|
||||||
|
value={sourcePath}
|
||||||
|
onChange={(e) => setSourcePath(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="mb-1 block text-xs font-medium text-sand-500">目标路径(留空则用默认)</span>
|
||||||
|
<input
|
||||||
|
className="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-ink-600 outline-none focus:border-primary-400"
|
||||||
|
value={destPath}
|
||||||
|
onChange={(e) => setDestPath(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="mb-1 block text-xs font-medium text-sand-500">转移方式</span>
|
||||||
|
<select
|
||||||
|
className="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-ink-600 outline-none focus:border-primary-400"
|
||||||
|
value={transferMode}
|
||||||
|
onChange={(e) => setTransferMode(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="hardlink">硬链接</option>
|
||||||
|
<option value="move">移动</option>
|
||||||
|
<option value="copy">复制</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<label className="flex items-center gap-1.5 text-sm text-ink-600">
|
||||||
|
<input type="checkbox" checked={scanAfter} onChange={(e) => setScanAfter(e.target.checked)} />
|
||||||
|
入库
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-1.5 text-sm text-ink-600">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={scrapeAfter}
|
||||||
|
disabled={!scanAfter}
|
||||||
|
onChange={(e) => setScrapeAfter(e.target.checked)}
|
||||||
|
/>
|
||||||
|
刮削
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{item.kind === 'scan' && (
|
||||||
|
<p className="text-sm text-sand-600">
|
||||||
|
将重新扫描媒体库「{item.name}」以入库。完成后会自动触发刮削(如已启用)。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{item.kind === 'scrape' && (
|
||||||
|
<p className="text-sm text-sand-600">
|
||||||
|
将重新刮削媒体库「{item.name}」。会重试上次未匹配的条目并刷新元数据。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-1">
|
||||||
|
<button type="button" className="btn-ghost" onClick={onClose} disabled={busy}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn-primary" onClick={() => void run()} disabled={busy}>
|
||||||
|
{busy ? '处理中…' : '开始处理'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+277
-50
@@ -1,9 +1,14 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
import { Activity, Copy } from 'lucide-react'
|
import { Activity, Copy, RotateCw, SlidersHorizontal } from 'lucide-react'
|
||||||
|
|
||||||
import { tasksAPI, type BackgroundTask, type TasksSnapshot } from '../api/tasks'
|
import { toolsAPI } from '../api/tools'
|
||||||
|
import { libraryAPI } from '../api/library'
|
||||||
|
import { tasksAPI, type BackgroundTask, type TaskItem, type TasksSnapshot } from '../api/tasks'
|
||||||
|
import { useWebSocket } from '../hooks/useWebSocket'
|
||||||
|
import { confirmAction } from '../components/confirmAction'
|
||||||
import { TorrentTaskTable, TranscodeTaskTable } from './TaskRuntimeTables'
|
import { TorrentTaskTable, TranscodeTaskTable } from './TaskRuntimeTables'
|
||||||
|
import { ItemRetryDialog } from './TaskItemRetryDialog'
|
||||||
|
|
||||||
const metricLabels: Record<string, string> = {
|
const metricLabels: Record<string, string> = {
|
||||||
organized: '新增',
|
organized: '新增',
|
||||||
@@ -51,6 +56,19 @@ function hasTaskIssues(task: BackgroundTask): boolean {
|
|||||||
return Boolean(task.metrics?.errors || task.metrics?.scan_errors || task.metrics?.scrape_errors)
|
return Boolean(task.metrics?.errors || task.metrics?.scan_errors || task.metrics?.scrape_errors)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const itemKindLabels: Record<string, string> = {
|
||||||
|
organize: '整理/重命名',
|
||||||
|
scan: '入库',
|
||||||
|
scrape: '刮削',
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemStatusLabels: Record<string, string> = {
|
||||||
|
pending: '待进行',
|
||||||
|
running: '进行中',
|
||||||
|
succeeded: '成功',
|
||||||
|
failed: '失败',
|
||||||
|
}
|
||||||
|
|
||||||
function statusBadge(task: BackgroundTask) {
|
function statusBadge(task: BackgroundTask) {
|
||||||
if (task.status === 'failed') {
|
if (task.status === 'failed') {
|
||||||
return <span className="rounded-lg border border-red-400/40 px-1.5 py-0.5 text-xs text-red-500">failed</span>
|
return <span className="rounded-lg border border-red-400/40 px-1.5 py-0.5 text-xs text-red-500">failed</span>
|
||||||
@@ -64,6 +82,19 @@ function statusBadge(task: BackgroundTask) {
|
|||||||
return <span className="rounded-lg border border-yellow-400/40 px-1.5 py-0.5 text-xs text-yellow-500">running</span>
|
return <span className="rounded-lg border border-yellow-400/40 px-1.5 py-0.5 text-xs text-yellow-500">running</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function itemStatusBadge(item: TaskItem) {
|
||||||
|
switch (item.status) {
|
||||||
|
case 'failed':
|
||||||
|
return <span className="rounded-lg border border-red-400/40 px-1.5 py-0.5 text-xs text-red-500">失败</span>
|
||||||
|
case 'running':
|
||||||
|
return <span className="rounded-lg border border-yellow-400/40 px-1.5 py-0.5 text-xs text-yellow-500">进行中</span>
|
||||||
|
case 'succeeded':
|
||||||
|
return <span className="rounded-lg border border-emerald-400/40 px-1.5 py-0.5 text-xs text-emerald-500">成功</span>
|
||||||
|
default:
|
||||||
|
return <span className="rounded-lg border border-gray-300 px-1.5 py-0.5 text-xs text-sand-500">待进行</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function taskCopyText(task: BackgroundTask): string {
|
function taskCopyText(task: BackgroundTask): string {
|
||||||
const lines = [
|
const lines = [
|
||||||
`任务: ${task.name}`,
|
`任务: ${task.name}`,
|
||||||
@@ -79,6 +110,17 @@ function taskCopyText(task: BackgroundTask): string {
|
|||||||
lines.push('详情:')
|
lines.push('详情:')
|
||||||
lines.push(...task.details)
|
lines.push(...task.details)
|
||||||
}
|
}
|
||||||
|
if (task.items?.length) {
|
||||||
|
lines.push('条目:')
|
||||||
|
lines.push(
|
||||||
|
...task.items.map(
|
||||||
|
(item) =>
|
||||||
|
`[${itemKindLabels[item.kind] ?? item.kind}] ${item.name} → ${itemStatusLabels[item.status] ?? item.status}${
|
||||||
|
item.error ? ` (${item.error})` : ''
|
||||||
|
}`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
return lines.join('\n')
|
return lines.join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,69 +133,200 @@ async function copyTask(task: BackgroundTask) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function BackgroundTaskTable({ tasks, empty }: { tasks: BackgroundTask[]; empty: string }) {
|
// Renders one task's per-item rows, each with status + (for failed items)
|
||||||
|
// retry / manual-handle actions.
|
||||||
|
function TaskItemRows({
|
||||||
|
task,
|
||||||
|
onRetry,
|
||||||
|
onManual,
|
||||||
|
}: {
|
||||||
|
task: BackgroundTask
|
||||||
|
onRetry: (item: TaskItem) => void
|
||||||
|
onManual: (item: TaskItem) => void
|
||||||
|
}) {
|
||||||
|
const items = task.items ?? []
|
||||||
|
if (items.length === 0) {
|
||||||
|
// Fall back to the compact aggregate view for legacy tasks without items.
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-3 py-1">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate font-medium text-ink-600" title={task.source_path || task.dest_path}>
|
||||||
|
{task.name}
|
||||||
|
</div>
|
||||||
|
<div className="truncate font-mono text-xs text-sand-500">
|
||||||
|
{task.source_path || task.dest_path || task.message || '-'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{statusBadge(task)}
|
||||||
|
{hasTaskIssues(task) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-lg border border-gray-200 bg-white p-1 text-sand-500 hover:border-primary-400/40 hover:text-brand-500"
|
||||||
|
title="复制任务详情"
|
||||||
|
onClick={() => void copyTask(task)}
|
||||||
|
>
|
||||||
|
<Copy size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead className="text-xs uppercase tracking-wider text-sand-500">
|
||||||
|
<tr>
|
||||||
|
<th className="py-1">名称</th>
|
||||||
|
<th>类型</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>路径</th>
|
||||||
|
<th className="text-right">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((item) => (
|
||||||
|
<tr key={item.id} className="border-t border-gray-100 align-top">
|
||||||
|
<td className="max-w-xs py-1.5">
|
||||||
|
<div className="truncate font-medium text-ink-600" title={item.name}>
|
||||||
|
{item.name}
|
||||||
|
</div>
|
||||||
|
{item.error && (
|
||||||
|
<div className="truncate font-mono text-[11px] text-red-500" title={item.error}>
|
||||||
|
{item.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5">
|
||||||
|
<span className="rounded-md bg-gray-100 px-1.5 py-0.5 text-xs text-sand-600">
|
||||||
|
{itemKindLabels[item.kind] ?? item.kind}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5">{itemStatusBadge(item)}</td>
|
||||||
|
<td className="max-w-sm py-1.5">
|
||||||
|
<div className="truncate font-mono text-xs text-sand-500" title={item.source || item.dest_path}>
|
||||||
|
{item.source || item.dest_path || '-'}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 text-right whitespace-nowrap">
|
||||||
|
{item.status === 'failed' && (
|
||||||
|
<div className="inline-flex items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-1 rounded-lg border border-gray-200 bg-white px-2 py-1 text-xs text-ink-600 hover:border-primary-400/40 hover:text-brand-500"
|
||||||
|
title="一键重试该条目"
|
||||||
|
onClick={() => onRetry(item)}
|
||||||
|
>
|
||||||
|
<RotateCw size={12} />
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-1 rounded-lg border border-gray-200 bg-white px-2 py-1 text-xs text-ink-600 hover:border-primary-400/40 hover:text-brand-500"
|
||||||
|
title="打开手动处理表单"
|
||||||
|
onClick={() => onManual(item)}
|
||||||
|
>
|
||||||
|
<SlidersHorizontal size={12} />
|
||||||
|
手动处理
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskSection({
|
||||||
|
tasks,
|
||||||
|
empty,
|
||||||
|
onRetry,
|
||||||
|
onManual,
|
||||||
|
}: {
|
||||||
|
tasks: BackgroundTask[]
|
||||||
|
empty: string
|
||||||
|
onRetry: (task: BackgroundTask, item: TaskItem) => void
|
||||||
|
onManual: (task: BackgroundTask, item: TaskItem) => void
|
||||||
|
}) {
|
||||||
|
const withItems = tasks.filter((task) => (task.items ?? []).length > 0)
|
||||||
|
const withoutItems = tasks.filter((task) => (task.items ?? []).length === 0)
|
||||||
if (tasks.length === 0) return <p className="text-sand-500">{empty}</p>
|
if (tasks.length === 0) return <p className="text-sand-500">{empty}</p>
|
||||||
return (
|
return (
|
||||||
<table className="w-full text-left text-sm">
|
<div className="space-y-4">
|
||||||
<thead className="text-xs uppercase tracking-wider text-sand-500">
|
{withItems.length > 0 && (
|
||||||
<tr>
|
<div className="space-y-3">
|
||||||
<th className="py-2">任务</th>
|
{withItems.map((task) => (
|
||||||
<th>阶段</th>
|
<div key={task.id} className="rounded-xl border border-gray-200 bg-gray-50/60 p-3">
|
||||||
<th>状态</th>
|
<div className="mb-1 flex items-center justify-between gap-3">
|
||||||
<th>结果</th>
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<th>时间</th>
|
<span className="truncate font-medium text-ink-600">{task.name}</span>
|
||||||
</tr>
|
{statusBadge(task)}
|
||||||
</thead>
|
</div>
|
||||||
<tbody>
|
|
||||||
{tasks.map((task) => (
|
|
||||||
<tr key={task.id} className="border-t border-gray-200 align-top">
|
|
||||||
<td className="max-w-md py-2">
|
|
||||||
<div className="font-medium text-ink-600">{task.name}</div>
|
|
||||||
<div className="truncate font-mono text-xs text-sand-500" title={task.source_path || task.dest_path}>
|
|
||||||
{task.source_path || task.dest_path || task.message || '-'}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="text-ink-100">{task.stage || '-'}</td>
|
|
||||||
<td>{statusBadge(task)}</td>
|
|
||||||
<td className="max-w-md text-ink-100">
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<div className="min-w-0 flex-1 select-text break-words">{task.error || task.message || '-'}</div>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded-lg border border-gray-200 bg-white p-1 text-sand-500 hover:border-primary-400/40 hover:text-brand-500"
|
className="rounded-lg border border-gray-200 bg-white p-1 text-sand-500 hover:border-primary-400/40 hover:text-brand-500"
|
||||||
title="复制任务详情"
|
title="复制任务详情"
|
||||||
onClick={() => void copyTask(task)}
|
onClick={() => void copyTask(task)}
|
||||||
>
|
>
|
||||||
<Copy size={14} />
|
<Copy size={13} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<TaskItemRows task={task} onRetry={(item) => onRetry(task, item)} onManual={(item) => onManual(task, item)} />
|
||||||
{formatMetrics(task.metrics) && (
|
{formatMetrics(task.metrics) && (
|
||||||
<div className="mt-1 select-text text-xs text-sand-500">{formatMetrics(task.metrics)}</div>
|
<div className="mt-2 select-text text-xs text-sand-500">{formatMetrics(task.metrics)}</div>
|
||||||
)}
|
)}
|
||||||
{task.details && task.details.length > 0 && (
|
</div>
|
||||||
<div className="mt-2 max-h-64 select-text overflow-auto rounded-lg border border-gray-200 bg-gray-50 p-2 font-mono text-[11px] leading-relaxed text-sand-600">
|
))}
|
||||||
{task.details.map((line, index) => (
|
</div>
|
||||||
<div key={`${task.id}-detail-${index}`} className="whitespace-pre-wrap break-words">
|
)}
|
||||||
{line}
|
{withoutItems.length > 0 && (
|
||||||
</div>
|
<div className="space-y-2">
|
||||||
))}
|
{withoutItems.map((task) => (
|
||||||
</div>
|
<div key={task.id} className="rounded-xl border border-gray-200 bg-gray-50/60 p-3">
|
||||||
)}
|
<TaskItemRows
|
||||||
</td>
|
task={task}
|
||||||
<td className="text-ink-100">
|
onRetry={(item) => onRetry(task, item)}
|
||||||
{new Date(task.finished_at || task.updated_at || task.started_at).toLocaleTimeString()}
|
onManual={(item) => onManual(task, item)}
|
||||||
</td>
|
/>
|
||||||
</tr>
|
</div>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TasksPage shows everything the backend is doing right now: ffmpeg
|
// TasksPage shows everything the backend is doing right now: ffmpeg
|
||||||
// transcodes + qBittorrent downloads. Refreshes every 3 s.
|
// transcodes + qBittorrent downloads + item-level organize/ingest/scrape
|
||||||
|
// progress. Refreshes every 3 s and reconciles against live WS "task" events.
|
||||||
export function TasksPage() {
|
export function TasksPage() {
|
||||||
const [snap, setSnap] = useState<TasksSnapshot | null>(null)
|
const [snap, setSnap] = useState<TasksSnapshot | null>(null)
|
||||||
|
const [manualTarget, setManualTarget] = useState<{ task: BackgroundTask; item: TaskItem } | null>(null)
|
||||||
|
|
||||||
|
const mergeEvent = useCallback((payload: unknown) => {
|
||||||
|
const eventTask = payload as BackgroundTask
|
||||||
|
if (!eventTask || typeof eventTask.id !== 'string') return
|
||||||
|
setSnap((prev) => {
|
||||||
|
if (!prev) return prev
|
||||||
|
const merge = (list: BackgroundTask[]) =>
|
||||||
|
list.map((t) => (t.id === eventTask.id ? { ...eventTask } : t))
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
background_tasks: {
|
||||||
|
active: merge(prev.background_tasks?.active ?? []),
|
||||||
|
recent: merge(prev.background_tasks?.recent ?? []),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useWebSocket((topic, payload) => {
|
||||||
|
if (topic === 'task') mergeEvent(payload)
|
||||||
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
@@ -174,6 +347,42 @@ export function TasksPage() {
|
|||||||
const torrents = snap.torrents ?? []
|
const torrents = snap.torrents ?? []
|
||||||
const background = snap.background_tasks ?? { active: [], recent: [] }
|
const background = snap.background_tasks ?? { active: [], recent: [] }
|
||||||
|
|
||||||
|
const handleRetry = async (task: BackgroundTask, item: TaskItem) => {
|
||||||
|
const ok = await confirmAction({
|
||||||
|
title: '重试失败条目',
|
||||||
|
message: `确定要重新处理「${item.name}」吗?\n类型:${itemKindLabels[item.kind] ?? item.kind}`,
|
||||||
|
confirmText: '重试',
|
||||||
|
})
|
||||||
|
if (!ok) return
|
||||||
|
try {
|
||||||
|
if (item.kind === 'scan' && item.library_id) {
|
||||||
|
await libraryAPI.scan(item.library_id)
|
||||||
|
toast.success(`已重新入库:${item.name}`)
|
||||||
|
} else if (item.kind === 'scrape' && item.library_id) {
|
||||||
|
await libraryAPI.scrape(item.library_id)
|
||||||
|
toast.success(`已重新刮削:${item.name}`)
|
||||||
|
} else if (item.kind === 'organize' && item.source) {
|
||||||
|
const dest = task.dest_path || item.dest_path || undefined
|
||||||
|
await toolsAPI.organizeDirectory({
|
||||||
|
source_path: item.source,
|
||||||
|
dest_path: dest,
|
||||||
|
scan_after: true,
|
||||||
|
scrape_after: true,
|
||||||
|
})
|
||||||
|
toast.success(`已重新整理:${item.name}`)
|
||||||
|
} else {
|
||||||
|
toast.error(`无法重试:缺少必要信息(${item.kind})`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '重试失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleManual = (task: BackgroundTask, item: TaskItem) => {
|
||||||
|
setManualTarget({ task, item })
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<header className="flex items-center gap-3">
|
<header className="flex items-center gap-3">
|
||||||
@@ -186,11 +395,21 @@ export function TasksPage() {
|
|||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-2 text-sm font-semibold text-ink-500">运行中</h3>
|
<h3 className="mb-2 text-sm font-semibold text-ink-500">运行中</h3>
|
||||||
<BackgroundTaskTable tasks={background.active} empty="暂无运行中的整理、重命名、入库或刮削任务。" />
|
<TaskSection
|
||||||
|
tasks={background.active}
|
||||||
|
empty="暂无运行中的整理、重命名、入库或刮削任务。"
|
||||||
|
onRetry={(task, item) => void handleRetry(task, item)}
|
||||||
|
onManual={handleManual}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-2 text-sm font-semibold text-ink-500">最近完成</h3>
|
<h3 className="mb-2 text-sm font-semibold text-ink-500">最近完成</h3>
|
||||||
<BackgroundTaskTable tasks={background.recent.slice(0, 10)} empty="暂无最近完成的后台任务。" />
|
<TaskSection
|
||||||
|
tasks={background.recent.slice(0, 10)}
|
||||||
|
empty="暂无最近完成的后台任务。"
|
||||||
|
onRetry={(task, item) => void handleRetry(task, item)}
|
||||||
|
onManual={handleManual}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -204,6 +423,14 @@ export function TasksPage() {
|
|||||||
<h2 className="mb-3 font-display text-lg font-semibold text-ink-600">下载任务</h2>
|
<h2 className="mb-3 font-display text-lg font-semibold text-ink-600">下载任务</h2>
|
||||||
<TorrentTaskTable torrents={torrents} />
|
<TorrentTaskTable torrents={torrents} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{manualTarget && (
|
||||||
|
<ItemRetryDialog
|
||||||
|
task={manualTarget.task}
|
||||||
|
item={manualTarget.item}
|
||||||
|
onClose={() => setManualTarget(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user