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:
2026-05-21 20:52:46 +03:00
co-authored by Claude Sonnet 4.6
parent 6953c151fe
commit 9fd02fb5bf
12 changed files with 718 additions and 28 deletions
+22 -8
View File
@@ -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
}
+38
View File
@@ -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)
}
+2
View File
@@ -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) {