From 839ff494a4102e1cc7a00e9a42599171abae7c96 Mon Sep 17 00:00:00 2001 From: Michael Chus Date: Thu, 21 May 2026 21:51:39 +0300 Subject: [PATCH] Switch dashboard to watcher-based multi-disk view, fix transcoding FPS display - dashboard.html: remove standalone "Mounted Disk" input panel; show all disks from GET /api/disks (watcher), auto-refresh every 5s - detect.go: use avg_frame_rate when r_frame_rate is unrealistic (>120 fps or 0), fixes MJPEG/mjpeg showing 90000fps - transcoder.go: parse fps= from ffmpeg progress output and expose in Progress struct - copier.go: update task message with real-time encoding fps (@ 45.3 fps), clear speed_bps/eta during transcoding to avoid showing stale copy speed Co-Authored-By: Claude Sonnet 4.6 --- internal/copier/copier.go | 21 +- internal/transcoder/detect.go | 22 ++- internal/transcoder/transcoder.go | 26 ++- web/templates/dashboard.html | 314 +++++++++++++++--------------- 4 files changed, 198 insertions(+), 185 deletions(-) diff --git a/internal/copier/copier.go b/internal/copier/copier.go index d235d11..675cbe6 100644 --- a/internal/copier/copier.go +++ b/internal/copier/copier.go @@ -393,23 +393,26 @@ func (c *Copier) processVideo(ctx context.Context, taskID string, database *db.D ext := transcoder.OutputExt(profile.OutputFormat) dstTranscoded := strings.TrimSuffix(dst, filepath.Ext(dst)) + ext - srcFPS := fmt.Sprintf("%.2f", info.FPS) - msg := fmt.Sprintf("Transcoding %s (%s/%dch/%sfps → %s/%s/%dfps %s)", - filepath.Base(src), - info.Codec, info.AudioChannels, srcFPS, - profile.VideoCodec, profile.AudioCodec, profile.MaxFPS, profile.OutputFormat, - ) + srcInfo := fmt.Sprintf("%s/%dch/%.0ffps", info.Codec, info.AudioChannels, info.FPS) + dstInfo := fmt.Sprintf("%s/%s/%dfps %s", profile.VideoCodec, profile.AudioCodec, profile.MaxFPS, profile.OutputFormat) + baseMsg := fmt.Sprintf("Transcoding %s (%s → %s)", filepath.Base(src), srcInfo, dstInfo) + c.tasks.Update(taskID, func(t *task.Task) { t.Phase = task.PhaseTranscoding - t.Message = msg + t.Message = baseMsg }) if t, ok := c.tasks.Get(taskID); ok { _ = database.UpdateTask(*t) } - progressFn := func(pct float64) { + progressFn := func(p transcoder.Progress) { c.tasks.Update(taskID, func(t *task.Task) { - t.Progress = int(pct * 100) + t.Progress = int(p.Pct * 100) + t.SpeedBPS = 0 + t.ETASec = 0 + if p.EncodeFPS > 0 { + t.Message = fmt.Sprintf("%s @ %.1f fps", baseMsg, p.EncodeFPS) + } }) } diff --git a/internal/transcoder/detect.go b/internal/transcoder/detect.go index 4264534..f9a7b8e 100644 --- a/internal/transcoder/detect.go +++ b/internal/transcoder/detect.go @@ -34,13 +34,14 @@ func ProbeVideo(path string) (VideoInfo, error) { var raw struct { Streams []struct { - CodecType string `json:"codec_type"` - CodecName string `json:"codec_name"` - Width int `json:"width"` - Height int `json:"height"` - RFrameRate string `json:"r_frame_rate"` - BitRate string `json:"bit_rate"` - Channels int `json:"channels"` + CodecType string `json:"codec_type"` + CodecName string `json:"codec_name"` + Width int `json:"width"` + Height int `json:"height"` + RFrameRate string `json:"r_frame_rate"` + AvgFrameRate string `json:"avg_frame_rate"` + BitRate string `json:"bit_rate"` + Channels int `json:"channels"` } `json:"streams"` Format struct { Duration string `json:"duration"` @@ -58,7 +59,12 @@ func ProbeVideo(path string) (VideoInfo, error) { info.Codec = s.CodecName info.Width = s.Width info.Height = s.Height - info.FPS = parseFraction(s.RFrameRate) + // avg_frame_rate надёжнее для MJPEG и кодеков с нестандартным таймбейсом + fps := parseFraction(s.RFrameRate) + if avg := parseFraction(s.AvgFrameRate); avg > 0 && (fps <= 0 || fps > 120) { + fps = avg + } + info.FPS = fps if br, err := strconv.ParseInt(s.BitRate, 10, 64); err == nil { info.VideoBitrate = br } diff --git a/internal/transcoder/transcoder.go b/internal/transcoder/transcoder.go index 5ea48f4..bab83ef 100644 --- a/internal/transcoder/transcoder.go +++ b/internal/transcoder/transcoder.go @@ -21,8 +21,13 @@ type Options struct { SourceInfo VideoInfo } -// Transcode запускает ffmpeg. progress вызывается с 0..1 по мере работы. -func Transcode(ctx context.Context, opts Options, progress func(float64)) error { +type Progress struct { + Pct float64 // 0..1 + EncodeFPS float64 // текущая скорость кодирования, кадр/с +} + +// Transcode запускает ffmpeg. progress вызывается при каждом обновлении прогресса. +func Transcode(ctx context.Context, opts Options, progress func(Progress)) error { if err := os.MkdirAll(filepath.Dir(opts.Output), 0o755); err != nil { return err } @@ -42,20 +47,29 @@ func Transcode(ctx context.Context, opts Options, progress func(float64)) error } // Парсим прогресс из stdout (-progress pipe:1) - if opts.SourceInfo.DurationSec > 0 && progress != nil { + if progress != nil { + var cur Progress scanner := bufio.NewScanner(stdout) for scanner.Scan() { line := scanner.Text() - if strings.HasPrefix(line, "out_time_us=") { + switch { + case strings.HasPrefix(line, "out_time_us="): val := strings.TrimPrefix(line, "out_time_us=") - if us, err := strconv.ParseInt(val, 10, 64); err == nil && us > 0 { + if us, err := strconv.ParseInt(val, 10, 64); err == nil && us > 0 && opts.SourceInfo.DurationSec > 0 { sec := float64(us) / 1e6 pct := sec / opts.SourceInfo.DurationSec if pct > 1 { pct = 1 } - progress(pct) + cur.Pct = pct } + case strings.HasPrefix(line, "fps="): + val := strings.TrimPrefix(line, "fps=") + if fps, err := strconv.ParseFloat(strings.TrimSpace(val), 64); err == nil && fps > 0 { + cur.EncodeFPS = fps + } + case line == "progress=continue" || line == "progress=end": + progress(cur) } } } diff --git a/web/templates/dashboard.html b/web/templates/dashboard.html index 6b6856c..87bc00a 100644 --- a/web/templates/dashboard.html +++ b/web/templates/dashboard.html @@ -1,20 +1,16 @@ {{define "content"}}
-

Mounted Disk

+

Disks

-
- - -
-
Choose the directory where the removable disk is mounted. The app works with one selected disk at a time in standalone mode.
+
Loading disks...
-
+
{{end}}