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>
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
package transcoder
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"jukebox_maker/internal/disk"
|
||||
)
|
||||
|
||||
type VideoInfo struct {
|
||||
Codec string
|
||||
Width int
|
||||
Height int
|
||||
FPS float64
|
||||
DurationSec float64
|
||||
VideoBitrate int64
|
||||
AudioCodec string
|
||||
AudioChannels int
|
||||
}
|
||||
|
||||
func ProbeVideo(path string) (VideoInfo, error) {
|
||||
out, err := exec.Command("ffprobe",
|
||||
"-v", "quiet",
|
||||
"-print_format", "json",
|
||||
"-show_streams",
|
||||
"-show_format",
|
||||
path,
|
||||
).Output()
|
||||
if err != nil {
|
||||
return VideoInfo{}, err
|
||||
}
|
||||
|
||||
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"`
|
||||
} `json:"streams"`
|
||||
Format struct {
|
||||
Duration string `json:"duration"`
|
||||
BitRate string `json:"bit_rate"`
|
||||
} `json:"format"`
|
||||
}
|
||||
if err := json.Unmarshal(out, &raw); err != nil {
|
||||
return VideoInfo{}, err
|
||||
}
|
||||
|
||||
var info VideoInfo
|
||||
for _, s := range raw.Streams {
|
||||
switch s.CodecType {
|
||||
case "video":
|
||||
info.Codec = s.CodecName
|
||||
info.Width = s.Width
|
||||
info.Height = s.Height
|
||||
info.FPS = parseFraction(s.RFrameRate)
|
||||
if br, err := strconv.ParseInt(s.BitRate, 10, 64); err == nil {
|
||||
info.VideoBitrate = br
|
||||
}
|
||||
case "audio":
|
||||
if info.AudioCodec == "" {
|
||||
info.AudioCodec = s.CodecName
|
||||
info.AudioChannels = s.Channels
|
||||
}
|
||||
}
|
||||
}
|
||||
if d, err := strconv.ParseFloat(raw.Format.Duration, 64); err == nil {
|
||||
info.DurationSec = d
|
||||
}
|
||||
// Если стрим-битрейт не известен — используем битрейт контейнера
|
||||
if info.VideoBitrate == 0 {
|
||||
if br, err := strconv.ParseInt(raw.Format.BitRate, 10, 64); err == nil {
|
||||
info.VideoBitrate = br
|
||||
}
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func NeedsTranscode(info VideoInfo, p *disk.TranscodeProfile) bool {
|
||||
// Несовместимый видеокодек
|
||||
if normalizeCodec(info.Codec) != p.VideoCodec {
|
||||
return true
|
||||
}
|
||||
|
||||
// Разрешение превышает лимит
|
||||
if maxH := maxHeight(p.MaxResolution); maxH > 0 && info.Height > maxH {
|
||||
return true
|
||||
}
|
||||
|
||||
// Битрейт видео превышает лимит
|
||||
if p.MaxVideoBitrate != "" {
|
||||
if maxBR := parseBitrate(p.MaxVideoBitrate); maxBR > 0 && info.VideoBitrate > maxBR {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// FPS превышает лимит
|
||||
if p.MaxFPS > 0 && info.FPS > float64(p.MaxFPS)+0.01 {
|
||||
return true
|
||||
}
|
||||
|
||||
// Несовместимый аудиокодек
|
||||
if p.AudioCodec != "" && normalizeCodec(info.AudioCodec) != p.AudioCodec {
|
||||
return true
|
||||
}
|
||||
|
||||
// Каналов больше лимита
|
||||
if p.MaxAudioChannels > 0 && info.AudioChannels > p.MaxAudioChannels {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizeCodec(codec string) string {
|
||||
switch strings.ToLower(codec) {
|
||||
case "hevc", "h265":
|
||||
return "h265"
|
||||
case "h264", "avc", "avc1":
|
||||
return "h264"
|
||||
case "mpeg4", "mp4v":
|
||||
return "mpeg4"
|
||||
case "aac":
|
||||
return "aac"
|
||||
case "mp3":
|
||||
return "mp3"
|
||||
default:
|
||||
return strings.ToLower(codec)
|
||||
}
|
||||
}
|
||||
|
||||
func maxHeight(res string) int {
|
||||
switch res {
|
||||
case "480p":
|
||||
return 480
|
||||
case "720p":
|
||||
return 720
|
||||
case "1080p":
|
||||
return 1080
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func parseBitrate(s string) int64 {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
if strings.HasSuffix(s, "k") {
|
||||
v, _ := strconv.ParseInt(s[:len(s)-1], 10, 64)
|
||||
return v * 1000
|
||||
}
|
||||
v, _ := strconv.ParseInt(s, 10, 64)
|
||||
return v
|
||||
}
|
||||
|
||||
func parseFraction(s string) float64 {
|
||||
parts := strings.SplitN(s, "/", 2)
|
||||
if len(parts) != 2 {
|
||||
v, _ := strconv.ParseFloat(s, 64)
|
||||
return v
|
||||
}
|
||||
num, _ := strconv.ParseFloat(parts[0], 64)
|
||||
den, _ := strconv.ParseFloat(parts[1], 64)
|
||||
if den == 0 {
|
||||
return 0
|
||||
}
|
||||
return num / den
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user