17 Commits

Author SHA1 Message Date
mchus 478f5928d4 Fix dashboard settings panel closing on auto-refresh
Replace full innerHTML re-render with incremental DOM updates:
- renderedDisks map tracks what's already in the DOM
- On refresh: only update diskProgress_KEY and diskBtns_KEY elements
- Full card re-render only on first appearance or state/disk_id change
- If an edited disk disconnects: toast error, panel removed cleanly
- openPanels Set persists panel open state across partial updates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 22:03:58 +03:00
mchus 839ff494a4 Switch dashboard to watcher-based multi-disk view, fix transcoding FPS display
- dashboard.html: remove standalone "Mounted Disk" input panel; show all disks
  from GET /api/disks (watcher), auto-refresh every 5s
- detect.go: use avg_frame_rate when r_frame_rate is unrealistic (>120 fps or 0),
  fixes MJPEG/mjpeg showing 90000fps
- transcoder.go: parse fps= from ffmpeg progress output and expose in Progress struct
- copier.go: update task message with real-time encoding fps (@ 45.3 fps),
  clear speed_bps/eta during transcoding to avoid showing stale copy speed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 21:51:39 +03:00
mchus 2bad23da3a Add configurable shuffle depth for copy order
ShuffleDepth in DiskProfile controls how files are selected:
  -1 = no shuffle (preserve source order)
   0 = all files in random order
   N = group files by folder at depth N from /media, shuffle groups,
       copy entire group before moving to next

Exposed in disk profile UI as a select with level descriptions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 21:30:55 +03:00
mchus 0a17d11bd1 Revert to Docker-only source paths, fix config validation, improve transcoding info
- handlers_sources.go: revert to relative paths rooted at /media (remove standalone absolute-path mode)
- settings.html: remove manual path input, restore auto-loading source tree from /media
- config.go: remove filesystem existence checks from Validate() — paths may be temporarily unavailable
- transcoder.go: always specify fps in ffmpeg args when MaxFPS is set, preserving source fps if lower than limit
- copier.go: include source codec/format and target codec/format in transcoding task message

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 21:28:28 +03:00
mchus e885e49647 Show disk profile panel via Settings button
Profile panel is now hidden by default; a gear Settings button in
the disk card toggles it open/closed. Reduces visual clutter for
the common case when no profile changes are needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 21:09:52 +03:00
mchus 70d301f78f Remove native folder picker, use text input instead
Native OS dialog (zenity/AppleScript/PowerShell) fails on Linux with
"native folder picker is not supported on this platform". Replaced:
- dashboard: removed the "+" button, users type mount path manually
- settings: replaced "Add source folder" native dialog with inline
  text input field

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 21:08:00 +03:00
mchus 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
mchus 6953c151fe Add configurable allowed file types 2026-04-24 16:36:48 +03:00
mchus 50246ada85 Add standalone desktop workflow 2026-04-24 11:54:33 +03:00
mchus 75c6b928ae Tighten disk safety checks 2026-04-24 07:18:17 +03:00
mchus b8eabee393 Store unfinished tasks on disks 2026-04-24 07:10:26 +03:00
mchus 0afc1d761b Fix empty disk mount detection 2026-04-23 22:58:07 +03:00
mchus e7917b41b5 Improve disk UI and build performance 2026-04-23 22:51:36 +03:00
mchus 31bac2b5d8 Add multi-disk copy workflow 2026-04-23 22:24:32 +03:00
mchus 5b3cb9e393 copier: прогресс по байтам, скорость и ETA
- Прогрессбар по скопированным байтам (doneBytes / totalBytes)
- SpeedBPS и ETASec добавлены в Task
- Dashboard показывает скорость (МБ/с) и ETA справа от прогрессбара

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 22:00:14 +03:00
mchus 7c5736b935 copier: rsync, случайный порядок, папка назначения, права 777
- rsync --partial --append-verify для возобновления передачи
- --no-perms --chmod=ugo=rwx — права 777 на диске
- Структура: <mount>/<dest_folder>/<rel path from /media>, default media
- Shuffle файлов перед копированием
- Диск заполняется пока есть место, файлы которые не влезают пропускаются
- Прогресс по количеству обработанных файлов
- rsync добавлен в Dockerfile

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:57:57 +03:00
mchus 8f36d4e824 copier: rsync с возобновлением, папка назначения, права 777
- Заменить ручное копирование на rsync --partial --append-verify
- Структура на диске: <mount>/<dest_folder>/<rel path from /media>
- dest_folder настраивается (default: media)
- Права на диске: --no-perms --chmod=ugo=rwx
- rsync добавлен в Dockerfile
- Режим "удалить": удаляет только dest_folder, а не весь диск

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:56:26 +03:00
32 changed files with 3384 additions and 411 deletions
+7
View File
@@ -0,0 +1,7 @@
.git
.gitignore
.DS_Store
bin
dist
tmp
.tmp
+3 -1
View File
@@ -27,7 +27,9 @@ go.work.sum
# Build output # Build output
/jukebox /jukebox
/release/
/.tmp/
/.gocache/
# Temp copy files # Temp copy files
*.juketmp *.juketmp
+13 -4
View File
@@ -1,16 +1,25 @@
FROM golang:1.25-alpine AS builder # syntax=docker/dockerfile:1.7
FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder
WORKDIR /src WORKDIR /src
ARG VERSION=dev
ARG TARGETOS
ARG TARGETARCH
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go mod download
COPY . . COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" \ RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -trimpath -ldflags="-s -w -X main.Version=${VERSION}" \
-o /out/jukebox ./cmd/jukebox -o /out/jukebox ./cmd/jukebox
FROM alpine:3.19 FROM alpine:3.19
RUN apk add --no-cache tzdata ca-certificates RUN apk add --no-cache tzdata ca-certificates rsync ffmpeg
WORKDIR /app WORKDIR /app
COPY --from=builder /out/jukebox . COPY --from=builder /out/jukebox .
+51
View File
@@ -0,0 +1,51 @@
APP := jukebox
APP_PKG := ./cmd/jukebox
APP_URL := http://127.0.0.1:8080
ROOT_DIR := $(CURDIR)
DEV_DIR := $(ROOT_DIR)/.tmp
CONFIG_DIR := $(DEV_DIR)/config
MEDIA_DIR := $(DEV_DIR)/media
MOUNT_DIR := $(DEV_DIR)/mount
CONFIG_PATH := $(CONFIG_DIR)/config.json
GOCACHE_DIR := $(DEV_DIR)/gocache
.PHONY: run test build release dev-dirs
dev-dirs:
mkdir -p "$(CONFIG_DIR)" "$(MEDIA_DIR)" "$(MOUNT_DIR)" "$(GOCACHE_DIR)"
run: dev-dirs
@sh -c '\
url="$(APP_URL)"; \
open_url() { \
if command -v open >/dev/null 2>&1; then \
open "$$1"; \
elif command -v powershell >/dev/null 2>&1; then \
powershell -NoProfile -Command "Start-Process '\''$$1'\''" >/dev/null; \
elif command -v xdg-open >/dev/null 2>&1; then \
xdg-open "$$1" >/dev/null 2>&1; \
fi; \
}; \
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do \
if curl -fsS "$$url/health" >/dev/null 2>&1; then \
open_url "$$url"; \
exit 0; \
fi; \
sleep 1; \
done \
' &
GOCACHE="$(GOCACHE_DIR)" go run $(APP_PKG) \
-config "$(CONFIG_PATH)" \
-media "$(MEDIA_DIR)" \
-mount "$(MOUNT_DIR)" \
-addr ":8080"
test: dev-dirs
GOCACHE="$(GOCACHE_DIR)" go test ./...
build: dev-dirs
GOCACHE="$(GOCACHE_DIR)" go build -o "$(ROOT_DIR)/$(APP)" $(APP_PKG)
release:
./ops/build-release.sh
+144 -44
View File
@@ -2,6 +2,8 @@ package main
import ( import (
"context" "context"
"encoding/json"
"errors"
"flag" "flag"
"log" "log"
"net/http" "net/http"
@@ -19,6 +21,8 @@ import (
"jukebox_maker/internal/watcher" "jukebox_maker/internal/watcher"
) )
var Version = "dev"
func main() { func main() {
configPath := flag.String("config", "/config/config.json", "path to config file") configPath := flag.String("config", "/config/config.json", "path to config file")
addr := flag.String("addr", ":8080", "HTTP listen address") addr := flag.String("addr", ":8080", "HTTP listen address")
@@ -30,72 +34,140 @@ func main() {
if err != nil { if err != nil {
log.Fatalf("load config: %v", err) log.Fatalf("load config: %v", err)
} }
if cfg.MediaPath == "" {
cfg.MediaPath = config.NormalizeMediaPath(*mediaPath)
}
taskStore := task.NewStore() taskStore := task.NewStore()
cp := copier.New(taskStore) cp := copier.New(taskStore)
var activeDB *db.DB activeDBs := make(map[string]*db.DB)
var activeDiskID string mountToDiskID := make(map[string]string)
resumeDiskTask := func(info disk.DiskInfo, database *db.DB) {
rec, ok, err := database.ActiveTask()
if err != nil {
log.Printf("load active task for %s: %v", info.DiskID, err)
return
}
if !ok || rec.Task.Type != "copy" {
return
}
var opts copier.Options
if err := json.Unmarshal(rec.Payload, &opts); err != nil {
log.Printf("decode task payload for %s: %v", info.DiskID, err)
return
}
opts.DiskID = info.DiskID
opts.MountPath = info.MountPath
if rec.Task.Phase != task.PhaseQueued && rec.Task.Phase != task.PhasePreparing && rec.Task.Phase != task.PhaseReplacing && opts.OverwriteMode == config.OverwriteDelete {
opts.OverwriteMode = config.OverwriteSkip
}
taskStore.Upsert(rec.Task)
if err := cp.Resume(context.Background(), rec.Task.ID, opts); err != nil {
log.Printf("resume task %s for %s: %v", rec.Task.ID, info.DiskID, err)
}
}
openDiskDB := func(info disk.DiskInfo) { openDiskDB := func(info disk.DiskInfo) {
if activeDiskID == info.DiskID { if info.DiskID == "" {
return // already open for this disk return
} }
if activeDB != nil {
activeDB.Close() if prevDiskID, ok := mountToDiskID[info.MountPath]; ok && prevDiskID != info.DiskID {
activeDB = nil if prevDB := activeDBs[prevDiskID]; prevDB != nil {
activeDiskID = "" prevDB.Close()
delete(activeDBs, prevDiskID)
cp.SetDB(prevDiskID, nil)
}
} }
mountToDiskID[info.MountPath] = info.DiskID
if _, ok := activeDBs[info.DiskID]; ok {
return
}
d, err := db.Open(disk.DBPath(info.MountPath)) d, err := db.Open(disk.DBPath(info.MountPath))
if err != nil { if err != nil {
log.Printf("open disk DB: %v", err) log.Printf("open disk DB: %v", err)
return return
} }
activeDB = d activeDBs[info.DiskID] = d
activeDiskID = info.DiskID cp.SetDB(info.DiskID, d)
cp.SetDB(d)
log.Printf("disk DB opened for %s", info.DiskID) log.Printf("disk DB opened for %s", info.DiskID)
resumeDiskTask(info, d)
} }
closeDiskDB := func() { closeDiskDB := func(info disk.DiskInfo) {
if activeDB != nil { diskID := info.DiskID
activeDB.Close() if diskID == "" {
activeDB = nil diskID = mountToDiskID[info.MountPath]
activeDiskID = ""
cp.SetDB(nil)
log.Println("disk DB closed")
} }
if diskID == "" {
return
}
cp.Cancel(diskID)
cp.SetDB(diskID, nil)
if d := activeDBs[diskID]; d != nil {
d.Close()
delete(activeDBs, diskID)
log.Printf("disk DB closed for %s", diskID)
}
delete(mountToDiskID, info.MountPath)
} }
watcherReady := false
w := watcher.New(*mountPath, 5*time.Second, func(ev watcher.DiskEvent) { w := watcher.New(*mountPath, 5*time.Second, func(ev watcher.DiskEvent) {
log.Printf("disk: %s -> %s", ev.Prev, ev.Info.State) log.Printf("disk: %s %s -> %s", ev.Info.MountPath, ev.Prev.State, ev.Info.State)
switch ev.Info.State { switch ev.Info.State {
case disk.DiskKnown: case disk.DiskKnown:
openDiskDB(ev.Info) openDiskDB(ev.Info)
if ev.Prev != disk.DiskKnown && cfg.AutoCopy { if watcherReady && ev.Prev.State != disk.DiskKnown {
triggerAutoCopy(cp, cfg, ev.Info, *mediaPath) triggerAutoCopy(cp, cfg, ev.Info)
} }
case disk.DiskForeign:
closeDiskDB(ev.Prev)
case disk.DiskAbsent: case disk.DiskAbsent:
closeDiskDB() closeDiskDB(ev.Prev)
} }
}) })
w.ProbeNow()
watcherReady = true
// Open DB immediately if disk already connected at startup probeDisk := func(mountPath string) (disk.DiskInfo, error) {
{ mountPath = config.NormalizeMediaPath(mountPath)
info, _ := disk.Probe(*mountPath) if mountPath == "" {
return disk.DiskInfo{}, errors.New("mount_path is required")
}
info, err := disk.Probe(mountPath)
if err != nil {
return info, err
}
if info.State == disk.DiskKnown { if info.State == disk.DiskKnown {
openDiskDB(info) openDiskDB(info)
} }
return info, nil
} }
srv, err := api.New(api.Deps{ srv, err := api.New(api.Deps{
Config: cfg, Config: cfg,
ConfigPath: *configPath, ConfigPath: *configPath,
Version: Version,
Watcher: w, Watcher: w,
Copier: cp, Copier: cp,
Tasks: taskStore, Tasks: taskStore,
MediaPath: *mediaPath, ProbeDisk: probeDisk,
MountPath: *mountPath, OnDiskInit: func(mountPath, diskID string) {
openDiskDB(disk.DiskInfo{
State: disk.DiskKnown,
DiskID: diskID,
MountPath: mountPath,
})
},
}) })
if err != nil { if err != nil {
log.Fatalf("init server: %v", err) log.Fatalf("init server: %v", err)
@@ -119,29 +191,57 @@ func main() {
shutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) shutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
httpSrv.Shutdown(shutCtx) httpSrv.Shutdown(shutCtx)
closeDiskDB() for _, info := range w.ListDisks() {
closeDiskDB(info)
}
} }
func triggerAutoCopy(cp *copier.Copier, cfg *config.Config, info disk.DiskInfo, mediaPath string) { func triggerAutoCopy(cp *copier.Copier, cfg *config.Config, info disk.DiskInfo) {
var sources []string // Используем AutoCopy из профиля диска, если он есть; иначе — из глобального config
for _, s := range cfg.Sources { autoCopy := cfg.AutoCopy
if s.Enabled { if info.Profile != nil {
sources = append(sources, s.Path) autoCopy = info.Profile.AutoCopy
}
} }
if len(sources) == 0 { if !autoCopy {
return return
} }
hasEnabledSources := false
for _, s := range cfg.Sources {
if s.Enabled {
hasEnabledSources = true
break
}
}
if !hasEnabledSources {
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
opts.ShuffleDepth = p.ShuffleDepth
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: mediaPath,
EnabledSources: sources,
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)
} }
+145 -20
View File
@@ -2,41 +2,102 @@ package api
import ( import (
"context" "context"
"encoding/json"
"errors"
"io"
"net/http" "net/http"
"jukebox_maker/internal/config"
"jukebox_maker/internal/copier" "jukebox_maker/internal/copier"
"jukebox_maker/internal/disk" "jukebox_maker/internal/disk"
) )
func (s *Server) copyOptions(cfg *config.Config, diskInfo disk.DiskInfo, overwriteMode config.OverwriteMode) copier.Options {
opts := copier.Options{
DiskID: diskInfo.DiskID,
MountPath: diskInfo.MountPath,
MediaPath: cfg.MediaPath,
SourceRules: cfg.Sources,
AllowedExtensions: cfg.EffectiveAllowedExtensions(),
OverwriteMode: overwriteMode,
}
if p := diskInfo.Profile; p != nil {
opts.DestFolder = p.DestFolder
opts.ReserveFreeGB = p.ReserveFreeGB
opts.FileSelectMode = config.FileSelectMode(p.FileSelectMode)
opts.Transcode = p.Transcode
opts.ShuffleDepth = p.ShuffleDepth
} else {
opts.DestFolder = cfg.DestFolder
opts.ReserveFreeGB = cfg.ReserveFreeGB
opts.FileSelectMode = cfg.FileSelectMode
}
return opts
}
func hasEnabledSources(cfg *config.Config) bool {
for _, src := range cfg.Sources {
if src.Enabled {
return true
}
}
return false
}
func decodeCopyMode(r *http.Request, fallback config.OverwriteMode) (config.OverwriteMode, error) {
var req struct {
Mode string `json:"mode"`
}
if r.Body != nil {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
return "", err
}
}
switch req.Mode {
case "", "add":
return config.OverwriteSkip, nil
case "replace":
return config.OverwriteDelete, nil
default:
return fallback, errors.New("invalid copy mode")
}
}
func (s *Server) handleCopyStart(w http.ResponseWriter, r *http.Request) { func (s *Server) handleCopyStart(w http.ResponseWriter, r *http.Request) {
diskInfo := s.deps.Watcher.CurrentDisk() diskID := r.PathValue("diskID")
if diskInfo.State != disk.DiskKnown { diskInfo, ok := s.deps.Watcher.DiskByID(diskID)
jsonErr(w, http.StatusUnprocessableEntity, "no known disk connected") if !ok || diskInfo.State != disk.DiskKnown {
jsonErr(w, http.StatusUnprocessableEntity, "no initialized disk connected")
return return
} }
cfg := s.deps.Config cfg := s.deps.Config
var enabledSources []string if !hasEnabledSources(cfg) {
for _, src := range cfg.Sources { jsonErr(w, http.StatusUnprocessableEntity, "no source folders selected")
if src.Enabled {
enabledSources = append(enabledSources, src.Path)
}
}
if len(enabledSources) == 0 {
jsonErr(w, http.StatusUnprocessableEntity, "no sources enabled")
return return
} }
opts := copier.Options{ overwriteMode, err := decodeCopyMode(r, cfg.OverwriteMode)
DiskID: diskInfo.DiskID, if err != nil {
MountPath: diskInfo.MountPath, if err.Error() == "invalid copy mode" {
MediaPath: s.deps.MediaPath, jsonErr(w, http.StatusBadRequest, err.Error())
EnabledSources: enabledSources, return
ReserveFreeGB: cfg.ReserveFreeGB, }
OverwriteMode: cfg.OverwriteMode, jsonErr(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
FileSelectMode: cfg.FileSelectMode, return
} }
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
}
opts := s.copyOptions(cfg, diskInfo, overwriteMode)
taskID, err := s.deps.Copier.Start(context.Background(), opts) taskID, err := s.deps.Copier.Start(context.Background(), opts)
if err != nil { if err != nil {
switch err.Error() { switch err.Error() {
@@ -52,8 +113,72 @@ func (s *Server) handleCopyStart(w http.ResponseWriter, r *http.Request) {
jsonOK(w, map[string]string{"task_id": taskID}) jsonOK(w, map[string]string{"task_id": taskID})
} }
func (s *Server) handleCopyStartSelected(w http.ResponseWriter, r *http.Request) {
cfg := s.deps.Config
if !hasEnabledSources(cfg) {
jsonErr(w, http.StatusUnprocessableEntity, "no source folders selected")
return
}
var req struct {
MountPath string `json:"mount_path"`
Mode string `json:"mode"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
diskInfo, err := s.deps.ProbeDisk(req.MountPath)
if err != nil {
jsonErr(w, http.StatusBadRequest, err.Error())
return
}
if diskInfo.State != disk.DiskKnown {
jsonErr(w, http.StatusUnprocessableEntity, "selected directory is not an initialized jukebox disk")
return
}
overwriteMode := cfg.OverwriteMode
switch req.Mode {
case "", "add":
overwriteMode = config.OverwriteSkip
case "replace":
overwriteMode = config.OverwriteDelete
default:
jsonErr(w, http.StatusBadRequest, "invalid copy mode")
return
}
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
}
if s.deps.OnDiskInit != nil {
s.deps.OnDiskInit(diskInfo.MountPath, diskInfo.DiskID)
}
taskID, err := s.deps.Copier.Start(context.Background(), s.copyOptions(cfg, diskInfo, overwriteMode))
if err != nil {
switch err.Error() {
case "copy already running":
jsonErr(w, http.StatusConflict, err.Error())
default:
jsonErr(w, http.StatusUnprocessableEntity, err.Error())
}
return
}
w.WriteHeader(http.StatusAccepted)
jsonOK(w, map[string]string{"task_id": taskID})
}
func (s *Server) handleCopyCancel(w http.ResponseWriter, r *http.Request) { func (s *Server) handleCopyCancel(w http.ResponseWriter, r *http.Request) {
s.deps.Copier.Cancel() diskID := r.PathValue("diskID")
s.deps.Copier.Cancel(diskID)
jsonOK(w, map[string]bool{"ok": true}) jsonOK(w, map[string]bool{"ok": true})
} }
+132 -13
View File
@@ -1,34 +1,153 @@
package api package api
import ( import (
"encoding/json"
"net/http" "net/http"
"time"
"jukebox_maker/internal/config"
"jukebox_maker/internal/disk" "jukebox_maker/internal/disk"
) )
func (s *Server) handleDiskStatus(w http.ResponseWriter, r *http.Request) { func (s *Server) diskResponse(info disk.DiskInfo) map[string]any {
info := s.deps.Watcher.CurrentDisk() item := map[string]any{
"state": info.State,
"disk_id": info.DiskID,
"total_bytes": info.TotalBytes,
"free_bytes": info.FreeBytes,
"mount_path": info.MountPath,
"profile": info.Profile,
}
if info.DiskID != "" {
if s.deps.OnDiskInit != nil {
s.deps.OnDiskInit(info.MountPath, info.DiskID)
}
if lastCopiedAt, ok, err := s.deps.Copier.LastCopiedAt(info.DiskID); err == nil && ok {
item["last_copied_at"] = lastCopiedAt.Format(time.RFC3339)
}
if t, ok := s.deps.Tasks.ActiveTaskByDisk(info.DiskID); ok {
item["active_task_id"] = t.ID
}
}
return item
}
func (s *Server) handleDiskStatus(w http.ResponseWriter, r *http.Request) {
type response struct { type response struct {
State disk.DiskState `json:"state"` State disk.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"`
LastCopiedAt string `json:"last_copied_at,omitempty"`
ActiveTaskID string `json:"active_task_id,omitempty"` ActiveTaskID string `json:"active_task_id,omitempty"`
} }
resp := response{ disks := s.deps.Watcher.ListDisks()
State: info.State, resp := make([]response, 0, len(disks))
DiskID: info.DiskID, for _, info := range disks {
TotalBytes: info.TotalBytes, item := response{
FreeBytes: info.FreeBytes, State: info.State,
MountPath: info.MountPath, DiskID: info.DiskID,
TotalBytes: info.TotalBytes,
FreeBytes: info.FreeBytes,
MountPath: info.MountPath,
}
if payload := s.diskResponse(info); payload != nil {
if v, ok := payload["last_copied_at"].(string); ok {
item.LastCopiedAt = v
}
if v, ok := payload["active_task_id"].(string); ok {
item.ActiveTaskID = v
}
}
resp = append(resp, item)
} }
if t, ok := s.deps.Tasks.ActiveTask(); ok { jsonOK(w, map[string]any{"items": resp})
resp.ActiveTaskID = t.ID }
}
func (s *Server) handleDiskProbe(w http.ResponseWriter, r *http.Request) {
jsonOK(w, resp) mountPath := r.URL.Query().Get("mount_path")
info, err := s.deps.ProbeDisk(mountPath)
if err != nil {
jsonErr(w, http.StatusBadRequest, err.Error())
return
}
jsonOK(w, s.diskResponse(info))
}
func (s *Server) handleDiskInit(w http.ResponseWriter, r *http.Request) {
var req struct {
MountPath string `json:"mount_path"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
info, err := s.deps.ProbeDisk(req.MountPath)
if err != nil {
jsonErr(w, http.StatusBadRequest, err.Error())
return
}
if info.State == disk.DiskAbsent {
jsonErr(w, http.StatusUnprocessableEntity, "no disk connected")
return
}
if info.State == disk.DiskKnown {
jsonErr(w, http.StatusConflict, "disk already initialized")
return
}
if err := disk.CheckWritable(info.MountPath); err != nil {
jsonErr(w, http.StatusUnprocessableEntity, "disk is not writable: "+err.Error())
return
}
diskID, err := disk.InitDisk(info.MountPath)
if err != nil {
jsonErr(w, http.StatusInternalServerError, "init disk: "+err.Error())
return
}
if s.deps.OnDiskInit != nil {
s.deps.OnDiskInit(info.MountPath, 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)
} }
+64 -11
View File
@@ -1,26 +1,79 @@
package api package api
import ( import (
"errors"
"net/http" "net/http"
"net/url"
"os" "os"
"path/filepath"
"sort"
"strings"
) )
func (s *Server) handleSources(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSources(w http.ResponseWriter, r *http.Request) {
entries, err := os.ReadDir(s.deps.MediaPath) relPath, err := normalizeSourcePath(r.URL.Query().Get("path"))
if err != nil { if err != nil {
jsonOK(w, map[string][]string{"items": {}}) jsonErr(w, http.StatusBadRequest, err.Error())
return return
} }
var items []string absPath := s.deps.Config.MediaPath
for _, e := range entries { if relPath != "" {
if e.IsDir() && e.Name()[0] != '.' { absPath = filepath.Join(absPath, relPath)
items = append(items, e.Name())
}
}
if items == nil {
items = []string{}
} }
jsonOK(w, map[string][]string{"items": items}) entries, err := os.ReadDir(absPath)
if err != nil {
jsonOK(w, map[string]any{"path": relPath, "items": []map[string]string{}})
return
}
type item struct {
Name string `json:"name"`
Path string `json:"path"`
}
var items []item
for _, e := range entries {
if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
continue
}
childPath := e.Name()
if relPath != "" {
childPath = filepath.Join(relPath, childPath)
}
items = append(items, item{
Name: e.Name(),
Path: filepath.ToSlash(childPath),
})
}
sort.Slice(items, func(i, j int) bool {
return strings.ToLower(items[i].Name) < strings.ToLower(items[j].Name)
})
jsonOK(w, map[string]any{
"path": relPath,
"items": items,
})
}
func normalizeSourcePath(raw string) (string, error) {
raw, _ = url.QueryUnescape(raw)
raw = strings.TrimSpace(raw)
raw = filepath.ToSlash(raw)
raw = strings.TrimPrefix(raw, "/")
if raw == "" || raw == "." {
return "", nil
}
clean := filepath.Clean(raw)
clean = filepath.ToSlash(clean)
if clean == "." {
return "", nil
}
if clean == ".." || strings.HasPrefix(clean, "../") {
return "", errors.New("invalid source path")
}
return clean, nil
} }
+16
View File
@@ -0,0 +1,16 @@
package api
import (
"net/http"
"jukebox_maker/internal/dialog"
)
func (s *Server) handlePickFolder(w http.ResponseWriter, r *http.Request) {
path, err := dialog.PickFolder()
if err != nil {
jsonErr(w, http.StatusUnprocessableEntity, err.Error())
return
}
jsonOK(w, map[string]string{"path": path})
}
+22 -7
View File
@@ -9,6 +9,7 @@ import (
"jukebox_maker/internal/config" "jukebox_maker/internal/config"
"jukebox_maker/internal/copier" "jukebox_maker/internal/copier"
"jukebox_maker/internal/disk"
"jukebox_maker/internal/task" "jukebox_maker/internal/task"
"jukebox_maker/internal/watcher" "jukebox_maker/internal/watcher"
) )
@@ -16,11 +17,13 @@ import (
type Deps struct { type Deps struct {
Config *config.Config Config *config.Config
ConfigPath string ConfigPath string
Version string
Watcher *watcher.Watcher Watcher *watcher.Watcher
Copier *copier.Copier Copier *copier.Copier
Tasks *task.Store Tasks *task.Store
MediaPath string ProbeDisk func(mountPath string) (disk.DiskInfo, error)
MountPath string // OnDiskInit вызывается при ручной инициализации диска через UI.
OnDiskInit func(mountPath, diskID string)
} }
type Server struct { type Server struct {
@@ -56,13 +59,19 @@ func (s *Server) routes() {
s.mux.HandleFunc("GET /settings", s.handleSettings) s.mux.HandleFunc("GET /settings", s.handleSettings)
s.mux.HandleFunc("GET /health", s.handleHealth) s.mux.HandleFunc("GET /health", s.handleHealth)
s.mux.HandleFunc("GET /api/disk", s.handleDiskStatus) s.mux.HandleFunc("GET /api/disks", s.handleDiskStatus)
s.mux.HandleFunc("GET /api/disks/probe", s.handleDiskProbe)
s.mux.HandleFunc("POST /api/disks/init", s.handleDiskInit)
s.mux.HandleFunc("POST /api/disks/copy/start", s.handleCopyStartSelected)
s.mux.HandleFunc("GET /api/sources", s.handleSources) s.mux.HandleFunc("GET /api/sources", s.handleSources)
s.mux.HandleFunc("GET /api/config", s.handleGetConfig) s.mux.HandleFunc("GET /api/config", s.handleGetConfig)
s.mux.HandleFunc("PUT /api/config", s.handlePutConfig) s.mux.HandleFunc("PUT /api/config", s.handlePutConfig)
s.mux.HandleFunc("POST /api/copy/start", s.handleCopyStart) s.mux.HandleFunc("POST /api/system/pick-folder", s.handlePickFolder)
s.mux.HandleFunc("POST /api/copy/cancel", s.handleCopyCancel) 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/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) {
@@ -74,7 +83,7 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
} }
func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
s.render(w, s.settings, map[string]any{"Title": "Настройки", "Page": "settings"}) s.render(w, s.settings, map[string]any{"Title": "Settings", "Page": "settings"})
} }
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
@@ -83,7 +92,13 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
func (s *Server) render(w http.ResponseWriter, tmpl *template.Template, data any) { func (s *Server) render(w http.ResponseWriter, tmpl *template.Template, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.ExecuteTemplate(w, "layout", data); err != nil { payload := map[string]any{"Version": s.deps.Version}
if incoming, ok := data.(map[string]any); ok {
for k, v := range incoming {
payload[k] = v
}
}
if err := tmpl.ExecuteTemplate(w, "layout", payload); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
} }
} }
+256 -9
View File
@@ -5,38 +5,63 @@ import (
"errors" "errors"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"jukebox_maker/internal/disk"
) )
type OverwriteMode string type OverwriteMode string
type FileSelectMode string type FileSelectMode string
type AllowedFilesMode string
const ( const (
DefaultDestFolder = "media"
OverwriteSkip OverwriteMode = "skip" OverwriteSkip OverwriteMode = "skip"
OverwriteDelete OverwriteMode = "delete" OverwriteDelete OverwriteMode = "delete"
SelectNew FileSelectMode = "new" SelectNew FileSelectMode = "new"
SelectAll FileSelectMode = "all" SelectAll FileSelectMode = "all"
AllowedFilesByMediaType AllowedFilesMode = "media_types"
AllowedFilesByExtensions AllowedFilesMode = "extensions"
MediaTypeAudio = "audio"
MediaTypeVideo = "video"
MediaTypePhoto = "photo"
) )
type SourceFolder struct { type SourceFolder struct {
Path string `json:"path"` Path string `json:"path"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Root bool `json:"root,omitempty"`
} }
type Config struct { type Config struct {
ReserveFreeGB float64 `json:"reserve_free_gb"` MediaPath string `json:"media_path"`
Sources []SourceFolder `json:"sources"` ReserveFreeGB float64 `json:"reserve_free_gb"`
OverwriteMode OverwriteMode `json:"overwrite_mode"` DestFolder string `json:"dest_folder"`
FileSelectMode FileSelectMode `json:"file_select_mode"` Sources []SourceFolder `json:"sources"`
AutoCopy bool `json:"auto_copy"` OverwriteMode OverwriteMode `json:"overwrite_mode"`
FileSelectMode FileSelectMode `json:"file_select_mode"`
AllowedFilesMode AllowedFilesMode `json:"allowed_files_mode"`
EnabledMediaTypes []string `json:"enabled_media_types,omitempty"`
AllowedExtensions []string `json:"allowed_extensions,omitempty"`
AutoCopy bool `json:"auto_copy"`
FileReplicaCounts map[string]int `json:"file_replica_counts,omitempty"`
DiskReplicaFiles map[string][]string `json:"disk_replica_files,omitempty"`
} }
func defaults() Config { func defaults() Config {
return Config{ return Config{
ReserveFreeGB: 2.0, ReserveFreeGB: 2.0,
OverwriteMode: OverwriteSkip, DestFolder: DefaultDestFolder,
FileSelectMode: SelectNew, OverwriteMode: OverwriteSkip,
AutoCopy: false, FileSelectMode: SelectNew,
AllowedFilesMode: AllowedFilesByMediaType,
EnabledMediaTypes: DefaultEnabledMediaTypes(),
AllowedExtensions: DefaultAllowedExtensions(),
AutoCopy: false,
} }
} }
@@ -53,6 +78,24 @@ func Load(path string) (*Config, error) {
if err := json.Unmarshal(data, &cfg); err != nil { if err := json.Unmarshal(data, &cfg); err != nil {
return nil, err return nil, err
} }
if destFolder, err := NormalizeDestFolder(cfg.DestFolder); err == nil {
cfg.DestFolder = destFolder
} else {
cfg.DestFolder = defaults().DestFolder
}
if cfg.AllowedFilesMode != AllowedFilesByMediaType && cfg.AllowedFilesMode != AllowedFilesByExtensions {
cfg.AllowedFilesMode = defaults().AllowedFilesMode
}
cfg.EnabledMediaTypes = NormalizeMediaTypes(cfg.EnabledMediaTypes)
if len(cfg.EnabledMediaTypes) == 0 {
cfg.EnabledMediaTypes = defaults().EnabledMediaTypes
}
cfg.AllowedExtensions = NormalizeExtensions(cfg.AllowedExtensions)
if len(cfg.AllowedExtensions) == 0 {
cfg.AllowedExtensions = defaults().AllowedExtensions
}
cfg.MediaPath = NormalizeMediaPath(cfg.MediaPath)
cfg.Sources = NormalizeSources(cfg.Sources, cfg.MediaPath)
return &cfg, nil return &cfg, nil
} }
@@ -72,9 +115,14 @@ func Save(path string, cfg *Config) error {
} }
func (c *Config) Validate() error { func (c *Config) Validate() error {
c.MediaPath = NormalizeMediaPath(c.MediaPath)
c.Sources = NormalizeSources(c.Sources, c.MediaPath)
if c.ReserveFreeGB < 0 { if c.ReserveFreeGB < 0 {
return errors.New("reserve_free_gb must be >= 0") return errors.New("reserve_free_gb must be >= 0")
} }
if _, err := NormalizeDestFolder(c.DestFolder); err != nil {
return err
}
switch c.OverwriteMode { switch c.OverwriteMode {
case OverwriteSkip, OverwriteDelete: case OverwriteSkip, OverwriteDelete:
default: default:
@@ -85,5 +133,204 @@ func (c *Config) Validate() error {
default: default:
return errors.New("file_select_mode must be 'new' or 'all'") return errors.New("file_select_mode must be 'new' or 'all'")
} }
switch c.AllowedFilesMode {
case "", AllowedFilesByMediaType:
c.AllowedFilesMode = AllowedFilesByMediaType
c.EnabledMediaTypes = NormalizeMediaTypes(c.EnabledMediaTypes)
if len(c.EnabledMediaTypes) == 0 {
return errors.New("enabled_media_types must contain at least one of: audio, video, photo")
}
case AllowedFilesByExtensions:
c.AllowedExtensions = NormalizeExtensions(c.AllowedExtensions)
if len(c.AllowedExtensions) == 0 {
return errors.New("allowed_extensions must contain at least one file extension")
}
default:
return errors.New("allowed_files_mode must be 'media_types' or 'extensions'")
}
return nil return nil
} }
func DefaultEnabledMediaTypes() []string {
return []string{MediaTypeAudio, MediaTypeVideo}
}
func DefaultAllowedExtensions() []string {
return extensionsForMediaTypes(DefaultEnabledMediaTypes())
}
func BuiltInMediaTypeExtensions() map[string][]string {
return map[string][]string{
MediaTypeAudio: {
".aac", ".aif", ".aiff", ".alac", ".ape", ".flac", ".m4a", ".mp2", ".mp3", ".ogg", ".opus", ".wav", ".wma",
},
MediaTypeVideo: {
".3gp", ".avi", ".m2ts", ".m4v", ".mkv", ".mov", ".mp4", ".mpeg", ".mpg", ".mts", ".ts", ".webm", ".wmv",
},
MediaTypePhoto: {
".bmp", ".gif", ".heic", ".heif", ".jpeg", ".jpg", ".png", ".tif", ".tiff", ".webp",
},
}
}
func NormalizeMediaTypes(items []string) []string {
order := []string{MediaTypeAudio, MediaTypeVideo, MediaTypePhoto}
allowed := make(map[string]struct{}, len(order))
for _, item := range order {
allowed[item] = struct{}{}
}
seen := make(map[string]struct{}, len(items))
result := make([]string, 0, len(order))
for _, item := range items {
value := strings.ToLower(strings.TrimSpace(item))
if _, ok := allowed[value]; !ok {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
}
for _, item := range order {
if _, ok := seen[item]; ok {
result = append(result, item)
}
}
return result
}
func NormalizeExtensions(items []string) []string {
seen := make(map[string]struct{}, len(items))
result := make([]string, 0, len(items))
for _, item := range items {
value := normalizeExtension(item)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
return result
}
func normalizeExtension(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
value = strings.TrimPrefix(value, "*")
if value == "" {
return ""
}
if !strings.HasPrefix(value, ".") {
value = "." + value
}
if len(value) < 2 {
return ""
}
for _, ch := range value[1:] {
switch {
case ch >= 'a' && ch <= 'z':
case ch >= '0' && ch <= '9':
default:
return ""
}
}
return value
}
func (c Config) EffectiveAllowedExtensions() []string {
switch c.AllowedFilesMode {
case AllowedFilesByExtensions:
if items := NormalizeExtensions(c.AllowedExtensions); len(items) > 0 {
return items
}
default:
types := NormalizeMediaTypes(c.EnabledMediaTypes)
if len(types) == 0 {
types = DefaultEnabledMediaTypes()
}
return extensionsForMediaTypes(types)
}
return DefaultAllowedExtensions()
}
func extensionsForMediaTypes(items []string) []string {
sets := BuiltInMediaTypeExtensions()
result := make([]string, 0)
seen := make(map[string]struct{})
for _, mediaType := range NormalizeMediaTypes(items) {
for _, ext := range sets[mediaType] {
if _, ok := seen[ext]; ok {
continue
}
seen[ext] = struct{}{}
result = append(result, ext)
}
}
return result
}
func NormalizeMediaPath(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
return filepath.Clean(value)
}
func NormalizeSources(items []SourceFolder, mediaPath string) []SourceFolder {
seen := make(map[string]struct{}, len(items))
result := make([]SourceFolder, 0, len(items))
for _, item := range items {
path := normalizeSourcePath(item.Path, mediaPath)
if path == "" {
continue
}
if _, ok := seen[path]; ok {
continue
}
seen[path] = struct{}{}
result = append(result, SourceFolder{
Path: path,
Enabled: item.Enabled,
Root: item.Root,
})
}
return result
}
func normalizeSourcePath(value string, mediaPath string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
if !filepath.IsAbs(value) && mediaPath != "" {
value = filepath.Join(mediaPath, value)
}
return filepath.Clean(value)
}
func NormalizeDestFolder(value string) (string, error) {
value = strings.TrimSpace(value)
if value == "" {
return DefaultDestFolder, nil
}
clean := filepath.ToSlash(filepath.Clean(value))
clean = strings.TrimPrefix(clean, "./")
clean = strings.TrimPrefix(clean, "/")
switch clean {
case "", ".", "..":
return "", errors.New("dest_folder must be a subfolder on disk, not the disk root")
}
if strings.HasPrefix(clean, "../") {
return "", errors.New("dest_folder must stay inside the disk")
}
if clean == disk.MarkerDir || strings.HasPrefix(clean, disk.MarkerDir+"/") {
return "", errors.New("dest_folder conflicts with internal disk metadata")
}
return clean, nil
}
+68
View File
@@ -0,0 +1,68 @@
package config
import "testing"
func TestDefaultsAllowedFiles(t *testing.T) {
cfg := defaults()
if cfg.AllowedFilesMode != AllowedFilesByMediaType {
t.Fatalf("allowed files mode = %q, want %q", cfg.AllowedFilesMode, AllowedFilesByMediaType)
}
wantTypes := []string{MediaTypeAudio, MediaTypeVideo}
if len(cfg.EnabledMediaTypes) != len(wantTypes) {
t.Fatalf("enabled media types len = %d, want %d", len(cfg.EnabledMediaTypes), len(wantTypes))
}
for i, want := range wantTypes {
if cfg.EnabledMediaTypes[i] != want {
t.Fatalf("enabled media types[%d] = %q, want %q", i, cfg.EnabledMediaTypes[i], want)
}
}
exts := cfg.EffectiveAllowedExtensions()
if containsString(exts, ".jpg") {
t.Fatalf("default extensions unexpectedly include photo files: %v", exts)
}
if !containsString(exts, ".mp3") || !containsString(exts, ".mp4") {
t.Fatalf("default extensions = %v, want audio/video entries", exts)
}
}
func TestValidateAllowedFilesModeExtensions(t *testing.T) {
cfg := defaults()
cfg.AllowedFilesMode = AllowedFilesByExtensions
cfg.AllowedExtensions = []string{" mp3 ", ".MP4", "*.jpg", ".mp3"}
if err := cfg.Validate(); err != nil {
t.Fatalf("Validate() error = %v", err)
}
want := []string{".mp3", ".mp4", ".jpg"}
if len(cfg.AllowedExtensions) != len(want) {
t.Fatalf("allowed extensions len = %d, want %d", len(cfg.AllowedExtensions), len(want))
}
for i, item := range want {
if cfg.AllowedExtensions[i] != item {
t.Fatalf("allowed extensions[%d] = %q, want %q", i, cfg.AllowedExtensions[i], item)
}
}
}
func TestValidateRejectsEmptyAllowedFiles(t *testing.T) {
cfg := defaults()
cfg.AllowedFilesMode = AllowedFilesByExtensions
cfg.AllowedExtensions = nil
if err := cfg.Validate(); err == nil {
t.Fatal("Validate() error = nil, want non-nil")
}
}
func containsString(items []string, want string) bool {
for _, item := range items {
if item == want {
return true
}
}
return false
}
+580 -82
View File
@@ -2,105 +2,217 @@ package copier
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"hash/fnv"
"io" "io"
"math/rand/v2"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings"
"sync" "sync"
"time"
"jukebox_maker/internal/config" "jukebox_maker/internal/config"
"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 {
DiskID string DiskID string
MountPath string MountPath string
MediaPath string MediaPath string
EnabledSources []string DestFolder string // subfolder on disk, default "media"
ReserveFreeGB float64 SourceRules []config.SourceFolder
OverwriteMode config.OverwriteMode AllowedExtensions []string
FileSelectMode config.FileSelectMode ReserveFreeGB float64
OverwriteMode config.OverwriteMode
FileSelectMode config.FileSelectMode
Transcode *disk.TranscodeProfile // nil = не транскодировать
// ShuffleDepth: -1=выкл, 0=файлы вразнобой, 1+=группировка по папке на глубине N
ShuffleDepth int
} }
type Copier struct { type Copier struct {
tasks *task.Store tasks *task.Store
mu sync.Mutex mu sync.Mutex
cancel context.CancelFunc cancels map[string]context.CancelFunc
dbMu sync.RWMutex dbMu sync.RWMutex
db *db.DB dbs map[string]*db.DB
} }
func New(tasks *task.Store) *Copier { func New(tasks *task.Store) *Copier {
return &Copier{tasks: tasks} return &Copier{
tasks: tasks,
cancels: make(map[string]context.CancelFunc),
dbs: make(map[string]*db.DB),
}
} }
// SetDB replaces the active disk database (called when a disk connects or disconnects). func (c *Copier) SetDB(diskID string, d *db.DB) {
func (c *Copier) SetDB(d *db.DB) {
c.dbMu.Lock() c.dbMu.Lock()
c.db = d if d == nil {
delete(c.dbs, diskID)
} else {
c.dbs[diskID] = d
}
c.dbMu.Unlock() c.dbMu.Unlock()
} }
func (c *Copier) getDB() *db.DB { func (c *Copier) getDB(diskID string) *db.DB {
c.dbMu.RLock() c.dbMu.RLock()
defer c.dbMu.RUnlock() defer c.dbMu.RUnlock()
return c.db return c.dbs[diskID]
}
func (c *Copier) LastCopiedAt(diskID string) (time.Time, bool, error) {
database := c.getDB(diskID)
if database == nil {
return time.Time{}, false, nil
}
return database.LastCopiedAt(diskID)
} }
func (c *Copier) Start(ctx context.Context, opts Options) (string, error) { func (c *Copier) Start(ctx context.Context, opts Options) (string, error) {
return c.startTask(ctx, "", opts)
}
func (c *Copier) Resume(ctx context.Context, taskID string, opts Options) error {
_, err := c.startTask(ctx, taskID, opts)
return err
}
func (c *Copier) startTask(ctx context.Context, existingTaskID string, opts Options) (string, error) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
if _, active := c.tasks.ActiveTask(); active { if _, active := c.cancels[opts.DiskID]; active {
return "", errors.New("copy already running") return "", errors.New("copy already running")
} }
database := c.getDB() database := c.getDB(opts.DiskID)
if database == nil { if database == nil {
return "", errors.New("no disk database available") return "", errors.New("no disk database available")
} }
t := c.tasks.Create("copy") if opts.DestFolder == "" {
copyCtx, cancel := context.WithCancel(ctx) opts.DestFolder = config.DefaultDestFolder
c.cancel = cancel }
destFolder, err := config.NormalizeDestFolder(opts.DestFolder)
if err != nil {
destFolder = config.DefaultDestFolder
}
opts.DestFolder = destFolder
go c.run(copyCtx, t.ID, opts, database) _, free, err := disk.DiskUsage(opts.MountPath)
return t.ID, nil if err != nil {
return "", err
}
reserveBytes := int64(opts.ReserveFreeGB * 1e9)
if free <= reserveBytes {
return "", errors.New("free space is below reserve threshold")
}
var taskID string
if existingTaskID == "" {
t := c.tasks.Create("copy", opts.DiskID)
payload, err := json.Marshal(opts)
if err != nil {
return "", err
}
if err := database.UpsertTask(*t, payload); err != nil {
return "", err
}
taskID = t.ID
} else {
taskID = existingTaskID
c.tasks.Update(taskID, func(t *task.Task) {
t.Status = task.StatusQueued
t.Phase = task.PhaseQueued
t.Message = "Resuming after restart..."
t.Error = ""
t.SpeedBPS = 0
t.ETASec = 0
})
if t, ok := c.tasks.Get(taskID); ok {
if err := database.UpdateTask(*t); err != nil {
return "", err
}
}
}
copyCtx, cancel := context.WithCancel(ctx)
c.cancels[opts.DiskID] = cancel
go c.run(copyCtx, taskID, opts, database)
return taskID, nil
} }
func (c *Copier) Cancel() { func (c *Copier) Cancel(diskID string) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
if c.cancel != nil { if cancel, ok := c.cancels[diskID]; ok {
c.cancel() cancel()
} }
} }
func (c *Copier) run(ctx context.Context, taskID string, opts Options, database *db.DB) { func (c *Copier) run(ctx context.Context, taskID string, opts Options, database *db.DB) {
defer func() {
c.mu.Lock()
delete(c.cancels, opts.DiskID)
c.mu.Unlock()
}()
setStatus := func(s task.Status, msg string, prog int) { setStatus := func(s task.Status, msg string, prog int) {
c.tasks.Update(taskID, func(t *task.Task) { c.tasks.Update(taskID, func(t *task.Task) {
t.Status = s t.Status = s
t.Message = msg t.Message = msg
t.Progress = prog t.Progress = prog
}) })
if t, ok := c.tasks.Get(taskID); ok {
_ = database.UpdateTask(*t)
}
} }
fail := func(err error) { fail := func(err error) {
c.tasks.Update(taskID, func(t *task.Task) { c.tasks.Update(taskID, func(t *task.Task) {
t.Status = task.StatusFailed t.Status = task.StatusFailed
t.Error = err.Error() t.Error = err.Error()
}) })
if t, ok := c.tasks.Get(taskID); ok {
_ = database.UpdateTask(*t)
}
} }
setStatus(task.StatusRunning, "Подготовка…", 0) c.tasks.Update(taskID, func(t *task.Task) {
t.Status = task.StatusRunning
t.Phase = task.PhasePreparing
t.Message = "Preparing..."
t.Progress = 0
t.Error = ""
})
if t, ok := c.tasks.Get(taskID); ok {
_ = database.UpdateTask(*t)
}
destRoot := filepath.Join(opts.MountPath, opts.DestFolder)
if opts.OverwriteMode == config.OverwriteDelete { if opts.OverwriteMode == config.OverwriteDelete {
setStatus(task.StatusRunning, "Удаление данных с диска…", 0) c.tasks.Update(taskID, func(t *task.Task) {
if err := deleteOurData(opts.MountPath); err != nil { t.Status = task.StatusRunning
t.Phase = task.PhaseReplacing
t.Message = "Replacing destination media..."
t.Progress = 0
})
if t, ok := c.tasks.Get(taskID); ok {
_ = database.UpdateTask(*t)
}
if err := os.RemoveAll(destRoot); err != nil {
fail(err) fail(err)
return return
} }
@@ -108,7 +220,15 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database
var copiedPaths map[string]struct{} var copiedPaths map[string]struct{}
if opts.FileSelectMode == config.SelectNew { if opts.FileSelectMode == config.SelectNew {
setStatus(task.StatusRunning, "Загрузка истории…", 0) c.tasks.Update(taskID, func(t *task.Task) {
t.Status = task.StatusRunning
t.Phase = task.PhaseLoadingHistory
t.Message = "Loading copy history..."
t.Progress = 0
})
if t, ok := c.tasks.Get(taskID); ok {
_ = database.UpdateTask(*t)
}
var err error var err error
copiedPaths, err = database.CopiedPaths(opts.DiskID) copiedPaths, err = database.CopiedPaths(opts.DiskID)
if err != nil { if err != nil {
@@ -117,17 +237,27 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database
} }
} }
setStatus(task.StatusRunning, "Сканирование источников…", 0) c.tasks.Update(taskID, func(t *task.Task) {
files, err := buildFileList(opts.MediaPath, opts.EnabledSources, copiedPaths) t.Status = task.StatusRunning
t.Phase = task.PhaseScanning
t.Message = "Scanning sources..."
t.Progress = 0
})
if t, ok := c.tasks.Get(taskID); ok {
_ = database.UpdateTask(*t)
}
files, err := buildFileList(opts.MediaPath, opts.SourceRules, copiedPaths, opts.AllowedExtensions)
if err != nil { if err != nil {
fail(err) fail(err)
return return
} }
if len(files) == 0 { if len(files) == 0 {
setStatus(task.StatusSuccess, "Нет новых файлов для копирования.", 100) setStatus(task.StatusSuccess, "No files to copy.", 100)
return return
} }
files = applyShuffleDepth(files, opts.ShuffleDepth)
_, free, err := disk.DiskUsage(opts.MountPath) _, free, err := disk.DiskUsage(opts.MountPath)
if err != nil { if err != nil {
fail(err) fail(err)
@@ -136,19 +266,33 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database
reserveBytes := int64(opts.ReserveFreeGB * 1e9) reserveBytes := int64(opts.ReserveFreeGB * 1e9)
available := free - reserveBytes available := free - reserveBytes
if available <= 0 { if available <= 0 {
setStatus(task.StatusSuccess, "Недостаточно свободного места на диске.", 100) setStatus(task.StatusFailed, "Free space is below the reserved threshold.", 100)
return return
} }
// суммарный объём для прогресса (всех файлов в списке)
var totalBytes int64
for _, f := range files {
totalBytes += f.size
}
total := len(files) total := len(files)
copied := 0 copied := 0
var doneBytes int64
startTime := time.Now()
for i, f := range files { for i, f := range files {
select { select {
case <-ctx.Done(): case <-ctx.Done():
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 = "Отменено" t.Message = "Canceled"
t.SpeedBPS = 0
t.ETASec = 0
}) })
if t, ok := c.tasks.Get(taskID); ok {
_ = database.UpdateTask(*t)
}
return return
default: default:
} }
@@ -157,23 +301,56 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database
continue continue
} }
msg := fmt.Sprintf("Копирование %s (%d/%d)", filepath.Base(f.srcAbs), i+1, total) elapsed := time.Since(startTime).Seconds()
prog := int(float64(i+1) / float64(total) * 100) var speedBPS, etaSec int64
setStatus(task.StatusRunning, msg, prog) if elapsed > 0 && doneBytes > 0 {
speedBPS = int64(float64(doneBytes) / elapsed)
remaining := totalBytes - doneBytes
if speedBPS > 0 {
etaSec = remaining / speedBPS
}
}
dstAbs := filepath.Join(opts.MountPath, f.relPath) prog := int(float64(doneBytes) / float64(totalBytes) * 100)
if err := copyFile(ctx, f.srcAbs, dstAbs); err != nil { msg := fmt.Sprintf("Copying %s (%d/%d)", filepath.Base(f.srcAbs), i+1, total)
if errors.Is(err, context.Canceled) {
c.tasks.Update(taskID, func(t *task.Task) {
t.Status = task.StatusRunning
t.Phase = task.PhaseCopying
t.Message = msg
t.Progress = prog
t.SpeedBPS = speedBPS
t.ETASec = int(etaSec)
})
if t, ok := c.tasks.Get(taskID); ok {
_ = database.UpdateTask(*t)
}
dstAbs := filepath.Join(destRoot, f.relPath)
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) { c.tasks.Update(taskID, func(t *task.Task) {
t.Status = task.StatusCanceled t.Status = task.StatusCanceled
t.Message = "Отменено" t.Message = "Canceled"
t.SpeedBPS = 0
t.ETASec = 0
}) })
if t, ok := c.tasks.Get(taskID); ok {
_ = database.UpdateTask(*t)
}
return return
} }
continue continue
} }
available -= f.size available -= f.size
doneBytes += f.size
copied++ copied++
_ = database.RecordCopy(db.CopyRecord{ _ = database.RecordCopy(db.CopyRecord{
DiskID: opts.DiskID, DiskID: opts.DiskID,
@@ -182,32 +359,135 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database
}) })
} }
setStatus(task.StatusSuccess, fmt.Sprintf("Готово. Скопировано файлов: %d.", 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
srcInfo := fmt.Sprintf("%s/%dch/%.0ffps", info.Codec, info.AudioChannels, info.FPS)
dstInfo := fmt.Sprintf("%s/%s/%dfps %s", profile.VideoCodec, profile.AudioCodec, profile.MaxFPS, profile.OutputFormat)
baseMsg := fmt.Sprintf("Transcoding %s (%s → %s)", filepath.Base(src), srcInfo, dstInfo)
c.tasks.Update(taskID, func(t *task.Task) {
t.Phase = task.PhaseTranscoding
t.Message = baseMsg
})
if t, ok := c.tasks.Get(taskID); ok {
_ = database.UpdateTask(*t)
}
progressFn := func(p transcoder.Progress) {
c.tasks.Update(taskID, func(t *task.Task) {
t.Progress = int(p.Pct * 100)
t.SpeedBPS = 0
t.ETASec = 0
if p.EncodeFPS > 0 {
t.Message = fmt.Sprintf("%s @ %.1f fps", baseMsg, p.EncodeFPS)
}
})
}
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 relPath string // relative to /media
size int64 size int64
} }
func buildFileList(mediaPath string, sources []string, skip map[string]struct{}) ([]fileEntry, error) { func buildFileList(mediaPath string, rules []config.SourceFolder, skip map[string]struct{}, allowedExtensions []string) ([]fileEntry, error) {
_ = mediaPath
roots, selectedRoots, ruleMap := normalizeSourceRules(rules)
aliases := sourceAliases(roots)
allowedExts := makeAllowedExtensionSet(allowedExtensions)
var result []fileEntry var result []fileEntry
for _, src := range sources { for _, src := range selectedRoots {
dir := filepath.Join(mediaPath, src) root := owningRoot(src, roots)
if root == "" {
root = src
}
alias := aliases[root]
if alias == "" {
alias = filepath.Base(root)
if alias == "." || alias == "" || alias == string(filepath.Separator) {
alias = "source-" + shortHash(root)
}
}
dir := src
err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() { if err != nil || d.IsDir() {
if err != nil {
return nil
}
if path == dir {
return nil
}
rel, relErr := filepath.Rel(root, path)
if relErr != nil {
return nil
}
rel = filepath.ToSlash(rel)
if !isPathEnabled(path, ruleMap) && !hasEnabledDescendant(path, ruleMap) {
return filepath.SkipDir
}
return nil return nil
} }
rel, _ := filepath.Rel(mediaPath, path) if !isPathEnabled(path, ruleMap) {
return nil
}
if !isExtensionAllowed(path, allowedExts) {
return nil
}
rel, _ := filepath.Rel(root, path)
rel = filepath.ToSlash(rel)
destRel := filepath.ToSlash(filepath.Join(alias, rel))
if _, skipped := skip[rel]; skipped { if _, skipped := skip[rel]; skipped {
return nil return nil
} }
if _, skipped := skip[destRel]; skipped {
return nil
}
info, err := d.Info() info, err := d.Info()
if err != nil { if err != nil {
return nil return nil
} }
result = append(result, fileEntry{srcAbs: path, relPath: rel, size: info.Size()}) result = append(result, fileEntry{srcAbs: path, relPath: destRel, size: info.Size()})
return nil return nil
}) })
if err != nil { if err != nil {
@@ -217,64 +497,282 @@ func buildFileList(mediaPath string, sources []string, skip map[string]struct{})
return result, nil return result, nil
} }
func deleteOurData(mountPath string) error { func makeAllowedExtensionSet(items []string) map[string]struct{} {
entries, err := os.ReadDir(mountPath) normalized := config.NormalizeExtensions(items)
if err != nil { if len(normalized) == 0 {
return err normalized = config.DefaultAllowedExtensions()
} }
for _, e := range entries { result := make(map[string]struct{}, len(normalized))
if e.Name() == ".jukebox" { for _, item := range normalized {
result[item] = struct{}{}
}
return result
}
func isExtensionAllowed(path string, allowed map[string]struct{}) bool {
ext := strings.ToLower(filepath.Ext(path))
if ext == "" {
return false
}
_, ok := allowed[ext]
return ok
}
func normalizeSourceRules(rules []config.SourceFolder) ([]string, []string, map[string]bool) {
ruleMap := make(map[string]bool, len(rules))
rootSet := make(map[string]struct{})
for _, rule := range rules {
src := filepath.Clean(strings.TrimSpace(rule.Path))
if src == "" || src == "." {
continue continue
} }
if err := os.RemoveAll(filepath.Join(mountPath, e.Name())); err != nil { ruleMap[src] = rule.Enabled
return err if rule.Root {
rootSet[src] = struct{}{}
} }
} }
return nil
var roots []string
for src := range rootSet {
roots = append(roots, src)
}
sort.Strings(roots)
var selectedRoots []string
for src, enabled := range ruleMap {
if !enabled || hasEnabledAncestor(src, ruleMap) {
continue
}
selectedRoots = append(selectedRoots, src)
}
sort.Strings(selectedRoots)
return roots, selectedRoots, ruleMap
}
func hasEnabledAncestor(path string, ruleMap map[string]bool) bool {
for parent := parentSourcePath(path); parent != ""; parent = parentSourcePath(parent) {
if ruleMap[parent] {
return true
}
}
return false
}
func hasEnabledDescendant(path string, ruleMap map[string]bool) bool {
for other, enabled := range ruleMap {
if enabled && isPathInside(path, other) && other != path {
return true
}
}
return false
}
func isPathEnabled(path string, ruleMap map[string]bool) bool {
for current := path; current != ""; current = parentSourcePath(current) {
if enabled, ok := ruleMap[current]; ok {
return enabled
}
}
return false
}
func parentSourcePath(path string) string {
parent := filepath.Dir(path)
if parent == "." || parent == path {
return ""
}
return parent
}
func owningRoot(path string, roots []string) string {
var best string
for _, root := range roots {
if isPathInside(root, path) {
if len(root) > len(best) {
best = root
}
}
}
return best
}
func sourceAliases(roots []string) map[string]string {
counts := make(map[string]int, len(roots))
for _, root := range roots {
counts[strings.ToLower(filepath.Base(root))]++
}
aliases := make(map[string]string, len(roots))
for _, root := range roots {
base := filepath.Base(root)
if base == "." || base == string(filepath.Separator) || base == "" {
base = "source"
}
key := strings.ToLower(base)
if counts[key] > 1 {
base = fmt.Sprintf("%s-%s", base, shortHash(root))
}
aliases[root] = base
}
return aliases
}
func shortHash(value string) string {
h := fnv.New32a()
_, _ = h.Write([]byte(value))
return fmt.Sprintf("%08x", h.Sum32())[:6]
}
func isPathInside(base, candidate string) bool {
if candidate == base {
return true
}
rel, err := filepath.Rel(base, candidate)
if err != nil {
return false
}
return rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator))
} }
func copyFile(ctx context.Context, src, dst string) error { func copyFile(ctx context.Context, src, dst string) error {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err return err
} }
in, err := os.Open(src)
srcFile, err := os.Open(src)
if err != nil { if err != nil {
return err return err
} }
defer in.Close() defer srcFile.Close()
tmp := dst + ".juketmp" srcInfo, err := srcFile.Stat()
out, err := os.Create(tmp)
if err != nil { if err != nil {
return err return err
} }
buf := make([]byte, 512*1024) offset := int64(0)
for { if dstInfo, err := os.Stat(dst); err == nil {
select { switch {
case <-ctx.Done(): case dstInfo.Size() < srcInfo.Size():
out.Close() offset = dstInfo.Size()
os.Remove(tmp) case dstInfo.Size() == srcInfo.Size():
return ctx.Err() return os.Chtimes(dst, srcInfo.ModTime(), srcInfo.ModTime())
default: default:
} if err := os.Remove(dst); err != nil {
n, readErr := in.Read(buf) return err
if n > 0 {
if _, werr := out.Write(buf[:n]); werr != nil {
out.Close()
os.Remove(tmp)
return werr
} }
} }
if errors.Is(readErr, io.EOF) { } else if !errors.Is(err, os.ErrNotExist) {
break return err
}
if offset > 0 {
if _, err := srcFile.Seek(offset, io.SeekStart); err != nil {
return err
} }
}
dstFile, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return err
}
defer dstFile.Close()
if offset > 0 {
if _, err := dstFile.Seek(offset, io.SeekStart); err != nil {
return err
}
} else if err := dstFile.Truncate(0); err != nil {
return err
}
buf := make([]byte, 1024*1024)
for {
if err := ctx.Err(); err != nil {
return err
}
nr, readErr := srcFile.Read(buf)
if nr > 0 {
nw, writeErr := dstFile.Write(buf[:nr])
if writeErr != nil {
return writeErr
}
if nw != nr {
return io.ErrShortWrite
}
}
if readErr != nil { if readErr != nil {
out.Close() if errors.Is(readErr, io.EOF) {
os.Remove(tmp) break
}
return readErr return readErr
} }
} }
out.Close()
return os.Rename(tmp, dst) if err := dstFile.Sync(); err != nil {
return err
}
if err := os.Chtimes(dst, srcInfo.ModTime(), srcInfo.ModTime()); err != nil {
return err
}
dstInfo, err := os.Stat(dst)
if err != nil {
return err
}
if dstInfo.Size() != srcInfo.Size() {
return fmt.Errorf("copied size mismatch for %s", filepath.Base(src))
}
return nil
}
// applyShuffleDepth упорядочивает файлы по заданной глубине шафлера.
// depth < 0 → оригинальный порядок (без шафла)
// depth == 0 → все файлы перемешиваются случайно
// depth >= 1 → файлы группируются по папке на уровне depth от корня /media,
// группы перемешиваются, внутри каждой группы порядок сохраняется.
func applyShuffleDepth(files []fileEntry, depth int) []fileEntry {
if depth < 0 || len(files) == 0 {
return files
}
if depth == 0 {
rand.Shuffle(len(files), func(i, j int) { files[i], files[j] = files[j], files[i] })
return files
}
type group struct {
key string
files []fileEntry
}
groupMap := make(map[string]*group, 64)
var order []string
for _, f := range files {
key := folderKeyAtDepth(f.relPath, depth)
if _, ok := groupMap[key]; !ok {
groupMap[key] = &group{key: key}
order = append(order, key)
}
groupMap[key].files = append(groupMap[key].files, f)
}
rand.Shuffle(len(order), func(i, j int) { order[i], order[j] = order[j], order[i] })
result := make([]fileEntry, 0, len(files))
for _, key := range order {
result = append(result, groupMap[key].files...)
}
return result
}
// folderKeyAtDepth возвращает путь к папке глубины depth из relPath.
// relPath вида "anime/Naruto/Season1/ep01.mkv", depth=2 → "anime/Naruto"
func folderKeyAtDepth(relPath string, depth int) string {
relPath = filepath.ToSlash(relPath)
parts := strings.Split(relPath, "/")
maxDepth := len(parts) - 1 // последний элемент — имя файла
if depth >= maxDepth {
depth = maxDepth
}
return strings.Join(parts[:depth], "/")
} }
+54
View File
@@ -0,0 +1,54 @@
package copier
import (
"os"
"path/filepath"
"testing"
"jukebox_maker/internal/config"
)
func TestBuildFileListFiltersAllowedExtensions(t *testing.T) {
root := t.TempDir()
source := filepath.Join(root, "music")
if err := os.MkdirAll(filepath.Join(source, "nested"), 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
files := map[string]string{
filepath.Join(source, "track.mp3"): "audio",
filepath.Join(source, "clip.mp4"): "video",
filepath.Join(source, "cover.jpg"): "photo",
filepath.Join(source, "nested", "note.txt"): "text",
}
for path, body := range files {
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatalf("WriteFile(%q) error = %v", path, err)
}
}
items, err := buildFileList("", []config.SourceFolder{
{Path: source, Enabled: true, Root: true},
}, nil, config.DefaultAllowedExtensions())
if err != nil {
t.Fatalf("buildFileList() error = %v", err)
}
if len(items) != 2 {
t.Fatalf("buildFileList() len = %d, want 2", len(items))
}
got := make(map[string]struct{}, len(items))
for _, item := range items {
got[filepath.Base(item.relPath)] = struct{}{}
}
if _, ok := got["track.mp3"]; !ok {
t.Fatalf("missing mp3 file: %v", got)
}
if _, ok := got["clip.mp4"]; !ok {
t.Fatalf("missing mp4 file: %v", got)
}
if _, ok := got["cover.jpg"]; ok {
t.Fatalf("unexpected jpg file: %v", got)
}
}
+157 -3
View File
@@ -2,8 +2,11 @@ package db
import ( import (
"database/sql" "database/sql"
"encoding/json"
"time" "time"
"jukebox_maker/internal/task"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
@@ -18,6 +21,11 @@ type CopyRecord struct {
CopiedAt time.Time CopiedAt time.Time
} }
type TaskRecord struct {
Task task.Task
Payload json.RawMessage
}
func Open(path string) (*DB, error) { func Open(path string) (*DB, error) {
conn, err := sql.Open("sqlite", path+"?_journal=WAL&_timeout=5000") conn, err := sql.Open("sqlite", path+"?_journal=WAL&_timeout=5000")
if err != nil { if err != nil {
@@ -47,6 +55,26 @@ func (d *DB) migrate() error {
); );
CREATE UNIQUE INDEX IF NOT EXISTS idx_copy_history_disk_path CREATE UNIQUE INDEX IF NOT EXISTS idx_copy_history_disk_path
ON copy_history (disk_id, source_path); ON copy_history (disk_id, source_path);
CREATE TABLE IF NOT EXISTS disk_stats (
disk_id TEXT PRIMARY KEY,
last_copied_at DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
disk_id TEXT NOT NULL,
type TEXT NOT NULL,
status TEXT NOT NULL,
phase TEXT NOT NULL DEFAULT 'queued',
progress INTEGER NOT NULL DEFAULT 0,
message TEXT NOT NULL DEFAULT '',
speed_bps INTEGER NOT NULL DEFAULT 0,
eta_sec INTEGER NOT NULL DEFAULT 0,
error TEXT NOT NULL DEFAULT '',
payload TEXT NOT NULL DEFAULT '{}',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_tasks_status_updated ON tasks (status, updated_at);
`) `)
return err return err
} }
@@ -65,11 +93,26 @@ func (d *DB) RecordCopy(rec CopyRecord) error {
if t.IsZero() { if t.IsZero() {
t = time.Now().UTC() t = time.Now().UTC()
} }
_, err := d.sql.Exec( tx, err := d.sql.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(
`INSERT OR IGNORE INTO copy_history (disk_id, source_path, file_size, copied_at) VALUES (?,?,?,?)`, `INSERT OR IGNORE INTO copy_history (disk_id, source_path, file_size, copied_at) VALUES (?,?,?,?)`,
rec.DiskID, rec.SourcePath, rec.FileSize, t.Format(time.RFC3339), rec.DiskID, rec.SourcePath, rec.FileSize, t.Format(time.RFC3339),
) ); err != nil {
return err return err
}
if _, err := tx.Exec(
`INSERT INTO disk_stats (disk_id, last_copied_at) VALUES (?, ?)
ON CONFLICT(disk_id) DO UPDATE SET last_copied_at=excluded.last_copied_at`,
rec.DiskID, t.Format(time.RFC3339),
); err != nil {
return err
}
return tx.Commit()
} }
func (d *DB) CopiedPaths(diskID string) (map[string]struct{}, error) { func (d *DB) CopiedPaths(diskID string) (map[string]struct{}, error) {
@@ -90,3 +133,114 @@ func (d *DB) CopiedPaths(diskID string) (map[string]struct{}, error) {
} }
return m, rows.Err() return m, rows.Err()
} }
func (d *DB) LastCopiedAt(diskID string) (time.Time, bool, error) {
var raw string
err := d.sql.QueryRow(
`SELECT last_copied_at FROM disk_stats WHERE disk_id=?`,
diskID,
).Scan(&raw)
if err == sql.ErrNoRows {
return time.Time{}, false, nil
}
if err != nil {
return time.Time{}, false, err
}
t, err := time.Parse(time.RFC3339, raw)
if err != nil {
return time.Time{}, false, err
}
return t, true, nil
}
func (d *DB) UpsertTask(t task.Task, payload json.RawMessage) error {
if payload == nil {
payload = json.RawMessage(`{}`)
}
_, err := d.sql.Exec(
`INSERT INTO tasks (id, disk_id, type, status, phase, progress, message, speed_bps, eta_sec, error, payload, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
status=excluded.status,
phase=excluded.phase,
progress=excluded.progress,
message=excluded.message,
speed_bps=excluded.speed_bps,
eta_sec=excluded.eta_sec,
error=excluded.error,
payload=excluded.payload,
updated_at=excluded.updated_at`,
t.ID, t.DiskID, t.Type, t.Status, t.Phase, t.Progress, t.Message, t.SpeedBPS, t.ETASec, t.Error,
string(payload), t.CreatedAt.Format(time.RFC3339), t.UpdatedAt.Format(time.RFC3339),
)
return err
}
func (d *DB) UpdateTask(t task.Task) error {
_, err := d.sql.Exec(
`UPDATE tasks
SET status=?, phase=?, progress=?, message=?, speed_bps=?, eta_sec=?, error=?, updated_at=?
WHERE id=?`,
t.Status, t.Phase, t.Progress, t.Message, t.SpeedBPS, t.ETASec, t.Error, t.UpdatedAt.Format(time.RFC3339), t.ID,
)
return err
}
func (d *DB) ActiveTask() (*TaskRecord, bool, error) {
row := d.sql.QueryRow(
`SELECT id, disk_id, type, status, phase, progress, message, speed_bps, eta_sec, error, payload, created_at, updated_at
FROM tasks
WHERE status IN ('queued','running')
ORDER BY updated_at DESC
LIMIT 1`,
)
rec, err := scanTaskRecord(row)
if err == sql.ErrNoRows {
return nil, false, nil
}
if err != nil {
return nil, false, err
}
return rec, true, nil
}
type scanner interface {
Scan(dest ...any) error
}
func scanTaskRecord(s scanner) (*TaskRecord, error) {
var rec TaskRecord
var payloadRaw, createdAtRaw, updatedAtRaw string
err := s.Scan(
&rec.Task.ID,
&rec.Task.DiskID,
&rec.Task.Type,
&rec.Task.Status,
&rec.Task.Phase,
&rec.Task.Progress,
&rec.Task.Message,
&rec.Task.SpeedBPS,
&rec.Task.ETASec,
&rec.Task.Error,
&payloadRaw,
&createdAtRaw,
&updatedAtRaw,
)
if err != nil {
return nil, err
}
createdAt, err := time.Parse(time.RFC3339, createdAtRaw)
if err != nil {
return nil, err
}
updatedAt, err := time.Parse(time.RFC3339, updatedAtRaw)
if err != nil {
return nil, err
}
rec.Task.CreatedAt = createdAt
rec.Task.UpdatedAt = updatedAt
rec.Payload = json.RawMessage(payloadRaw)
return &rec, nil
}
+20
View File
@@ -0,0 +1,20 @@
package dialog
import (
"fmt"
"os/exec"
"strings"
)
func PickFolder() (string, error) {
cmd := exec.Command("osascript", "-e", `POSIX path of (choose folder with prompt "Select a folder")`)
out, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("folder selection failed: %s", strings.TrimSpace(string(out)))
}
path := strings.TrimSpace(string(out))
if path == "" {
return "", fmt.Errorf("folder selection canceled")
}
return strings.TrimSuffix(path, "/"), nil
}
+9
View File
@@ -0,0 +1,9 @@
//go:build !darwin && !windows
package dialog
import "fmt"
func PickFolder() (string, error) {
return "", fmt.Errorf("native folder picker is not supported on this platform")
}
+21
View File
@@ -0,0 +1,21 @@
package dialog
import (
"fmt"
"os/exec"
"strings"
)
func PickFolder() (string, error) {
script := `Add-Type -AssemblyName System.Windows.Forms; $dialog = New-Object System.Windows.Forms.FolderBrowserDialog; $dialog.ShowNewFolderButton = $false; if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::Write($dialog.SelectedPath) }`
cmd := exec.Command("powershell", "-NoProfile", "-STA", "-Command", script)
out, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("folder selection failed: %s", strings.TrimSpace(string(out)))
}
path := strings.TrimSpace(string(out))
if path == "" {
return "", fmt.Errorf("folder selection canceled")
}
return path, nil
}
+28 -22
View File
@@ -5,7 +5,6 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"syscall"
"github.com/google/uuid" "github.com/google/uuid"
) )
@@ -19,21 +18,21 @@ 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"
const idFile = "disk.id" const idFile = "disk.id"
func Probe(mountPath string) (DiskInfo, error) { func Probe(mountPath string) (DiskInfo, error) {
info := DiskInfo{MountPath: mountPath, State: DiskAbsent} info := DiskInfo{MountPath: mountPath, State: DiskAbsent}
entries, err := os.ReadDir(mountPath) if _, err := os.ReadDir(mountPath); err != nil {
if err != nil || len(entries) == 0 {
return info, nil return info, nil
} }
@@ -44,7 +43,7 @@ func Probe(mountPath string) (DiskInfo, error) {
info.TotalBytes = total info.TotalBytes = total
info.FreeBytes = free info.FreeBytes = free
idPath := filepath.Join(mountPath, markerDir, idFile) idPath := filepath.Join(mountPath, MarkerDir, idFile)
data, err := os.ReadFile(idPath) data, err := os.ReadFile(idPath)
if errors.Is(err, os.ErrNotExist) { if errors.Is(err, os.ErrNotExist) {
info.State = DiskForeign info.State = DiskForeign
@@ -57,11 +56,27 @@ 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
} }
func CheckWritable(path string) error {
f, err := os.CreateTemp(path, ".jukebox-writecheck-*")
if err != nil {
return err
}
name := f.Name()
if err := f.Close(); err != nil {
_ = os.Remove(name)
return err
}
return os.Remove(name)
}
func InitDisk(mountPath string) (string, error) { func InitDisk(mountPath string) (string, error) {
dir := filepath.Join(mountPath, markerDir) dir := filepath.Join(mountPath, MarkerDir)
if err := os.MkdirAll(dir, 0o755); err != nil { if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err return "", err
} }
@@ -70,19 +85,10 @@ 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
} }
func DBPath(mountPath string) string { func DBPath(mountPath string) string {
return filepath.Join(mountPath, markerDir, "history.db") return filepath.Join(mountPath, MarkerDir, "history.db")
}
func DiskUsage(mountPath string) (total, free int64, err error) {
var stat syscall.Statfs_t
if err = syscall.Statfs(mountPath, &stat); err != nil {
return 0, 0, err
}
total = int64(stat.Blocks) * int64(stat.Bsize)
free = int64(stat.Bavail) * int64(stat.Bsize)
return total, free, nil
} }
+73
View File
@@ -0,0 +1,73 @@
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"`
// ShuffleDepth: -1=выкл, 0=файлы вразнобой, 1+=папки на глубине N от корня /media
ShuffleDepth int `json:"shuffle_depth"`
// 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,
ShuffleDepth: -1,
}
}
+43
View File
@@ -0,0 +1,43 @@
//go:build !windows
package disk
import (
"os"
"path/filepath"
"syscall"
)
func IsMountPoint(path string) bool {
pathInfo, err := os.Stat(path)
if err != nil {
return false
}
parent := filepath.Dir(filepath.Clean(path))
parentInfo, err := os.Stat(parent)
if err != nil {
return false
}
pathStat, ok := pathInfo.Sys().(*syscall.Stat_t)
if !ok {
return false
}
parentStat, ok := parentInfo.Sys().(*syscall.Stat_t)
if !ok {
return false
}
return pathStat.Dev != parentStat.Dev
}
func DiskUsage(mountPath string) (total, free int64, err error) {
var stat syscall.Statfs_t
if err = syscall.Statfs(mountPath, &stat); err != nil {
return 0, 0, err
}
total = int64(stat.Blocks) * int64(stat.Bsize)
free = int64(stat.Bavail) * int64(stat.Bsize)
return total, free, nil
}
+40
View File
@@ -0,0 +1,40 @@
//go:build windows
package disk
import (
"path/filepath"
"strings"
"golang.org/x/sys/windows"
)
func IsMountPoint(path string) bool {
clean := filepath.Clean(path)
volume := filepath.VolumeName(clean)
if volume == "" {
return false
}
root := volume + `\`
return strings.EqualFold(clean, root)
}
func DiskUsage(mountPath string) (total, free int64, err error) {
root := filepath.Clean(mountPath)
if volume := filepath.VolumeName(root); volume != "" {
root = volume + `\`
}
pathPtr, err := windows.UTF16PtrFromString(root)
if err != nil {
return 0, 0, err
}
var freeBytesAvailable uint64
var totalNumberOfBytes uint64
var totalNumberOfFreeBytes uint64
if err := windows.GetDiskFreeSpaceEx(pathPtr, &freeBytesAvailable, &totalNumberOfBytes, &totalNumberOfFreeBytes); err != nil {
return 0, 0, err
}
return int64(totalNumberOfBytes), int64(freeBytesAvailable), nil
}
+29 -5
View File
@@ -17,12 +17,26 @@ const (
StatusCanceled Status = "canceled" StatusCanceled Status = "canceled"
) )
const (
PhaseQueued = "queued"
PhasePreparing = "preparing"
PhaseReplacing = "replacing"
PhaseLoadingHistory = "loading_history"
PhaseScanning = "scanning"
PhaseTranscoding = "transcoding"
PhaseCopying = "copying"
)
type Task struct { type Task struct {
ID string `json:"id"` ID string `json:"id"`
DiskID string `json:"disk_id"`
Type string `json:"type"` Type string `json:"type"`
Status Status `json:"status"` Status Status `json:"status"`
Phase string `json:"phase,omitempty"`
Progress int `json:"progress"` Progress int `json:"progress"`
Message string `json:"message"` Message string `json:"message"`
SpeedBPS int64 `json:"speed_bps"`
ETASec int `json:"eta_sec"`
Error string `json:"error"` Error string `json:"error"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
@@ -41,13 +55,16 @@ func NewStore() *Store {
return &Store{tasks: make(map[string]*Task)} return &Store{tasks: make(map[string]*Task)}
} }
func (s *Store) Create(taskType string) *Task { func (s *Store) Create(taskType, diskID string) *Task {
now := time.Now().UTC()
t := &Task{ t := &Task{
ID: uuid.New().String(), ID: uuid.New().String(),
DiskID: diskID,
Type: taskType, Type: taskType,
Status: StatusQueued, Status: StatusQueued,
CreatedAt: time.Now().UTC(), Phase: PhaseQueued,
UpdatedAt: time.Now().UTC(), CreatedAt: now,
UpdatedAt: now,
} }
s.mu.Lock() s.mu.Lock()
s.tasks[t.ID] = t s.tasks[t.ID] = t
@@ -55,6 +72,13 @@ func (s *Store) Create(taskType string) *Task {
return t return t
} }
func (s *Store) Upsert(t Task) {
copy := t
s.mu.Lock()
s.tasks[t.ID] = &copy
s.mu.Unlock()
}
func (s *Store) Get(id string) (*Task, bool) { func (s *Store) Get(id string) (*Task, bool) {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
@@ -75,11 +99,11 @@ func (s *Store) Update(id string, fn func(*Task)) {
} }
} }
func (s *Store) ActiveTask() (*Task, bool) { func (s *Store) ActiveTaskByDisk(diskID string) (*Task, bool) {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
for _, t := range s.tasks { for _, t := range s.tasks {
if t.Status == StatusQueued || t.Status == StatusRunning { if t.DiskID == diskID && (t.Status == StatusQueued || t.Status == StatusRunning) {
copy := *t copy := *t
return &copy, true return &copy, true
} }
+178
View File
@@ -0,0 +1,178 @@
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"`
AvgFrameRate string `json:"avg_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
// avg_frame_rate надёжнее для MJPEG и кодеков с нестандартным таймбейсом
fps := parseFraction(s.RFrameRate)
if avg := parseFraction(s.AvgFrameRate); avg > 0 && (fps <= 0 || fps > 120) {
fps = avg
}
info.FPS = fps
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
}
+187
View File
@@ -0,0 +1,187 @@
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
}
type Progress struct {
Pct float64 // 0..1
EncodeFPS float64 // текущая скорость кодирования, кадр/с
}
// Transcode запускает ffmpeg. progress вызывается при каждом обновлении прогресса.
func Transcode(ctx context.Context, opts Options, progress func(Progress)) 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 progress != nil {
var cur Progress
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
line := scanner.Text()
switch {
case 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 && opts.SourceInfo.DurationSec > 0 {
sec := float64(us) / 1e6
pct := sec / opts.SourceInfo.DurationSec
if pct > 1 {
pct = 1
}
cur.Pct = pct
}
case strings.HasPrefix(line, "fps="):
val := strings.TrimPrefix(line, "fps=")
if fps, err := strconv.ParseFloat(strings.TrimSpace(val), 64); err == nil && fps > 0 {
cur.EncodeFPS = fps
}
case line == "progress=continue" || line == "progress=end":
progress(cur)
}
}
}
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 {
targetFPS := p.MaxFPS
if src.FPS > 0 && src.FPS < float64(targetFPS) {
// Источник медленнее лимита — сохраняем исходный FPS
filters = append(filters, fmt.Sprintf("fps=%.3f", src.FPS))
} else {
filters = append(filters, fmt.Sprintf("fps=%d", targetFPS))
}
}
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"
}
}
+98 -11
View File
@@ -2,6 +2,10 @@ package watcher
import ( import (
"context" "context"
"os"
"path/filepath"
"sort"
"strings"
"sync" "sync"
"time" "time"
@@ -10,7 +14,7 @@ import (
type DiskEvent struct { type DiskEvent struct {
Info disk.DiskInfo Info disk.DiskInfo
Prev disk.DiskState Prev disk.DiskInfo
} }
type Handler func(event DiskEvent) type Handler func(event DiskEvent)
@@ -20,8 +24,8 @@ type Watcher struct {
interval time.Duration interval time.Duration
handler Handler handler Handler
mu sync.RWMutex mu sync.RWMutex
current disk.DiskInfo disks map[string]disk.DiskInfo
} }
func New(mountPath string, interval time.Duration, handler Handler) *Watcher { func New(mountPath string, interval time.Duration, handler Handler) *Watcher {
@@ -29,13 +33,42 @@ func New(mountPath string, interval time.Duration, handler Handler) *Watcher {
mountPath: mountPath, mountPath: mountPath,
interval: interval, interval: interval,
handler: handler, handler: handler,
disks: make(map[string]disk.DiskInfo),
} }
} }
func (w *Watcher) CurrentDisk() disk.DiskInfo { func (w *Watcher) ListDisks() []disk.DiskInfo {
w.mu.RLock() w.mu.RLock()
defer w.mu.RUnlock() defer w.mu.RUnlock()
return w.current
items := make([]disk.DiskInfo, 0, len(w.disks))
for _, info := range w.disks {
items = append(items, info)
}
sort.Slice(items, func(i, j int) bool { return items[i].MountPath < items[j].MountPath })
return items
}
func (w *Watcher) DiskByMountPath(mountPath string) (disk.DiskInfo, bool) {
w.mu.RLock()
defer w.mu.RUnlock()
info, ok := w.disks[mountPath]
return info, ok
}
func (w *Watcher) DiskByID(diskID string) (disk.DiskInfo, bool) {
w.mu.RLock()
defer w.mu.RUnlock()
for _, info := range w.disks {
if info.DiskID == diskID {
return info, true
}
}
return disk.DiskInfo{}, false
}
func (w *Watcher) ProbeNow() {
w.probe()
} }
func (w *Watcher) Run(ctx context.Context) { func (w *Watcher) Run(ctx context.Context) {
@@ -56,15 +89,69 @@ func (w *Watcher) Run(ctx context.Context) {
} }
func (w *Watcher) probe() { func (w *Watcher) probe() {
info, _ := disk.Probe(w.mountPath) next := discoverDisks(w.mountPath)
w.mu.Lock() w.mu.Lock()
prev := w.current.State prev := w.disks
changed := prev != info.State w.disks = next
w.current = info
w.mu.Unlock() w.mu.Unlock()
if changed && w.handler != nil { if w.handler == nil {
w.handler(DiskEvent{Info: info, Prev: prev}) return
}
seen := make(map[string]struct{}, len(prev)+len(next))
for mountPath, info := range next {
seen[mountPath] = struct{}{}
prevInfo := prev[mountPath]
if prevInfo.State != info.State || prevInfo.DiskID != info.DiskID {
w.handler(DiskEvent{Info: info, Prev: prevInfo})
}
}
for mountPath, prevInfo := range prev {
if _, ok := seen[mountPath]; ok {
continue
}
w.handler(DiskEvent{
Info: disk.DiskInfo{
State: disk.DiskAbsent,
MountPath: mountPath,
},
Prev: prevInfo,
})
} }
} }
func discoverDisks(root string) map[string]disk.DiskInfo {
disks := make(map[string]disk.DiskInfo)
entries, err := os.ReadDir(root)
if err != nil {
return disks
}
for _, entry := range entries {
if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
continue
}
mountPath := filepath.Join(root, entry.Name())
if !disk.IsMountPoint(mountPath) {
continue
}
info, _ := disk.Probe(mountPath)
if info.State == disk.DiskAbsent {
continue
}
disks[mountPath] = info
}
// If no child mountpoints were detected, the disk may be mounted directly at root.
if len(disks) == 0 {
if disk.IsMountPoint(root) {
if info, _ := disk.Probe(root); info.State != disk.DiskAbsent {
disks[root] = info
}
}
}
return disks
}
+10
View File
@@ -21,8 +21,10 @@ ROOT_DIR=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
die() { echo "error: $*" >&2; exit 1; } die() { echo "error: $*" >&2; exit 1; }
command -v docker >/dev/null 2>&1 || die "docker not found in PATH" command -v docker >/dev/null 2>&1 || die "docker not found in PATH"
command -v go >/dev/null 2>&1 || die "go not found in PATH"
DEFAULT_TAG=$(git -C "${ROOT_DIR}" rev-parse --short HEAD 2>/dev/null || echo dev) DEFAULT_TAG=$(git -C "${ROOT_DIR}" rev-parse --short HEAD 2>/dev/null || echo dev)
DEFAULT_VERSION=$(git -C "${ROOT_DIR}" describe --tags --always 2>/dev/null || echo dev)
ask() { ask() {
# $1=varname $2=prompt $3=default # $1=varname $2=prompt $3=default
@@ -46,6 +48,12 @@ else
ask IMAGE "Image" "" ask IMAGE "Image" ""
fi fi
echo "checking Go build"
(
cd "${ROOT_DIR}"
go build ./...
)
if [ -n "${IMAGE}" ]; then if [ -n "${IMAGE}" ]; then
# multi-arch build + push # multi-arch build + push
docker buildx version >/dev/null 2>&1 || die "docker buildx not available" docker buildx version >/dev/null 2>&1 || die "docker buildx not available"
@@ -66,6 +74,7 @@ if [ -n "${IMAGE}" ]; then
docker buildx build \ docker buildx build \
--platform "${PLATFORMS}" \ --platform "${PLATFORMS}" \
--file "${ROOT_DIR}/Dockerfile" \ --file "${ROOT_DIR}/Dockerfile" \
--build-arg "VERSION=${DEFAULT_VERSION}" \
-t "${IMAGE}:${IMAGE_TAG}" \ -t "${IMAGE}:${IMAGE_TAG}" \
-t "${IMAGE}:latest" \ -t "${IMAGE}:latest" \
--push \ --push \
@@ -80,6 +89,7 @@ else
echo "building locally (no push)" echo "building locally (no push)"
docker build \ docker build \
--file "${ROOT_DIR}/Dockerfile" \ --file "${ROOT_DIR}/Dockerfile" \
--build-arg "VERSION=${DEFAULT_VERSION}" \
-t "jukebox-maker:${IMAGE_TAG}" \ -t "jukebox-maker:${IMAGE_TAG}" \
-t "jukebox-maker:latest" \ -t "jukebox-maker:latest" \
"${ROOT_DIR}" "${ROOT_DIR}"
+39
View File
@@ -0,0 +1,39 @@
#!/bin/sh
set -eu
ROOT_DIR=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
RELEASE_DIR="${ROOT_DIR}/release"
CACHE_DIR="${RELEASE_DIR}/.gocache"
VERSION=${VERSION:-$(git -C "${ROOT_DIR}" describe --tags --always 2>/dev/null || echo dev)}
build_target() {
goos="$1"
goarch="$2"
output_name="$3"
echo "building ${goos}/${goarch} -> ${output_name}"
env \
GOCACHE="${CACHE_DIR}" \
GOOS="${goos}" \
GOARCH="${goarch}" \
go build \
-ldflags "-X main.Version=${VERSION}" \
-o "${RELEASE_DIR}/${output_name}" \
./cmd/jukebox
}
command -v go >/dev/null 2>&1 || {
echo "error: go not found in PATH" >&2
exit 1
}
mkdir -p "${RELEASE_DIR}" "${CACHE_DIR}"
build_target darwin arm64 jukebox-darwin-arm64
build_target windows 386 jukebox-windows-386.exe
echo ""
echo "artifacts:"
echo " ${RELEASE_DIR}/jukebox-darwin-arm64"
echo " ${RELEASE_DIR}/jukebox-windows-386.exe"
+165
View File
@@ -69,6 +69,14 @@ a:hover { text-decoration: underline; }
margin: 28px auto 56px; margin: 28px auto 56px;
} }
.page-footer {
width: min(var(--content-width), calc(100vw - 48px));
margin: -28px auto 24px;
color: var(--muted);
font-size: 12px;
text-align: right;
}
/* Panel */ /* Panel */
.panel { .panel {
margin-bottom: 24px; margin-bottom: 24px;
@@ -89,6 +97,24 @@ a:hover { text-decoration: underline; }
color: var(--ink); color: var(--ink);
} }
.panel-body {
padding: 14px 16px;
}
.disk-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 20px;
}
.disk-card {
margin-bottom: 0;
}
.progress-wrap {
border-top: 1px solid var(--border-lite);
}
/* KV table */ /* KV table */
.kv-table { .kv-table {
width: 100%; width: 100%;
@@ -199,6 +225,17 @@ a:hover { text-decoration: underline; }
.form-group { display: flex; flex-direction: column; gap: 5px; } .form-group { display: flex; flex-direction: column; gap: 5px; }
.path-input-row {
display: flex;
gap: 8px;
align-items: center;
}
.path-input-row .form-input {
flex: 1;
min-width: 0;
}
.form-label { .form-label {
font-size: 13px; font-size: 13px;
font-weight: 700; font-weight: 700;
@@ -241,6 +278,124 @@ a:hover { text-decoration: underline; }
/* Checkbox list */ /* Checkbox list */
.source-list { display: flex; flex-direction: column; gap: 0; } .source-list { display: flex; flex-direction: column; gap: 0; }
.source-tree {
padding: 12px;
background: linear-gradient(180deg, rgba(33, 133, 208, 0.03), rgba(34, 36, 38, 0.015));
}
.source-tree-empty {
padding: 20px 16px;
border: 1px dashed var(--border);
border-radius: calc(var(--radius) + 2px);
background: rgba(255, 255, 255, 0.75);
}
.source-root-card {
margin-bottom: 12px;
overflow: hidden;
border: 1px solid rgba(33, 133, 208, 0.18);
border-radius: calc(var(--radius) + 2px);
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 8px 24px rgba(27, 28, 29, 0.04);
}
.source-root-card:last-child {
margin-bottom: 0;
}
.source-node {
border-bottom: 1px solid var(--border-lite);
}
.source-node:last-child {
border-bottom: 0;
}
.source-row {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
transition: background 0.12s ease;
}
.source-row:hover {
background: rgba(33, 133, 208, 0.04);
}
.source-root-row {
padding-top: 12px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(33, 133, 208, 0.12);
background:
linear-gradient(90deg, rgba(33, 133, 208, 0.08), rgba(33, 133, 208, 0.015) 42%, rgba(255, 255, 255, 0.96) 100%);
}
.source-root-row:hover {
background:
linear-gradient(90deg, rgba(33, 133, 208, 0.12), rgba(33, 133, 208, 0.03) 42%, rgba(255, 255, 255, 1) 100%);
}
.source-toggle {
width: 24px;
height: 24px;
border: 1px solid transparent;
border-radius: var(--radius);
background: transparent;
color: var(--muted);
cursor: pointer;
flex: 0 0 24px;
}
.source-toggle:hover {
border-color: var(--border);
background: var(--surface-2);
}
.source-toggle-empty {
visibility: hidden;
}
.source-check {
width: 15px;
height: 15px;
accent-color: var(--accent);
cursor: pointer;
}
.source-label {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1;
}
.source-root-title {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.source-root-badge {
display: inline-flex;
align-items: center;
padding: 2px 7px;
border-radius: 999px;
background: rgba(33, 133, 208, 0.1);
color: var(--accent-dark);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.source-item-hint {
font-size: 12px;
color: var(--muted);
}
.source-children {
position: relative;
padding: 6px 0 8px 0;
}
.source-children::before {
content: "";
position: absolute;
left: 26px;
top: 0;
bottom: 8px;
width: 1px;
background: linear-gradient(180deg, rgba(33, 133, 208, 0.2), rgba(33, 133, 208, 0.03));
}
.source-loading {
padding: 6px 16px 10px 48px;
color: var(--muted);
font-size: 12px;
}
.source-item { .source-item {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -325,5 +480,15 @@ a:hover { text-decoration: underline; }
@media (max-width: 720px) { @media (max-width: 720px) {
.page-header { flex-wrap: wrap; padding: 12px 16px; } .page-header { flex-wrap: wrap; padding: 12px 16px; }
.page-main { width: calc(100vw - 24px); margin-top: 20px; } .page-main { width: calc(100vw - 24px); margin-top: 20px; }
.page-footer { width: calc(100vw - 24px); margin-top: -8px; }
.disk-grid { grid-template-columns: 1fr; }
.kv-table th { width: 130px; } .kv-table th { width: 130px; }
.btn-row { flex-wrap: wrap; }
.path-input-row { flex-wrap: wrap; }
.source-root-row {
align-items: flex-start;
}
.source-root-title {
flex-wrap: wrap;
}
} }
+407 -114
View File
@@ -1,137 +1,430 @@
{{define "content"}} {{define "content"}}
<section class="panel"> <section class="panel">
<h2>Накопитель</h2> <h2>Disks</h2>
<table class="kv-table"> <div class="panel-body">
<tbody> <div id="diskSummary" class="text-muted">Loading disks...</div>
<tr>
<th>Статус</th>
<td id="diskState"><span class="badge badge-unknown">Не подключён</span></td>
</tr>
<tr id="rowDiskID" class="hidden">
<th>ID диска</th>
<td><span class="mono" id="valDiskID"></span></td>
</tr>
<tr id="rowTotal" class="hidden">
<th>Всего на диске</th>
<td id="valTotal"></td>
</tr>
<tr id="rowFree" class="hidden">
<th>Свободно</th>
<td id="valFree"></td>
</tr>
</tbody>
</table>
</section>
<section class="panel hidden" id="progressPanel">
<h2>Копирование</h2>
<div style="padding:14px 16px">
<div class="progress-bar-bg">
<div class="progress-bar-fill" id="progressFill" style="width:0%"></div>
</div>
<div class="progress-label" id="progressMsg">Подготовка…</div>
</div> </div>
</section> </section>
<div class="btn-row" style="background:transparent;border:none;padding:0;margin-bottom:24px"> <div class="disk-grid" id="diskGrid"></div>
<button class="button-primary" id="btnStart" onclick="startCopy()" disabled>▶ Запустить копирование</button>
<button class="button-danger hidden" id="btnCancel" onclick="cancelCopy()">✕ Отменить</button>
</div>
<script> <script>
let pollInterval = null; let disks = [];
let activeTaskId = null; const taskState = new Map(); // taskID -> task
const taskPollers = new Map(); // taskID -> intervalID
const openPanels = new Set(); // disk key -> panel is open
const renderedDisks = new Map(); // disk key -> { state, disk_id } — what we last fully rendered
async function refreshDisk() { // ─── helpers ────────────────────────────────────────────────────────────────
try {
const r = await fetch('/api/disk');
if (!r.ok) return;
const d = await r.json();
const labels = { absent: 'Не подключён', foreign: 'Незнакомый диск', known: 'Диск подключён' }; function escapeHTML(v) {
const cls = { absent: 'badge-unknown', foreign: 'badge-warn', known: 'badge-ok' }; return String(v || '').replace(/[&<>"']/g, c =>
document.getElementById('diskState').innerHTML = ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
`<span class="badge ${cls[d.state]||'badge-unknown'}">${labels[d.state]||'—'}</span>`;
const known = d.state === 'known';
['rowDiskID','rowTotal','rowFree'].forEach(id =>
document.getElementById(id).classList.toggle('hidden', !known));
if (known) {
document.getElementById('valDiskID').textContent = d.disk_id;
document.getElementById('valTotal').textContent = fmtBytes(d.total_bytes);
document.getElementById('valFree').textContent = fmtBytes(d.free_bytes);
}
const hasTask = !!d.active_task_id;
document.getElementById('btnStart').disabled = !known || hasTask;
document.getElementById('btnStart').classList.toggle('hidden', hasTask);
document.getElementById('btnCancel').classList.toggle('hidden', !hasTask);
document.getElementById('progressPanel').classList.toggle('hidden', !hasTask);
if (d.active_task_id && d.active_task_id !== activeTaskId) {
activeTaskId = d.active_task_id;
startTaskPoll(activeTaskId);
}
if (!d.active_task_id && activeTaskId) {
activeTaskId = null; stopTaskPoll();
document.getElementById('progressPanel').classList.add('hidden');
}
} catch(e) {}
} }
function startTaskPoll(id) { stopTaskPoll(); pollInterval = setInterval(() => pollTask(id), 1500); } function diskKey(disk) { return disk.disk_id || disk.mount_path; }
function stopTaskPoll() { if (pollInterval) { clearInterval(pollInterval); pollInterval = null; } }
async function pollTask(id) { function badgeClass(s) {
try { return ({ absent:'badge-unknown', foreign:'badge-warn', known:'badge-ok' })[s] || 'badge-unknown';
const r = await fetch('/api/tasks/' + id); }
if (!r.ok) return; function badgeLabel(s) {
const t = await r.json(); return ({ absent:'Not connected', foreign:'Uninitialized disk', known:'Ready' })[s] || '—';
document.getElementById('progressFill').style.width = t.progress + '%';
document.getElementById('progressMsg').textContent = t.message || '…';
if (['success','failed','canceled'].includes(t.status)) {
stopTaskPoll(); activeTaskId = null;
document.getElementById('btnStart').disabled = false;
document.getElementById('btnStart').classList.remove('hidden');
document.getElementById('btnCancel').classList.add('hidden');
document.getElementById('progressPanel').classList.add('hidden');
if (t.status === 'success') toast(t.message || 'Готово', 'ok');
if (t.status === 'failed') toast('Ошибка: ' + t.error, 'error');
if (t.status === 'canceled') toast('Копирование отменено', 'error');
refreshDisk();
}
} catch(e) {}
} }
async function startCopy() { function fmtSpeed(bps) {
document.getElementById('btnStart').disabled = true; if (!bps) return '';
try { if (bps >= 1e9) return (bps/1e9).toFixed(1)+' GB/s';
const r = await fetch('/api/copy/start', { method: 'POST' }); if (bps >= 1e6) return (bps/1e6).toFixed(1)+' MB/s';
const d = await r.json(); if (bps >= 1e3) return (bps/1e3).toFixed(0)+' KB/s';
if (!r.ok) { return bps+' B/s';
toast(d.error || 'Ошибка запуска', 'error'); }
document.getElementById('btnStart').disabled = false; function fmtETA(sec) {
return; if (!sec || sec <= 0) return '';
if (sec >= 3600) return Math.floor(sec/3600)+' h '+Math.floor((sec%3600)/60)+' min';
if (sec >= 60) return Math.floor(sec/60)+' min';
return sec+' s';
}
function fmtBytes(b) {
if (!b) return '—';
if (b >= 1e12) return (b/1e12).toFixed(1)+' TB';
if (b >= 1e9) return (b/1e9).toFixed(1)+' GB';
if (b >= 1e6) return (b/1e6).toFixed(1)+' MB';
return b+' B';
}
function fmtDateTime(v) {
if (!v) return 'Never';
const d = new Date(v);
if (isNaN(d)) return v;
return d.toLocaleString('en-US', { year:'numeric', month:'short', day:'2-digit', hour:'2-digit', minute:'2-digit' });
}
function taskMeta(t) {
if (!t) return '';
return [fmtSpeed(t.speed_bps), t.eta_sec ? 'ETA: '+fmtETA(t.eta_sec) : ''].filter(Boolean).join(' · ');
}
// ─── progress section (dynamic) ─────────────────────────────────────────────
function progressHTML(disk) {
const task = disk.active_task_id ? taskState.get(disk.active_task_id) : null;
if (!task) return '';
const pct = task.progress || 0;
const message = task.message || 'Preparing...';
const meta = taskMeta(task);
return `
<div class="panel-body progress-wrap">
<div class="progress-bar-bg">
<div class="progress-bar-fill" style="width:${pct}%"></div>
</div>
<div class="progress-label">${escapeHTML(message)}</div>
${meta ? `<div class="progress-label">${escapeHTML(meta)}</div>` : ''}
</div>`;
}
function btnsHTML(disk) {
const task = disk.active_task_id ? taskState.get(disk.active_task_id) : null;
const key = escapeHTML(diskKey(disk));
const busy = !!task;
const isKnown = disk.state === 'known';
const isForeign = disk.state === 'foreign';
return `
${isKnown ? `
<button class="button-danger" data-action="start-copy" data-mode="replace" data-disk-id="${key}" ${busy?'disabled':''}>Replace media</button>
<button class="button-primary" data-action="start-copy" data-mode="add" data-disk-id="${key}" ${busy?'disabled':''}>Add media</button>
<button class="button-danger ${busy?'':'hidden'}" data-action="cancel-copy" data-disk-id="${key}">Cancel</button>
<button class="button-secondary" data-action="disk-settings" data-disk-key="${key}" style="margin-left:auto">⚙ Settings${openPanels.has(diskKey(disk))?' ✕':''}</button>
` : ''}
${isForeign ? `
<button class="button-secondary" data-action="init-disk" data-mount-path="${escapeHTML(disk.mount_path)}">Initialize disk</button>
` : ''}`;
}
// ─── full card render (only on first appearance or state change) ─────────────
function renderProfile(disk) {
const p = disk.profile || {};
const t = p.transcode || null;
const te = !!t;
const k = escapeHTML(diskKey(disk));
const sel = (name, value, opts) => {
const o = opts.map(([v,l]) =>
`<option value="${v}" ${v===value?'selected':''}>${escapeHTML(l)}</option>`
).join('');
return `<select class="form-input" id="prof_${name}_${k}">${o}</select>`;
};
return `
<section class="panel" id="profilePanel_${k}" style="display:${openPanels.has(diskKey(disk))?'':'none'}">
<h2>Профиль диска</h2>
<div class="panel-body">
<h3>Параметры копирования</h3>
<div class="form-group">
<label>Папка назначения</label>
<input class="form-input" type="text" id="prof_dest_folder_${k}" 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>
${sel('shuffle_depth', String(p.shuffle_depth??-1), [['-1','По порядку (без перемешивания)'],['0','Случайный порядок файлов'],['1','Случайная папка 1-го уровня (жанр)'],['2','Случайная папка 2-го уровня (сериал)'],['3','Случайная папка 3-го уровня (сезон)']])}
<span class="form-hint">Уровень задаёт глубину вложения: все файлы выбранной папки копируются целиком.</span>
</div>
<div class="form-group">
<label>Резерв свободного места (ГБ)</label>
<input class="form-input" type="number" id="prof_reserve_free_gb_${k}" value="${p.reserve_free_gb??2}" min="0" step="0.5">
</div>
<div class="form-group">
<label><input type="checkbox" id="prof_auto_copy_${k}" ${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_${k}" ${te?'checked':''}
onchange="document.getElementById('transcodeFields_${k}').style.display=this.checked?'':'none'">
Ограничить видео под устройство
</label>
</div>
<div id="transcodeFields_${k}" style="${te?'':'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>
<div class="btn-row" style="margin-top:1em">
<button class="button-primary" onclick="saveProfile('${escapeHTML(disk.mount_path)}','${k}')">Сохранить профиль</button>
</div>
</div>
</section>`;
}
function diskCardHTML(disk) {
const key = escapeHTML(diskKey(disk));
const hasCapacity = disk.state !== 'absent';
return `
<section class="panel disk-card" id="diskCard_${key}">
<h2>${escapeHTML(disk.mount_path)}</h2>
<table class="kv-table"><tbody>
<tr><th>Status</th><td><span class="badge ${badgeClass(disk.state)}">${badgeLabel(disk.state)}</span></td></tr>
<tr><th>Disk ID</th><td>${disk.disk_id ? `<span class="mono">${escapeHTML(disk.disk_id)}</span>` : '<span class="text-muted">not initialized yet</span>'}</td></tr>
<tr><th>Total capacity</th><td>${hasCapacity ? fmtBytes(disk.total_bytes) : '—'}</td></tr>
<tr><th>Free space</th><td>${hasCapacity ? fmtBytes(disk.free_bytes) : '—'}</td></tr>
<tr><th>Last copied</th><td>${fmtDateTime(disk.last_copied_at)}</td></tr>
</tbody></table>
<div id="diskProgress_${key}">${progressHTML(disk)}</div>
<div class="btn-row" id="diskBtns_${key}">${btnsHTML(disk)}</div>
</section>
${disk.state === 'known' ? renderProfile(disk) : ''}`;
}
// ─── incremental DOM update ──────────────────────────────────────────────────
function renderDisks() {
const grid = document.getElementById('diskGrid');
const summary = document.getElementById('diskSummary');
const knownCount = disks.filter(d => d.state === 'known').length;
summary.textContent = disks.length
? `Disks found: ${disks.length}. Ready to copy: ${knownCount}.`
: 'No disks found.';
const incoming = new Map(disks.map(d => [diskKey(d), d]));
// Remove disappeared disks
for (const [key] of renderedDisks) {
if (!incoming.has(key)) {
if (openPanels.has(key)) {
toast('Диск отключён — несохранённые изменения потеряны', 'error');
openPanels.delete(key);
}
document.getElementById('diskCard_' + key)?.remove();
document.getElementById('profilePanel_' + key)?.remove();
renderedDisks.delete(key);
} }
activeTaskId = d.task_id; }
document.getElementById('btnStart').classList.add('hidden');
document.getElementById('btnCancel').classList.remove('hidden'); // Add or update disks
document.getElementById('progressPanel').classList.remove('hidden'); for (const disk of disks) {
document.getElementById('progressFill').style.width = '0%'; const key = diskKey(disk);
document.getElementById('progressMsg').textContent = 'Подготовка…'; const prev = renderedDisks.get(key);
startTaskPoll(activeTaskId);
} catch(e) { // Need full re-render: first appearance OR state / disk_id changed
toast('Ошибка связи', 'error'); const needFull = !prev || prev.state !== disk.state || prev.disk_id !== disk.disk_id;
document.getElementById('btnStart').disabled = false;
if (needFull) {
const existing = document.getElementById('diskCard_' + key);
const tmp = document.createElement('div');
tmp.innerHTML = diskCardHTML(disk);
if (existing) {
// Replace in-place (profile panel follows immediately after)
const panel = document.getElementById('profilePanel_' + key);
existing.replaceWith(...tmp.childNodes);
panel?.remove();
// Profile panel was re-rendered inside tmp; it's already inserted
} else {
// Append new
while (tmp.firstChild) grid.appendChild(tmp.firstChild);
}
} else {
// Partial update: only progress and buttons
const progEl = document.getElementById('diskProgress_' + key);
if (progEl) progEl.innerHTML = progressHTML(disk);
const btnsEl = document.getElementById('diskBtns_' + key);
if (btnsEl) btnsEl.innerHTML = btnsHTML(disk);
}
renderedDisks.set(key, { state: disk.state, disk_id: disk.disk_id });
} }
} }
async function cancelCopy() { // ─── profile save ────────────────────────────────────────────────────────────
try { await fetch('/api/copy/cancel', { method: 'POST' }); toast('Отмена…', 'ok'); } catch(e) {}
async function saveProfile(mountPath, key) {
const g = id => document.getElementById(id);
const te = g(`prof_transcode_enabled_${key}`)?.checked;
const profile = {
dest_folder: g(`prof_dest_folder_${key}`)?.value.trim() || 'media',
overwrite_mode: g(`prof_overwrite_mode_${key}`)?.value || 'skip',
file_select_mode: g(`prof_file_select_mode_${key}`)?.value || 'new',
reserve_free_gb: parseFloat(g(`prof_reserve_free_gb_${key}`)?.value || '2') || 0,
auto_copy: g(`prof_auto_copy_${key}`)?.checked || false,
shuffle_depth: parseInt(g(`prof_shuffle_depth_${key}`)?.value ?? '-1', 10),
};
if (te) {
profile.transcode = {
video_codec: g(`prof_video_codec_${key}`)?.value || 'h264',
max_resolution: g(`prof_max_resolution_${key}`)?.value || '720p',
max_video_bitrate: g(`prof_max_video_bitrate_${key}`)?.value || '',
max_fps: parseInt(g(`prof_max_fps_${key}`)?.value || '0', 10),
audio_codec: g(`prof_audio_codec_${key}`)?.value || 'aac',
max_audio_bitrate: g(`prof_max_audio_bitrate_${key}`)?.value || '',
max_audio_channels: parseInt(g(`prof_max_audio_channels_${key}`)?.value || '0', 10),
output_format: g(`prof_output_format_${key}`)?.value || 'mp4',
};
}
try {
const r = await fetch('/api/disks/profile?mount_path=' + encodeURIComponent(mountPath), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(profile),
});
const payload = await r.json();
if (!r.ok) { toast(payload.error || 'Ошибка сохранения профиля', 'error'); return; }
toast('Профиль сохранён', 'ok');
} catch { toast('Ошибка сети', 'error'); }
} }
refreshDisk(); // ─── task polling ─────────────────────────────────────────────────────────────
setInterval(refreshDisk, 5000);
function stopTaskPoll(id) {
if (!taskPollers.has(id)) return;
clearInterval(taskPollers.get(id));
taskPollers.delete(id);
}
function startTaskPoll(id) {
if (!id || taskPollers.has(id)) return;
taskPollers.set(id, setInterval(() => pollTask(id), 1500));
pollTask(id);
}
async function pollTask(taskID) {
try {
const r = await fetch('/api/tasks/' + taskID);
if (!r.ok) return;
const task = await r.json();
taskState.set(taskID, task);
renderDisks();
if (['success','failed','canceled'].includes(task.status)) {
stopTaskPoll(taskID);
taskState.delete(taskID);
if (task.status === 'success') toast(task.message || 'Done', 'ok');
if (task.status === 'failed') toast('Error: ' + task.error, 'error');
if (task.status === 'canceled') toast('Copy canceled', 'error');
refreshDisks();
}
} catch {}
}
// ─── disk list refresh ────────────────────────────────────────────────────────
async function refreshDisks() {
try {
const r = await fetch('/api/disks');
if (!r.ok) return;
const payload = await r.json();
disks = payload.items || [];
renderDisks();
const active = new Set(disks.map(d => d.active_task_id).filter(Boolean));
for (const id of taskPollers.keys()) {
if (!active.has(id)) { stopTaskPoll(id); taskState.delete(id); }
}
for (const id of active) startTaskPoll(id);
} catch {}
}
// ─── copy / init actions ─────────────────────────────────────────────────────
async function startCopy(diskID, mode) {
try {
const r = await fetch('/api/disks/' + encodeURIComponent(diskID) + '/copy/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode }),
});
const p = await r.json();
if (!r.ok) { toast(p.error || 'Failed to start copy', 'error'); return; }
startTaskPoll(p.task_id);
refreshDisks();
} catch { toast('Network error', 'error'); }
}
async function cancelCopy(diskID) {
try {
await fetch('/api/disks/' + encodeURIComponent(diskID) + '/copy/cancel', { method: 'POST' });
toast('Canceling...', 'ok');
} catch { toast('Network error', 'error'); }
}
async function initDisk(mountPath) {
try {
const r = await fetch('/api/disks/init', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mount_path: mountPath }),
});
const p = await r.json();
if (!r.ok) { toast(p.error || 'Failed to initialize disk', 'error'); return; }
toast('Disk initialized', 'ok');
refreshDisks();
} catch { toast('Network error', 'error'); }
}
// ─── event delegation ─────────────────────────────────────────────────────────
document.getElementById('diskGrid').addEventListener('click', (event) => {
const btn = event.target.closest('button[data-action]');
if (!btn) return;
const action = btn.dataset.action;
if (action === 'start-copy') startCopy(btn.dataset.diskId, btn.dataset.mode || 'add');
if (action === 'cancel-copy') cancelCopy(btn.dataset.diskId);
if (action === 'init-disk') initDisk(btn.dataset.mountPath);
if (action === 'disk-settings') {
const key = btn.dataset.diskKey;
const panel = document.getElementById('profilePanel_' + key);
if (!panel) return;
const open = panel.style.display !== 'none';
panel.style.display = open ? 'none' : '';
if (open) openPanels.delete(key); else openPanels.add(key);
btn.textContent = open ? '⚙ Settings' : '⚙ Settings ✕';
}
});
// ─── init ─────────────────────────────────────────────────────────────────────
refreshDisks();
setInterval(refreshDisks, 5000);
</script> </script>
{{end}} {{end}}
+10 -6
View File
@@ -1,5 +1,5 @@
{{define "layout"}}<!DOCTYPE html> {{define "layout"}}<!DOCTYPE html>
<html lang="ru"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -12,7 +12,7 @@
<h1>🎵 Jukebox Maker</h1> <h1>🎵 Jukebox Maker</h1>
<nav class="header-nav"> <nav class="header-nav">
<a href="/" class="header-action {{if eq .Page "dashboard"}}active{{end}}">Dashboard</a> <a href="/" class="header-action {{if eq .Page "dashboard"}}active{{end}}">Dashboard</a>
<a href="/settings" class="header-action {{if eq .Page "settings"}}active{{end}}">Настройки</a> <a href="/settings" class="header-action {{if eq .Page "settings"}}active{{end}}">Settings</a>
</nav> </nav>
</header> </header>
@@ -20,6 +20,10 @@
{{template "content" .}} {{template "content" .}}
</main> </main>
<footer class="page-footer">
<span>Version {{.Version}}</span>
</footer>
<div class="toast-container" id="toastContainer"></div> <div class="toast-container" id="toastContainer"></div>
<script> <script>
@@ -33,10 +37,10 @@ function toast(msg, type) {
} }
function fmtBytes(b) { function fmtBytes(b) {
if (!b) return '—'; if (!b) return '—';
if (b >= 1e12) return (b/1e12).toFixed(1) + ' ТБ'; if (b >= 1e12) return (b/1e12).toFixed(1) + ' TB';
if (b >= 1e9) return (b/1e9).toFixed(1) + ' ГБ'; if (b >= 1e9) return (b/1e9).toFixed(1) + ' GB';
if (b >= 1e6) return (b/1e6).toFixed(1) + ' МБ'; if (b >= 1e6) return (b/1e6).toFixed(1) + ' MB';
return (b/1e3).toFixed(0) + ' КБ'; return (b/1e3).toFixed(0) + ' KB';
} }
</script> </script>
</body> </body>
+315 -59
View File
@@ -2,94 +2,312 @@
<form id="settingsForm" onsubmit="saveSettings(event)"> <form id="settingsForm" onsubmit="saveSettings(event)">
<section class="panel"> <section class="panel">
<h2>Источники копирования</h2> <h2>Copy Sources</h2>
<div class="source-list" id="sourceList"> <div class="panel-body">
<div class="text-muted" style="padding:12px 16px">Загрузка…</div> <div class="form-hint">Select top-level folders or expand branches and choose individual nested directories.</div>
</div>
<div class="source-list">
<div class="source-tree" id="sourceTree">
<div class="text-muted source-tree-empty">Loading...</div>
</div>
</div> </div>
<div class="btn-row"> <div class="btn-row">
<button type="button" class="button-secondary button-sm" onclick="loadSources()">↻ Обновить список</button> <button type="button" class="button-secondary button-sm" onclick="reloadSourceTree()">Refresh list</button>
</div> </div>
</section> </section>
<section class="panel"> <section class="panel">
<h2>Параметры копирования</h2> <h2>Copy Settings</h2>
<div class="form-body"> <div class="form-body">
<div class="form-group"> <div class="form-group">
<label class="form-label" for="reserveGB">Оставить свободным на диске (ГБ)</label> <label class="form-label" for="reserveGB">Reserved free space on disk (GB)</label>
<input class="form-input" type="number" id="reserveGB" min="0" max="1000" step="0.5" value="2"> <input class="form-input" type="number" id="reserveGB" min="0" max="1000" step="0.5" value="2">
<span class="form-hint">Копирование остановится, когда свободного места останется меньше этого значения.</span> <span class="form-hint">Copying will stop when free space falls below this value.</span>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label" for="fileSelectMode">Какие файлы копировать</label> <label class="form-label" for="fileSelectMode">Files to copy</label>
<select class="form-select" id="fileSelectMode" style="width:auto;max-width:420px"> <select class="form-select" id="fileSelectMode" style="width:auto;max-width:420px">
<option value="new">Только новые (не копировавшиеся на этот диск)</option> <option value="new">Only new files not copied to this disk before</option>
<option value="all">Все подряд</option> <option value="all">All matching files</option>
</select> </select>
<span class="form-hint">«Только новые» — пропускает файлы, уже скопированные на данный диск, даже если они были удалены с него (считаются просмотренными).</span> <span class="form-hint">The new-only mode skips files already copied to this disk, even if they were later removed.</span>
</div>
<div class="form-group" id="mediaTypesGroup">
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap">
<label class="form-label" style="margin:0">Allowed file types</label>
<button type="button" class="button-secondary button-sm" id="editAllowedFilesButton" onclick="toggleAllowedFilesEditor()">Edit list</button>
</div>
<div style="display:grid;gap:8px">
<label style="display:flex;align-items:flex-start;gap:8px;cursor:pointer">
<input type="checkbox" id="mediaTypeAudio" style="width:15px;height:15px;accent-color:var(--accent)">
<span>
<strong>Audio</strong>
<span class="form-hint" id="mediaTypeAudioHint" style="display:block"></span>
</span>
</label>
<label style="display:flex;align-items:flex-start;gap:8px;cursor:pointer">
<input type="checkbox" id="mediaTypeVideo" style="width:15px;height:15px;accent-color:var(--accent)">
<span>
<strong>Video</strong>
<span class="form-hint" id="mediaTypeVideoHint" style="display:block"></span>
</span>
</label>
<label style="display:flex;align-items:flex-start;gap:8px;cursor:pointer">
<input type="checkbox" id="mediaTypePhoto" style="width:15px;height:15px;accent-color:var(--accent)">
<span>
<strong>Photo</strong>
<span class="form-hint" id="mediaTypePhotoHint" style="display:block"></span>
</span>
</label>
</div>
<span class="form-hint">Built into the app by default: audio, video, and photo. New installations start with only audio and video enabled.</span>
</div>
<div class="form-group" id="extensionsGroup" style="display:none">
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap">
<label class="form-label" for="allowedExtensions" style="margin:0">Allowed extensions</label>
<button type="button" class="button-secondary button-sm" onclick="toggleAllowedFilesEditor()">Use media types</button>
</div>
<textarea class="form-input" id="allowedExtensions" rows="5" placeholder=".mp3, .flac, .mp4"></textarea>
<span class="form-hint">One extension per line or separated by commas. You can write <code>mp3</code> or <code>.mp3</code>.</span>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label" for="overwriteMode">Режим записи</label> <label class="form-label" for="destFolder">Destination folder on disk</label>
<input class="form-input" type="text" id="destFolder" placeholder="media" style="width:200px">
<span class="form-hint">Files will be copied into this subfolder while preserving the selected source structure. The disk root and <code>.jukebox</code> are never allowed here.</span>
</div>
<div class="form-group">
<label class="form-label" for="overwriteMode">Default write mode</label>
<select class="form-select" id="overwriteMode" style="width:auto;max-width:420px"> <select class="form-select" id="overwriteMode" style="width:auto;max-width:420px">
<option value="skip">Пропустить существующие файлы</option> <option value="skip">Keep existing files</option>
<option value="delete">Удалить наши данные с диска и перезаписать заново</option> <option value="delete">Replace destination folder contents</option>
</select> </select>
<span class="form-hint">«Удалить и перезаписать» — удаляет с диска всё кроме папки .jukebox, затем копирует заново.</span> <span class="form-hint">This is used for automatic copy runs. Manual dashboard actions can override it.</span>
</div> </div>
</div> </div>
</section> </section>
<section class="panel"> <section class="panel">
<h2>Автоматизация</h2> <h2>Automation</h2>
<div class="form-body"> <div class="form-body">
<div class="form-group"> <div class="form-group">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer"> <label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="autoCopy" style="width:15px;height:15px;accent-color:var(--accent)"> <input type="checkbox" id="autoCopy" style="width:15px;height:15px;accent-color:var(--accent)">
<span class="form-label" style="margin:0">Автоматическое копирование</span> <span class="form-label" style="margin:0">Automatic copy</span>
</label> </label>
<span class="form-hint">При обнаружении знакомого накопителя копирование запустится автоматически.</span> <span class="form-hint">Start copying automatically when a known disk is detected.</span>
</div> </div>
</div> </div>
</section> </section>
<div style="display:flex;gap:8px;margin-bottom:24px"> <div style="display:flex;gap:8px;margin-bottom:24px">
<button type="submit" class="button-primary">Сохранить настройки</button> <button type="submit" class="button-primary">Save settings</button>
<button type="button" class="button-secondary" onclick="loadSettings()">Сбросить</button> <button type="button" class="button-secondary" onclick="loadSettings()">Reset</button>
</div> </div>
</form> </form>
<script> <script>
let allSources = []; const sourceTree = new Map();
let enabledSources = {}; const expandedNodes = new Set();
const loadingNodes = new Set();
const builtInMediaTypes = {
audio: ['.aac', '.aif', '.aiff', '.alac', '.ape', '.flac', '.m4a', '.mp2', '.mp3', '.ogg', '.opus', '.wav', '.wma'],
video: ['.3gp', '.avi', '.m2ts', '.m4v', '.mkv', '.mov', '.mp4', '.mpeg', '.mpg', '.mts', '.ts', '.webm', '.wmv'],
photo: ['.bmp', '.gif', '.heic', '.heif', '.jpeg', '.jpg', '.png', '.tif', '.tiff', '.webp'],
};
let sourceConfig = {};
let allowedFilesMode = 'media_types';
function escapeHTML(value) {
return String(value || '').replace(/[&<>"']/g, (char) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[char]));
}
function pathDepth(path) {
return path ? path.split('/').length : 0;
}
function parentPath(path) {
if (!path || !path.includes('/')) return '';
return path.slice(0, path.lastIndexOf('/'));
}
function effectiveSourceState(path) {
let current = path;
while (true) {
if (Object.prototype.hasOwnProperty.call(sourceConfig, current)) {
return sourceConfig[current];
}
if (!current) return true;
current = parentPath(current);
}
}
function collectSourcesForSave() {
const items = [];
const seen = new Set();
const roots = sourceTree.get('') || [];
for (const item of roots) {
items.push({ path: item.path, enabled: effectiveSourceState(item.path) });
seen.add(item.path);
}
Object.entries(sourceConfig).forEach(([path, enabled]) => {
if (seen.has(path)) return;
items.push({ path, enabled });
});
return items.sort((a, b) => a.path.localeCompare(b.path));
}
async function loadSourceChildren(path = '') {
if (loadingNodes.has(path)) return;
loadingNodes.add(path);
renderSources();
async function loadSources() {
try { try {
const r = await fetch('/api/sources'); const query = path ? '?path=' + encodeURIComponent(path) : '';
if (!r.ok) return; const response = await fetch('/api/sources' + query);
const d = await r.json(); if (!response.ok) return;
allSources = d.items || []; const payload = await response.json();
sourceTree.set(path, payload.items || []);
} catch (error) {
} finally {
loadingNodes.delete(path);
renderSources(); renderSources();
} catch(e) {} }
}
async function ensureExpanded(path) {
expandedNodes.add(path);
if (!sourceTree.has(path)) {
await loadSourceChildren(path);
return;
}
renderSources();
}
function toggleSource(path, checked) {
sourceConfig[path] = checked;
renderSources();
}
function renderSourceNodes(parent = '') {
const items = sourceTree.get(parent) || [];
return items.map((item) => {
const checked = effectiveSourceState(item.path);
const expanded = expandedNodes.has(item.path);
const childrenKnown = sourceTree.has(item.path);
const children = childrenKnown ? sourceTree.get(item.path) : [];
const hasChildren = !childrenKnown || children.length > 0;
const pad = 16 + pathDepth(item.path) * 20;
return `
<div class="source-node">
<div class="source-row" style="padding-left:${pad}px">
<button
type="button"
class="source-toggle ${hasChildren ? '' : 'source-toggle-empty'}"
data-action="toggle-expand"
data-path="${escapeHTML(item.path)}"
${hasChildren ? '' : 'tabindex="-1" aria-hidden="true"'}
>${expanded ? '▾' : '▸'}</button>
<input class="source-check" type="checkbox" data-action="toggle-check" data-path="${escapeHTML(item.path)}" ${checked ? 'checked' : ''}>
<div class="source-label">
<span class="source-item-name">${escapeHTML(item.name)}</span>
<span class="source-item-path">/media/${escapeHTML(item.path)}</span>
</div>
</div>
${expanded && loadingNodes.has(item.path) ? '<div class="source-loading">Loading...</div>' : ''}
${expanded && childrenKnown && children.length ? `<div class="source-children">${renderSourceNodes(item.path)}</div>` : ''}
</div>
`;
}).join('');
} }
function renderSources() { function renderSources() {
const el = document.getElementById('sourceList'); const el = document.getElementById('sourceTree');
if (!allSources.length) { const roots = sourceTree.get('');
el.innerHTML = '<div class="text-muted" style="padding:12px 16px">Папки в /media не найдены.</div>';
if (loadingNodes.has('') && !roots) {
el.innerHTML = '<div class="text-muted source-tree-empty">Loading...</div>';
return; return;
} }
el.innerHTML = allSources.map(path => { if (!roots || !roots.length) {
const checked = enabledSources[path] !== false; el.innerHTML = '<div class="text-muted source-tree-empty">No folders found in /media.</div>';
return `<label class="source-item"> return;
<input type="checkbox" data-source="${path}" ${checked ? 'checked' : ''}> }
<span class="source-item-name">${path}</span>
<span class="source-item-path">/media/${path}</span> el.innerHTML = renderSourceNodes('');
</label>`; }
}).join('');
async function reloadSourceTree() {
sourceTree.clear();
expandedNodes.clear();
await loadSourceChildren('');
}
function defaultAllowedExtensions() {
return [...builtInMediaTypes.audio, ...builtInMediaTypes.video];
}
function parseExtensionsInput(value) {
const items = String(value || '')
.split(/[\n,]+/)
.map((item) => item.trim())
.filter(Boolean);
const result = [];
const seen = new Set();
items.forEach((item) => {
let value = item.toLowerCase().replace(/^\*/, '');
if (!value.startsWith('.')) value = '.' + value;
if (!/^\.[a-z0-9]+$/.test(value)) return;
if (seen.has(value)) return;
seen.add(value);
result.push(value);
});
return result;
}
function formatExtensionsInput(items) {
return (items || []).join('\n');
}
function selectedMediaTypes() {
return ['audio', 'video', 'photo'].filter((name) => {
const id = 'mediaType' + name.charAt(0).toUpperCase() + name.slice(1);
return document.getElementById(id).checked;
});
}
function updateAllowedFilesModeUI() {
document.getElementById('mediaTypesGroup').style.display = allowedFilesMode === 'media_types' ? '' : 'none';
document.getElementById('extensionsGroup').style.display = allowedFilesMode === 'extensions' ? '' : 'none';
}
function toggleAllowedFilesEditor() {
allowedFilesMode = allowedFilesMode === 'extensions' ? 'media_types' : 'extensions';
updateAllowedFilesModeUI();
}
function renderMediaTypeHints() {
document.getElementById('mediaTypeAudioHint').textContent = builtInMediaTypes.audio.join(', ');
document.getElementById('mediaTypeVideoHint').textContent = builtInMediaTypes.video.join(', ');
document.getElementById('mediaTypePhotoHint').textContent = builtInMediaTypes.photo.join(', ');
} }
async function loadSettings() { async function loadSettings() {
@@ -98,41 +316,79 @@ async function loadSettings() {
if (!r.ok) return; if (!r.ok) return;
const cfg = await r.json(); const cfg = await r.json();
document.getElementById('reserveGB').value = cfg.reserve_free_gb ?? 2; document.getElementById('reserveGB').value = cfg.reserve_free_gb ?? 2;
document.getElementById('destFolder').value = cfg.dest_folder || 'media';
document.getElementById('fileSelectMode').value = cfg.file_select_mode || 'new'; document.getElementById('fileSelectMode').value = cfg.file_select_mode || 'new';
document.getElementById('overwriteMode').value = cfg.overwrite_mode || 'skip'; document.getElementById('overwriteMode').value = cfg.overwrite_mode || 'skip';
document.getElementById('autoCopy').checked = !!cfg.auto_copy; document.getElementById('autoCopy').checked = !!cfg.auto_copy;
enabledSources = {}; allowedFilesMode = cfg.allowed_files_mode || 'media_types';
(cfg.sources || []).forEach(s => { enabledSources[s.path] = s.enabled; }); document.getElementById('mediaTypeAudio').checked = (cfg.enabled_media_types || ['audio', 'video']).includes('audio');
document.getElementById('mediaTypeVideo').checked = (cfg.enabled_media_types || ['audio', 'video']).includes('video');
document.getElementById('mediaTypePhoto').checked = (cfg.enabled_media_types || []).includes('photo');
document.getElementById('allowedExtensions').value = formatExtensionsInput((cfg.allowed_extensions || []).length ? cfg.allowed_extensions : defaultAllowedExtensions());
updateAllowedFilesModeUI();
sourceConfig = {};
(cfg.sources || []).forEach((source) => {
sourceConfig[source.path] = !!source.enabled;
});
renderSources(); renderSources();
} catch(e) {} } catch (error) {}
} }
async function saveSettings(e) { async function saveSettings(event) {
e.preventDefault(); event.preventDefault();
const checkboxes = document.querySelectorAll('[data-source]');
const sources = Array.from(checkboxes).map(cb => ({ path: cb.dataset.source, enabled: cb.checked }));
Object.keys(enabledSources).forEach(path => {
if (!sources.find(s => s.path === path)) sources.push({ path, enabled: false });
});
const body = { const body = {
reserve_free_gb: parseFloat(document.getElementById('reserveGB').value) || 2, reserve_free_gb: parseFloat(document.getElementById('reserveGB').value) || 2,
file_select_mode: document.getElementById('fileSelectMode').value, dest_folder: document.getElementById('destFolder').value.trim() || 'media',
overwrite_mode: document.getElementById('overwriteMode').value, file_select_mode: document.getElementById('fileSelectMode').value,
auto_copy: document.getElementById('autoCopy').checked, allowed_files_mode: allowedFilesMode,
sources, enabled_media_types: selectedMediaTypes(),
allowed_extensions: parseExtensionsInput(document.getElementById('allowedExtensions').value),
overwrite_mode: document.getElementById('overwriteMode').value,
auto_copy: document.getElementById('autoCopy').checked,
sources: collectSourcesForSave(),
}; };
try { try {
const r = await fetch('/api/config', { const response = await fetch('/api/config', {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
if (r.ok) { toast('Настройки сохранены', 'ok'); await loadSettings(); } if (response.ok) {
else { const d = await r.json(); toast(d.error || 'Ошибка сохранения', 'error'); } toast('Settings saved', 'ok');
} catch(e) { toast('Ошибка связи', 'error'); } await loadSettings();
return;
}
const payload = await response.json();
toast(payload.error || 'Failed to save settings', 'error');
} catch (error) {
toast('Network error', 'error');
}
} }
document.getElementById('sourceTree').addEventListener('click', async (event) => {
const button = event.target.closest('[data-action="toggle-expand"]');
if (!button) return;
const path = button.dataset.path;
if (expandedNodes.has(path)) {
expandedNodes.delete(path);
renderSources();
return;
}
await ensureExpanded(path);
});
document.getElementById('sourceTree').addEventListener('change', (event) => {
const checkbox = event.target.closest('[data-action="toggle-check"]');
if (!checkbox) return;
toggleSource(checkbox.dataset.path, checkbox.checked);
});
renderMediaTypeHints();
loadSettings(); loadSettings();
loadSources(); loadSourceChildren('');
</script> </script>
{{end}} {{end}}