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:
@@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/go/pkg/mod \
|
|||||||
|
|
||||||
FROM alpine:3.19
|
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
|
WORKDIR /app
|
||||||
COPY --from=builder /out/jukebox .
|
COPY --from=builder /out/jukebox .
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ func main() {
|
|||||||
switch ev.Info.State {
|
switch ev.Info.State {
|
||||||
case disk.DiskKnown:
|
case disk.DiskKnown:
|
||||||
openDiskDB(ev.Info)
|
openDiskDB(ev.Info)
|
||||||
if watcherReady && ev.Prev.State != disk.DiskKnown && cfg.AutoCopy {
|
if watcherReady && ev.Prev.State != disk.DiskKnown {
|
||||||
triggerAutoCopy(cp, cfg, ev.Info)
|
triggerAutoCopy(cp, cfg, ev.Info)
|
||||||
}
|
}
|
||||||
case disk.DiskForeign:
|
case disk.DiskForeign:
|
||||||
@@ -197,6 +197,15 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func triggerAutoCopy(cp *copier.Copier, cfg *config.Config, info disk.DiskInfo) {
|
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
|
hasEnabledSources := false
|
||||||
for _, s := range cfg.Sources {
|
for _, s := range cfg.Sources {
|
||||||
if s.Enabled {
|
if s.Enabled {
|
||||||
@@ -207,18 +216,31 @@ func triggerAutoCopy(cp *copier.Copier, cfg *config.Config, info disk.DiskInfo)
|
|||||||
if !hasEnabledSources {
|
if !hasEnabledSources {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
opts := copier.Options{
|
||||||
|
DiskID: info.DiskID,
|
||||||
|
MountPath: info.MountPath,
|
||||||
|
MediaPath: cfg.MediaPath,
|
||||||
|
SourceRules: cfg.Sources,
|
||||||
|
AllowedExtensions: cfg.EffectiveAllowedExtensions(),
|
||||||
|
OverwriteMode: cfg.OverwriteMode,
|
||||||
|
}
|
||||||
|
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() {
|
go func() {
|
||||||
_, err := cp.Start(context.Background(), copier.Options{
|
_, err := cp.Start(context.Background(), opts)
|
||||||
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 err != nil {
|
if err != nil {
|
||||||
log.Printf("auto-copy: %v", err)
|
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 {
|
func (s *Server) copyOptions(cfg *config.Config, diskInfo disk.DiskInfo, overwriteMode config.OverwriteMode) copier.Options {
|
||||||
return copier.Options{
|
opts := copier.Options{
|
||||||
DiskID: diskInfo.DiskID,
|
DiskID: diskInfo.DiskID,
|
||||||
MountPath: diskInfo.MountPath,
|
MountPath: diskInfo.MountPath,
|
||||||
MediaPath: cfg.MediaPath,
|
MediaPath: cfg.MediaPath,
|
||||||
DestFolder: cfg.DestFolder,
|
|
||||||
SourceRules: cfg.Sources,
|
SourceRules: cfg.Sources,
|
||||||
AllowedExtensions: cfg.EffectiveAllowedExtensions(),
|
AllowedExtensions: cfg.EffectiveAllowedExtensions(),
|
||||||
ReserveFreeGB: cfg.ReserveFreeGB,
|
|
||||||
OverwriteMode: overwriteMode,
|
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 {
|
func hasEnabledSources(cfg *config.Config) bool {
|
||||||
@@ -78,8 +86,11 @@ func (s *Server) handleCopyStart(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
reserveBytes := int64(cfg.ReserveFreeGB * 1e9)
|
reserveGB := cfg.ReserveFreeGB
|
||||||
if diskInfo.FreeBytes <= reserveBytes {
|
if diskInfo.Profile != nil {
|
||||||
|
reserveGB = diskInfo.Profile.ReserveFreeGB
|
||||||
|
}
|
||||||
|
if diskInfo.FreeBytes <= int64(reserveGB*1e9) {
|
||||||
jsonErr(w, http.StatusUnprocessableEntity, "free space is below reserve threshold")
|
jsonErr(w, http.StatusUnprocessableEntity, "free space is below reserve threshold")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -137,8 +148,11 @@ func (s *Server) handleCopyStartSelected(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
reserveBytes := int64(cfg.ReserveFreeGB * 1e9)
|
reserveGB := cfg.ReserveFreeGB
|
||||||
if diskInfo.FreeBytes <= reserveBytes {
|
if diskInfo.Profile != nil {
|
||||||
|
reserveGB = diskInfo.Profile.ReserveFreeGB
|
||||||
|
}
|
||||||
|
if diskInfo.FreeBytes <= int64(reserveGB*1e9) {
|
||||||
jsonErr(w, http.StatusUnprocessableEntity, "free space is below reserve threshold")
|
jsonErr(w, http.StatusUnprocessableEntity, "free space is below reserve threshold")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"jukebox_maker/internal/config"
|
||||||
"jukebox_maker/internal/disk"
|
"jukebox_maker/internal/disk"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ func (s *Server) diskResponse(info disk.DiskInfo) map[string]any {
|
|||||||
"total_bytes": info.TotalBytes,
|
"total_bytes": info.TotalBytes,
|
||||||
"free_bytes": info.FreeBytes,
|
"free_bytes": info.FreeBytes,
|
||||||
"mount_path": info.MountPath,
|
"mount_path": info.MountPath,
|
||||||
|
"profile": info.Profile,
|
||||||
}
|
}
|
||||||
if info.DiskID != "" {
|
if info.DiskID != "" {
|
||||||
if s.deps.OnDiskInit != nil {
|
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})
|
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/start", s.handleCopyStart)
|
||||||
s.mux.HandleFunc("POST /api/disks/{diskID}/copy/cancel", s.handleCopyCancel)
|
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/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) {
|
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"jukebox_maker/internal/db"
|
"jukebox_maker/internal/db"
|
||||||
"jukebox_maker/internal/disk"
|
"jukebox_maker/internal/disk"
|
||||||
"jukebox_maker/internal/task"
|
"jukebox_maker/internal/task"
|
||||||
|
"jukebox_maker/internal/transcoder"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Options struct {
|
type Options struct {
|
||||||
@@ -31,6 +32,7 @@ type Options struct {
|
|||||||
ReserveFreeGB float64
|
ReserveFreeGB float64
|
||||||
OverwriteMode config.OverwriteMode
|
OverwriteMode config.OverwriteMode
|
||||||
FileSelectMode config.FileSelectMode
|
FileSelectMode config.FileSelectMode
|
||||||
|
Transcode *disk.TranscodeProfile // nil = не транскодировать
|
||||||
}
|
}
|
||||||
|
|
||||||
type Copier struct {
|
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)
|
dstAbs := filepath.Join(destRoot, f.relPath)
|
||||||
if err := copyFile(ctx, f.srcAbs, dstAbs); err != nil {
|
var fileErr error
|
||||||
if errors.Is(err, context.Canceled) {
|
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) {
|
c.tasks.Update(taskID, func(t *task.Task) {
|
||||||
t.Status = task.StatusCanceled
|
t.Status = task.StatusCanceled
|
||||||
t.Message = "Canceled"
|
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)
|
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 {
|
type fileEntry struct {
|
||||||
srcAbs string
|
srcAbs string
|
||||||
relPath string // relative to /media
|
relPath string // relative to /media
|
||||||
|
|||||||
@@ -18,11 +18,12 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type DiskInfo struct {
|
type DiskInfo struct {
|
||||||
State DiskState `json:"state"`
|
State DiskState `json:"state"`
|
||||||
DiskID string `json:"disk_id"`
|
DiskID string `json:"disk_id"`
|
||||||
TotalBytes int64 `json:"total_bytes"`
|
TotalBytes int64 `json:"total_bytes"`
|
||||||
FreeBytes int64 `json:"free_bytes"`
|
FreeBytes int64 `json:"free_bytes"`
|
||||||
MountPath string `json:"mount_path"`
|
MountPath string `json:"mount_path"`
|
||||||
|
Profile *DiskProfile `json:"profile,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const MarkerDir = ".jukebox"
|
const MarkerDir = ".jukebox"
|
||||||
@@ -55,6 +56,9 @@ func Probe(mountPath string) (DiskInfo, error) {
|
|||||||
|
|
||||||
info.DiskID = strings.TrimSpace(string(data))
|
info.DiskID = strings.TrimSpace(string(data))
|
||||||
info.State = DiskKnown
|
info.State = DiskKnown
|
||||||
|
if p, err := LoadProfile(mountPath); err == nil {
|
||||||
|
info.Profile = p
|
||||||
|
}
|
||||||
return info, nil
|
return info, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +85,7 @@ func InitDisk(mountPath string) (string, error) {
|
|||||||
if err := os.WriteFile(idPath, []byte(id), 0o644); err != nil {
|
if err := os.WriteFile(idPath, []byte(id), 0o644); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
_ = SaveProfile(mountPath, DefaultProfile())
|
||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
70
internal/disk/profile.go
Normal file
70
internal/disk/profile.go
Normal file
@@ -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"
|
PhaseReplacing = "replacing"
|
||||||
PhaseLoadingHistory = "loading_history"
|
PhaseLoadingHistory = "loading_history"
|
||||||
PhaseScanning = "scanning"
|
PhaseScanning = "scanning"
|
||||||
|
PhaseTranscoding = "transcoding"
|
||||||
PhaseCopying = "copying"
|
PhaseCopying = "copying"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
172
internal/transcoder/detect.go
Normal file
172
internal/transcoder/detect.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
167
internal/transcoder/transcoder.go
Normal file
167
internal/transcoder/transcoder.go
Normal file
@@ -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>
|
</div>
|
||||||
</section>
|
</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) {
|
function stopTaskPoll(taskID) {
|
||||||
if (!taskPollers.has(taskID)) return;
|
if (!taskPollers.has(taskID)) return;
|
||||||
clearInterval(taskPollers.get(taskID));
|
clearInterval(taskPollers.get(taskID));
|
||||||
|
|||||||
Reference in New Issue
Block a user