mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-04 20:46:37 +08:00
bug处理优化
bug处理优化
This commit is contained in:
@@ -20,8 +20,12 @@ func updateProfileHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
uid, _ := c.Get(middleware.CtxUserID)
|
||||
userID := uid.(string)
|
||||
uid, exists := c.Get(middleware.CtxUserID)
|
||||
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)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
|
||||
@@ -118,6 +118,7 @@ func (p *OrganizePipelineService) Run(ctx context.Context, req OrganizePipelineR
|
||||
Message: "整理/重命名完成,准备扫描入库",
|
||||
Metrics: OrganizeTaskMetrics(res),
|
||||
Details: OrganizeTaskDetails(res, 8),
|
||||
Items: organizeItemsFromResult(res),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -128,6 +129,7 @@ func (p *OrganizePipelineService) Run(ctx context.Context, req OrganizePipelineR
|
||||
Message: "正在扫描入库并按设置刮削",
|
||||
Metrics: OrganizeTaskMetrics(res),
|
||||
Details: OrganizeTaskDetails(res, 8),
|
||||
Items: combineOrganizeItems(res),
|
||||
})
|
||||
}
|
||||
scanRoot := organizeScanRoot(res, path)
|
||||
|
||||
@@ -34,6 +34,7 @@ func (p *OrganizePipelineService) finishTask(task *TaskHandle, err error, stage,
|
||||
Message: message,
|
||||
Metrics: OrganizeTaskMetrics(res),
|
||||
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",
|
||||
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)
|
||||
req.Result.Organized++
|
||||
return nil
|
||||
|
||||
@@ -2,10 +2,11 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
@@ -185,33 +186,109 @@ func (o *OrganizerService) existingByFolder(destDir, episodeTag string) []string
|
||||
return out
|
||||
}
|
||||
|
||||
// replaceVersions removes the existing lower-resolution files (and their NFO
|
||||
// sidecars + DB rows) and transfers src into dst.
|
||||
// replaceVersions promotes a higher-resolution src into dst, then removes the
|
||||
// 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 {
|
||||
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 {
|
||||
if err := os.Remove(e); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("remove existing %s: %w", e, err)
|
||||
}
|
||||
if nfo := nfoPath(e); 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 {
|
||||
_ = 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
|
||||
}
|
||||
if err := transferFile(src, dst, mode); err != nil {
|
||||
return err
|
||||
}
|
||||
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))
|
||||
}
|
||||
moveSidecarRename(nfoPath(stage), nfoPath(dst))
|
||||
moveStagedArtwork(stage, dst)
|
||||
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 洗版
|
||||
// comparison. It prefers ffprobe; when unavailable it falls back to a
|
||||
// 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.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",
|
||||
zap.String("media", m.ID),
|
||||
zap.String("from", m.Path),
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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 != "" {
|
||||
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
|
||||
}
|
||||
if local.BackdropURL != "" {
|
||||
if local.BackdropURL != "" && !isHTTPish(match.BackdropURL) {
|
||||
match.BackdropURL = local.BackdropURL
|
||||
}
|
||||
if local.Rating > 0 {
|
||||
|
||||
@@ -34,6 +34,7 @@ type BackgroundTask struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
Details []string `json:"details,omitempty"`
|
||||
Metrics map[string]int64 `json:"metrics,omitempty"`
|
||||
Items []TaskItemRecord `json:"items,omitempty"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
FinishedAt *time.Time `json:"finished_at,omitempty"`
|
||||
@@ -46,6 +47,7 @@ type TaskUpdate struct {
|
||||
Message string
|
||||
Details []string
|
||||
Metrics map[string]int64
|
||||
Items []TaskItemRecord
|
||||
}
|
||||
|
||||
type TaskSnapshot struct {
|
||||
@@ -214,6 +216,9 @@ func applyTaskUpdate(task *BackgroundTask, update TaskUpdate) {
|
||||
if update.Metrics != nil {
|
||||
task.Metrics = cloneTaskMetrics(update.Metrics)
|
||||
}
|
||||
if update.Items != nil {
|
||||
task.Items = append([]TaskItemRecord(nil), update.Items...)
|
||||
}
|
||||
}
|
||||
|
||||
func cloneBackgroundTask(task BackgroundTask) BackgroundTask {
|
||||
@@ -221,6 +226,9 @@ func cloneBackgroundTask(task BackgroundTask) BackgroundTask {
|
||||
if task.Details != nil {
|
||||
task.Details = append([]string(nil), task.Details...)
|
||||
}
|
||||
if task.Items != nil {
|
||||
task.Items = append([]TaskItemRecord(nil), task.Items...)
|
||||
}
|
||||
if task.FinishedAt != nil {
|
||||
finishedAt := *task.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"])
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
t.mu.Lock()
|
||||
configuredPath := strings.TrimSpace(t.cfg.App.FFmpegPath)
|
||||
t.mu.Unlock()
|
||||
|
||||
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 {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
t.mu.Lock()
|
||||
t.cfg.App.FFmpegPath = bin
|
||||
t.mu.Unlock()
|
||||
return bin, nil
|
||||
}
|
||||
if lastErr != nil {
|
||||
|
||||
@@ -109,6 +109,9 @@ func TestTransferFileSymlinkKeepsSource(t *testing.T) {
|
||||
src := writeTemp(t, dir, "src.mkv", "payload")
|
||||
dst := filepath.Join(dir, "dst.mkv")
|
||||
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)
|
||||
}
|
||||
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++ {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if a.sessionID != "" {
|
||||
req.Header.Set("X-Transmission-Session-Id", a.sessionID)
|
||||
if !retry {
|
||||
return res, nil
|
||||
}
|
||||
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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user