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:
+1
-1
@@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
|
||||
FROM alpine:3.19
|
||||
|
||||
RUN apk add --no-cache tzdata ca-certificates rsync
|
||||
RUN apk add --no-cache tzdata ca-certificates rsync ffmpeg
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /out/jukebox .
|
||||
|
||||
+29
-7
@@ -126,7 +126,7 @@ func main() {
|
||||
switch ev.Info.State {
|
||||
case disk.DiskKnown:
|
||||
openDiskDB(ev.Info)
|
||||
if watcherReady && ev.Prev.State != disk.DiskKnown && cfg.AutoCopy {
|
||||
if watcherReady && ev.Prev.State != disk.DiskKnown {
|
||||
triggerAutoCopy(cp, cfg, ev.Info)
|
||||
}
|
||||
case disk.DiskForeign:
|
||||
@@ -197,6 +197,15 @@ func main() {
|
||||
}
|
||||
|
||||
func triggerAutoCopy(cp *copier.Copier, cfg *config.Config, info disk.DiskInfo) {
|
||||
// Используем AutoCopy из профиля диска, если он есть; иначе — из глобального config
|
||||
autoCopy := cfg.AutoCopy
|
||||
if info.Profile != nil {
|
||||
autoCopy = info.Profile.AutoCopy
|
||||
}
|
||||
if !autoCopy {
|
||||
return
|
||||
}
|
||||
|
||||
hasEnabledSources := false
|
||||
for _, s := range cfg.Sources {
|
||||
if s.Enabled {
|
||||
@@ -207,18 +216,31 @@ func triggerAutoCopy(cp *copier.Copier, cfg *config.Config, info disk.DiskInfo)
|
||||
if !hasEnabledSources {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
_, err := cp.Start(context.Background(), copier.Options{
|
||||
|
||||
opts := copier.Options{
|
||||
DiskID: info.DiskID,
|
||||
MountPath: info.MountPath,
|
||||
MediaPath: cfg.MediaPath,
|
||||
DestFolder: cfg.DestFolder,
|
||||
SourceRules: cfg.Sources,
|
||||
AllowedExtensions: cfg.EffectiveAllowedExtensions(),
|
||||
ReserveFreeGB: cfg.ReserveFreeGB,
|
||||
OverwriteMode: cfg.OverwriteMode,
|
||||
FileSelectMode: cfg.FileSelectMode,
|
||||
})
|
||||
}
|
||||
if p := info.Profile; p != nil {
|
||||
opts.DestFolder = p.DestFolder
|
||||
opts.ReserveFreeGB = p.ReserveFreeGB
|
||||
opts.FileSelectMode = config.FileSelectMode(p.FileSelectMode)
|
||||
opts.Transcode = p.Transcode
|
||||
if p.OverwriteMode != "" {
|
||||
opts.OverwriteMode = config.OverwriteMode(p.OverwriteMode)
|
||||
}
|
||||
} else {
|
||||
opts.DestFolder = cfg.DestFolder
|
||||
opts.ReserveFreeGB = cfg.ReserveFreeGB
|
||||
opts.FileSelectMode = cfg.FileSelectMode
|
||||
}
|
||||
|
||||
go func() {
|
||||
_, err := cp.Start(context.Background(), opts)
|
||||
if err != nil {
|
||||
log.Printf("auto-copy: %v", err)
|
||||
}
|
||||
|
||||
@@ -13,17 +13,25 @@ import (
|
||||
)
|
||||
|
||||
func (s *Server) copyOptions(cfg *config.Config, diskInfo disk.DiskInfo, overwriteMode config.OverwriteMode) copier.Options {
|
||||
return copier.Options{
|
||||
opts := copier.Options{
|
||||
DiskID: diskInfo.DiskID,
|
||||
MountPath: diskInfo.MountPath,
|
||||
MediaPath: cfg.MediaPath,
|
||||
DestFolder: cfg.DestFolder,
|
||||
SourceRules: cfg.Sources,
|
||||
AllowedExtensions: cfg.EffectiveAllowedExtensions(),
|
||||
ReserveFreeGB: cfg.ReserveFreeGB,
|
||||
OverwriteMode: overwriteMode,
|
||||
FileSelectMode: cfg.FileSelectMode,
|
||||
}
|
||||
if p := diskInfo.Profile; p != nil {
|
||||
opts.DestFolder = p.DestFolder
|
||||
opts.ReserveFreeGB = p.ReserveFreeGB
|
||||
opts.FileSelectMode = config.FileSelectMode(p.FileSelectMode)
|
||||
opts.Transcode = p.Transcode
|
||||
} else {
|
||||
opts.DestFolder = cfg.DestFolder
|
||||
opts.ReserveFreeGB = cfg.ReserveFreeGB
|
||||
opts.FileSelectMode = cfg.FileSelectMode
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
func hasEnabledSources(cfg *config.Config) bool {
|
||||
@@ -78,8 +86,11 @@ func (s *Server) handleCopyStart(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
reserveBytes := int64(cfg.ReserveFreeGB * 1e9)
|
||||
if diskInfo.FreeBytes <= reserveBytes {
|
||||
reserveGB := cfg.ReserveFreeGB
|
||||
if diskInfo.Profile != nil {
|
||||
reserveGB = diskInfo.Profile.ReserveFreeGB
|
||||
}
|
||||
if diskInfo.FreeBytes <= int64(reserveGB*1e9) {
|
||||
jsonErr(w, http.StatusUnprocessableEntity, "free space is below reserve threshold")
|
||||
return
|
||||
}
|
||||
@@ -137,8 +148,11 @@ func (s *Server) handleCopyStartSelected(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
reserveBytes := int64(cfg.ReserveFreeGB * 1e9)
|
||||
if diskInfo.FreeBytes <= reserveBytes {
|
||||
reserveGB := cfg.ReserveFreeGB
|
||||
if diskInfo.Profile != nil {
|
||||
reserveGB = diskInfo.Profile.ReserveFreeGB
|
||||
}
|
||||
if diskInfo.FreeBytes <= int64(reserveGB*1e9) {
|
||||
jsonErr(w, http.StatusUnprocessableEntity, "free space is below reserve threshold")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"jukebox_maker/internal/config"
|
||||
"jukebox_maker/internal/disk"
|
||||
)
|
||||
|
||||
@@ -15,6 +16,7 @@ func (s *Server) diskResponse(info disk.DiskInfo) map[string]any {
|
||||
"total_bytes": info.TotalBytes,
|
||||
"free_bytes": info.FreeBytes,
|
||||
"mount_path": info.MountPath,
|
||||
"profile": info.Profile,
|
||||
}
|
||||
if info.DiskID != "" {
|
||||
if s.deps.OnDiskInit != nil {
|
||||
@@ -113,3 +115,39 @@ func (s *Server) handleDiskInit(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
jsonOK(w, map[string]string{"disk_id": diskID})
|
||||
}
|
||||
|
||||
func (s *Server) handleGetProfile(w http.ResponseWriter, r *http.Request) {
|
||||
mountPath := config.NormalizeMediaPath(r.URL.Query().Get("mount_path"))
|
||||
if mountPath == "" {
|
||||
jsonErr(w, http.StatusBadRequest, "mount_path is required")
|
||||
return
|
||||
}
|
||||
p, err := disk.LoadProfile(mountPath)
|
||||
if err != nil {
|
||||
jsonErr(w, http.StatusNotFound, "profile not found")
|
||||
return
|
||||
}
|
||||
jsonOK(w, p)
|
||||
}
|
||||
|
||||
func (s *Server) handlePutProfile(w http.ResponseWriter, r *http.Request) {
|
||||
mountPath := config.NormalizeMediaPath(r.URL.Query().Get("mount_path"))
|
||||
if mountPath == "" {
|
||||
jsonErr(w, http.StatusBadRequest, "mount_path is required")
|
||||
return
|
||||
}
|
||||
var p disk.DiskProfile
|
||||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||
jsonErr(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := disk.SaveProfile(mountPath, &p); err != nil {
|
||||
jsonErr(w, http.StatusInternalServerError, "save profile: "+err.Error())
|
||||
return
|
||||
}
|
||||
// Обновляем информацию о диске в watcher если он там есть
|
||||
if s.deps.Watcher != nil {
|
||||
s.deps.Watcher.ProbeNow()
|
||||
}
|
||||
jsonOK(w, &p)
|
||||
}
|
||||
|
||||
@@ -70,6 +70,8 @@ func (s *Server) routes() {
|
||||
s.mux.HandleFunc("POST /api/disks/{diskID}/copy/start", s.handleCopyStart)
|
||||
s.mux.HandleFunc("POST /api/disks/{diskID}/copy/cancel", s.handleCopyCancel)
|
||||
s.mux.HandleFunc("GET /api/tasks/{id}", s.handleTaskGet)
|
||||
s.mux.HandleFunc("GET /api/disks/profile", s.handleGetProfile)
|
||||
s.mux.HandleFunc("PUT /api/disks/profile", s.handlePutProfile)
|
||||
}
|
||||
|
||||
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"jukebox_maker/internal/db"
|
||||
"jukebox_maker/internal/disk"
|
||||
"jukebox_maker/internal/task"
|
||||
"jukebox_maker/internal/transcoder"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
@@ -31,6 +32,7 @@ type Options struct {
|
||||
ReserveFreeGB float64
|
||||
OverwriteMode config.OverwriteMode
|
||||
FileSelectMode config.FileSelectMode
|
||||
Transcode *disk.TranscodeProfile // nil = не транскодировать
|
||||
}
|
||||
|
||||
type Copier struct {
|
||||
@@ -324,8 +326,14 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database
|
||||
}
|
||||
|
||||
dstAbs := filepath.Join(destRoot, f.relPath)
|
||||
if err := copyFile(ctx, f.srcAbs, dstAbs); err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
var fileErr error
|
||||
if opts.Transcode != nil && isVideoFile(f.srcAbs) {
|
||||
fileErr = c.processVideo(ctx, taskID, database, opts.Transcode, f.srcAbs, dstAbs)
|
||||
} else {
|
||||
fileErr = copyFile(ctx, f.srcAbs, dstAbs)
|
||||
}
|
||||
if fileErr != nil {
|
||||
if errors.Is(fileErr, context.Canceled) {
|
||||
c.tasks.Update(taskID, func(t *task.Task) {
|
||||
t.Status = task.StatusCanceled
|
||||
t.Message = "Canceled"
|
||||
@@ -353,6 +361,59 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database
|
||||
setStatus(task.StatusSuccess, fmt.Sprintf("Done. Copied %d files.", copied), 100)
|
||||
}
|
||||
|
||||
// videoExtensions — расширения видеофайлов из встроенного справочника.
|
||||
var videoExtensions = func() map[string]struct{} {
|
||||
exts := config.BuiltInMediaTypeExtensions()[config.MediaTypeVideo]
|
||||
set := make(map[string]struct{}, len(exts))
|
||||
for _, e := range exts {
|
||||
set[e] = struct{}{}
|
||||
}
|
||||
return set
|
||||
}()
|
||||
|
||||
func isVideoFile(path string) bool {
|
||||
_, ok := videoExtensions[strings.ToLower(filepath.Ext(path))]
|
||||
return ok
|
||||
}
|
||||
|
||||
// processVideo определяет: транскодировать или скопировать файл.
|
||||
func (c *Copier) processVideo(ctx context.Context, taskID string, database *db.DB, profile *disk.TranscodeProfile, src, dst string) error {
|
||||
info, err := transcoder.ProbeVideo(src)
|
||||
if err != nil {
|
||||
// Не смогли зондировать — просто копируем
|
||||
return copyFile(ctx, src, dst)
|
||||
}
|
||||
|
||||
if !transcoder.NeedsTranscode(info, profile) {
|
||||
return copyFile(ctx, src, dst)
|
||||
}
|
||||
|
||||
// Меняем расширение выходного файла под формат контейнера
|
||||
ext := transcoder.OutputExt(profile.OutputFormat)
|
||||
dstTranscoded := strings.TrimSuffix(dst, filepath.Ext(dst)) + ext
|
||||
|
||||
c.tasks.Update(taskID, func(t *task.Task) {
|
||||
t.Phase = task.PhaseTranscoding
|
||||
t.Message = "Transcoding " + filepath.Base(src)
|
||||
})
|
||||
if t, ok := c.tasks.Get(taskID); ok {
|
||||
_ = database.UpdateTask(*t)
|
||||
}
|
||||
|
||||
progressFn := func(pct float64) {
|
||||
c.tasks.Update(taskID, func(t *task.Task) {
|
||||
t.Progress = int(pct * 100)
|
||||
})
|
||||
}
|
||||
|
||||
return transcoder.Transcode(ctx, transcoder.Options{
|
||||
Input: src,
|
||||
Output: dstTranscoded,
|
||||
Profile: profile,
|
||||
SourceInfo: info,
|
||||
}, progressFn)
|
||||
}
|
||||
|
||||
type fileEntry struct {
|
||||
srcAbs string
|
||||
relPath string // relative to /media
|
||||
|
||||
@@ -23,6 +23,7 @@ type DiskInfo struct {
|
||||
TotalBytes int64 `json:"total_bytes"`
|
||||
FreeBytes int64 `json:"free_bytes"`
|
||||
MountPath string `json:"mount_path"`
|
||||
Profile *DiskProfile `json:"profile,omitempty"`
|
||||
}
|
||||
|
||||
const MarkerDir = ".jukebox"
|
||||
@@ -55,6 +56,9 @@ func Probe(mountPath string) (DiskInfo, error) {
|
||||
|
||||
info.DiskID = strings.TrimSpace(string(data))
|
||||
info.State = DiskKnown
|
||||
if p, err := LoadProfile(mountPath); err == nil {
|
||||
info.Profile = p
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
@@ -81,6 +85,7 @@ func InitDisk(mountPath string) (string, error) {
|
||||
if err := os.WriteFile(idPath, []byte(id), 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
_ = SaveProfile(mountPath, DefaultProfile())
|
||||
return id, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package disk
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type DiskProfile struct {
|
||||
DestFolder string `json:"dest_folder"`
|
||||
OverwriteMode string `json:"overwrite_mode"`
|
||||
FileSelectMode string `json:"file_select_mode"`
|
||||
ReserveFreeGB float64 `json:"reserve_free_gb"`
|
||||
AutoCopy bool `json:"auto_copy"`
|
||||
|
||||
// nil = не транскодировать видео
|
||||
Transcode *TranscodeProfile `json:"transcode,omitempty"`
|
||||
}
|
||||
|
||||
type TranscodeProfile struct {
|
||||
VideoCodec string `json:"video_codec"` // "h264" | "h265" | "mpeg4"
|
||||
MaxResolution string `json:"max_resolution"` // "480p" | "720p" | "1080p"
|
||||
MaxVideoBitrate string `json:"max_video_bitrate"` // "1000k" | "2000k" | "" (без лимита)
|
||||
MaxFPS int `json:"max_fps"` // 0=без лимита | 24 | 25 | 30
|
||||
AudioCodec string `json:"audio_codec"` // "aac" | "mp3"
|
||||
MaxAudioBitrate string `json:"max_audio_bitrate"` // "128k" | "192k" | ""
|
||||
MaxAudioChannels int `json:"max_audio_channels"` // 2=стерео | 6=5.1
|
||||
OutputFormat string `json:"output_format"` // "mp4" | "mkv" | "avi"
|
||||
}
|
||||
|
||||
const profileFile = "profile.json"
|
||||
|
||||
func profilePath(mountPath string) string {
|
||||
return filepath.Join(mountPath, MarkerDir, profileFile)
|
||||
}
|
||||
|
||||
func LoadProfile(mountPath string) (*DiskProfile, error) {
|
||||
data, err := os.ReadFile(profilePath(mountPath))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var p DiskProfile
|
||||
if err := json.Unmarshal(data, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func SaveProfile(mountPath string, p *DiskProfile) error {
|
||||
data, err := json.MarshalIndent(p, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := profilePath(mountPath)
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
func DefaultProfile() *DiskProfile {
|
||||
return &DiskProfile{
|
||||
DestFolder: "media",
|
||||
OverwriteMode: "skip",
|
||||
FileSelectMode: "new",
|
||||
ReserveFreeGB: 2.0,
|
||||
AutoCopy: false,
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ const (
|
||||
PhaseReplacing = "replacing"
|
||||
PhaseLoadingHistory = "loading_history"
|
||||
PhaseScanning = "scanning"
|
||||
PhaseTranscoding = "transcoding"
|
||||
PhaseCopying = "copying"
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -137,9 +137,147 @@ function renderDisk() {
|
||||
` : ''}
|
||||
</div>
|
||||
</section>
|
||||
${isKnown ? renderProfile(disk) : ''}
|
||||
`;
|
||||
}
|
||||
|
||||
function renderProfile(disk) {
|
||||
const p = disk.profile || {};
|
||||
const t = p.transcode || null;
|
||||
const transcodeEnabled = !!t;
|
||||
|
||||
const sel = (name, value, options) => {
|
||||
const opts = options.map(([v, label]) =>
|
||||
`<option value="${v}" ${v === value ? 'selected' : ''}>${escapeHTML(label)}</option>`
|
||||
).join('');
|
||||
return `<select class="form-input" id="prof_${name}">${opts}</select>`;
|
||||
};
|
||||
|
||||
const transcodeSection = `
|
||||
<div id="transcodeFields" style="${transcodeEnabled ? '' : 'display:none'}">
|
||||
<div class="form-group">
|
||||
<label>Видеокодек</label>
|
||||
${sel('video_codec', t?.video_codec || 'h264', [['h264','H.264 (AVC)'],['h265','H.265 (HEVC)'],['mpeg4','MPEG-4']])}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Макс. разрешение</label>
|
||||
${sel('max_resolution', t?.max_resolution || '720p', [['480p','480p'],['720p','720p (HD)'],['1080p','1080p (Full HD)']])}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Макс. битрейт видео</label>
|
||||
${sel('max_video_bitrate', t?.max_video_bitrate || '2000k', [['','Без лимита'],['1000k','1000 кбит/с'],['2000k','2000 кбит/с'],['4000k','4000 кбит/с'],['8000k','8000 кбит/с']])}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Макс. FPS</label>
|
||||
${sel('max_fps', String(t?.max_fps ?? 0), [['0','Без лимита'],['24','24'],['25','25'],['30','30']])}
|
||||
</div>
|
||||
<hr>
|
||||
<div class="form-group">
|
||||
<label>Аудиокодек</label>
|
||||
${sel('audio_codec', t?.audio_codec || 'aac', [['aac','AAC'],['mp3','MP3']])}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Макс. битрейт аудио</label>
|
||||
${sel('max_audio_bitrate', t?.max_audio_bitrate || '192k', [['','Без лимита'],['128k','128 кбит/с'],['192k','192 кбит/с'],['320k','320 кбит/с']])}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Каналы</label>
|
||||
${sel('max_audio_channels', String(t?.max_audio_channels ?? 0), [['0','Копировать'],['2','Стерео (2.0)'],['6','5.1']])}
|
||||
</div>
|
||||
<hr>
|
||||
<div class="form-group">
|
||||
<label>Формат контейнера</label>
|
||||
${sel('output_format', t?.output_format || 'mp4', [['mp4','MP4'],['mkv','MKV'],['avi','AVI']])}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return `
|
||||
<section class="panel" id="profilePanel">
|
||||
<h2>Профиль диска</h2>
|
||||
<div class="panel-body">
|
||||
<h3>Параметры копирования</h3>
|
||||
<div class="form-group">
|
||||
<label>Папка назначения</label>
|
||||
<input class="form-input" type="text" id="prof_dest_folder" value="${escapeHTML(p.dest_folder || 'media')}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Режим перезаписи</label>
|
||||
${sel('overwrite_mode', p.overwrite_mode || 'skip', [['skip','Пропускать существующие'],['delete','Заменять всё']])}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Выбор файлов</label>
|
||||
${sel('file_select_mode', p.file_select_mode || 'new', [['new','Только новые'],['all','Все подходящие']])}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Резерв свободного места (ГБ)</label>
|
||||
<input class="form-input" type="number" id="prof_reserve_free_gb" value="${p.reserve_free_gb ?? 2}" min="0" step="0.5">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label><input type="checkbox" id="prof_auto_copy" ${p.auto_copy ? 'checked' : ''}> Автокопирование при подключении</label>
|
||||
</div>
|
||||
|
||||
<h3 style="margin-top:1.5em">Транскодирование видео</h3>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" id="prof_transcode_enabled" ${transcodeEnabled ? 'checked' : ''}
|
||||
onchange="document.getElementById('transcodeFields').style.display=this.checked?'':'none'">
|
||||
Ограничить видео под устройство
|
||||
</label>
|
||||
</div>
|
||||
${transcodeSection}
|
||||
|
||||
<div class="btn-row" style="margin-top:1em">
|
||||
<button class="button-primary" onclick="saveProfile('${escapeHTML(disk.mount_path)}')">Сохранить профиль</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
async function saveProfile(mountPath) {
|
||||
const g = id => document.getElementById(id);
|
||||
const transcodeEnabled = g('prof_transcode_enabled')?.checked;
|
||||
|
||||
const profile = {
|
||||
dest_folder: g('prof_dest_folder')?.value.trim() || 'media',
|
||||
overwrite_mode: g('prof_overwrite_mode')?.value || 'skip',
|
||||
file_select_mode: g('prof_file_select_mode')?.value || 'new',
|
||||
reserve_free_gb: parseFloat(g('prof_reserve_free_gb')?.value || '2') || 0,
|
||||
auto_copy: g('prof_auto_copy')?.checked || false,
|
||||
};
|
||||
|
||||
if (transcodeEnabled) {
|
||||
profile.transcode = {
|
||||
video_codec: g('prof_video_codec')?.value || 'h264',
|
||||
max_resolution: g('prof_max_resolution')?.value || '720p',
|
||||
max_video_bitrate: g('prof_max_video_bitrate')?.value || '',
|
||||
max_fps: parseInt(g('prof_max_fps')?.value || '0', 10),
|
||||
audio_codec: g('prof_audio_codec')?.value || 'aac',
|
||||
max_audio_bitrate: g('prof_max_audio_bitrate')?.value || '',
|
||||
max_audio_channels: parseInt(g('prof_max_audio_channels')?.value || '0', 10),
|
||||
output_format: g('prof_output_format')?.value || 'mp4',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/disks/profile?mount_path=' + encodeURIComponent(mountPath), {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(profile)
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
toast(payload.error || 'Ошибка сохранения профиля', 'error');
|
||||
return;
|
||||
}
|
||||
toast('Профиль сохранён', 'ok');
|
||||
refreshSelectedDisk();
|
||||
} catch (error) {
|
||||
toast('Ошибка сети', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function stopTaskPoll(taskID) {
|
||||
if (!taskPollers.has(taskID)) return;
|
||||
clearInterval(taskPollers.get(taskID));
|
||||
|
||||
Reference in New Issue
Block a user