Files
jukebox_maker/internal/transcoder/transcoder.go
Michael Chus 9fd02fb5bf Add per-disk profiles with video transcoding support
Each disk stores .jukebox/profile.json with copy parameters (dest
folder, overwrite mode, file select, reserve space, auto-copy) and
optional transcoding limits for the target player (codec, resolution,
bitrate, FPS, audio channels, output format).

On copy, video files are probed with ffprobe; if they exceed the
profile limits they are transcoded via ffmpeg (-threads 0 for full
CPU usage), otherwise copied as-is. Scale filter never upscales.

New: internal/disk/profile.go, internal/transcoder/{detect,transcoder}.go
API: GET/PUT /api/disks/profile?mount_path=
UI: disk profile panel in dashboard for known disks
Dockerfile: adds ffmpeg to the runtime image

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 20:52:46 +03:00

168 lines
3.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package transcoder
import (
"bufio"
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"jukebox_maker/internal/disk"
)
type Options struct {
Input string
Output string
Profile *disk.TranscodeProfile
SourceInfo VideoInfo
}
// Transcode запускает ffmpeg. progress вызывается с 0..1 по мере работы.
func Transcode(ctx context.Context, opts Options, progress func(float64)) error {
if err := os.MkdirAll(filepath.Dir(opts.Output), 0o755); err != nil {
return err
}
args := buildArgs(opts)
cmd := exec.CommandContext(ctx, "ffmpeg", args...)
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Start(); err != nil {
return fmt.Errorf("ffmpeg start: %w", err)
}
// Парсим прогресс из stdout (-progress pipe:1)
if opts.SourceInfo.DurationSec > 0 && progress != nil {
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
line := scanner.Text()
if 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 {
sec := float64(us) / 1e6
pct := sec / opts.SourceInfo.DurationSec
if pct > 1 {
pct = 1
}
progress(pct)
}
}
}
}
if err := cmd.Wait(); err != nil {
msg := strings.TrimSpace(stderr.String())
if msg != "" {
return fmt.Errorf("ffmpeg: %w: %s", err, msg)
}
return fmt.Errorf("ffmpeg: %w", err)
}
return nil
}
func buildArgs(opts Options) []string {
p := opts.Profile
src := opts.SourceInfo
args := []string{
"-i", opts.Input,
"-threads", "0",
"-progress", "pipe:1",
"-v", "error",
}
// Видеокодек
args = append(args, "-c:v", ffmpegVideoCodec(p.VideoCodec))
// Битрейт — только если источник выше лимита
if p.MaxVideoBitrate != "" {
if targetBR := parseBitrate(p.MaxVideoBitrate); targetBR > 0 &&
(src.VideoBitrate == 0 || src.VideoBitrate > targetBR) {
args = append(args, "-b:v", p.MaxVideoBitrate)
}
}
// Масштаб + FPS — строим vf
var filters []string
if maxH := maxHeight(p.MaxResolution); maxH > 0 {
filters = append(filters, fmt.Sprintf("scale=-2:min(%d\\,ih)", maxH))
}
if p.MaxFPS > 0 && src.FPS > float64(p.MaxFPS)+0.01 {
filters = append(filters, fmt.Sprintf("fps=%d", p.MaxFPS))
}
if len(filters) > 0 {
args = append(args, "-vf", strings.Join(filters, ","))
}
// Аудиокодек
if p.AudioCodec != "" {
args = append(args, "-c:a", ffmpegAudioCodec(p.AudioCodec))
} else {
args = append(args, "-c:a", "copy")
}
// Аудио-битрейт
if p.MaxAudioBitrate != "" {
args = append(args, "-b:a", p.MaxAudioBitrate)
}
// Каналы — только даунмикс
if p.MaxAudioChannels > 0 && src.AudioChannels > p.MaxAudioChannels {
args = append(args, "-ac", strconv.Itoa(p.MaxAudioChannels))
}
// Для mp4 — оптимизация для стриминга
if p.OutputFormat == "mp4" {
args = append(args, "-movflags", "+faststart")
}
args = append(args, "-y", opts.Output)
return args
}
func ffmpegVideoCodec(codec string) string {
switch codec {
case "h264":
return "libx264"
case "h265":
return "libx265"
case "mpeg4":
return "mpeg4"
default:
return "libx264"
}
}
func ffmpegAudioCodec(codec string) string {
switch codec {
case "aac":
return "aac"
case "mp3":
return "libmp3lame"
default:
return "aac"
}
}
// OutputExt возвращает расширение файла для заданного формата контейнера.
func OutputExt(format string) string {
switch format {
case "mkv":
return ".mkv"
case "avi":
return ".avi"
default:
return ".mp4"
}
}