8 Commits

Author SHA1 Message Date
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
6953c151fe Add configurable allowed file types 2026-04-24 16:36:48 +03:00
50246ada85 Add standalone desktop workflow 2026-04-24 11:54:33 +03:00
75c6b928ae Tighten disk safety checks 2026-04-24 07:18:17 +03:00
b8eabee393 Store unfinished tasks on disks 2026-04-24 07:10:26 +03:00
0afc1d761b Fix empty disk mount detection 2026-04-23 22:58:07 +03:00
e7917b41b5 Improve disk UI and build performance 2026-04-23 22:51:36 +03:00
31bac2b5d8 Add multi-disk copy workflow 2026-04-23 22:24:32 +03:00
32 changed files with 3386 additions and 413 deletions

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
.git
.gitignore
.DS_Store
bin
dist
tmp
.tmp

4
.gitignore vendored
View File

@@ -27,7 +27,9 @@ go.work.sum
# Build output
/jukebox
/release/
/.tmp/
/.gocache/
# Temp copy files
*.juketmp

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
ARG VERSION=dev
ARG TARGETOS
ARG TARGETARCH
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 . .
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
FROM alpine:3.19
RUN apk add --no-cache tzdata ca-certificates rsync
RUN apk add --no-cache tzdata ca-certificates rsync ffmpeg
WORKDIR /app
COPY --from=builder /out/jukebox .

51
Makefile Normal file
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

View File

@@ -2,6 +2,8 @@ package main
import (
"context"
"encoding/json"
"errors"
"flag"
"log"
"net/http"
@@ -19,6 +21,8 @@ import (
"jukebox_maker/internal/watcher"
)
var Version = "dev"
func main() {
configPath := flag.String("config", "/config/config.json", "path to config file")
addr := flag.String("addr", ":8080", "HTTP listen address")
@@ -30,72 +34,140 @@ func main() {
if err != nil {
log.Fatalf("load config: %v", err)
}
if cfg.MediaPath == "" {
cfg.MediaPath = config.NormalizeMediaPath(*mediaPath)
}
taskStore := task.NewStore()
cp := copier.New(taskStore)
var activeDB *db.DB
var activeDiskID string
activeDBs := make(map[string]*db.DB)
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) {
if activeDiskID == info.DiskID {
return // already open for this disk
if info.DiskID == "" {
return
}
if activeDB != nil {
activeDB.Close()
activeDB = nil
activeDiskID = ""
if prevDiskID, ok := mountToDiskID[info.MountPath]; ok && prevDiskID != info.DiskID {
if prevDB := activeDBs[prevDiskID]; prevDB != nil {
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))
if err != nil {
log.Printf("open disk DB: %v", err)
return
}
activeDB = d
activeDiskID = info.DiskID
cp.SetDB(d)
activeDBs[info.DiskID] = d
cp.SetDB(info.DiskID, d)
log.Printf("disk DB opened for %s", info.DiskID)
resumeDiskTask(info, d)
}
closeDiskDB := func() {
if activeDB != nil {
activeDB.Close()
activeDB = nil
activeDiskID = ""
cp.SetDB(nil)
log.Println("disk DB closed")
closeDiskDB := func(info disk.DiskInfo) {
diskID := info.DiskID
if diskID == "" {
diskID = mountToDiskID[info.MountPath]
}
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) {
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 {
case disk.DiskKnown:
openDiskDB(ev.Info)
if ev.Prev != disk.DiskKnown && cfg.AutoCopy {
triggerAutoCopy(cp, cfg, ev.Info, *mediaPath)
if watcherReady && ev.Prev.State != disk.DiskKnown {
triggerAutoCopy(cp, cfg, ev.Info)
}
case disk.DiskForeign:
closeDiskDB(ev.Prev)
case disk.DiskAbsent:
closeDiskDB()
closeDiskDB(ev.Prev)
}
})
w.ProbeNow()
watcherReady = true
// Open DB immediately if disk already connected at startup
{
info, _ := disk.Probe(*mountPath)
probeDisk := func(mountPath string) (disk.DiskInfo, error) {
mountPath = config.NormalizeMediaPath(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 {
openDiskDB(info)
}
return info, nil
}
srv, err := api.New(api.Deps{
Config: cfg,
ConfigPath: *configPath,
Version: Version,
Watcher: w,
Copier: cp,
Tasks: taskStore,
MediaPath: *mediaPath,
MountPath: *mountPath,
ProbeDisk: probeDisk,
OnDiskInit: func(mountPath, diskID string) {
openDiskDB(disk.DiskInfo{
State: disk.DiskKnown,
DiskID: diskID,
MountPath: mountPath,
})
},
})
if err != nil {
log.Fatalf("init server: %v", err)
@@ -119,30 +191,56 @@ func main() {
shutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
httpSrv.Shutdown(shutCtx)
closeDiskDB()
for _, info := range w.ListDisks() {
closeDiskDB(info)
}
}
func triggerAutoCopy(cp *copier.Copier, cfg *config.Config, info disk.DiskInfo, mediaPath string) {
var sources []string
for _, s := range cfg.Sources {
if s.Enabled {
sources = append(sources, s.Path)
}
func triggerAutoCopy(cp *copier.Copier, cfg *config.Config, info disk.DiskInfo) {
// Используем AutoCopy из профиля диска, если он есть; иначе — из глобального config
autoCopy := cfg.AutoCopy
if info.Profile != nil {
autoCopy = info.Profile.AutoCopy
}
if len(sources) == 0 {
if !autoCopy {
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
if p.OverwriteMode != "" {
opts.OverwriteMode = config.OverwriteMode(p.OverwriteMode)
}
} else {
opts.DestFolder = cfg.DestFolder
opts.ReserveFreeGB = cfg.ReserveFreeGB
opts.FileSelectMode = cfg.FileSelectMode
}
go func() {
_, err := cp.Start(context.Background(), copier.Options{
DiskID: info.DiskID,
MountPath: info.MountPath,
MediaPath: mediaPath,
DestFolder: cfg.DestFolder,
EnabledSources: sources,
ReserveFreeGB: cfg.ReserveFreeGB,
OverwriteMode: cfg.OverwriteMode,
FileSelectMode: cfg.FileSelectMode,
})
_, err := cp.Start(context.Background(), opts)
if err != nil {
log.Printf("auto-copy: %v", err)
}

View File

@@ -2,42 +2,101 @@ package api
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"jukebox_maker/internal/config"
"jukebox_maker/internal/copier"
"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
} 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) {
diskInfo := s.deps.Watcher.CurrentDisk()
if diskInfo.State != disk.DiskKnown {
jsonErr(w, http.StatusUnprocessableEntity, "no known disk connected")
diskID := r.PathValue("diskID")
diskInfo, ok := s.deps.Watcher.DiskByID(diskID)
if !ok || diskInfo.State != disk.DiskKnown {
jsonErr(w, http.StatusUnprocessableEntity, "no initialized disk connected")
return
}
cfg := s.deps.Config
var enabledSources []string
for _, src := range cfg.Sources {
if src.Enabled {
enabledSources = append(enabledSources, src.Path)
}
}
if len(enabledSources) == 0 {
jsonErr(w, http.StatusUnprocessableEntity, "no sources enabled")
if !hasEnabledSources(cfg) {
jsonErr(w, http.StatusUnprocessableEntity, "no source folders selected")
return
}
opts := copier.Options{
DiskID: diskInfo.DiskID,
MountPath: diskInfo.MountPath,
MediaPath: s.deps.MediaPath,
DestFolder: cfg.DestFolder,
EnabledSources: enabledSources,
ReserveFreeGB: cfg.ReserveFreeGB,
OverwriteMode: cfg.OverwriteMode,
FileSelectMode: cfg.FileSelectMode,
overwriteMode, err := decodeCopyMode(r, cfg.OverwriteMode)
if err != nil {
if err.Error() == "invalid copy mode" {
jsonErr(w, http.StatusBadRequest, err.Error())
return
}
jsonErr(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
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)
if err != nil {
switch err.Error() {
@@ -53,8 +112,72 @@ func (s *Server) handleCopyStart(w http.ResponseWriter, r *http.Request) {
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) {
s.deps.Copier.Cancel()
diskID := r.PathValue("diskID")
s.deps.Copier.Cancel(diskID)
jsonOK(w, map[string]bool{"ok": true})
}

View File

@@ -1,34 +1,153 @@
package api
import (
"encoding/json"
"net/http"
"time"
"jukebox_maker/internal/config"
"jukebox_maker/internal/disk"
)
func (s *Server) handleDiskStatus(w http.ResponseWriter, r *http.Request) {
info := s.deps.Watcher.CurrentDisk()
func (s *Server) diskResponse(info disk.DiskInfo) map[string]any {
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 {
State disk.DiskState `json:"state"`
DiskID string `json:"disk_id"`
TotalBytes int64 `json:"total_bytes"`
FreeBytes int64 `json:"free_bytes"`
MountPath string `json:"mount_path"`
LastCopiedAt string `json:"last_copied_at,omitempty"`
ActiveTaskID string `json:"active_task_id,omitempty"`
}
resp := response{
State: info.State,
DiskID: info.DiskID,
TotalBytes: info.TotalBytes,
FreeBytes: info.FreeBytes,
MountPath: info.MountPath,
disks := s.deps.Watcher.ListDisks()
resp := make([]response, 0, len(disks))
for _, info := range disks {
item := response{
State: info.State,
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 {
resp.ActiveTaskID = t.ID
}
jsonOK(w, resp)
jsonOK(w, map[string]any{"items": resp})
}
func (s *Server) handleDiskProbe(w http.ResponseWriter, r *http.Request) {
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)
}

View File

@@ -1,26 +1,66 @@
package api
import (
"errors"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
)
func (s *Server) handleSources(w http.ResponseWriter, r *http.Request) {
entries, err := os.ReadDir(s.deps.MediaPath)
absPath, err := normalizeSourcePathQuery(r.URL.Query().Get("path"))
if err != nil {
jsonOK(w, map[string][]string{"items": {}})
jsonErr(w, http.StatusBadRequest, err.Error())
return
}
if absPath == "" {
jsonOK(w, map[string]any{"path": "", "items": []map[string]string{}})
return
}
var items []string
for _, e := range entries {
if e.IsDir() && e.Name()[0] != '.' {
items = append(items, e.Name())
}
}
if items == nil {
items = []string{}
entries, err := os.ReadDir(absPath)
if err != nil {
jsonOK(w, map[string]any{"path": absPath, "items": []map[string]string{}})
return
}
jsonOK(w, map[string][]string{"items": items})
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 := filepath.Join(absPath, e.Name())
items = append(items, item{
Name: e.Name(),
Path: 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": absPath,
"items": items,
})
}
func normalizeSourcePathQuery(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" || raw == "." {
return "", nil
}
clean := filepath.Clean(raw)
if !filepath.IsAbs(clean) {
return "", errors.New("invalid source path")
}
return clean, nil
}

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})
}

View File

@@ -9,6 +9,7 @@ import (
"jukebox_maker/internal/config"
"jukebox_maker/internal/copier"
"jukebox_maker/internal/disk"
"jukebox_maker/internal/task"
"jukebox_maker/internal/watcher"
)
@@ -16,11 +17,13 @@ import (
type Deps struct {
Config *config.Config
ConfigPath string
Version string
Watcher *watcher.Watcher
Copier *copier.Copier
Tasks *task.Store
MediaPath string
MountPath string
ProbeDisk func(mountPath string) (disk.DiskInfo, error)
// OnDiskInit вызывается при ручной инициализации диска через UI.
OnDiskInit func(mountPath, diskID string)
}
type Server struct {
@@ -56,13 +59,19 @@ func (s *Server) routes() {
s.mux.HandleFunc("GET /settings", s.handleSettings)
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/config", s.handleGetConfig)
s.mux.HandleFunc("PUT /api/config", s.handlePutConfig)
s.mux.HandleFunc("POST /api/copy/start", s.handleCopyStart)
s.mux.HandleFunc("POST /api/copy/cancel", s.handleCopyCancel)
s.mux.HandleFunc("POST /api/system/pick-folder", s.handlePickFolder)
s.mux.HandleFunc("POST /api/disks/{diskID}/copy/start", s.handleCopyStart)
s.mux.HandleFunc("POST /api/disks/{diskID}/copy/cancel", s.handleCopyCancel)
s.mux.HandleFunc("GET /api/tasks/{id}", s.handleTaskGet)
s.mux.HandleFunc("GET /api/disks/profile", s.handleGetProfile)
s.mux.HandleFunc("PUT /api/disks/profile", s.handlePutProfile)
}
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
@@ -74,7 +83,7 @@ func (s *Server) handleDashboard(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) {
@@ -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) {
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)
}
}

View File

@@ -5,40 +5,63 @@ import (
"errors"
"os"
"path/filepath"
"strings"
"jukebox_maker/internal/disk"
)
type OverwriteMode string
type FileSelectMode string
type AllowedFilesMode string
const (
DefaultDestFolder = "media"
OverwriteSkip OverwriteMode = "skip"
OverwriteDelete OverwriteMode = "delete"
SelectNew FileSelectMode = "new"
SelectAll FileSelectMode = "all"
AllowedFilesByMediaType AllowedFilesMode = "media_types"
AllowedFilesByExtensions AllowedFilesMode = "extensions"
MediaTypeAudio = "audio"
MediaTypeVideo = "video"
MediaTypePhoto = "photo"
)
type SourceFolder struct {
Path string `json:"path"`
Enabled bool `json:"enabled"`
Root bool `json:"root,omitempty"`
}
type Config struct {
ReserveFreeGB float64 `json:"reserve_free_gb"`
DestFolder string `json:"dest_folder"`
Sources []SourceFolder `json:"sources"`
OverwriteMode OverwriteMode `json:"overwrite_mode"`
FileSelectMode FileSelectMode `json:"file_select_mode"`
AutoCopy bool `json:"auto_copy"`
MediaPath string `json:"media_path"`
ReserveFreeGB float64 `json:"reserve_free_gb"`
DestFolder string `json:"dest_folder"`
Sources []SourceFolder `json:"sources"`
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 {
return Config{
ReserveFreeGB: 2.0,
DestFolder: "media",
OverwriteMode: OverwriteSkip,
FileSelectMode: SelectNew,
AutoCopy: false,
ReserveFreeGB: 2.0,
DestFolder: DefaultDestFolder,
OverwriteMode: OverwriteSkip,
FileSelectMode: SelectNew,
AllowedFilesMode: AllowedFilesByMediaType,
EnabledMediaTypes: DefaultEnabledMediaTypes(),
AllowedExtensions: DefaultAllowedExtensions(),
AutoCopy: false,
}
}
@@ -55,6 +78,24 @@ func Load(path string) (*Config, error) {
if err := json.Unmarshal(data, &cfg); err != nil {
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
}
@@ -74,9 +115,32 @@ func Save(path string, cfg *Config) error {
}
func (c *Config) Validate() error {
c.MediaPath = NormalizeMediaPath(c.MediaPath)
if c.MediaPath != "" {
info, err := os.Stat(c.MediaPath)
if err != nil {
return errors.New("media_path is not accessible")
}
if !info.IsDir() {
return errors.New("media_path must be a directory")
}
}
c.Sources = NormalizeSources(c.Sources, c.MediaPath)
for _, source := range c.Sources {
info, err := os.Stat(source.Path)
if err != nil {
return errors.New("source path is not accessible: " + source.Path)
}
if !info.IsDir() {
return errors.New("source path must be a directory: " + source.Path)
}
}
if c.ReserveFreeGB < 0 {
return errors.New("reserve_free_gb must be >= 0")
}
if _, err := NormalizeDestFolder(c.DestFolder); err != nil {
return err
}
switch c.OverwriteMode {
case OverwriteSkip, OverwriteDelete:
default:
@@ -87,5 +151,204 @@ func (c *Config) Validate() error {
default:
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
}
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
}

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
}

View File

@@ -2,12 +2,16 @@ package copier
import (
"context"
"encoding/json"
"errors"
"fmt"
"hash/fnv"
"io"
"math/rand/v2"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"sync"
"time"
@@ -15,99 +19,197 @@ import (
"jukebox_maker/internal/db"
"jukebox_maker/internal/disk"
"jukebox_maker/internal/task"
"jukebox_maker/internal/transcoder"
)
type Options struct {
DiskID string
MountPath string
MediaPath string
DestFolder string // subfolder on disk, default "media"
EnabledSources []string
ReserveFreeGB float64
OverwriteMode config.OverwriteMode
FileSelectMode config.FileSelectMode
DiskID string
MountPath string
MediaPath string
DestFolder string // subfolder on disk, default "media"
SourceRules []config.SourceFolder
AllowedExtensions []string
ReserveFreeGB float64
OverwriteMode config.OverwriteMode
FileSelectMode config.FileSelectMode
Transcode *disk.TranscodeProfile // nil = не транскодировать
}
type Copier struct {
tasks *task.Store
mu sync.Mutex
cancel context.CancelFunc
mu sync.Mutex
cancels map[string]context.CancelFunc
dbMu sync.RWMutex
db *db.DB
dbs map[string]*db.DB
}
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),
}
}
func (c *Copier) SetDB(d *db.DB) {
func (c *Copier) SetDB(diskID string, d *db.DB) {
c.dbMu.Lock()
c.db = d
if d == nil {
delete(c.dbs, diskID)
} else {
c.dbs[diskID] = d
}
c.dbMu.Unlock()
}
func (c *Copier) getDB() *db.DB {
func (c *Copier) getDB(diskID string) *db.DB {
c.dbMu.RLock()
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) {
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()
defer c.mu.Unlock()
if _, active := c.tasks.ActiveTask(); active {
if _, active := c.cancels[opts.DiskID]; active {
return "", errors.New("copy already running")
}
database := c.getDB()
database := c.getDB(opts.DiskID)
if database == nil {
return "", errors.New("no disk database available")
}
if opts.DestFolder == "" {
opts.DestFolder = "media"
opts.DestFolder = config.DefaultDestFolder
}
destFolder, err := config.NormalizeDestFolder(opts.DestFolder)
if err != nil {
destFolder = config.DefaultDestFolder
}
opts.DestFolder = destFolder
_, free, err := disk.DiskUsage(opts.MountPath)
if err != nil {
return "", err
}
reserveBytes := int64(opts.ReserveFreeGB * 1e9)
if free <= reserveBytes {
return "", errors.New("free space is below reserve threshold")
}
t := c.tasks.Create("copy")
copyCtx, cancel := context.WithCancel(ctx)
c.cancel = cancel
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
}
}
}
go c.run(copyCtx, t.ID, opts, database)
return t.ID, nil
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()
defer c.mu.Unlock()
if c.cancel != nil {
c.cancel()
if cancel, ok := c.cancels[diskID]; ok {
cancel()
}
}
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) {
c.tasks.Update(taskID, func(t *task.Task) {
t.Status = s
t.Message = msg
t.Progress = prog
})
if t, ok := c.tasks.Get(taskID); ok {
_ = database.UpdateTask(*t)
}
}
fail := func(err error) {
c.tasks.Update(taskID, func(t *task.Task) {
t.Status = task.StatusFailed
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 {
setStatus(task.StatusRunning, "Удаление данных с диска…", 0)
c.tasks.Update(taskID, func(t *task.Task) {
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)
return
@@ -116,7 +218,15 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database
var copiedPaths map[string]struct{}
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
copiedPaths, err = database.CopiedPaths(opts.DiskID)
if err != nil {
@@ -125,14 +235,22 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database
}
}
setStatus(task.StatusRunning, "Сканирование источников…", 0)
files, err := buildFileList(opts.MediaPath, opts.EnabledSources, copiedPaths)
c.tasks.Update(taskID, func(t *task.Task) {
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 {
fail(err)
return
}
if len(files) == 0 {
setStatus(task.StatusSuccess, "Нет новых файлов для копирования.", 100)
setStatus(task.StatusSuccess, "No files to copy.", 100)
return
}
@@ -147,7 +265,7 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database
reserveBytes := int64(opts.ReserveFreeGB * 1e9)
available := free - reserveBytes
if available <= 0 {
setStatus(task.StatusSuccess, "Недостаточно свободного места на диске.", 100)
setStatus(task.StatusFailed, "Free space is below the reserved threshold.", 100)
return
}
@@ -167,10 +285,13 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database
case <-ctx.Done():
c.tasks.Update(taskID, func(t *task.Task) {
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
default:
}
@@ -190,25 +311,38 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database
}
prog := int(float64(doneBytes) / float64(totalBytes) * 100)
msg := fmt.Sprintf("Копирование %s (%d/%d)", filepath.Base(f.srcAbs), i+1, total)
msg := fmt.Sprintf("Copying %s (%d/%d)", filepath.Base(f.srcAbs), i+1, total)
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)
if err := rsyncFile(ctx, f.srcAbs, dstAbs); err != nil {
if errors.Is(err, context.Canceled) {
var fileErr error
if opts.Transcode != nil && isVideoFile(f.srcAbs) {
fileErr = c.processVideo(ctx, taskID, database, opts.Transcode, f.srcAbs, dstAbs)
} else {
fileErr = copyFile(ctx, f.srcAbs, dstAbs)
}
if fileErr != nil {
if errors.Is(fileErr, context.Canceled) {
c.tasks.Update(taskID, func(t *task.Task) {
t.Status = task.StatusCanceled
t.Message = "Отменено"
t.Message = "Canceled"
t.SpeedBPS = 0
t.ETASec = 0
})
if t, ok := c.tasks.Get(taskID); ok {
_ = database.UpdateTask(*t)
}
return
}
continue
@@ -224,7 +358,60 @@ 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
c.tasks.Update(taskID, func(t *task.Task) {
t.Phase = task.PhaseTranscoding
t.Message = "Transcoding " + filepath.Base(src)
})
if t, ok := c.tasks.Get(taskID); ok {
_ = database.UpdateTask(*t)
}
progressFn := func(pct float64) {
c.tasks.Update(taskID, func(t *task.Task) {
t.Progress = int(pct * 100)
})
}
return transcoder.Transcode(ctx, transcoder.Options{
Input: src,
Output: dstTranscoded,
Profile: profile,
SourceInfo: info,
}, progressFn)
}
type fileEntry struct {
@@ -233,23 +420,64 @@ type fileEntry struct {
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
for _, src := range sources {
dir := filepath.Join(mediaPath, src)
for _, src := range selectedRoots {
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 {
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
}
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 {
return nil
}
if _, skipped := skip[destRel]; skipped {
return nil
}
info, err := d.Info()
if err != 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
})
if err != nil {
@@ -259,29 +487,233 @@ func buildFileList(mediaPath string, sources []string, skip map[string]struct{})
return result, nil
}
// rsyncFile copies src to dst using rsync with resume support.
// --partial keeps partial files on interruption.
// --append-verify resumes partial transfers and verifies checksums.
func rsyncFile(ctx context.Context, src, dst string) error {
func makeAllowedExtensionSet(items []string) map[string]struct{} {
normalized := config.NormalizeExtensions(items)
if len(normalized) == 0 {
normalized = config.DefaultAllowedExtensions()
}
result := make(map[string]struct{}, len(normalized))
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
}
ruleMap[src] = rule.Enabled
if rule.Root {
rootSet[src] = struct{}{}
}
}
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 {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
cmd := exec.CommandContext(ctx, "rsync",
"--partial",
"--append-verify",
"--times",
"--no-perms",
"--no-owner",
"--no-group",
"--chmod=ugo=rwx",
src, dst,
)
out, err := cmd.CombinedOutput()
srcFile, err := os.Open(src)
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
return err
}
defer srcFile.Close()
srcInfo, err := srcFile.Stat()
if err != nil {
return err
}
offset := int64(0)
if dstInfo, err := os.Stat(dst); err == nil {
switch {
case dstInfo.Size() < srcInfo.Size():
offset = dstInfo.Size()
case dstInfo.Size() == srcInfo.Size():
return os.Chtimes(dst, srcInfo.ModTime(), srcInfo.ModTime())
default:
if err := os.Remove(dst); err != nil {
return err
}
}
return fmt.Errorf("rsync: %w: %s", err, out)
} else if !errors.Is(err, os.ErrNotExist) {
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 errors.Is(readErr, io.EOF) {
break
}
return readErr
}
}
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
}

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)
}
}

View File

@@ -2,8 +2,11 @@ package db
import (
"database/sql"
"encoding/json"
"time"
"jukebox_maker/internal/task"
_ "modernc.org/sqlite"
)
@@ -18,6 +21,11 @@ type CopyRecord struct {
CopiedAt time.Time
}
type TaskRecord struct {
Task task.Task
Payload json.RawMessage
}
func Open(path string) (*DB, error) {
conn, err := sql.Open("sqlite", path+"?_journal=WAL&_timeout=5000")
if err != nil {
@@ -47,6 +55,26 @@ func (d *DB) migrate() error {
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_copy_history_disk_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
}
@@ -65,11 +93,26 @@ func (d *DB) RecordCopy(rec CopyRecord) error {
if t.IsZero() {
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 (?,?,?,?)`,
rec.DiskID, rec.SourcePath, rec.FileSize, t.Format(time.RFC3339),
)
return err
); err != nil {
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) {
@@ -90,3 +133,114 @@ func (d *DB) CopiedPaths(diskID string) (map[string]struct{}, error) {
}
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
}

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
}

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")
}

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
}

View File

@@ -5,7 +5,6 @@ import (
"os"
"path/filepath"
"strings"
"syscall"
"github.com/google/uuid"
)
@@ -19,21 +18,21 @@ const (
)
type DiskInfo struct {
State DiskState `json:"state"`
DiskID string `json:"disk_id"`
TotalBytes int64 `json:"total_bytes"`
FreeBytes int64 `json:"free_bytes"`
MountPath string `json:"mount_path"`
State DiskState `json:"state"`
DiskID string `json:"disk_id"`
TotalBytes int64 `json:"total_bytes"`
FreeBytes int64 `json:"free_bytes"`
MountPath string `json:"mount_path"`
Profile *DiskProfile `json:"profile,omitempty"`
}
const markerDir = ".jukebox"
const MarkerDir = ".jukebox"
const idFile = "disk.id"
func Probe(mountPath string) (DiskInfo, error) {
info := DiskInfo{MountPath: mountPath, State: DiskAbsent}
entries, err := os.ReadDir(mountPath)
if err != nil || len(entries) == 0 {
if _, err := os.ReadDir(mountPath); err != nil {
return info, nil
}
@@ -44,7 +43,7 @@ func Probe(mountPath string) (DiskInfo, error) {
info.TotalBytes = total
info.FreeBytes = free
idPath := filepath.Join(mountPath, markerDir, idFile)
idPath := filepath.Join(mountPath, MarkerDir, idFile)
data, err := os.ReadFile(idPath)
if errors.Is(err, os.ErrNotExist) {
info.State = DiskForeign
@@ -57,11 +56,27 @@ func Probe(mountPath string) (DiskInfo, error) {
info.DiskID = strings.TrimSpace(string(data))
info.State = DiskKnown
if p, err := LoadProfile(mountPath); err == nil {
info.Profile = p
}
return info, nil
}
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) {
dir := filepath.Join(mountPath, markerDir)
dir := filepath.Join(mountPath, MarkerDir)
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
@@ -70,19 +85,10 @@ func InitDisk(mountPath string) (string, error) {
if err := os.WriteFile(idPath, []byte(id), 0o644); err != nil {
return "", err
}
_ = SaveProfile(mountPath, DefaultProfile())
return id, nil
}
func DBPath(mountPath string) string {
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
return filepath.Join(mountPath, MarkerDir, "history.db")
}

70
internal/disk/profile.go Normal file
View File

@@ -0,0 +1,70 @@
package disk
import (
"encoding/json"
"os"
"path/filepath"
)
type DiskProfile struct {
DestFolder string `json:"dest_folder"`
OverwriteMode string `json:"overwrite_mode"`
FileSelectMode string `json:"file_select_mode"`
ReserveFreeGB float64 `json:"reserve_free_gb"`
AutoCopy bool `json:"auto_copy"`
// nil = не транскодировать видео
Transcode *TranscodeProfile `json:"transcode,omitempty"`
}
type TranscodeProfile struct {
VideoCodec string `json:"video_codec"` // "h264" | "h265" | "mpeg4"
MaxResolution string `json:"max_resolution"` // "480p" | "720p" | "1080p"
MaxVideoBitrate string `json:"max_video_bitrate"` // "1000k" | "2000k" | "" (без лимита)
MaxFPS int `json:"max_fps"` // 0=без лимита | 24 | 25 | 30
AudioCodec string `json:"audio_codec"` // "aac" | "mp3"
MaxAudioBitrate string `json:"max_audio_bitrate"` // "128k" | "192k" | ""
MaxAudioChannels int `json:"max_audio_channels"` // 2=стерео | 6=5.1
OutputFormat string `json:"output_format"` // "mp4" | "mkv" | "avi"
}
const profileFile = "profile.json"
func profilePath(mountPath string) string {
return filepath.Join(mountPath, MarkerDir, profileFile)
}
func LoadProfile(mountPath string) (*DiskProfile, error) {
data, err := os.ReadFile(profilePath(mountPath))
if err != nil {
return nil, err
}
var p DiskProfile
if err := json.Unmarshal(data, &p); err != nil {
return nil, err
}
return &p, nil
}
func SaveProfile(mountPath string, p *DiskProfile) error {
data, err := json.MarshalIndent(p, "", " ")
if err != nil {
return err
}
path := profilePath(mountPath)
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return err
}
return os.Rename(tmp, path)
}
func DefaultProfile() *DiskProfile {
return &DiskProfile{
DestFolder: "media",
OverwriteMode: "skip",
FileSelectMode: "new",
ReserveFreeGB: 2.0,
AutoCopy: false,
}
}

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
}

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
}

View File

@@ -17,10 +17,22 @@ const (
StatusCanceled Status = "canceled"
)
const (
PhaseQueued = "queued"
PhasePreparing = "preparing"
PhaseReplacing = "replacing"
PhaseLoadingHistory = "loading_history"
PhaseScanning = "scanning"
PhaseTranscoding = "transcoding"
PhaseCopying = "copying"
)
type Task struct {
ID string `json:"id"`
DiskID string `json:"disk_id"`
Type string `json:"type"`
Status Status `json:"status"`
Phase string `json:"phase,omitempty"`
Progress int `json:"progress"`
Message string `json:"message"`
SpeedBPS int64 `json:"speed_bps"`
@@ -43,13 +55,16 @@ func NewStore() *Store {
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{
ID: uuid.New().String(),
DiskID: diskID,
Type: taskType,
Status: StatusQueued,
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
Phase: PhaseQueued,
CreatedAt: now,
UpdatedAt: now,
}
s.mu.Lock()
s.tasks[t.ID] = t
@@ -57,6 +72,13 @@ func (s *Store) Create(taskType string) *Task {
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) {
s.mu.RLock()
defer s.mu.RUnlock()
@@ -77,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()
defer s.mu.RUnlock()
for _, t := range s.tasks {
if t.Status == StatusQueued || t.Status == StatusRunning {
if t.DiskID == diskID && (t.Status == StatusQueued || t.Status == StatusRunning) {
copy := *t
return &copy, true
}

View File

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

View File

@@ -0,0 +1,167 @@
package transcoder
import (
"bufio"
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"jukebox_maker/internal/disk"
)
type Options struct {
Input string
Output string
Profile *disk.TranscodeProfile
SourceInfo VideoInfo
}
// Transcode запускает ffmpeg. progress вызывается с 0..1 по мере работы.
func Transcode(ctx context.Context, opts Options, progress func(float64)) error {
if err := os.MkdirAll(filepath.Dir(opts.Output), 0o755); err != nil {
return err
}
args := buildArgs(opts)
cmd := exec.CommandContext(ctx, "ffmpeg", args...)
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Start(); err != nil {
return fmt.Errorf("ffmpeg start: %w", err)
}
// Парсим прогресс из stdout (-progress pipe:1)
if opts.SourceInfo.DurationSec > 0 && progress != nil {
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "out_time_us=") {
val := strings.TrimPrefix(line, "out_time_us=")
if us, err := strconv.ParseInt(val, 10, 64); err == nil && us > 0 {
sec := float64(us) / 1e6
pct := sec / opts.SourceInfo.DurationSec
if pct > 1 {
pct = 1
}
progress(pct)
}
}
}
}
if err := cmd.Wait(); err != nil {
msg := strings.TrimSpace(stderr.String())
if msg != "" {
return fmt.Errorf("ffmpeg: %w: %s", err, msg)
}
return fmt.Errorf("ffmpeg: %w", err)
}
return nil
}
func buildArgs(opts Options) []string {
p := opts.Profile
src := opts.SourceInfo
args := []string{
"-i", opts.Input,
"-threads", "0",
"-progress", "pipe:1",
"-v", "error",
}
// Видеокодек
args = append(args, "-c:v", ffmpegVideoCodec(p.VideoCodec))
// Битрейт — только если источник выше лимита
if p.MaxVideoBitrate != "" {
if targetBR := parseBitrate(p.MaxVideoBitrate); targetBR > 0 &&
(src.VideoBitrate == 0 || src.VideoBitrate > targetBR) {
args = append(args, "-b:v", p.MaxVideoBitrate)
}
}
// Масштаб + FPS — строим vf
var filters []string
if maxH := maxHeight(p.MaxResolution); maxH > 0 {
filters = append(filters, fmt.Sprintf("scale=-2:min(%d\\,ih)", maxH))
}
if p.MaxFPS > 0 && src.FPS > float64(p.MaxFPS)+0.01 {
filters = append(filters, fmt.Sprintf("fps=%d", p.MaxFPS))
}
if len(filters) > 0 {
args = append(args, "-vf", strings.Join(filters, ","))
}
// Аудиокодек
if p.AudioCodec != "" {
args = append(args, "-c:a", ffmpegAudioCodec(p.AudioCodec))
} else {
args = append(args, "-c:a", "copy")
}
// Аудио-битрейт
if p.MaxAudioBitrate != "" {
args = append(args, "-b:a", p.MaxAudioBitrate)
}
// Каналы — только даунмикс
if p.MaxAudioChannels > 0 && src.AudioChannels > p.MaxAudioChannels {
args = append(args, "-ac", strconv.Itoa(p.MaxAudioChannels))
}
// Для mp4 — оптимизация для стриминга
if p.OutputFormat == "mp4" {
args = append(args, "-movflags", "+faststart")
}
args = append(args, "-y", opts.Output)
return args
}
func ffmpegVideoCodec(codec string) string {
switch codec {
case "h264":
return "libx264"
case "h265":
return "libx265"
case "mpeg4":
return "mpeg4"
default:
return "libx264"
}
}
func ffmpegAudioCodec(codec string) string {
switch codec {
case "aac":
return "aac"
case "mp3":
return "libmp3lame"
default:
return "aac"
}
}
// OutputExt возвращает расширение файла для заданного формата контейнера.
func OutputExt(format string) string {
switch format {
case "mkv":
return ".mkv"
case "avi":
return ".avi"
default:
return ".mp4"
}
}

View File

@@ -2,6 +2,10 @@ package watcher
import (
"context"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
@@ -10,7 +14,7 @@ import (
type DiskEvent struct {
Info disk.DiskInfo
Prev disk.DiskState
Prev disk.DiskInfo
}
type Handler func(event DiskEvent)
@@ -20,8 +24,8 @@ type Watcher struct {
interval time.Duration
handler Handler
mu sync.RWMutex
current disk.DiskInfo
mu sync.RWMutex
disks map[string]disk.DiskInfo
}
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,
interval: interval,
handler: handler,
disks: make(map[string]disk.DiskInfo),
}
}
func (w *Watcher) CurrentDisk() disk.DiskInfo {
func (w *Watcher) ListDisks() []disk.DiskInfo {
w.mu.RLock()
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) {
@@ -56,15 +89,69 @@ func (w *Watcher) Run(ctx context.Context) {
}
func (w *Watcher) probe() {
info, _ := disk.Probe(w.mountPath)
next := discoverDisks(w.mountPath)
w.mu.Lock()
prev := w.current.State
changed := prev != info.State
w.current = info
prev := w.disks
w.disks = next
w.mu.Unlock()
if changed && w.handler != nil {
w.handler(DiskEvent{Info: info, Prev: prev})
if w.handler == nil {
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
}

View File

@@ -21,8 +21,10 @@ ROOT_DIR=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
die() { echo "error: $*" >&2; exit 1; }
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_VERSION=$(git -C "${ROOT_DIR}" describe --tags --always 2>/dev/null || echo dev)
ask() {
# $1=varname $2=prompt $3=default
@@ -46,6 +48,12 @@ else
ask IMAGE "Image" ""
fi
echo "checking Go build"
(
cd "${ROOT_DIR}"
go build ./...
)
if [ -n "${IMAGE}" ]; then
# multi-arch build + push
docker buildx version >/dev/null 2>&1 || die "docker buildx not available"
@@ -66,6 +74,7 @@ if [ -n "${IMAGE}" ]; then
docker buildx build \
--platform "${PLATFORMS}" \
--file "${ROOT_DIR}/Dockerfile" \
--build-arg "VERSION=${DEFAULT_VERSION}" \
-t "${IMAGE}:${IMAGE_TAG}" \
-t "${IMAGE}:latest" \
--push \
@@ -80,6 +89,7 @@ else
echo "building locally (no push)"
docker build \
--file "${ROOT_DIR}/Dockerfile" \
--build-arg "VERSION=${DEFAULT_VERSION}" \
-t "jukebox-maker:${IMAGE_TAG}" \
-t "jukebox-maker:latest" \
"${ROOT_DIR}"

39
ops/build-release.sh Executable file
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"

View File

@@ -69,6 +69,14 @@ a:hover { text-decoration: underline; }
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 {
margin-bottom: 24px;
@@ -89,6 +97,24 @@ a:hover { text-decoration: underline; }
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 {
width: 100%;
@@ -199,6 +225,17 @@ a:hover { text-decoration: underline; }
.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 {
font-size: 13px;
font-weight: 700;
@@ -241,6 +278,124 @@ a:hover { text-decoration: underline; }
/* Checkbox list */
.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 {
display: flex;
align-items: center;
@@ -325,5 +480,15 @@ a:hover { text-decoration: underline; }
@media (max-width: 720px) {
.page-header { flex-wrap: wrap; padding: 12px 16px; }
.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; }
.btn-row { flex-wrap: wrap; }
.path-input-row { flex-wrap: wrap; }
.source-root-row {
align-items: flex-start;
}
.source-root-title {
flex-wrap: wrap;
}
}

View File

@@ -1,161 +1,441 @@
{{define "content"}}
<section class="panel">
<h2>Накопитель</h2>
<table class="kv-table">
<tbody>
<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 style="display:flex;justify-content:space-between;align-items:baseline;margin-top:6px">
<div class="progress-label" id="progressMsg">Подготовка…</div>
<div class="progress-label" id="progressMeta" style="text-align:right"></div>
<h2>Mounted Disk</h2>
<div class="panel-body">
<div class="path-input-row">
<input class="form-input" type="text" id="mountPath" placeholder="/Volumes/JUKEBOX or E:\\">
<button type="button" class="button-primary" onclick="pickMountPath()">+</button>
<button type="button" class="button-secondary" onclick="refreshSelectedDisk()">Refresh</button>
</div>
<div class="form-hint">Choose the directory where the removable disk is mounted. The app works with one selected disk at a time in standalone mode.</div>
</div>
</section>
<div class="btn-row" style="background:transparent;border:none;padding:0;margin-bottom:24px">
<button class="button-primary" id="btnStart" onclick="startCopy()" disabled>▶ Запустить копирование</button>
<button class="button-danger hidden" id="btnCancel" onclick="cancelCopy()">✕ Отменить</button>
</div>
<div id="diskState"></div>
<script>
let pollInterval = null;
let activeTaskId = null;
const selectedDisk = { info: null };
const taskState = new Map();
const taskPollers = new Map();
async function refreshDisk() {
try {
const r = await fetch('/api/disk');
if (!r.ok) return;
const d = await r.json();
const labels = { absent: 'Не подключён', foreign: 'Незнакомый диск', known: 'Диск подключён' };
const cls = { absent: 'badge-unknown', foreign: 'badge-warn', known: 'badge-ok' };
document.getElementById('diskState').innerHTML =
`<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 escapeHTML(value) {
return String(value || '').replace(/[&<>"']/g, (char) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[char]));
}
function startTaskPoll(id) { stopTaskPoll(); pollInterval = setInterval(() => pollTask(id), 1500); }
function stopTaskPoll() { if (pollInterval) { clearInterval(pollInterval); pollInterval = null; } }
function badgeClass(state) {
return ({ absent: 'badge-unknown', foreign: 'badge-warn', known: 'badge-ok' })[state] || 'badge-unknown';
}
function badgeLabel(state) {
return ({ absent: 'Directory unavailable', foreign: 'Uninitialized disk', known: 'Ready' })[state] || '—';
}
function fmtSpeed(bps) {
if (!bps) return '';
if (bps >= 1e9) return (bps/1e9).toFixed(1) + ' ГБ/с';
if (bps >= 1e6) return (bps/1e6).toFixed(1) + ' МБ/с';
if (bps >= 1e3) return (bps/1e3).toFixed(0) + ' КБ/с';
return bps + ' Б/с';
if (bps >= 1e9) return (bps / 1e9).toFixed(1) + ' GB/s';
if (bps >= 1e6) return (bps / 1e6).toFixed(1) + ' MB/s';
if (bps >= 1e3) return (bps / 1e3).toFixed(0) + ' KB/s';
return bps + ' B/s';
}
function fmtETA(sec) {
if (!sec || sec <= 0) return '';
if (sec >= 3600) return Math.floor(sec/3600) + ' ч ' + Math.floor((sec%3600)/60) + ' мин';
if (sec >= 60) return Math.floor(sec/60) + ' мин';
return sec + ' с';
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';
}
async function pollTask(id) {
try {
const r = await fetch('/api/tasks/' + id);
if (!r.ok) return;
const t = await r.json();
document.getElementById('progressFill').style.width = t.progress + '%';
document.getElementById('progressMsg').textContent = t.message || '…';
const speed = fmtSpeed(t.speed_bps);
const eta = fmtETA(t.eta_sec);
const meta = [speed, eta ? 'ETA: ' + eta : ''].filter(Boolean).join(' · ');
document.getElementById('progressMeta').textContent = meta;
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');
document.getElementById('progressMeta').textContent = '';
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) {}
function fmtDateTime(value) {
if (!value) return 'Never';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}
async function startCopy() {
document.getElementById('btnStart').disabled = true;
function taskMeta(task) {
if (!task) return '';
return [fmtSpeed(task.speed_bps), task.eta_sec ? 'ETA: ' + fmtETA(task.eta_sec) : ''].filter(Boolean).join(' · ');
}
function renderDisk() {
const root = document.getElementById('diskState');
const disk = selectedDisk.info;
if (!disk) {
root.innerHTML = `
<section class="panel">
<div class="panel-body text-muted">Choose a mounted disk directory to inspect it.</div>
</section>
`;
return;
}
const activeTask = disk.active_task_id ? taskState.get(disk.active_task_id) : null;
const progress = activeTask ? activeTask.progress : 0;
const message = activeTask ? (activeTask.message || 'Preparing...') : '';
const meta = activeTask ? taskMeta(activeTask) : '';
const isKnown = disk.state === 'known';
const isForeign = disk.state === 'foreign';
const hasCapacity = disk.state !== 'absent';
root.innerHTML = `
<section class="panel disk-card">
<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>
${activeTask ? `
<div class="panel-body progress-wrap">
<div class="progress-bar-bg">
<div class="progress-bar-fill" style="width:${progress}%"></div>
</div>
<div class="progress-label">${escapeHTML(message)}</div>
<div class="progress-label">${escapeHTML(meta)}</div>
</div>
` : ''}
<div class="btn-row">
${isKnown ? `
<button class="button-danger" data-action="start-copy" data-mode="replace" ${activeTask ? 'disabled' : ''}>Replace media</button>
<button class="button-primary" data-action="start-copy" data-mode="add" ${activeTask ? 'disabled' : ''}>Add media</button>
<button class="button-danger ${activeTask ? '' : 'hidden'}" data-action="cancel-copy">Cancel</button>
` : ''}
${isForeign ? `
<button class="button-secondary" data-action="init-disk">Initialize disk</button>
` : ''}
</div>
</section>
${isKnown ? renderProfile(disk) : ''}
`;
}
function renderProfile(disk) {
const p = disk.profile || {};
const t = p.transcode || null;
const transcodeEnabled = !!t;
const sel = (name, value, options) => {
const opts = options.map(([v, label]) =>
`<option value="${v}" ${v === value ? 'selected' : ''}>${escapeHTML(label)}</option>`
).join('');
return `<select class="form-input" id="prof_${name}">${opts}</select>`;
};
const transcodeSection = `
<div id="transcodeFields" style="${transcodeEnabled ? '' : 'display:none'}">
<div class="form-group">
<label>Видеокодек</label>
${sel('video_codec', t?.video_codec || 'h264', [['h264','H.264 (AVC)'],['h265','H.265 (HEVC)'],['mpeg4','MPEG-4']])}
</div>
<div class="form-group">
<label>Макс. разрешение</label>
${sel('max_resolution', t?.max_resolution || '720p', [['480p','480p'],['720p','720p (HD)'],['1080p','1080p (Full HD)']])}
</div>
<div class="form-group">
<label>Макс. битрейт видео</label>
${sel('max_video_bitrate', t?.max_video_bitrate || '2000k', [['','Без лимита'],['1000k','1000 кбит/с'],['2000k','2000 кбит/с'],['4000k','4000 кбит/с'],['8000k','8000 кбит/с']])}
</div>
<div class="form-group">
<label>Макс. FPS</label>
${sel('max_fps', String(t?.max_fps ?? 0), [['0','Без лимита'],['24','24'],['25','25'],['30','30']])}
</div>
<hr>
<div class="form-group">
<label>Аудиокодек</label>
${sel('audio_codec', t?.audio_codec || 'aac', [['aac','AAC'],['mp3','MP3']])}
</div>
<div class="form-group">
<label>Макс. битрейт аудио</label>
${sel('max_audio_bitrate', t?.max_audio_bitrate || '192k', [['','Без лимита'],['128k','128 кбит/с'],['192k','192 кбит/с'],['320k','320 кбит/с']])}
</div>
<div class="form-group">
<label>Каналы</label>
${sel('max_audio_channels', String(t?.max_audio_channels ?? 0), [['0','Копировать'],['2','Стерео (2.0)'],['6','5.1']])}
</div>
<hr>
<div class="form-group">
<label>Формат контейнера</label>
${sel('output_format', t?.output_format || 'mp4', [['mp4','MP4'],['mkv','MKV'],['avi','AVI']])}
</div>
</div>
`;
return `
<section class="panel" id="profilePanel">
<h2>Профиль диска</h2>
<div class="panel-body">
<h3>Параметры копирования</h3>
<div class="form-group">
<label>Папка назначения</label>
<input class="form-input" type="text" id="prof_dest_folder" value="${escapeHTML(p.dest_folder || 'media')}">
</div>
<div class="form-group">
<label>Режим перезаписи</label>
${sel('overwrite_mode', p.overwrite_mode || 'skip', [['skip','Пропускать существующие'],['delete','Заменять всё']])}
</div>
<div class="form-group">
<label>Выбор файлов</label>
${sel('file_select_mode', p.file_select_mode || 'new', [['new','Только новые'],['all','Все подходящие']])}
</div>
<div class="form-group">
<label>Резерв свободного места (ГБ)</label>
<input class="form-input" type="number" id="prof_reserve_free_gb" value="${p.reserve_free_gb ?? 2}" min="0" step="0.5">
</div>
<div class="form-group">
<label><input type="checkbox" id="prof_auto_copy" ${p.auto_copy ? 'checked' : ''}> Автокопирование при подключении</label>
</div>
<h3 style="margin-top:1.5em">Транскодирование видео</h3>
<div class="form-group">
<label>
<input type="checkbox" id="prof_transcode_enabled" ${transcodeEnabled ? 'checked' : ''}
onchange="document.getElementById('transcodeFields').style.display=this.checked?'':'none'">
Ограничить видео под устройство
</label>
</div>
${transcodeSection}
<div class="btn-row" style="margin-top:1em">
<button class="button-primary" onclick="saveProfile('${escapeHTML(disk.mount_path)}')">Сохранить профиль</button>
</div>
</div>
</section>
`;
}
async function saveProfile(mountPath) {
const g = id => document.getElementById(id);
const transcodeEnabled = g('prof_transcode_enabled')?.checked;
const profile = {
dest_folder: g('prof_dest_folder')?.value.trim() || 'media',
overwrite_mode: g('prof_overwrite_mode')?.value || 'skip',
file_select_mode: g('prof_file_select_mode')?.value || 'new',
reserve_free_gb: parseFloat(g('prof_reserve_free_gb')?.value || '2') || 0,
auto_copy: g('prof_auto_copy')?.checked || false,
};
if (transcodeEnabled) {
profile.transcode = {
video_codec: g('prof_video_codec')?.value || 'h264',
max_resolution: g('prof_max_resolution')?.value || '720p',
max_video_bitrate: g('prof_max_video_bitrate')?.value || '',
max_fps: parseInt(g('prof_max_fps')?.value || '0', 10),
audio_codec: g('prof_audio_codec')?.value || 'aac',
max_audio_bitrate: g('prof_max_audio_bitrate')?.value || '',
max_audio_channels: parseInt(g('prof_max_audio_channels')?.value || '0', 10),
output_format: g('prof_output_format')?.value || 'mp4',
};
}
try {
const r = await fetch('/api/copy/start', { method: 'POST' });
const d = await r.json();
if (!r.ok) {
toast(d.error || 'Ошибка запуска', 'error');
document.getElementById('btnStart').disabled = false;
const response = await fetch('/api/disks/profile?mount_path=' + encodeURIComponent(mountPath), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(profile)
});
const payload = await response.json();
if (!response.ok) {
toast(payload.error || 'Ошибка сохранения профиля', 'error');
return;
}
activeTaskId = d.task_id;
document.getElementById('btnStart').classList.add('hidden');
document.getElementById('btnCancel').classList.remove('hidden');
document.getElementById('progressPanel').classList.remove('hidden');
document.getElementById('progressFill').style.width = '0%';
document.getElementById('progressMsg').textContent = 'Подготовка…';
startTaskPoll(activeTaskId);
} catch(e) {
toast('Ошибка связи', 'error');
document.getElementById('btnStart').disabled = false;
toast('Профиль сохранён', 'ok');
refreshSelectedDisk();
} catch (error) {
toast('Ошибка сети', 'error');
}
}
function stopTaskPoll(taskID) {
if (!taskPollers.has(taskID)) return;
clearInterval(taskPollers.get(taskID));
taskPollers.delete(taskID);
}
function startTaskPoll(taskID) {
if (!taskID || taskPollers.has(taskID)) return;
taskPollers.set(taskID, setInterval(() => pollTask(taskID), 1500));
pollTask(taskID);
}
async function pickFolder() {
const response = await fetch('/api/system/pick-folder', { method: 'POST' });
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || 'Failed to choose folder');
}
return payload.path || '';
}
async function pickMountPath() {
try {
const path = await pickFolder();
if (!path) return;
document.getElementById('mountPath').value = path;
localStorage.setItem('jukebox.selectedMountPath', path);
await refreshSelectedDisk();
} catch (error) {
toast(error.message || 'Failed to choose folder', 'error');
}
}
async function refreshSelectedDisk() {
const mountPath = document.getElementById('mountPath').value.trim();
if (!mountPath) {
selectedDisk.info = null;
renderDisk();
return;
}
localStorage.setItem('jukebox.selectedMountPath', mountPath);
try {
const response = await fetch('/api/disks/probe?mount_path=' + encodeURIComponent(mountPath));
const payload = await response.json();
if (!response.ok) {
toast(payload.error || 'Failed to inspect directory', 'error');
return;
}
selectedDisk.info = payload;
renderDisk();
if (payload.active_task_id) {
for (const taskID of Array.from(taskPollers.keys())) {
if (taskID !== payload.active_task_id) {
stopTaskPoll(taskID);
taskState.delete(taskID);
}
}
startTaskPoll(payload.active_task_id);
} else {
for (const taskID of Array.from(taskPollers.keys())) {
stopTaskPoll(taskID);
taskState.delete(taskID);
}
}
} catch (error) {
toast('Network error', 'error');
}
}
async function pollTask(taskID) {
try {
const response = await fetch('/api/tasks/' + taskID);
if (!response.ok) return;
const task = await response.json();
taskState.set(taskID, task);
renderDisk();
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');
refreshSelectedDisk();
}
} catch (error) {}
}
async function startCopy(mode) {
const mountPath = document.getElementById('mountPath').value.trim();
if (!mountPath) return;
try {
const response = await fetch('/api/disks/copy/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mount_path: mountPath, mode })
});
const payload = await response.json();
if (!response.ok) {
toast(payload.error || 'Failed to start copy', 'error');
return;
}
startTaskPoll(payload.task_id);
refreshSelectedDisk();
} catch (error) {
toast('Network error', 'error');
}
}
async function cancelCopy() {
try { await fetch('/api/copy/cancel', { method: 'POST' }); toast('Отмена…', 'ok'); } catch(e) {}
if (!selectedDisk.info || !selectedDisk.info.disk_id) return;
try {
await fetch('/api/disks/' + encodeURIComponent(selectedDisk.info.disk_id) + '/copy/cancel', { method: 'POST' });
toast('Canceling...', 'ok');
} catch (error) {
toast('Network error', 'error');
}
}
refreshDisk();
setInterval(refreshDisk, 5000);
async function initDisk() {
const mountPath = document.getElementById('mountPath').value.trim();
if (!mountPath) return;
try {
const response = await fetch('/api/disks/init', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mount_path: mountPath })
});
const payload = await response.json();
if (!response.ok) {
toast(payload.error || 'Failed to initialize disk', 'error');
return;
}
toast('Disk initialized', 'ok');
refreshSelectedDisk();
} catch (error) {
toast('Network error', 'error');
}
}
document.getElementById('diskState').addEventListener('click', (event) => {
const button = event.target.closest('button[data-action]');
if (!button) return;
const action = button.dataset.action;
if (action === 'start-copy') startCopy(button.dataset.mode || 'add');
if (action === 'cancel-copy') cancelCopy();
if (action === 'init-disk') initDisk();
});
const savedMountPath = localStorage.getItem('jukebox.selectedMountPath');
if (savedMountPath) {
document.getElementById('mountPath').value = savedMountPath;
refreshSelectedDisk();
} else {
renderDisk();
}
</script>
{{end}}

View File

@@ -1,5 +1,5 @@
{{define "layout"}}<!DOCTYPE html>
<html lang="ru">
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -12,7 +12,7 @@
<h1>🎵 Jukebox Maker</h1>
<nav class="header-nav">
<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>
</header>
@@ -20,6 +20,10 @@
{{template "content" .}}
</main>
<footer class="page-footer">
<span>Version {{.Version}}</span>
</footer>
<div class="toast-container" id="toastContainer"></div>
<script>
@@ -33,10 +37,10 @@ function toast(msg, type) {
}
function fmtBytes(b) {
if (!b) return '—';
if (b >= 1e12) return (b/1e12).toFixed(1) + ' ТБ';
if (b >= 1e9) return (b/1e9).toFixed(1) + ' ГБ';
if (b >= 1e6) return (b/1e6).toFixed(1) + ' МБ';
return (b/1e3).toFixed(0) + ' КБ';
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/1e3).toFixed(0) + ' KB';
}
</script>
</body>

View File

@@ -2,102 +2,425 @@
<form id="settingsForm" onsubmit="saveSettings(event)">
<section class="panel">
<h2>Источники копирования</h2>
<div class="source-list" id="sourceList">
<div class="text-muted" style="padding:12px 16px">Загрузка…</div>
<h2>Copy Sources</h2>
<div class="panel-body">
<div class="form-hint">Add one or more root folders with source files. After that, expand each root and enable or disable individual nested folders with checkboxes.</div>
</div>
<div class="btn-row">
<button type="button" class="button-secondary button-sm" onclick="loadSources()">↻ Обновить список</button>
<button type="button" class="button-primary" onclick="addSourceRoot()">Add source folder</button>
<button type="button" class="button-secondary button-sm" onclick="reloadAllSourceTrees()">Refresh trees</button>
</div>
<div class="source-list">
<div class="source-tree" id="sourceTree">
<div class="text-muted source-tree-empty">No source folders added yet.</div>
</div>
</div>
</section>
<section class="panel">
<h2>Параметры копирования</h2>
<h2>Copy Settings</h2>
<div class="form-body">
<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">
<span class="form-hint">Копирование остановится, когда свободного места останется меньше этого значения.</span>
<span class="form-hint">Copying will stop when free space falls below this value.</span>
</div>
<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">
<option value="new">Только новые (не копировавшиеся на этот диск)</option>
<option value="all">Все подряд</option>
<option value="new">Only new files not copied to this disk before</option>
<option value="all">All matching files</option>
</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">
<label class="form-label" for="destFolder">Папка назначения на диске</label>
<label class="form-label" for="allowedFilesMode">Allowed file types</label>
<select class="form-select" id="allowedFilesMode" style="width:auto;max-width:420px" onchange="updateAllowedFilesModeUI()">
<option value="media_types">Audio, video, photo</option>
<option value="extensions">Custom extensions list</option>
</select>
</div>
<div class="form-group" id="mediaTypesGroup">
<label class="form-label">Built-in media types</label>
<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">
<label class="form-label" for="allowedExtensions">Allowed extensions</label>
<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 class="form-group">
<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">Подпапка на диске куда копировать файлы. Структура источника воспроизводится внутри неё. По умолчанию: <code>media</code>.</span>
<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">Режим записи</label>
<label class="form-label" for="overwriteMode">Default write mode</label>
<select class="form-select" id="overwriteMode" style="width:auto;max-width:420px">
<option value="skip">Пропустить существующие файлы</option>
<option value="delete">Удалить папку назначения и перезаписать заново</option>
<option value="skip">Keep existing files</option>
<option value="delete">Replace destination folder contents</option>
</select>
<span class="form-hint">«Удалить и перезаписать» — удаляет папку назначения на диске, затем копирует заново.</span>
<span class="form-hint">This is used for automatic copy runs. Manual dashboard actions can override it.</span>
</div>
</div>
</section>
<section class="panel">
<h2>Автоматизация</h2>
<h2>Automation</h2>
<div class="form-body">
<div class="form-group">
<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)">
<span class="form-label" style="margin:0">Автоматическое копирование</span>
<span class="form-label" style="margin:0">Automatic copy</span>
</label>
<span class="form-hint">При обнаружении знакомого накопителя копирование запустится автоматически.</span>
<span class="form-hint">Start copying automatically when a known disk is detected.</span>
</div>
</div>
</section>
<div style="display:flex;gap:8px;margin-bottom:24px">
<button type="submit" class="button-primary">Сохранить настройки</button>
<button type="button" class="button-secondary" onclick="loadSettings()">Сбросить</button>
<button type="submit" class="button-primary">Save settings</button>
<button type="button" class="button-secondary" onclick="loadSettings()">Reset</button>
</div>
</form>
<script>
let allSources = [];
let enabledSources = {};
const sourceTree = new Map();
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 sourceRoots = [];
let sourceConfig = {};
function escapeHTML(value) {
return String(value || '').replace(/[&<>"']/g, (char) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[char]));
}
function pathSegments(path) {
return String(path || '').split(/[\\/]+/).filter(Boolean);
}
function nodeName(path) {
const parts = pathSegments(path);
return parts.length ? parts[parts.length - 1] : path;
}
function normalizeComparePath(path) {
return String(path || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
}
function isSamePath(a, b) {
return normalizeComparePath(a) === normalizeComparePath(b);
}
function isPathWithin(base, candidate) {
const baseNorm = normalizeComparePath(base);
const candidateNorm = normalizeComparePath(candidate);
return candidateNorm === baseNorm || candidateNorm.startsWith(baseNorm + '/');
}
function parentPath(path) {
const value = String(path || '').replace(/[\\/]+$/, '');
const slash = Math.max(value.lastIndexOf('/'), value.lastIndexOf('\\'));
if (slash < 0) return '';
if (slash === 2 && /^[A-Za-z]:/.test(value)) return value.slice(0, slash + 1);
if (slash === 0) return value.slice(0, 1);
return value.slice(0, slash);
}
function relativeDepth(root, path) {
if (isSamePath(root, path)) return 0;
const rootParts = pathSegments(root);
const pathParts = pathSegments(path);
return Math.max(0, pathParts.length - rootParts.length);
}
function effectiveSourceState(path) {
let current = path;
while (true) {
if (Object.prototype.hasOwnProperty.call(sourceConfig, current)) {
return sourceConfig[current];
}
current = parentPath(current);
if (!current) return true;
}
}
function collectSourcesForSave() {
const items = [];
const seen = new Set();
sourceRoots.forEach((root) => {
items.push({ path: root, enabled: effectiveSourceState(root), root: true });
seen.add(normalizeComparePath(root));
});
Object.entries(sourceConfig).forEach(([path, enabled]) => {
const key = normalizeComparePath(path);
if (seen.has(key)) return;
items.push({ path, enabled, root: false });
});
return items.sort((a, b) => a.path.localeCompare(b.path));
}
async function pickFolder() {
const response = await fetch('/api/system/pick-folder', { method: 'POST' });
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || 'Failed to choose folder');
}
return payload.path || '';
}
async function loadSourceChildren(path) {
if (!path || loadingNodes.has(path)) return;
loadingNodes.add(path);
renderSources();
async function loadSources() {
try {
const r = await fetch('/api/sources');
if (!r.ok) return;
const d = await r.json();
allSources = d.items || [];
const response = await fetch('/api/sources?path=' + encodeURIComponent(path));
if (!response.ok) return;
const payload = await response.json();
sourceTree.set(path, payload.items || []);
} catch (error) {
} finally {
loadingNodes.delete(path);
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 removeRoot(path) {
sourceRoots = sourceRoots.filter((root) => !isSamePath(root, path));
sourceTree.delete(path);
expandedNodes.delete(path);
loadingNodes.delete(path);
Object.keys(sourceConfig).forEach((key) => {
if (isPathWithin(path, key)) {
delete sourceConfig[key];
}
});
renderSources();
}
async function addSourceRoot() {
try {
const path = await pickFolder();
if (!path) return;
if (sourceRoots.some((root) => isSamePath(root, path))) {
toast('This source folder is already added', 'error');
return;
}
sourceRoots.push(path);
sourceRoots.sort((a, b) => a.localeCompare(b));
sourceConfig[path] = true;
expandedNodes.add(path);
await loadSourceChildren(path);
} catch (error) {
toast(error.message || 'Failed to choose folder', 'error');
}
}
async function reloadAllSourceTrees() {
const roots = [...sourceRoots];
sourceTree.clear();
for (const root of roots) {
if (expandedNodes.has(root)) {
await loadSourceChildren(root);
}
}
renderSources();
}
function renderSourceNodes(root, parentPathValue) {
const items = sourceTree.get(parentPathValue) || [];
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 + (relativeDepth(root, item.path) + 1) * 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">${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(root, item.path)}</div>` : ''}
</div>
`;
}).join('');
}
function renderSources() {
const el = document.getElementById('sourceList');
if (!allSources.length) {
el.innerHTML = '<div class="text-muted" style="padding:12px 16px">Папки в /media не найдены.</div>';
const el = document.getElementById('sourceTree');
if (!sourceRoots.length) {
el.innerHTML = '<div class="text-muted source-tree-empty">No source folders added yet.</div>';
return;
}
el.innerHTML = allSources.map(path => {
const checked = enabledSources[path] !== false;
return `<label class="source-item">
<input type="checkbox" data-source="${path}" ${checked ? 'checked' : ''}>
<span class="source-item-name">${path}</span>
<span class="source-item-path">/media/${path}</span>
</label>`;
el.innerHTML = sourceRoots.map((root) => {
const checked = effectiveSourceState(root);
const expanded = expandedNodes.has(root);
const childrenKnown = sourceTree.has(root);
const children = childrenKnown ? sourceTree.get(root) : [];
const hasChildren = !childrenKnown || children.length > 0;
return `
<div class="source-root-card">
<div class="source-row source-root-row">
<button
type="button"
class="source-toggle ${hasChildren ? '' : 'source-toggle-empty'}"
data-action="toggle-expand"
data-path="${escapeHTML(root)}"
${hasChildren ? '' : 'tabindex="-1" aria-hidden="true"'}
>${expanded ? '▾' : '▸'}</button>
<input class="source-check" type="checkbox" data-action="toggle-check" data-path="${escapeHTML(root)}" ${checked ? 'checked' : ''}>
<div class="source-label">
<div class="source-root-title">
<span class="source-item-name">${escapeHTML(nodeName(root))}</span>
<span class="source-root-badge">Root</span>
</div>
<span class="source-item-path">${escapeHTML(root)}</span>
</div>
<button type="button" class="button-secondary button-sm" data-action="remove-root" data-path="${escapeHTML(root)}">Remove</button>
</div>
${expanded && loadingNodes.has(root) ? '<div class="source-loading">Loading...</div>' : ''}
${expanded && childrenKnown && children.length ? `<div class="source-children">${renderSourceNodes(root, root)}</div>` : ''}
</div>
`;
}).join('');
}
function deriveRootsFromSources(sources) {
const explicitRoots = sources.filter((source) => source.root).map((source) => source.path);
if (explicitRoots.length) {
return explicitRoots;
}
return sources
.map((source) => source.path)
.filter((path, index, all) => !all.some((other, otherIndex) => otherIndex !== index && isPathWithin(other, path) && !isSamePath(other, path)));
}
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() {
const mode = document.getElementById('allowedFilesMode').value || 'media_types';
document.getElementById('mediaTypesGroup').style.display = mode === 'media_types' ? '' : 'none';
document.getElementById('extensionsGroup').style.display = mode === 'extensions' ? '' : 'none';
}
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() {
try {
const r = await fetch('/api/config');
@@ -106,41 +429,85 @@ async function loadSettings() {
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('allowedFilesMode').value = cfg.allowed_files_mode || 'media_types';
document.getElementById('overwriteMode').value = cfg.overwrite_mode || 'skip';
document.getElementById('autoCopy').checked = !!cfg.auto_copy;
enabledSources = {};
(cfg.sources || []).forEach(s => { enabledSources[s.path] = s.enabled; });
renderSources();
} catch(e) {}
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;
});
sourceRoots = deriveRootsFromSources(cfg.sources || []).sort((a, b) => a.localeCompare(b));
expandedNodes.clear();
sourceTree.clear();
await reloadAllSourceTrees();
} catch (error) {}
}
async function saveSettings(e) {
e.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 });
});
async function saveSettings(event) {
event.preventDefault();
const body = {
reserve_free_gb: parseFloat(document.getElementById('reserveGB').value) || 2,
dest_folder: document.getElementById('destFolder').value.trim() || 'media',
file_select_mode: document.getElementById('fileSelectMode').value,
overwrite_mode: document.getElementById('overwriteMode').value,
auto_copy: document.getElementById('autoCopy').checked,
sources,
reserve_free_gb: parseFloat(document.getElementById('reserveGB').value) || 2,
dest_folder: document.getElementById('destFolder').value.trim() || 'media',
file_select_mode: document.getElementById('fileSelectMode').value,
allowed_files_mode: document.getElementById('allowedFilesMode').value,
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 {
const r = await fetch('/api/config', {
const response = await fetch('/api/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (r.ok) { toast('Настройки сохранены', 'ok'); await loadSettings(); }
else { const d = await r.json(); toast(d.error || 'Ошибка сохранения', 'error'); }
} catch(e) { toast('Ошибка связи', 'error'); }
if (response.ok) {
toast('Settings saved', 'ok');
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('button[data-action]');
if (!button) return;
const action = button.dataset.action;
const path = button.dataset.path;
if (action === 'toggle-expand') {
if (expandedNodes.has(path)) {
expandedNodes.delete(path);
renderSources();
return;
}
await ensureExpanded(path);
}
if (action === 'remove-root') {
removeRoot(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();
loadSources();
</script>
{{end}}