17 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
5b3cb9e393 copier: прогресс по байтам, скорость и ETA
- Прогрессбар по скопированным байтам (doneBytes / totalBytes)
- SpeedBPS и ETASec добавлены в Task
- Dashboard показывает скорость (МБ/с) и ETA справа от прогрессбара

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:56:26 +03:00
f2a7505378 ops: merge build and push into single build-image.sh
Если указан image — собирает мультиарч и пушит.
Если image пустой — локальная сборка без пуша.
push-image.sh удалён.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:48:52 +03:00
7b68c66725 ops: simplify to single Image prompt instead of registry+owner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:47:35 +03:00
0df89bdff0 ops: fix interactive prompts (printf to stderr, no subshell)
printf шёл в stdout внутри $(...), поэтому prompt не выводился
и read висел. Теперь prompt идёт в stderr напрямую.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:46:07 +03:00
83d6ad5134 ops: remove hardcoded registry and owner from scripts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:43:32 +03:00
2355d32766 ops: add registry prefix to build-image, add push-image script
build-image.sh теперь тегирует как git.mchus.pro/reanimator/jukebox-maker.
push-image.sh собирает мультиарч образ и пушит в Gitea registry.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:41:14 +03:00
28ca583073 Fix Dockerfile: bump builder to golang:1.25-alpine
go.mod requires go 1.25.0 (generated by local go 1.26).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:35:16 +03:00
32 changed files with 3511 additions and 422 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 # Build output
/jukebox /jukebox
/release/
/.tmp/
/.gocache/
# Temp copy files # Temp copy files
*.juketmp *.juketmp

View File

@@ -1,16 +1,25 @@
FROM golang:1.22-alpine AS builder # syntax=docker/dockerfile:1.7
FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder
WORKDIR /src WORKDIR /src
ARG VERSION=dev
ARG TARGETOS
ARG TARGETARCH
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go mod download
COPY . . COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" \ RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -trimpath -ldflags="-s -w -X main.Version=${VERSION}" \
-o /out/jukebox ./cmd/jukebox -o /out/jukebox ./cmd/jukebox
FROM alpine:3.19 FROM alpine:3.19
RUN apk add --no-cache tzdata ca-certificates RUN apk add --no-cache tzdata ca-certificates rsync ffmpeg
WORKDIR /app WORKDIR /app
COPY --from=builder /out/jukebox . COPY --from=builder /out/jukebox .

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

View File

@@ -2,41 +2,101 @@ package api
import ( import (
"context" "context"
"encoding/json"
"errors"
"io"
"net/http" "net/http"
"jukebox_maker/internal/config"
"jukebox_maker/internal/copier" "jukebox_maker/internal/copier"
"jukebox_maker/internal/disk" "jukebox_maker/internal/disk"
) )
func (s *Server) copyOptions(cfg *config.Config, diskInfo disk.DiskInfo, overwriteMode config.OverwriteMode) copier.Options {
opts := copier.Options{
DiskID: diskInfo.DiskID,
MountPath: diskInfo.MountPath,
MediaPath: cfg.MediaPath,
SourceRules: cfg.Sources,
AllowedExtensions: cfg.EffectiveAllowedExtensions(),
OverwriteMode: overwriteMode,
}
if p := diskInfo.Profile; p != nil {
opts.DestFolder = p.DestFolder
opts.ReserveFreeGB = p.ReserveFreeGB
opts.FileSelectMode = config.FileSelectMode(p.FileSelectMode)
opts.Transcode = p.Transcode
} else {
opts.DestFolder = cfg.DestFolder
opts.ReserveFreeGB = cfg.ReserveFreeGB
opts.FileSelectMode = cfg.FileSelectMode
}
return opts
}
func hasEnabledSources(cfg *config.Config) bool {
for _, src := range cfg.Sources {
if src.Enabled {
return true
}
}
return false
}
func decodeCopyMode(r *http.Request, fallback config.OverwriteMode) (config.OverwriteMode, error) {
var req struct {
Mode string `json:"mode"`
}
if r.Body != nil {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
return "", err
}
}
switch req.Mode {
case "", "add":
return config.OverwriteSkip, nil
case "replace":
return config.OverwriteDelete, nil
default:
return fallback, errors.New("invalid copy mode")
}
}
func (s *Server) handleCopyStart(w http.ResponseWriter, r *http.Request) { func (s *Server) handleCopyStart(w http.ResponseWriter, r *http.Request) {
diskInfo := s.deps.Watcher.CurrentDisk() diskID := r.PathValue("diskID")
if diskInfo.State != disk.DiskKnown { diskInfo, ok := s.deps.Watcher.DiskByID(diskID)
jsonErr(w, http.StatusUnprocessableEntity, "no known disk connected") if !ok || diskInfo.State != disk.DiskKnown {
jsonErr(w, http.StatusUnprocessableEntity, "no initialized disk connected")
return return
} }
cfg := s.deps.Config cfg := s.deps.Config
var enabledSources []string if !hasEnabledSources(cfg) {
for _, src := range cfg.Sources { jsonErr(w, http.StatusUnprocessableEntity, "no source folders selected")
if src.Enabled {
enabledSources = append(enabledSources, src.Path)
}
}
if len(enabledSources) == 0 {
jsonErr(w, http.StatusUnprocessableEntity, "no sources enabled")
return return
} }
opts := copier.Options{ overwriteMode, err := decodeCopyMode(r, cfg.OverwriteMode)
DiskID: diskInfo.DiskID, if err != nil {
MountPath: diskInfo.MountPath, if err.Error() == "invalid copy mode" {
MediaPath: s.deps.MediaPath, jsonErr(w, http.StatusBadRequest, err.Error())
EnabledSources: enabledSources, return
ReserveFreeGB: cfg.ReserveFreeGB, }
OverwriteMode: cfg.OverwriteMode, jsonErr(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
FileSelectMode: cfg.FileSelectMode, return
} }
reserveGB := cfg.ReserveFreeGB
if diskInfo.Profile != nil {
reserveGB = diskInfo.Profile.ReserveFreeGB
}
if diskInfo.FreeBytes <= int64(reserveGB*1e9) {
jsonErr(w, http.StatusUnprocessableEntity, "free space is below reserve threshold")
return
}
opts := s.copyOptions(cfg, diskInfo, overwriteMode)
taskID, err := s.deps.Copier.Start(context.Background(), opts) taskID, err := s.deps.Copier.Start(context.Background(), opts)
if err != nil { if err != nil {
switch err.Error() { switch err.Error() {
@@ -52,8 +112,72 @@ func (s *Server) handleCopyStart(w http.ResponseWriter, r *http.Request) {
jsonOK(w, map[string]string{"task_id": taskID}) jsonOK(w, map[string]string{"task_id": taskID})
} }
func (s *Server) handleCopyStartSelected(w http.ResponseWriter, r *http.Request) {
cfg := s.deps.Config
if !hasEnabledSources(cfg) {
jsonErr(w, http.StatusUnprocessableEntity, "no source folders selected")
return
}
var req struct {
MountPath string `json:"mount_path"`
Mode string `json:"mode"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
diskInfo, err := s.deps.ProbeDisk(req.MountPath)
if err != nil {
jsonErr(w, http.StatusBadRequest, err.Error())
return
}
if diskInfo.State != disk.DiskKnown {
jsonErr(w, http.StatusUnprocessableEntity, "selected directory is not an initialized jukebox disk")
return
}
overwriteMode := cfg.OverwriteMode
switch req.Mode {
case "", "add":
overwriteMode = config.OverwriteSkip
case "replace":
overwriteMode = config.OverwriteDelete
default:
jsonErr(w, http.StatusBadRequest, "invalid copy mode")
return
}
reserveGB := cfg.ReserveFreeGB
if diskInfo.Profile != nil {
reserveGB = diskInfo.Profile.ReserveFreeGB
}
if diskInfo.FreeBytes <= int64(reserveGB*1e9) {
jsonErr(w, http.StatusUnprocessableEntity, "free space is below reserve threshold")
return
}
if s.deps.OnDiskInit != nil {
s.deps.OnDiskInit(diskInfo.MountPath, diskInfo.DiskID)
}
taskID, err := s.deps.Copier.Start(context.Background(), s.copyOptions(cfg, diskInfo, overwriteMode))
if err != nil {
switch err.Error() {
case "copy already running":
jsonErr(w, http.StatusConflict, err.Error())
default:
jsonErr(w, http.StatusUnprocessableEntity, err.Error())
}
return
}
w.WriteHeader(http.StatusAccepted)
jsonOK(w, map[string]string{"task_id": taskID})
}
func (s *Server) handleCopyCancel(w http.ResponseWriter, r *http.Request) { func (s *Server) handleCopyCancel(w http.ResponseWriter, r *http.Request) {
s.deps.Copier.Cancel() diskID := r.PathValue("diskID")
s.deps.Copier.Cancel(diskID)
jsonOK(w, map[string]bool{"ok": true}) jsonOK(w, map[string]bool{"ok": true})
} }

View File

@@ -1,34 +1,153 @@
package api package api
import ( import (
"encoding/json"
"net/http" "net/http"
"time"
"jukebox_maker/internal/config"
"jukebox_maker/internal/disk" "jukebox_maker/internal/disk"
) )
func (s *Server) handleDiskStatus(w http.ResponseWriter, r *http.Request) { func (s *Server) diskResponse(info disk.DiskInfo) map[string]any {
info := s.deps.Watcher.CurrentDisk() item := map[string]any{
"state": info.State,
"disk_id": info.DiskID,
"total_bytes": info.TotalBytes,
"free_bytes": info.FreeBytes,
"mount_path": info.MountPath,
"profile": info.Profile,
}
if info.DiskID != "" {
if s.deps.OnDiskInit != nil {
s.deps.OnDiskInit(info.MountPath, info.DiskID)
}
if lastCopiedAt, ok, err := s.deps.Copier.LastCopiedAt(info.DiskID); err == nil && ok {
item["last_copied_at"] = lastCopiedAt.Format(time.RFC3339)
}
if t, ok := s.deps.Tasks.ActiveTaskByDisk(info.DiskID); ok {
item["active_task_id"] = t.ID
}
}
return item
}
func (s *Server) handleDiskStatus(w http.ResponseWriter, r *http.Request) {
type response struct { type response struct {
State disk.DiskState `json:"state"` State disk.DiskState `json:"state"`
DiskID string `json:"disk_id"` DiskID string `json:"disk_id"`
TotalBytes int64 `json:"total_bytes"` TotalBytes int64 `json:"total_bytes"`
FreeBytes int64 `json:"free_bytes"` FreeBytes int64 `json:"free_bytes"`
MountPath string `json:"mount_path"` MountPath string `json:"mount_path"`
LastCopiedAt string `json:"last_copied_at,omitempty"`
ActiveTaskID string `json:"active_task_id,omitempty"` ActiveTaskID string `json:"active_task_id,omitempty"`
} }
resp := response{ disks := s.deps.Watcher.ListDisks()
State: info.State, resp := make([]response, 0, len(disks))
DiskID: info.DiskID, for _, info := range disks {
TotalBytes: info.TotalBytes, item := response{
FreeBytes: info.FreeBytes, State: info.State,
MountPath: info.MountPath, DiskID: info.DiskID,
TotalBytes: info.TotalBytes,
FreeBytes: info.FreeBytes,
MountPath: info.MountPath,
}
if payload := s.diskResponse(info); payload != nil {
if v, ok := payload["last_copied_at"].(string); ok {
item.LastCopiedAt = v
}
if v, ok := payload["active_task_id"].(string); ok {
item.ActiveTaskID = v
}
}
resp = append(resp, item)
} }
if t, ok := s.deps.Tasks.ActiveTask(); ok { jsonOK(w, map[string]any{"items": resp})
resp.ActiveTaskID = t.ID }
}
func (s *Server) handleDiskProbe(w http.ResponseWriter, r *http.Request) {
jsonOK(w, resp) mountPath := r.URL.Query().Get("mount_path")
info, err := s.deps.ProbeDisk(mountPath)
if err != nil {
jsonErr(w, http.StatusBadRequest, err.Error())
return
}
jsonOK(w, s.diskResponse(info))
}
func (s *Server) handleDiskInit(w http.ResponseWriter, r *http.Request) {
var req struct {
MountPath string `json:"mount_path"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
info, err := s.deps.ProbeDisk(req.MountPath)
if err != nil {
jsonErr(w, http.StatusBadRequest, err.Error())
return
}
if info.State == disk.DiskAbsent {
jsonErr(w, http.StatusUnprocessableEntity, "no disk connected")
return
}
if info.State == disk.DiskKnown {
jsonErr(w, http.StatusConflict, "disk already initialized")
return
}
if err := disk.CheckWritable(info.MountPath); err != nil {
jsonErr(w, http.StatusUnprocessableEntity, "disk is not writable: "+err.Error())
return
}
diskID, err := disk.InitDisk(info.MountPath)
if err != nil {
jsonErr(w, http.StatusInternalServerError, "init disk: "+err.Error())
return
}
if s.deps.OnDiskInit != nil {
s.deps.OnDiskInit(info.MountPath, diskID)
}
jsonOK(w, map[string]string{"disk_id": diskID})
}
func (s *Server) handleGetProfile(w http.ResponseWriter, r *http.Request) {
mountPath := config.NormalizeMediaPath(r.URL.Query().Get("mount_path"))
if mountPath == "" {
jsonErr(w, http.StatusBadRequest, "mount_path is required")
return
}
p, err := disk.LoadProfile(mountPath)
if err != nil {
jsonErr(w, http.StatusNotFound, "profile not found")
return
}
jsonOK(w, p)
}
func (s *Server) handlePutProfile(w http.ResponseWriter, r *http.Request) {
mountPath := config.NormalizeMediaPath(r.URL.Query().Get("mount_path"))
if mountPath == "" {
jsonErr(w, http.StatusBadRequest, "mount_path is required")
return
}
var p disk.DiskProfile
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
jsonErr(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
if err := disk.SaveProfile(mountPath, &p); err != nil {
jsonErr(w, http.StatusInternalServerError, "save profile: "+err.Error())
return
}
// Обновляем информацию о диске в watcher если он там есть
if s.deps.Watcher != nil {
s.deps.Watcher.ProbeNow()
}
jsonOK(w, &p)
} }

View File

@@ -1,26 +1,66 @@
package api package api
import ( import (
"errors"
"net/http" "net/http"
"os" "os"
"path/filepath"
"sort"
"strings"
) )
func (s *Server) handleSources(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSources(w http.ResponseWriter, r *http.Request) {
entries, err := os.ReadDir(s.deps.MediaPath) absPath, err := normalizeSourcePathQuery(r.URL.Query().Get("path"))
if err != nil { 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 return
} }
var items []string entries, err := os.ReadDir(absPath)
for _, e := range entries { if err != nil {
if e.IsDir() && e.Name()[0] != '.' { jsonOK(w, map[string]any{"path": absPath, "items": []map[string]string{}})
items = append(items, e.Name()) return
}
}
if items == nil {
items = []string{}
} }
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/config"
"jukebox_maker/internal/copier" "jukebox_maker/internal/copier"
"jukebox_maker/internal/disk"
"jukebox_maker/internal/task" "jukebox_maker/internal/task"
"jukebox_maker/internal/watcher" "jukebox_maker/internal/watcher"
) )
@@ -16,11 +17,13 @@ import (
type Deps struct { type Deps struct {
Config *config.Config Config *config.Config
ConfigPath string ConfigPath string
Version string
Watcher *watcher.Watcher Watcher *watcher.Watcher
Copier *copier.Copier Copier *copier.Copier
Tasks *task.Store Tasks *task.Store
MediaPath string ProbeDisk func(mountPath string) (disk.DiskInfo, error)
MountPath string // OnDiskInit вызывается при ручной инициализации диска через UI.
OnDiskInit func(mountPath, diskID string)
} }
type Server struct { type Server struct {
@@ -56,13 +59,19 @@ func (s *Server) routes() {
s.mux.HandleFunc("GET /settings", s.handleSettings) s.mux.HandleFunc("GET /settings", s.handleSettings)
s.mux.HandleFunc("GET /health", s.handleHealth) s.mux.HandleFunc("GET /health", s.handleHealth)
s.mux.HandleFunc("GET /api/disk", s.handleDiskStatus) s.mux.HandleFunc("GET /api/disks", s.handleDiskStatus)
s.mux.HandleFunc("GET /api/disks/probe", s.handleDiskProbe)
s.mux.HandleFunc("POST /api/disks/init", s.handleDiskInit)
s.mux.HandleFunc("POST /api/disks/copy/start", s.handleCopyStartSelected)
s.mux.HandleFunc("GET /api/sources", s.handleSources) s.mux.HandleFunc("GET /api/sources", s.handleSources)
s.mux.HandleFunc("GET /api/config", s.handleGetConfig) s.mux.HandleFunc("GET /api/config", s.handleGetConfig)
s.mux.HandleFunc("PUT /api/config", s.handlePutConfig) s.mux.HandleFunc("PUT /api/config", s.handlePutConfig)
s.mux.HandleFunc("POST /api/copy/start", s.handleCopyStart) s.mux.HandleFunc("POST /api/system/pick-folder", s.handlePickFolder)
s.mux.HandleFunc("POST /api/copy/cancel", s.handleCopyCancel) s.mux.HandleFunc("POST /api/disks/{diskID}/copy/start", s.handleCopyStart)
s.mux.HandleFunc("POST /api/disks/{diskID}/copy/cancel", s.handleCopyCancel)
s.mux.HandleFunc("GET /api/tasks/{id}", s.handleTaskGet) s.mux.HandleFunc("GET /api/tasks/{id}", s.handleTaskGet)
s.mux.HandleFunc("GET /api/disks/profile", s.handleGetProfile)
s.mux.HandleFunc("PUT /api/disks/profile", s.handlePutProfile)
} }
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
@@ -74,7 +83,7 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
} }
func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
s.render(w, s.settings, map[string]any{"Title": "Настройки", "Page": "settings"}) s.render(w, s.settings, map[string]any{"Title": "Settings", "Page": "settings"})
} }
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
@@ -83,7 +92,13 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
func (s *Server) render(w http.ResponseWriter, tmpl *template.Template, data any) { func (s *Server) render(w http.ResponseWriter, tmpl *template.Template, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.ExecuteTemplate(w, "layout", data); err != nil { payload := map[string]any{"Version": s.deps.Version}
if incoming, ok := data.(map[string]any); ok {
for k, v := range incoming {
payload[k] = v
}
}
if err := tmpl.ExecuteTemplate(w, "layout", payload); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
} }
} }

View File

@@ -5,38 +5,63 @@ import (
"errors" "errors"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"jukebox_maker/internal/disk"
) )
type OverwriteMode string type OverwriteMode string
type FileSelectMode string type FileSelectMode string
type AllowedFilesMode string
const ( const (
DefaultDestFolder = "media"
OverwriteSkip OverwriteMode = "skip" OverwriteSkip OverwriteMode = "skip"
OverwriteDelete OverwriteMode = "delete" OverwriteDelete OverwriteMode = "delete"
SelectNew FileSelectMode = "new" SelectNew FileSelectMode = "new"
SelectAll FileSelectMode = "all" SelectAll FileSelectMode = "all"
AllowedFilesByMediaType AllowedFilesMode = "media_types"
AllowedFilesByExtensions AllowedFilesMode = "extensions"
MediaTypeAudio = "audio"
MediaTypeVideo = "video"
MediaTypePhoto = "photo"
) )
type SourceFolder struct { type SourceFolder struct {
Path string `json:"path"` Path string `json:"path"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Root bool `json:"root,omitempty"`
} }
type Config struct { type Config struct {
ReserveFreeGB float64 `json:"reserve_free_gb"` MediaPath string `json:"media_path"`
Sources []SourceFolder `json:"sources"` ReserveFreeGB float64 `json:"reserve_free_gb"`
OverwriteMode OverwriteMode `json:"overwrite_mode"` DestFolder string `json:"dest_folder"`
FileSelectMode FileSelectMode `json:"file_select_mode"` Sources []SourceFolder `json:"sources"`
AutoCopy bool `json:"auto_copy"` OverwriteMode OverwriteMode `json:"overwrite_mode"`
FileSelectMode FileSelectMode `json:"file_select_mode"`
AllowedFilesMode AllowedFilesMode `json:"allowed_files_mode"`
EnabledMediaTypes []string `json:"enabled_media_types,omitempty"`
AllowedExtensions []string `json:"allowed_extensions,omitempty"`
AutoCopy bool `json:"auto_copy"`
FileReplicaCounts map[string]int `json:"file_replica_counts,omitempty"`
DiskReplicaFiles map[string][]string `json:"disk_replica_files,omitempty"`
} }
func defaults() Config { func defaults() Config {
return Config{ return Config{
ReserveFreeGB: 2.0, ReserveFreeGB: 2.0,
OverwriteMode: OverwriteSkip, DestFolder: DefaultDestFolder,
FileSelectMode: SelectNew, OverwriteMode: OverwriteSkip,
AutoCopy: false, FileSelectMode: SelectNew,
AllowedFilesMode: AllowedFilesByMediaType,
EnabledMediaTypes: DefaultEnabledMediaTypes(),
AllowedExtensions: DefaultAllowedExtensions(),
AutoCopy: false,
} }
} }
@@ -53,6 +78,24 @@ func Load(path string) (*Config, error) {
if err := json.Unmarshal(data, &cfg); err != nil { if err := json.Unmarshal(data, &cfg); err != nil {
return nil, err return nil, err
} }
if destFolder, err := NormalizeDestFolder(cfg.DestFolder); err == nil {
cfg.DestFolder = destFolder
} else {
cfg.DestFolder = defaults().DestFolder
}
if cfg.AllowedFilesMode != AllowedFilesByMediaType && cfg.AllowedFilesMode != AllowedFilesByExtensions {
cfg.AllowedFilesMode = defaults().AllowedFilesMode
}
cfg.EnabledMediaTypes = NormalizeMediaTypes(cfg.EnabledMediaTypes)
if len(cfg.EnabledMediaTypes) == 0 {
cfg.EnabledMediaTypes = defaults().EnabledMediaTypes
}
cfg.AllowedExtensions = NormalizeExtensions(cfg.AllowedExtensions)
if len(cfg.AllowedExtensions) == 0 {
cfg.AllowedExtensions = defaults().AllowedExtensions
}
cfg.MediaPath = NormalizeMediaPath(cfg.MediaPath)
cfg.Sources = NormalizeSources(cfg.Sources, cfg.MediaPath)
return &cfg, nil return &cfg, nil
} }
@@ -72,9 +115,32 @@ func Save(path string, cfg *Config) error {
} }
func (c *Config) Validate() 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 { if c.ReserveFreeGB < 0 {
return errors.New("reserve_free_gb must be >= 0") return errors.New("reserve_free_gb must be >= 0")
} }
if _, err := NormalizeDestFolder(c.DestFolder); err != nil {
return err
}
switch c.OverwriteMode { switch c.OverwriteMode {
case OverwriteSkip, OverwriteDelete: case OverwriteSkip, OverwriteDelete:
default: default:
@@ -85,5 +151,204 @@ func (c *Config) Validate() error {
default: default:
return errors.New("file_select_mode must be 'new' or 'all'") return errors.New("file_select_mode must be 'new' or 'all'")
} }
switch c.AllowedFilesMode {
case "", AllowedFilesByMediaType:
c.AllowedFilesMode = AllowedFilesByMediaType
c.EnabledMediaTypes = NormalizeMediaTypes(c.EnabledMediaTypes)
if len(c.EnabledMediaTypes) == 0 {
return errors.New("enabled_media_types must contain at least one of: audio, video, photo")
}
case AllowedFilesByExtensions:
c.AllowedExtensions = NormalizeExtensions(c.AllowedExtensions)
if len(c.AllowedExtensions) == 0 {
return errors.New("allowed_extensions must contain at least one file extension")
}
default:
return errors.New("allowed_files_mode must be 'media_types' or 'extensions'")
}
return nil return nil
} }
func DefaultEnabledMediaTypes() []string {
return []string{MediaTypeAudio, MediaTypeVideo}
}
func DefaultAllowedExtensions() []string {
return extensionsForMediaTypes(DefaultEnabledMediaTypes())
}
func BuiltInMediaTypeExtensions() map[string][]string {
return map[string][]string{
MediaTypeAudio: {
".aac", ".aif", ".aiff", ".alac", ".ape", ".flac", ".m4a", ".mp2", ".mp3", ".ogg", ".opus", ".wav", ".wma",
},
MediaTypeVideo: {
".3gp", ".avi", ".m2ts", ".m4v", ".mkv", ".mov", ".mp4", ".mpeg", ".mpg", ".mts", ".ts", ".webm", ".wmv",
},
MediaTypePhoto: {
".bmp", ".gif", ".heic", ".heif", ".jpeg", ".jpg", ".png", ".tif", ".tiff", ".webp",
},
}
}
func NormalizeMediaTypes(items []string) []string {
order := []string{MediaTypeAudio, MediaTypeVideo, MediaTypePhoto}
allowed := make(map[string]struct{}, len(order))
for _, item := range order {
allowed[item] = struct{}{}
}
seen := make(map[string]struct{}, len(items))
result := make([]string, 0, len(order))
for _, item := range items {
value := strings.ToLower(strings.TrimSpace(item))
if _, ok := allowed[value]; !ok {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
}
for _, item := range order {
if _, ok := seen[item]; ok {
result = append(result, item)
}
}
return result
}
func NormalizeExtensions(items []string) []string {
seen := make(map[string]struct{}, len(items))
result := make([]string, 0, len(items))
for _, item := range items {
value := normalizeExtension(item)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
return result
}
func normalizeExtension(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
value = strings.TrimPrefix(value, "*")
if value == "" {
return ""
}
if !strings.HasPrefix(value, ".") {
value = "." + value
}
if len(value) < 2 {
return ""
}
for _, ch := range value[1:] {
switch {
case ch >= 'a' && ch <= 'z':
case ch >= '0' && ch <= '9':
default:
return ""
}
}
return value
}
func (c Config) EffectiveAllowedExtensions() []string {
switch c.AllowedFilesMode {
case AllowedFilesByExtensions:
if items := NormalizeExtensions(c.AllowedExtensions); len(items) > 0 {
return items
}
default:
types := NormalizeMediaTypes(c.EnabledMediaTypes)
if len(types) == 0 {
types = DefaultEnabledMediaTypes()
}
return extensionsForMediaTypes(types)
}
return DefaultAllowedExtensions()
}
func extensionsForMediaTypes(items []string) []string {
sets := BuiltInMediaTypeExtensions()
result := make([]string, 0)
seen := make(map[string]struct{})
for _, mediaType := range NormalizeMediaTypes(items) {
for _, ext := range sets[mediaType] {
if _, ok := seen[ext]; ok {
continue
}
seen[ext] = struct{}{}
result = append(result, ext)
}
}
return result
}
func NormalizeMediaPath(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
return filepath.Clean(value)
}
func NormalizeSources(items []SourceFolder, mediaPath string) []SourceFolder {
seen := make(map[string]struct{}, len(items))
result := make([]SourceFolder, 0, len(items))
for _, item := range items {
path := normalizeSourcePath(item.Path, mediaPath)
if path == "" {
continue
}
if _, ok := seen[path]; ok {
continue
}
seen[path] = struct{}{}
result = append(result, SourceFolder{
Path: path,
Enabled: item.Enabled,
Root: item.Root,
})
}
return result
}
func normalizeSourcePath(value string, mediaPath string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
if !filepath.IsAbs(value) && mediaPath != "" {
value = filepath.Join(mediaPath, value)
}
return filepath.Clean(value)
}
func NormalizeDestFolder(value string) (string, error) {
value = strings.TrimSpace(value)
if value == "" {
return DefaultDestFolder, nil
}
clean := filepath.ToSlash(filepath.Clean(value))
clean = strings.TrimPrefix(clean, "./")
clean = strings.TrimPrefix(clean, "/")
switch clean {
case "", ".", "..":
return "", errors.New("dest_folder must be a subfolder on disk, not the disk root")
}
if strings.HasPrefix(clean, "../") {
return "", errors.New("dest_folder must stay inside the disk")
}
if clean == disk.MarkerDir || strings.HasPrefix(clean, disk.MarkerDir+"/") {
return "", errors.New("dest_folder conflicts with internal disk metadata")
}
return clean, nil
}

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,105 +2,215 @@ package copier
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"hash/fnv"
"io" "io"
"math/rand/v2"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings"
"sync" "sync"
"time"
"jukebox_maker/internal/config" "jukebox_maker/internal/config"
"jukebox_maker/internal/db" "jukebox_maker/internal/db"
"jukebox_maker/internal/disk" "jukebox_maker/internal/disk"
"jukebox_maker/internal/task" "jukebox_maker/internal/task"
"jukebox_maker/internal/transcoder"
) )
type Options struct { type Options struct {
DiskID string DiskID string
MountPath string MountPath string
MediaPath string MediaPath string
EnabledSources []string DestFolder string // subfolder on disk, default "media"
ReserveFreeGB float64 SourceRules []config.SourceFolder
OverwriteMode config.OverwriteMode AllowedExtensions []string
FileSelectMode config.FileSelectMode ReserveFreeGB float64
OverwriteMode config.OverwriteMode
FileSelectMode config.FileSelectMode
Transcode *disk.TranscodeProfile // nil = не транскодировать
} }
type Copier struct { type Copier struct {
tasks *task.Store tasks *task.Store
mu sync.Mutex mu sync.Mutex
cancel context.CancelFunc cancels map[string]context.CancelFunc
dbMu sync.RWMutex dbMu sync.RWMutex
db *db.DB dbs map[string]*db.DB
} }
func New(tasks *task.Store) *Copier { func New(tasks *task.Store) *Copier {
return &Copier{tasks: tasks} return &Copier{
tasks: tasks,
cancels: make(map[string]context.CancelFunc),
dbs: make(map[string]*db.DB),
}
} }
// SetDB replaces the active disk database (called when a disk connects or disconnects). func (c *Copier) SetDB(diskID string, d *db.DB) {
func (c *Copier) SetDB(d *db.DB) {
c.dbMu.Lock() c.dbMu.Lock()
c.db = d if d == nil {
delete(c.dbs, diskID)
} else {
c.dbs[diskID] = d
}
c.dbMu.Unlock() c.dbMu.Unlock()
} }
func (c *Copier) getDB() *db.DB { func (c *Copier) getDB(diskID string) *db.DB {
c.dbMu.RLock() c.dbMu.RLock()
defer c.dbMu.RUnlock() defer c.dbMu.RUnlock()
return c.db return c.dbs[diskID]
}
func (c *Copier) LastCopiedAt(diskID string) (time.Time, bool, error) {
database := c.getDB(diskID)
if database == nil {
return time.Time{}, false, nil
}
return database.LastCopiedAt(diskID)
} }
func (c *Copier) Start(ctx context.Context, opts Options) (string, error) { func (c *Copier) Start(ctx context.Context, opts Options) (string, error) {
return c.startTask(ctx, "", opts)
}
func (c *Copier) Resume(ctx context.Context, taskID string, opts Options) error {
_, err := c.startTask(ctx, taskID, opts)
return err
}
func (c *Copier) startTask(ctx context.Context, existingTaskID string, opts Options) (string, error) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
if _, active := c.tasks.ActiveTask(); active { if _, active := c.cancels[opts.DiskID]; active {
return "", errors.New("copy already running") return "", errors.New("copy already running")
} }
database := c.getDB() database := c.getDB(opts.DiskID)
if database == nil { if database == nil {
return "", errors.New("no disk database available") return "", errors.New("no disk database available")
} }
t := c.tasks.Create("copy") if opts.DestFolder == "" {
copyCtx, cancel := context.WithCancel(ctx) opts.DestFolder = config.DefaultDestFolder
c.cancel = cancel }
destFolder, err := config.NormalizeDestFolder(opts.DestFolder)
if err != nil {
destFolder = config.DefaultDestFolder
}
opts.DestFolder = destFolder
go c.run(copyCtx, t.ID, opts, database) _, free, err := disk.DiskUsage(opts.MountPath)
return t.ID, nil if err != nil {
return "", err
}
reserveBytes := int64(opts.ReserveFreeGB * 1e9)
if free <= reserveBytes {
return "", errors.New("free space is below reserve threshold")
}
var taskID string
if existingTaskID == "" {
t := c.tasks.Create("copy", opts.DiskID)
payload, err := json.Marshal(opts)
if err != nil {
return "", err
}
if err := database.UpsertTask(*t, payload); err != nil {
return "", err
}
taskID = t.ID
} else {
taskID = existingTaskID
c.tasks.Update(taskID, func(t *task.Task) {
t.Status = task.StatusQueued
t.Phase = task.PhaseQueued
t.Message = "Resuming after restart..."
t.Error = ""
t.SpeedBPS = 0
t.ETASec = 0
})
if t, ok := c.tasks.Get(taskID); ok {
if err := database.UpdateTask(*t); err != nil {
return "", err
}
}
}
copyCtx, cancel := context.WithCancel(ctx)
c.cancels[opts.DiskID] = cancel
go c.run(copyCtx, taskID, opts, database)
return taskID, nil
} }
func (c *Copier) Cancel() { func (c *Copier) Cancel(diskID string) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
if c.cancel != nil { if cancel, ok := c.cancels[diskID]; ok {
c.cancel() cancel()
} }
} }
func (c *Copier) run(ctx context.Context, taskID string, opts Options, database *db.DB) { func (c *Copier) run(ctx context.Context, taskID string, opts Options, database *db.DB) {
defer func() {
c.mu.Lock()
delete(c.cancels, opts.DiskID)
c.mu.Unlock()
}()
setStatus := func(s task.Status, msg string, prog int) { setStatus := func(s task.Status, msg string, prog int) {
c.tasks.Update(taskID, func(t *task.Task) { c.tasks.Update(taskID, func(t *task.Task) {
t.Status = s t.Status = s
t.Message = msg t.Message = msg
t.Progress = prog t.Progress = prog
}) })
if t, ok := c.tasks.Get(taskID); ok {
_ = database.UpdateTask(*t)
}
} }
fail := func(err error) { fail := func(err error) {
c.tasks.Update(taskID, func(t *task.Task) { c.tasks.Update(taskID, func(t *task.Task) {
t.Status = task.StatusFailed t.Status = task.StatusFailed
t.Error = err.Error() t.Error = err.Error()
}) })
if t, ok := c.tasks.Get(taskID); ok {
_ = database.UpdateTask(*t)
}
} }
setStatus(task.StatusRunning, "Подготовка…", 0) c.tasks.Update(taskID, func(t *task.Task) {
t.Status = task.StatusRunning
t.Phase = task.PhasePreparing
t.Message = "Preparing..."
t.Progress = 0
t.Error = ""
})
if t, ok := c.tasks.Get(taskID); ok {
_ = database.UpdateTask(*t)
}
destRoot := filepath.Join(opts.MountPath, opts.DestFolder)
if opts.OverwriteMode == config.OverwriteDelete { if opts.OverwriteMode == config.OverwriteDelete {
setStatus(task.StatusRunning, "Удаление данных с диска…", 0) c.tasks.Update(taskID, func(t *task.Task) {
if err := deleteOurData(opts.MountPath); err != nil { t.Status = task.StatusRunning
t.Phase = task.PhaseReplacing
t.Message = "Replacing destination media..."
t.Progress = 0
})
if t, ok := c.tasks.Get(taskID); ok {
_ = database.UpdateTask(*t)
}
if err := os.RemoveAll(destRoot); err != nil {
fail(err) fail(err)
return return
} }
@@ -108,7 +218,15 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database
var copiedPaths map[string]struct{} var copiedPaths map[string]struct{}
if opts.FileSelectMode == config.SelectNew { if opts.FileSelectMode == config.SelectNew {
setStatus(task.StatusRunning, "Загрузка истории…", 0) c.tasks.Update(taskID, func(t *task.Task) {
t.Status = task.StatusRunning
t.Phase = task.PhaseLoadingHistory
t.Message = "Loading copy history..."
t.Progress = 0
})
if t, ok := c.tasks.Get(taskID); ok {
_ = database.UpdateTask(*t)
}
var err error var err error
copiedPaths, err = database.CopiedPaths(opts.DiskID) copiedPaths, err = database.CopiedPaths(opts.DiskID)
if err != nil { if err != nil {
@@ -117,17 +235,28 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database
} }
} }
setStatus(task.StatusRunning, "Сканирование источников…", 0) c.tasks.Update(taskID, func(t *task.Task) {
files, err := buildFileList(opts.MediaPath, opts.EnabledSources, copiedPaths) t.Status = task.StatusRunning
t.Phase = task.PhaseScanning
t.Message = "Scanning sources..."
t.Progress = 0
})
if t, ok := c.tasks.Get(taskID); ok {
_ = database.UpdateTask(*t)
}
files, err := buildFileList(opts.MediaPath, opts.SourceRules, copiedPaths, opts.AllowedExtensions)
if err != nil { if err != nil {
fail(err) fail(err)
return return
} }
if len(files) == 0 { if len(files) == 0 {
setStatus(task.StatusSuccess, "Нет новых файлов для копирования.", 100) setStatus(task.StatusSuccess, "No files to copy.", 100)
return return
} }
// случайный порядок — выбираем что копировать до начала копирования
rand.Shuffle(len(files), func(i, j int) { files[i], files[j] = files[j], files[i] })
_, free, err := disk.DiskUsage(opts.MountPath) _, free, err := disk.DiskUsage(opts.MountPath)
if err != nil { if err != nil {
fail(err) fail(err)
@@ -136,19 +265,33 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database
reserveBytes := int64(opts.ReserveFreeGB * 1e9) reserveBytes := int64(opts.ReserveFreeGB * 1e9)
available := free - reserveBytes available := free - reserveBytes
if available <= 0 { if available <= 0 {
setStatus(task.StatusSuccess, "Недостаточно свободного места на диске.", 100) setStatus(task.StatusFailed, "Free space is below the reserved threshold.", 100)
return return
} }
// суммарный объём для прогресса (всех файлов в списке)
var totalBytes int64
for _, f := range files {
totalBytes += f.size
}
total := len(files) total := len(files)
copied := 0 copied := 0
var doneBytes int64
startTime := time.Now()
for i, f := range files { for i, f := range files {
select { select {
case <-ctx.Done(): case <-ctx.Done():
c.tasks.Update(taskID, func(t *task.Task) { c.tasks.Update(taskID, func(t *task.Task) {
t.Status = task.StatusCanceled t.Status = task.StatusCanceled
t.Message = "Отменено" t.Message = "Canceled"
t.SpeedBPS = 0
t.ETASec = 0
}) })
if t, ok := c.tasks.Get(taskID); ok {
_ = database.UpdateTask(*t)
}
return return
default: default:
} }
@@ -157,23 +300,56 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database
continue continue
} }
msg := fmt.Sprintf("Копирование %s (%d/%d)", filepath.Base(f.srcAbs), i+1, total) elapsed := time.Since(startTime).Seconds()
prog := int(float64(i+1) / float64(total) * 100) var speedBPS, etaSec int64
setStatus(task.StatusRunning, msg, prog) if elapsed > 0 && doneBytes > 0 {
speedBPS = int64(float64(doneBytes) / elapsed)
remaining := totalBytes - doneBytes
if speedBPS > 0 {
etaSec = remaining / speedBPS
}
}
dstAbs := filepath.Join(opts.MountPath, f.relPath) prog := int(float64(doneBytes) / float64(totalBytes) * 100)
if err := copyFile(ctx, f.srcAbs, dstAbs); err != nil { msg := fmt.Sprintf("Copying %s (%d/%d)", filepath.Base(f.srcAbs), i+1, total)
if errors.Is(err, context.Canceled) {
c.tasks.Update(taskID, func(t *task.Task) {
t.Status = task.StatusRunning
t.Phase = task.PhaseCopying
t.Message = msg
t.Progress = prog
t.SpeedBPS = speedBPS
t.ETASec = int(etaSec)
})
if t, ok := c.tasks.Get(taskID); ok {
_ = database.UpdateTask(*t)
}
dstAbs := filepath.Join(destRoot, f.relPath)
var fileErr error
if opts.Transcode != nil && isVideoFile(f.srcAbs) {
fileErr = c.processVideo(ctx, taskID, database, opts.Transcode, f.srcAbs, dstAbs)
} else {
fileErr = copyFile(ctx, f.srcAbs, dstAbs)
}
if fileErr != nil {
if errors.Is(fileErr, context.Canceled) {
c.tasks.Update(taskID, func(t *task.Task) { c.tasks.Update(taskID, func(t *task.Task) {
t.Status = task.StatusCanceled t.Status = task.StatusCanceled
t.Message = "Отменено" t.Message = "Canceled"
t.SpeedBPS = 0
t.ETASec = 0
}) })
if t, ok := c.tasks.Get(taskID); ok {
_ = database.UpdateTask(*t)
}
return return
} }
continue continue
} }
available -= f.size available -= f.size
doneBytes += f.size
copied++ copied++
_ = database.RecordCopy(db.CopyRecord{ _ = database.RecordCopy(db.CopyRecord{
DiskID: opts.DiskID, DiskID: opts.DiskID,
@@ -182,32 +358,126 @@ 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 { type fileEntry struct {
srcAbs string srcAbs string
relPath string relPath string // relative to /media
size int64 size int64
} }
func buildFileList(mediaPath string, sources []string, skip map[string]struct{}) ([]fileEntry, error) { func buildFileList(mediaPath string, rules []config.SourceFolder, skip map[string]struct{}, allowedExtensions []string) ([]fileEntry, error) {
_ = mediaPath
roots, selectedRoots, ruleMap := normalizeSourceRules(rules)
aliases := sourceAliases(roots)
allowedExts := makeAllowedExtensionSet(allowedExtensions)
var result []fileEntry var result []fileEntry
for _, src := range sources { for _, src := range selectedRoots {
dir := filepath.Join(mediaPath, src) root := owningRoot(src, roots)
if root == "" {
root = src
}
alias := aliases[root]
if alias == "" {
alias = filepath.Base(root)
if alias == "." || alias == "" || alias == string(filepath.Separator) {
alias = "source-" + shortHash(root)
}
}
dir := src
err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() { if err != nil || d.IsDir() {
if err != nil {
return nil
}
if path == dir {
return nil
}
rel, relErr := filepath.Rel(root, path)
if relErr != nil {
return nil
}
rel = filepath.ToSlash(rel)
if !isPathEnabled(path, ruleMap) && !hasEnabledDescendant(path, ruleMap) {
return filepath.SkipDir
}
return nil return nil
} }
rel, _ := filepath.Rel(mediaPath, path) if !isPathEnabled(path, ruleMap) {
return nil
}
if !isExtensionAllowed(path, allowedExts) {
return nil
}
rel, _ := filepath.Rel(root, path)
rel = filepath.ToSlash(rel)
destRel := filepath.ToSlash(filepath.Join(alias, rel))
if _, skipped := skip[rel]; skipped { if _, skipped := skip[rel]; skipped {
return nil return nil
} }
if _, skipped := skip[destRel]; skipped {
return nil
}
info, err := d.Info() info, err := d.Info()
if err != nil { if err != nil {
return nil return nil
} }
result = append(result, fileEntry{srcAbs: path, relPath: rel, size: info.Size()}) result = append(result, fileEntry{srcAbs: path, relPath: destRel, size: info.Size()})
return nil return nil
}) })
if err != nil { if err != nil {
@@ -217,64 +487,233 @@ func buildFileList(mediaPath string, sources []string, skip map[string]struct{})
return result, nil return result, nil
} }
func deleteOurData(mountPath string) error { func makeAllowedExtensionSet(items []string) map[string]struct{} {
entries, err := os.ReadDir(mountPath) normalized := config.NormalizeExtensions(items)
if err != nil { if len(normalized) == 0 {
return err normalized = config.DefaultAllowedExtensions()
} }
for _, e := range entries { result := make(map[string]struct{}, len(normalized))
if e.Name() == ".jukebox" { for _, item := range normalized {
result[item] = struct{}{}
}
return result
}
func isExtensionAllowed(path string, allowed map[string]struct{}) bool {
ext := strings.ToLower(filepath.Ext(path))
if ext == "" {
return false
}
_, ok := allowed[ext]
return ok
}
func normalizeSourceRules(rules []config.SourceFolder) ([]string, []string, map[string]bool) {
ruleMap := make(map[string]bool, len(rules))
rootSet := make(map[string]struct{})
for _, rule := range rules {
src := filepath.Clean(strings.TrimSpace(rule.Path))
if src == "" || src == "." {
continue continue
} }
if err := os.RemoveAll(filepath.Join(mountPath, e.Name())); err != nil { ruleMap[src] = rule.Enabled
return err if rule.Root {
rootSet[src] = struct{}{}
} }
} }
return nil
var roots []string
for src := range rootSet {
roots = append(roots, src)
}
sort.Strings(roots)
var selectedRoots []string
for src, enabled := range ruleMap {
if !enabled || hasEnabledAncestor(src, ruleMap) {
continue
}
selectedRoots = append(selectedRoots, src)
}
sort.Strings(selectedRoots)
return roots, selectedRoots, ruleMap
}
func hasEnabledAncestor(path string, ruleMap map[string]bool) bool {
for parent := parentSourcePath(path); parent != ""; parent = parentSourcePath(parent) {
if ruleMap[parent] {
return true
}
}
return false
}
func hasEnabledDescendant(path string, ruleMap map[string]bool) bool {
for other, enabled := range ruleMap {
if enabled && isPathInside(path, other) && other != path {
return true
}
}
return false
}
func isPathEnabled(path string, ruleMap map[string]bool) bool {
for current := path; current != ""; current = parentSourcePath(current) {
if enabled, ok := ruleMap[current]; ok {
return enabled
}
}
return false
}
func parentSourcePath(path string) string {
parent := filepath.Dir(path)
if parent == "." || parent == path {
return ""
}
return parent
}
func owningRoot(path string, roots []string) string {
var best string
for _, root := range roots {
if isPathInside(root, path) {
if len(root) > len(best) {
best = root
}
}
}
return best
}
func sourceAliases(roots []string) map[string]string {
counts := make(map[string]int, len(roots))
for _, root := range roots {
counts[strings.ToLower(filepath.Base(root))]++
}
aliases := make(map[string]string, len(roots))
for _, root := range roots {
base := filepath.Base(root)
if base == "." || base == string(filepath.Separator) || base == "" {
base = "source"
}
key := strings.ToLower(base)
if counts[key] > 1 {
base = fmt.Sprintf("%s-%s", base, shortHash(root))
}
aliases[root] = base
}
return aliases
}
func shortHash(value string) string {
h := fnv.New32a()
_, _ = h.Write([]byte(value))
return fmt.Sprintf("%08x", h.Sum32())[:6]
}
func isPathInside(base, candidate string) bool {
if candidate == base {
return true
}
rel, err := filepath.Rel(base, candidate)
if err != nil {
return false
}
return rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator))
} }
func copyFile(ctx context.Context, src, dst string) error { func copyFile(ctx context.Context, src, dst string) error {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err return err
} }
in, err := os.Open(src)
srcFile, err := os.Open(src)
if err != nil { if err != nil {
return err return err
} }
defer in.Close() defer srcFile.Close()
tmp := dst + ".juketmp" srcInfo, err := srcFile.Stat()
out, err := os.Create(tmp)
if err != nil { if err != nil {
return err return err
} }
buf := make([]byte, 512*1024) offset := int64(0)
for { if dstInfo, err := os.Stat(dst); err == nil {
select { switch {
case <-ctx.Done(): case dstInfo.Size() < srcInfo.Size():
out.Close() offset = dstInfo.Size()
os.Remove(tmp) case dstInfo.Size() == srcInfo.Size():
return ctx.Err() return os.Chtimes(dst, srcInfo.ModTime(), srcInfo.ModTime())
default: default:
} if err := os.Remove(dst); err != nil {
n, readErr := in.Read(buf) return err
if n > 0 {
if _, werr := out.Write(buf[:n]); werr != nil {
out.Close()
os.Remove(tmp)
return werr
} }
} }
if errors.Is(readErr, io.EOF) { } else if !errors.Is(err, os.ErrNotExist) {
break return err
}
if offset > 0 {
if _, err := srcFile.Seek(offset, io.SeekStart); err != nil {
return err
} }
}
dstFile, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return err
}
defer dstFile.Close()
if offset > 0 {
if _, err := dstFile.Seek(offset, io.SeekStart); err != nil {
return err
}
} else if err := dstFile.Truncate(0); err != nil {
return err
}
buf := make([]byte, 1024*1024)
for {
if err := ctx.Err(); err != nil {
return err
}
nr, readErr := srcFile.Read(buf)
if nr > 0 {
nw, writeErr := dstFile.Write(buf[:nr])
if writeErr != nil {
return writeErr
}
if nw != nr {
return io.ErrShortWrite
}
}
if readErr != nil { if readErr != nil {
out.Close() if errors.Is(readErr, io.EOF) {
os.Remove(tmp) break
}
return readErr return readErr
} }
} }
out.Close()
return os.Rename(tmp, dst) if err := dstFile.Sync(); err != nil {
return err
}
if err := os.Chtimes(dst, srcInfo.ModTime(), srcInfo.ModTime()); err != nil {
return err
}
dstInfo, err := os.Stat(dst)
if err != nil {
return err
}
if dstInfo.Size() != srcInfo.Size() {
return fmt.Errorf("copied size mismatch for %s", filepath.Base(src))
}
return nil
} }

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 ( import (
"database/sql" "database/sql"
"encoding/json"
"time" "time"
"jukebox_maker/internal/task"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
@@ -18,6 +21,11 @@ type CopyRecord struct {
CopiedAt time.Time CopiedAt time.Time
} }
type TaskRecord struct {
Task task.Task
Payload json.RawMessage
}
func Open(path string) (*DB, error) { func Open(path string) (*DB, error) {
conn, err := sql.Open("sqlite", path+"?_journal=WAL&_timeout=5000") conn, err := sql.Open("sqlite", path+"?_journal=WAL&_timeout=5000")
if err != nil { if err != nil {
@@ -47,6 +55,26 @@ func (d *DB) migrate() error {
); );
CREATE UNIQUE INDEX IF NOT EXISTS idx_copy_history_disk_path CREATE UNIQUE INDEX IF NOT EXISTS idx_copy_history_disk_path
ON copy_history (disk_id, source_path); ON copy_history (disk_id, source_path);
CREATE TABLE IF NOT EXISTS disk_stats (
disk_id TEXT PRIMARY KEY,
last_copied_at DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
disk_id TEXT NOT NULL,
type TEXT NOT NULL,
status TEXT NOT NULL,
phase TEXT NOT NULL DEFAULT 'queued',
progress INTEGER NOT NULL DEFAULT 0,
message TEXT NOT NULL DEFAULT '',
speed_bps INTEGER NOT NULL DEFAULT 0,
eta_sec INTEGER NOT NULL DEFAULT 0,
error TEXT NOT NULL DEFAULT '',
payload TEXT NOT NULL DEFAULT '{}',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_tasks_status_updated ON tasks (status, updated_at);
`) `)
return err return err
} }
@@ -65,11 +93,26 @@ func (d *DB) RecordCopy(rec CopyRecord) error {
if t.IsZero() { if t.IsZero() {
t = time.Now().UTC() t = time.Now().UTC()
} }
_, err := d.sql.Exec( tx, err := d.sql.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(
`INSERT OR IGNORE INTO copy_history (disk_id, source_path, file_size, copied_at) VALUES (?,?,?,?)`, `INSERT OR IGNORE INTO copy_history (disk_id, source_path, file_size, copied_at) VALUES (?,?,?,?)`,
rec.DiskID, rec.SourcePath, rec.FileSize, t.Format(time.RFC3339), rec.DiskID, rec.SourcePath, rec.FileSize, t.Format(time.RFC3339),
) ); err != nil {
return err return err
}
if _, err := tx.Exec(
`INSERT INTO disk_stats (disk_id, last_copied_at) VALUES (?, ?)
ON CONFLICT(disk_id) DO UPDATE SET last_copied_at=excluded.last_copied_at`,
rec.DiskID, t.Format(time.RFC3339),
); err != nil {
return err
}
return tx.Commit()
} }
func (d *DB) CopiedPaths(diskID string) (map[string]struct{}, error) { func (d *DB) CopiedPaths(diskID string) (map[string]struct{}, error) {
@@ -90,3 +133,114 @@ func (d *DB) CopiedPaths(diskID string) (map[string]struct{}, error) {
} }
return m, rows.Err() return m, rows.Err()
} }
func (d *DB) LastCopiedAt(diskID string) (time.Time, bool, error) {
var raw string
err := d.sql.QueryRow(
`SELECT last_copied_at FROM disk_stats WHERE disk_id=?`,
diskID,
).Scan(&raw)
if err == sql.ErrNoRows {
return time.Time{}, false, nil
}
if err != nil {
return time.Time{}, false, err
}
t, err := time.Parse(time.RFC3339, raw)
if err != nil {
return time.Time{}, false, err
}
return t, true, nil
}
func (d *DB) UpsertTask(t task.Task, payload json.RawMessage) error {
if payload == nil {
payload = json.RawMessage(`{}`)
}
_, err := d.sql.Exec(
`INSERT INTO tasks (id, disk_id, type, status, phase, progress, message, speed_bps, eta_sec, error, payload, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
status=excluded.status,
phase=excluded.phase,
progress=excluded.progress,
message=excluded.message,
speed_bps=excluded.speed_bps,
eta_sec=excluded.eta_sec,
error=excluded.error,
payload=excluded.payload,
updated_at=excluded.updated_at`,
t.ID, t.DiskID, t.Type, t.Status, t.Phase, t.Progress, t.Message, t.SpeedBPS, t.ETASec, t.Error,
string(payload), t.CreatedAt.Format(time.RFC3339), t.UpdatedAt.Format(time.RFC3339),
)
return err
}
func (d *DB) UpdateTask(t task.Task) error {
_, err := d.sql.Exec(
`UPDATE tasks
SET status=?, phase=?, progress=?, message=?, speed_bps=?, eta_sec=?, error=?, updated_at=?
WHERE id=?`,
t.Status, t.Phase, t.Progress, t.Message, t.SpeedBPS, t.ETASec, t.Error, t.UpdatedAt.Format(time.RFC3339), t.ID,
)
return err
}
func (d *DB) ActiveTask() (*TaskRecord, bool, error) {
row := d.sql.QueryRow(
`SELECT id, disk_id, type, status, phase, progress, message, speed_bps, eta_sec, error, payload, created_at, updated_at
FROM tasks
WHERE status IN ('queued','running')
ORDER BY updated_at DESC
LIMIT 1`,
)
rec, err := scanTaskRecord(row)
if err == sql.ErrNoRows {
return nil, false, nil
}
if err != nil {
return nil, false, err
}
return rec, true, nil
}
type scanner interface {
Scan(dest ...any) error
}
func scanTaskRecord(s scanner) (*TaskRecord, error) {
var rec TaskRecord
var payloadRaw, createdAtRaw, updatedAtRaw string
err := s.Scan(
&rec.Task.ID,
&rec.Task.DiskID,
&rec.Task.Type,
&rec.Task.Status,
&rec.Task.Phase,
&rec.Task.Progress,
&rec.Task.Message,
&rec.Task.SpeedBPS,
&rec.Task.ETASec,
&rec.Task.Error,
&payloadRaw,
&createdAtRaw,
&updatedAtRaw,
)
if err != nil {
return nil, err
}
createdAt, err := time.Parse(time.RFC3339, createdAtRaw)
if err != nil {
return nil, err
}
updatedAt, err := time.Parse(time.RFC3339, updatedAtRaw)
if err != nil {
return nil, err
}
rec.Task.CreatedAt = createdAt
rec.Task.UpdatedAt = updatedAt
rec.Payload = json.RawMessage(payloadRaw)
return &rec, nil
}

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

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,12 +17,26 @@ const (
StatusCanceled Status = "canceled" StatusCanceled Status = "canceled"
) )
const (
PhaseQueued = "queued"
PhasePreparing = "preparing"
PhaseReplacing = "replacing"
PhaseLoadingHistory = "loading_history"
PhaseScanning = "scanning"
PhaseTranscoding = "transcoding"
PhaseCopying = "copying"
)
type Task struct { type Task struct {
ID string `json:"id"` ID string `json:"id"`
DiskID string `json:"disk_id"`
Type string `json:"type"` Type string `json:"type"`
Status Status `json:"status"` Status Status `json:"status"`
Phase string `json:"phase,omitempty"`
Progress int `json:"progress"` Progress int `json:"progress"`
Message string `json:"message"` Message string `json:"message"`
SpeedBPS int64 `json:"speed_bps"`
ETASec int `json:"eta_sec"`
Error string `json:"error"` Error string `json:"error"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
@@ -41,13 +55,16 @@ func NewStore() *Store {
return &Store{tasks: make(map[string]*Task)} return &Store{tasks: make(map[string]*Task)}
} }
func (s *Store) Create(taskType string) *Task { func (s *Store) Create(taskType, diskID string) *Task {
now := time.Now().UTC()
t := &Task{ t := &Task{
ID: uuid.New().String(), ID: uuid.New().String(),
DiskID: diskID,
Type: taskType, Type: taskType,
Status: StatusQueued, Status: StatusQueued,
CreatedAt: time.Now().UTC(), Phase: PhaseQueued,
UpdatedAt: time.Now().UTC(), CreatedAt: now,
UpdatedAt: now,
} }
s.mu.Lock() s.mu.Lock()
s.tasks[t.ID] = t s.tasks[t.ID] = t
@@ -55,6 +72,13 @@ func (s *Store) Create(taskType string) *Task {
return t return t
} }
func (s *Store) Upsert(t Task) {
copy := t
s.mu.Lock()
s.tasks[t.ID] = &copy
s.mu.Unlock()
}
func (s *Store) Get(id string) (*Task, bool) { func (s *Store) Get(id string) (*Task, bool) {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
@@ -75,11 +99,11 @@ func (s *Store) Update(id string, fn func(*Task)) {
} }
} }
func (s *Store) ActiveTask() (*Task, bool) { func (s *Store) ActiveTaskByDisk(diskID string) (*Task, bool) {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
for _, t := range s.tasks { for _, t := range s.tasks {
if t.Status == StatusQueued || t.Status == StatusRunning { if t.DiskID == diskID && (t.Status == StatusQueued || t.Status == StatusRunning) {
copy := *t copy := *t
return &copy, true return &copy, true
} }

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 ( import (
"context" "context"
"os"
"path/filepath"
"sort"
"strings"
"sync" "sync"
"time" "time"
@@ -10,7 +14,7 @@ import (
type DiskEvent struct { type DiskEvent struct {
Info disk.DiskInfo Info disk.DiskInfo
Prev disk.DiskState Prev disk.DiskInfo
} }
type Handler func(event DiskEvent) type Handler func(event DiskEvent)
@@ -20,8 +24,8 @@ type Watcher struct {
interval time.Duration interval time.Duration
handler Handler handler Handler
mu sync.RWMutex mu sync.RWMutex
current disk.DiskInfo disks map[string]disk.DiskInfo
} }
func New(mountPath string, interval time.Duration, handler Handler) *Watcher { func New(mountPath string, interval time.Duration, handler Handler) *Watcher {
@@ -29,13 +33,42 @@ func New(mountPath string, interval time.Duration, handler Handler) *Watcher {
mountPath: mountPath, mountPath: mountPath,
interval: interval, interval: interval,
handler: handler, handler: handler,
disks: make(map[string]disk.DiskInfo),
} }
} }
func (w *Watcher) CurrentDisk() disk.DiskInfo { func (w *Watcher) ListDisks() []disk.DiskInfo {
w.mu.RLock() w.mu.RLock()
defer w.mu.RUnlock() defer w.mu.RUnlock()
return w.current
items := make([]disk.DiskInfo, 0, len(w.disks))
for _, info := range w.disks {
items = append(items, info)
}
sort.Slice(items, func(i, j int) bool { return items[i].MountPath < items[j].MountPath })
return items
}
func (w *Watcher) DiskByMountPath(mountPath string) (disk.DiskInfo, bool) {
w.mu.RLock()
defer w.mu.RUnlock()
info, ok := w.disks[mountPath]
return info, ok
}
func (w *Watcher) DiskByID(diskID string) (disk.DiskInfo, bool) {
w.mu.RLock()
defer w.mu.RUnlock()
for _, info := range w.disks {
if info.DiskID == diskID {
return info, true
}
}
return disk.DiskInfo{}, false
}
func (w *Watcher) ProbeNow() {
w.probe()
} }
func (w *Watcher) Run(ctx context.Context) { func (w *Watcher) Run(ctx context.Context) {
@@ -56,15 +89,69 @@ func (w *Watcher) Run(ctx context.Context) {
} }
func (w *Watcher) probe() { func (w *Watcher) probe() {
info, _ := disk.Probe(w.mountPath) next := discoverDisks(w.mountPath)
w.mu.Lock() w.mu.Lock()
prev := w.current.State prev := w.disks
changed := prev != info.State w.disks = next
w.current = info
w.mu.Unlock() w.mu.Unlock()
if changed && w.handler != nil { if w.handler == nil {
w.handler(DiskEvent{Info: info, Prev: prev}) return
}
seen := make(map[string]struct{}, len(prev)+len(next))
for mountPath, info := range next {
seen[mountPath] = struct{}{}
prevInfo := prev[mountPath]
if prevInfo.State != info.State || prevInfo.DiskID != info.DiskID {
w.handler(DiskEvent{Info: info, Prev: prevInfo})
}
}
for mountPath, prevInfo := range prev {
if _, ok := seen[mountPath]; ok {
continue
}
w.handler(DiskEvent{
Info: disk.DiskInfo{
State: disk.DiskAbsent,
MountPath: mountPath,
},
Prev: prevInfo,
})
} }
} }
func discoverDisks(root string) map[string]disk.DiskInfo {
disks := make(map[string]disk.DiskInfo)
entries, err := os.ReadDir(root)
if err != nil {
return disks
}
for _, entry := range entries {
if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
continue
}
mountPath := filepath.Join(root, entry.Name())
if !disk.IsMountPoint(mountPath) {
continue
}
info, _ := disk.Probe(mountPath)
if info.State == disk.DiskAbsent {
continue
}
disks[mountPath] = info
}
// If no child mountpoints were detected, the disk may be mounted directly at root.
if len(disks) == 0 {
if disk.IsMountPoint(root) {
if info, _ := disk.Probe(root); info.State != disk.DiskAbsent {
disks[root] = info
}
}
}
return disks
}

View File

@@ -1,20 +1,101 @@
#!/bin/sh #!/bin/sh
# Build a Docker image. If an image name is provided, builds multi-arch and pushes to registry.
# Otherwise builds locally for the current platform only.
#
# Usage (interactive):
# ./ops/build-image.sh
#
# Usage (non-interactive):
# ./ops/build-image.sh <tag> [image]
#
# Examples:
# ./ops/build-image.sh # prompts for tag and image
# ./ops/build-image.sh v1.0 # prompts for image
# ./ops/build-image.sh v1.0 registry.example.com/org/myapp # local only (no push)
# ./ops/build-image.sh v1.0 "" # local build, no push
set -eu set -eu
ROOT_DIR=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) ROOT_DIR=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
if ! command -v docker >/dev/null 2>&1; then die() { echo "error: $*" >&2; exit 1; }
echo "docker not found in PATH" >&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
if [ -n "$3" ]; then
printf "%s [%s]: " "$2" "$3" >&2
else
printf "%s (leave empty to build locally only): " "$2" >&2
fi
read -r _val
eval "$1=\"\${_val:-$3}\""
}
if [ $# -ge 2 ]; then
IMAGE_TAG="$1"
IMAGE="$2"
elif [ $# -ge 1 ]; then
IMAGE_TAG="$1"
ask IMAGE "Image" ""
else
ask IMAGE_TAG "Tag" "${DEFAULT_TAG}"
ask IMAGE "Image" ""
fi fi
IMAGE_NAME=${IMAGE_NAME:-jukebox-maker} echo "checking Go build"
DEFAULT_TAG=$(git -C "${ROOT_DIR}" rev-parse --short HEAD 2>/dev/null || echo dev) (
IMAGE_TAG=${1:-${IMAGE_TAG:-${DEFAULT_TAG}}} cd "${ROOT_DIR}"
go build ./...
)
echo "building ${IMAGE_NAME}:${IMAGE_TAG}" if [ -n "${IMAGE}" ]; then
docker build \ # multi-arch build + push
-f "${ROOT_DIR}/Dockerfile" \ docker buildx version >/dev/null 2>&1 || die "docker buildx not available"
-t "${IMAGE_NAME}:${IMAGE_TAG}" \
-t "${IMAGE_NAME}:latest" \ PLATFORMS="${PLATFORMS:-linux/amd64,linux/arm64}"
"${ROOT_DIR}" BUILDER_NAME="jukebox-multiarch"
if ! docker buildx inspect "${BUILDER_NAME}" >/dev/null 2>&1; then
echo "creating buildx builder: ${BUILDER_NAME}"
docker buildx create \
--name "${BUILDER_NAME}" \
--driver docker-container \
--bootstrap
fi
docker buildx use "${BUILDER_NAME}"
echo "building and pushing ${IMAGE}:${IMAGE_TAG} (${PLATFORMS})"
docker buildx build \
--platform "${PLATFORMS}" \
--file "${ROOT_DIR}/Dockerfile" \
--build-arg "VERSION=${DEFAULT_VERSION}" \
-t "${IMAGE}:${IMAGE_TAG}" \
-t "${IMAGE}:latest" \
--push \
"${ROOT_DIR}"
echo ""
echo "pushed:"
echo " ${IMAGE}:${IMAGE_TAG}"
echo " ${IMAGE}:latest"
else
# local build only
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}"
echo ""
echo "built:"
echo " jukebox-maker:${IMAGE_TAG}"
echo " jukebox-maker:latest"
fi

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; margin: 28px auto 56px;
} }
.page-footer {
width: min(var(--content-width), calc(100vw - 48px));
margin: -28px auto 24px;
color: var(--muted);
font-size: 12px;
text-align: right;
}
/* Panel */ /* Panel */
.panel { .panel {
margin-bottom: 24px; margin-bottom: 24px;
@@ -89,6 +97,24 @@ a:hover { text-decoration: underline; }
color: var(--ink); color: var(--ink);
} }
.panel-body {
padding: 14px 16px;
}
.disk-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 20px;
}
.disk-card {
margin-bottom: 0;
}
.progress-wrap {
border-top: 1px solid var(--border-lite);
}
/* KV table */ /* KV table */
.kv-table { .kv-table {
width: 100%; width: 100%;
@@ -199,6 +225,17 @@ a:hover { text-decoration: underline; }
.form-group { display: flex; flex-direction: column; gap: 5px; } .form-group { display: flex; flex-direction: column; gap: 5px; }
.path-input-row {
display: flex;
gap: 8px;
align-items: center;
}
.path-input-row .form-input {
flex: 1;
min-width: 0;
}
.form-label { .form-label {
font-size: 13px; font-size: 13px;
font-weight: 700; font-weight: 700;
@@ -241,6 +278,124 @@ a:hover { text-decoration: underline; }
/* Checkbox list */ /* Checkbox list */
.source-list { display: flex; flex-direction: column; gap: 0; } .source-list { display: flex; flex-direction: column; gap: 0; }
.source-tree {
padding: 12px;
background: linear-gradient(180deg, rgba(33, 133, 208, 0.03), rgba(34, 36, 38, 0.015));
}
.source-tree-empty {
padding: 20px 16px;
border: 1px dashed var(--border);
border-radius: calc(var(--radius) + 2px);
background: rgba(255, 255, 255, 0.75);
}
.source-root-card {
margin-bottom: 12px;
overflow: hidden;
border: 1px solid rgba(33, 133, 208, 0.18);
border-radius: calc(var(--radius) + 2px);
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 8px 24px rgba(27, 28, 29, 0.04);
}
.source-root-card:last-child {
margin-bottom: 0;
}
.source-node {
border-bottom: 1px solid var(--border-lite);
}
.source-node:last-child {
border-bottom: 0;
}
.source-row {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
transition: background 0.12s ease;
}
.source-row:hover {
background: rgba(33, 133, 208, 0.04);
}
.source-root-row {
padding-top: 12px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(33, 133, 208, 0.12);
background:
linear-gradient(90deg, rgba(33, 133, 208, 0.08), rgba(33, 133, 208, 0.015) 42%, rgba(255, 255, 255, 0.96) 100%);
}
.source-root-row:hover {
background:
linear-gradient(90deg, rgba(33, 133, 208, 0.12), rgba(33, 133, 208, 0.03) 42%, rgba(255, 255, 255, 1) 100%);
}
.source-toggle {
width: 24px;
height: 24px;
border: 1px solid transparent;
border-radius: var(--radius);
background: transparent;
color: var(--muted);
cursor: pointer;
flex: 0 0 24px;
}
.source-toggle:hover {
border-color: var(--border);
background: var(--surface-2);
}
.source-toggle-empty {
visibility: hidden;
}
.source-check {
width: 15px;
height: 15px;
accent-color: var(--accent);
cursor: pointer;
}
.source-label {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1;
}
.source-root-title {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.source-root-badge {
display: inline-flex;
align-items: center;
padding: 2px 7px;
border-radius: 999px;
background: rgba(33, 133, 208, 0.1);
color: var(--accent-dark);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.source-item-hint {
font-size: 12px;
color: var(--muted);
}
.source-children {
position: relative;
padding: 6px 0 8px 0;
}
.source-children::before {
content: "";
position: absolute;
left: 26px;
top: 0;
bottom: 8px;
width: 1px;
background: linear-gradient(180deg, rgba(33, 133, 208, 0.2), rgba(33, 133, 208, 0.03));
}
.source-loading {
padding: 6px 16px 10px 48px;
color: var(--muted);
font-size: 12px;
}
.source-item { .source-item {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -325,5 +480,15 @@ a:hover { text-decoration: underline; }
@media (max-width: 720px) { @media (max-width: 720px) {
.page-header { flex-wrap: wrap; padding: 12px 16px; } .page-header { flex-wrap: wrap; padding: 12px 16px; }
.page-main { width: calc(100vw - 24px); margin-top: 20px; } .page-main { width: calc(100vw - 24px); margin-top: 20px; }
.page-footer { width: calc(100vw - 24px); margin-top: -8px; }
.disk-grid { grid-template-columns: 1fr; }
.kv-table th { width: 130px; } .kv-table th { width: 130px; }
.btn-row { flex-wrap: wrap; }
.path-input-row { flex-wrap: wrap; }
.source-root-row {
align-items: flex-start;
}
.source-root-title {
flex-wrap: wrap;
}
} }

View File

@@ -1,137 +1,441 @@
{{define "content"}} {{define "content"}}
<section class="panel"> <section class="panel">
<h2>Накопитель</h2> <h2>Mounted Disk</h2>
<table class="kv-table"> <div class="panel-body">
<tbody> <div class="path-input-row">
<tr> <input class="form-input" type="text" id="mountPath" placeholder="/Volumes/JUKEBOX or E:\\">
<th>Статус</th> <button type="button" class="button-primary" onclick="pickMountPath()">+</button>
<td id="diskState"><span class="badge badge-unknown">Не подключён</span></td> <button type="button" class="button-secondary" onclick="refreshSelectedDisk()">Refresh</button>
</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>
<div class="progress-label" id="progressMsg">Подготовка…</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> </div>
</section> </section>
<div class="btn-row" style="background:transparent;border:none;padding:0;margin-bottom:24px"> <div id="diskState"></div>
<button class="button-primary" id="btnStart" onclick="startCopy()" disabled>▶ Запустить копирование</button>
<button class="button-danger hidden" id="btnCancel" onclick="cancelCopy()">✕ Отменить</button>
</div>
<script> <script>
let pollInterval = null; const selectedDisk = { info: null };
let activeTaskId = null; const taskState = new Map();
const taskPollers = new Map();
async function refreshDisk() { function escapeHTML(value) {
try { return String(value || '').replace(/[&<>"']/g, (char) => ({
const r = await fetch('/api/disk'); '&': '&amp;',
if (!r.ok) return; '<': '&lt;',
const d = await r.json(); '>': '&gt;',
'"': '&quot;',
const labels = { absent: 'Не подключён', foreign: 'Незнакомый диск', known: 'Диск подключён' }; "'": '&#39;'
const cls = { absent: 'badge-unknown', foreign: 'badge-warn', known: 'badge-ok' }; }[char]));
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 startTaskPoll(id) { stopTaskPoll(); pollInterval = setInterval(() => pollTask(id), 1500); } function badgeClass(state) {
function stopTaskPoll() { if (pollInterval) { clearInterval(pollInterval); pollInterval = null; } } return ({ absent: 'badge-unknown', foreign: 'badge-warn', known: 'badge-ok' })[state] || 'badge-unknown';
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 || '…';
if (['success','failed','canceled'].includes(t.status)) {
stopTaskPoll(); activeTaskId = null;
document.getElementById('btnStart').disabled = false;
document.getElementById('btnStart').classList.remove('hidden');
document.getElementById('btnCancel').classList.add('hidden');
document.getElementById('progressPanel').classList.add('hidden');
if (t.status === 'success') toast(t.message || 'Готово', 'ok');
if (t.status === 'failed') toast('Ошибка: ' + t.error, 'error');
if (t.status === 'canceled') toast('Копирование отменено', 'error');
refreshDisk();
}
} catch(e) {}
} }
async function startCopy() { function badgeLabel(state) {
document.getElementById('btnStart').disabled = true; return ({ absent: 'Directory unavailable', foreign: 'Uninitialized disk', known: 'Ready' })[state] || '—';
}
function fmtSpeed(bps) {
if (!bps) return '';
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) + ' h ' + Math.floor((sec % 3600) / 60) + ' min';
if (sec >= 60) return Math.floor(sec / 60) + ' min';
return sec + ' s';
}
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'
});
}
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 { try {
const r = await fetch('/api/copy/start', { method: 'POST' }); const response = await fetch('/api/disks/profile?mount_path=' + encodeURIComponent(mountPath), {
const d = await r.json(); method: 'PUT',
if (!r.ok) { headers: { 'Content-Type': 'application/json' },
toast(d.error || 'Ошибка запуска', 'error'); body: JSON.stringify(profile)
document.getElementById('btnStart').disabled = false; });
const payload = await response.json();
if (!response.ok) {
toast(payload.error || 'Ошибка сохранения профиля', 'error');
return; return;
} }
activeTaskId = d.task_id; toast('Профиль сохранён', 'ok');
document.getElementById('btnStart').classList.add('hidden'); refreshSelectedDisk();
document.getElementById('btnCancel').classList.remove('hidden'); } catch (error) {
document.getElementById('progressPanel').classList.remove('hidden'); toast('Ошибка сети', 'error');
document.getElementById('progressFill').style.width = '0%'; }
document.getElementById('progressMsg').textContent = 'Подготовка…'; }
startTaskPoll(activeTaskId);
} catch(e) { function stopTaskPoll(taskID) {
toast('Ошибка связи', 'error'); if (!taskPollers.has(taskID)) return;
document.getElementById('btnStart').disabled = false; 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() { 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(); async function initDisk() {
setInterval(refreshDisk, 5000); 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> </script>
{{end}} {{end}}

View File

@@ -1,5 +1,5 @@
{{define "layout"}}<!DOCTYPE html> {{define "layout"}}<!DOCTYPE html>
<html lang="ru"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -12,7 +12,7 @@
<h1>🎵 Jukebox Maker</h1> <h1>🎵 Jukebox Maker</h1>
<nav class="header-nav"> <nav class="header-nav">
<a href="/" class="header-action {{if eq .Page "dashboard"}}active{{end}}">Dashboard</a> <a href="/" class="header-action {{if eq .Page "dashboard"}}active{{end}}">Dashboard</a>
<a href="/settings" class="header-action {{if eq .Page "settings"}}active{{end}}">Настройки</a> <a href="/settings" class="header-action {{if eq .Page "settings"}}active{{end}}">Settings</a>
</nav> </nav>
</header> </header>
@@ -20,6 +20,10 @@
{{template "content" .}} {{template "content" .}}
</main> </main>
<footer class="page-footer">
<span>Version {{.Version}}</span>
</footer>
<div class="toast-container" id="toastContainer"></div> <div class="toast-container" id="toastContainer"></div>
<script> <script>
@@ -33,10 +37,10 @@ function toast(msg, type) {
} }
function fmtBytes(b) { function fmtBytes(b) {
if (!b) return '—'; if (!b) return '—';
if (b >= 1e12) return (b/1e12).toFixed(1) + ' ТБ'; if (b >= 1e12) return (b/1e12).toFixed(1) + ' TB';
if (b >= 1e9) return (b/1e9).toFixed(1) + ' ГБ'; if (b >= 1e9) return (b/1e9).toFixed(1) + ' GB';
if (b >= 1e6) return (b/1e6).toFixed(1) + ' МБ'; if (b >= 1e6) return (b/1e6).toFixed(1) + ' MB';
return (b/1e3).toFixed(0) + ' КБ'; return (b/1e3).toFixed(0) + ' KB';
} }
</script> </script>
</body> </body>

View File

@@ -2,137 +2,512 @@
<form id="settingsForm" onsubmit="saveSettings(event)"> <form id="settingsForm" onsubmit="saveSettings(event)">
<section class="panel"> <section class="panel">
<h2>Источники копирования</h2> <h2>Copy Sources</h2>
<div class="source-list" id="sourceList"> <div class="panel-body">
<div class="text-muted" style="padding:12px 16px">Загрузка…</div> <div class="form-hint">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>
<div class="btn-row"> <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> </div>
</section> </section>
<section class="panel"> <section class="panel">
<h2>Параметры копирования</h2> <h2>Copy Settings</h2>
<div class="form-body"> <div class="form-body">
<div class="form-group"> <div class="form-group">
<label class="form-label" for="reserveGB">Оставить свободным на диске (ГБ)</label> <label class="form-label" for="reserveGB">Reserved free space on disk (GB)</label>
<input class="form-input" type="number" id="reserveGB" min="0" max="1000" step="0.5" value="2"> <input class="form-input" type="number" id="reserveGB" min="0" max="1000" step="0.5" value="2">
<span class="form-hint">Копирование остановится, когда свободного места останется меньше этого значения.</span> <span class="form-hint">Copying will stop when free space falls below this value.</span>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label" for="fileSelectMode">Какие файлы копировать</label> <label class="form-label" for="fileSelectMode">Files to copy</label>
<select class="form-select" id="fileSelectMode" style="width:auto;max-width:420px"> <select class="form-select" id="fileSelectMode" style="width:auto;max-width:420px">
<option value="new">Только новые (не копировавшиеся на этот диск)</option> <option value="new">Only new files not copied to this disk before</option>
<option value="all">Все подряд</option> <option value="all">All matching files</option>
</select> </select>
<span class="form-hint">«Только новые» — пропускает файлы, уже скопированные на данный диск, даже если они были удалены с него (считаются просмотренными).</span> <span class="form-hint">The new-only mode skips files already copied to this disk, even if they were later removed.</span>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label" for="overwriteMode">Режим записи</label> <label class="form-label" for="allowedFilesMode">Allowed file types</label>
<select class="form-select" id="overwriteMode" style="width:auto;max-width:420px"> <select class="form-select" id="allowedFilesMode" style="width:auto;max-width:420px" onchange="updateAllowedFilesModeUI()">
<option value="skip">Пропустить существующие файлы</option> <option value="media_types">Audio, video, photo</option>
<option value="delete">Удалить наши данные с диска и перезаписать заново</option> <option value="extensions">Custom extensions list</option>
</select> </select>
<span class="form-hint">«Удалить и перезаписать» — удаляет с диска всё кроме папки .jukebox, затем копирует заново.</span> </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">Files will be copied into this subfolder while preserving the selected source structure. The disk root and <code>.jukebox</code> are never allowed here.</span>
</div>
<div class="form-group">
<label class="form-label" for="overwriteMode">Default write mode</label>
<select class="form-select" id="overwriteMode" style="width:auto;max-width:420px">
<option value="skip">Keep existing files</option>
<option value="delete">Replace destination folder contents</option>
</select>
<span class="form-hint">This is used for automatic copy runs. Manual dashboard actions can override it.</span>
</div> </div>
</div> </div>
</section> </section>
<section class="panel"> <section class="panel">
<h2>Автоматизация</h2> <h2>Automation</h2>
<div class="form-body"> <div class="form-body">
<div class="form-group"> <div class="form-group">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer"> <label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="autoCopy" style="width:15px;height:15px;accent-color:var(--accent)"> <input type="checkbox" id="autoCopy" style="width:15px;height:15px;accent-color:var(--accent)">
<span class="form-label" style="margin:0">Автоматическое копирование</span> <span class="form-label" style="margin:0">Automatic copy</span>
</label> </label>
<span class="form-hint">При обнаружении знакомого накопителя копирование запустится автоматически.</span> <span class="form-hint">Start copying automatically when a known disk is detected.</span>
</div> </div>
</div> </div>
</section> </section>
<div style="display:flex;gap:8px;margin-bottom:24px"> <div style="display:flex;gap:8px;margin-bottom:24px">
<button type="submit" class="button-primary">Сохранить настройки</button> <button type="submit" class="button-primary">Save settings</button>
<button type="button" class="button-secondary" onclick="loadSettings()">Сбросить</button> <button type="button" class="button-secondary" onclick="loadSettings()">Reset</button>
</div> </div>
</form> </form>
<script> <script>
let allSources = []; const sourceTree = new Map();
let enabledSources = {}; const expandedNodes = new Set();
const loadingNodes = new Set();
const builtInMediaTypes = {
audio: ['.aac', '.aif', '.aiff', '.alac', '.ape', '.flac', '.m4a', '.mp2', '.mp3', '.ogg', '.opus', '.wav', '.wma'],
video: ['.3gp', '.avi', '.m2ts', '.m4v', '.mkv', '.mov', '.mp4', '.mpeg', '.mpg', '.mts', '.ts', '.webm', '.wmv'],
photo: ['.bmp', '.gif', '.heic', '.heif', '.jpeg', '.jpg', '.png', '.tif', '.tiff', '.webp'],
};
let 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 { try {
const r = await fetch('/api/sources'); const response = await fetch('/api/sources?path=' + encodeURIComponent(path));
if (!r.ok) return; if (!response.ok) return;
const d = await r.json(); const payload = await response.json();
allSources = d.items || []; sourceTree.set(path, payload.items || []);
} catch (error) {
} finally {
loadingNodes.delete(path);
renderSources(); renderSources();
} catch(e) {} }
}
async function ensureExpanded(path) {
expandedNodes.add(path);
if (!sourceTree.has(path)) {
await loadSourceChildren(path);
return;
}
renderSources();
}
function toggleSource(path, checked) {
sourceConfig[path] = checked;
renderSources();
}
function 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() { function renderSources() {
const el = document.getElementById('sourceList'); const el = document.getElementById('sourceTree');
if (!allSources.length) { if (!sourceRoots.length) {
el.innerHTML = '<div class="text-muted" style="padding:12px 16px">Папки в /media не найдены.</div>'; el.innerHTML = '<div class="text-muted source-tree-empty">No source folders added yet.</div>';
return; return;
} }
el.innerHTML = allSources.map(path => {
const checked = enabledSources[path] !== false; el.innerHTML = sourceRoots.map((root) => {
return `<label class="source-item"> const checked = effectiveSourceState(root);
<input type="checkbox" data-source="${path}" ${checked ? 'checked' : ''}> const expanded = expandedNodes.has(root);
<span class="source-item-name">${path}</span> const childrenKnown = sourceTree.has(root);
<span class="source-item-path">/media/${path}</span> const children = childrenKnown ? sourceTree.get(root) : [];
</label>`; 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(''); }).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() { async function loadSettings() {
try { try {
const r = await fetch('/api/config'); const r = await fetch('/api/config');
if (!r.ok) return; if (!r.ok) return;
const cfg = await r.json(); const cfg = await r.json();
document.getElementById('reserveGB').value = cfg.reserve_free_gb ?? 2; document.getElementById('reserveGB').value = cfg.reserve_free_gb ?? 2;
document.getElementById('destFolder').value = cfg.dest_folder || 'media';
document.getElementById('fileSelectMode').value = cfg.file_select_mode || 'new'; document.getElementById('fileSelectMode').value = cfg.file_select_mode || 'new';
document.getElementById('allowedFilesMode').value = cfg.allowed_files_mode || 'media_types';
document.getElementById('overwriteMode').value = cfg.overwrite_mode || 'skip'; document.getElementById('overwriteMode').value = cfg.overwrite_mode || 'skip';
document.getElementById('autoCopy').checked = !!cfg.auto_copy; document.getElementById('autoCopy').checked = !!cfg.auto_copy;
enabledSources = {}; document.getElementById('mediaTypeAudio').checked = (cfg.enabled_media_types || ['audio', 'video']).includes('audio');
(cfg.sources || []).forEach(s => { enabledSources[s.path] = s.enabled; }); document.getElementById('mediaTypeVideo').checked = (cfg.enabled_media_types || ['audio', 'video']).includes('video');
renderSources(); document.getElementById('mediaTypePhoto').checked = (cfg.enabled_media_types || []).includes('photo');
} catch(e) {} 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) { async function saveSettings(event) {
e.preventDefault(); event.preventDefault();
const checkboxes = document.querySelectorAll('[data-source]');
const sources = Array.from(checkboxes).map(cb => ({ path: cb.dataset.source, enabled: cb.checked }));
Object.keys(enabledSources).forEach(path => {
if (!sources.find(s => s.path === path)) sources.push({ path, enabled: false });
});
const body = { const body = {
reserve_free_gb: parseFloat(document.getElementById('reserveGB').value) || 2, reserve_free_gb: parseFloat(document.getElementById('reserveGB').value) || 2,
file_select_mode: document.getElementById('fileSelectMode').value, dest_folder: document.getElementById('destFolder').value.trim() || 'media',
overwrite_mode: document.getElementById('overwriteMode').value, file_select_mode: document.getElementById('fileSelectMode').value,
auto_copy: document.getElementById('autoCopy').checked, allowed_files_mode: document.getElementById('allowedFilesMode').value,
sources, enabled_media_types: selectedMediaTypes(),
allowed_extensions: parseExtensionsInput(document.getElementById('allowedExtensions').value),
overwrite_mode: document.getElementById('overwriteMode').value,
auto_copy: document.getElementById('autoCopy').checked,
sources: collectSourcesForSave(),
}; };
try { try {
const r = await fetch('/api/config', { const response = await fetch('/api/config', {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
if (r.ok) { toast('Настройки сохранены', 'ok'); await loadSettings(); } if (response.ok) {
else { const d = await r.json(); toast(d.error || 'Ошибка сохранения', 'error'); } toast('Settings saved', 'ok');
} catch(e) { toast('Ошибка связи', 'error'); } await loadSettings();
return;
}
const payload = await response.json();
toast(payload.error || 'Failed to save settings', 'error');
} catch (error) {
toast('Network error', 'error');
}
} }
document.getElementById('sourceTree').addEventListener('click', async (event) => {
const button = event.target.closest('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(); loadSettings();
loadSources();
</script> </script>
{{end}} {{end}}