17 Commits
v1.1 ... main

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 21:08:00 +03:00
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
32 changed files with 3384 additions and 411 deletions

7
.dockerignore Normal file
View File

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

4
.gitignore vendored
View File

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

View File

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

51
Makefile Normal file
View File

@@ -0,0 +1,51 @@
APP := jukebox
APP_PKG := ./cmd/jukebox
APP_URL := http://127.0.0.1:8080
ROOT_DIR := $(CURDIR)
DEV_DIR := $(ROOT_DIR)/.tmp
CONFIG_DIR := $(DEV_DIR)/config
MEDIA_DIR := $(DEV_DIR)/media
MOUNT_DIR := $(DEV_DIR)/mount
CONFIG_PATH := $(CONFIG_DIR)/config.json
GOCACHE_DIR := $(DEV_DIR)/gocache
.PHONY: run test build release dev-dirs
dev-dirs:
mkdir -p "$(CONFIG_DIR)" "$(MEDIA_DIR)" "$(MOUNT_DIR)" "$(GOCACHE_DIR)"
run: dev-dirs
@sh -c '\
url="$(APP_URL)"; \
open_url() { \
if command -v open >/dev/null 2>&1; then \
open "$$1"; \
elif command -v powershell >/dev/null 2>&1; then \
powershell -NoProfile -Command "Start-Process '\''$$1'\''" >/dev/null; \
elif command -v xdg-open >/dev/null 2>&1; then \
xdg-open "$$1" >/dev/null 2>&1; \
fi; \
}; \
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do \
if curl -fsS "$$url/health" >/dev/null 2>&1; then \
open_url "$$url"; \
exit 0; \
fi; \
sleep 1; \
done \
' &
GOCACHE="$(GOCACHE_DIR)" go run $(APP_PKG) \
-config "$(CONFIG_PATH)" \
-media "$(MEDIA_DIR)" \
-mount "$(MOUNT_DIR)" \
-addr ":8080"
test: dev-dirs
GOCACHE="$(GOCACHE_DIR)" go test ./...
build: dev-dirs
GOCACHE="$(GOCACHE_DIR)" go build -o "$(ROOT_DIR)/$(APP)" $(APP_PKG)
release:
./ops/build-release.sh

View File

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

View File

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

View File

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

View File

@@ -1,26 +1,79 @@
package api
import (
"errors"
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
)
func (s *Server) handleSources(w http.ResponseWriter, r *http.Request) {
entries, err := os.ReadDir(s.deps.MediaPath)
relPath, err := normalizeSourcePath(r.URL.Query().Get("path"))
if err != nil {
jsonOK(w, map[string][]string{"items": {}})
jsonErr(w, http.StatusBadRequest, err.Error())
return
}
var items []string
for _, e := range entries {
if e.IsDir() && e.Name()[0] != '.' {
items = append(items, e.Name())
}
}
if items == nil {
items = []string{}
absPath := s.deps.Config.MediaPath
if relPath != "" {
absPath = filepath.Join(absPath, relPath)
}
jsonOK(w, map[string][]string{"items": items})
entries, err := os.ReadDir(absPath)
if err != nil {
jsonOK(w, map[string]any{"path": relPath, "items": []map[string]string{}})
return
}
type item struct {
Name string `json:"name"`
Path string `json:"path"`
}
var items []item
for _, e := range entries {
if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
continue
}
childPath := e.Name()
if relPath != "" {
childPath = filepath.Join(relPath, childPath)
}
items = append(items, item{
Name: e.Name(),
Path: filepath.ToSlash(childPath),
})
}
sort.Slice(items, func(i, j int) bool {
return strings.ToLower(items[i].Name) < strings.ToLower(items[j].Name)
})
jsonOK(w, map[string]any{
"path": relPath,
"items": items,
})
}
func normalizeSourcePath(raw string) (string, error) {
raw, _ = url.QueryUnescape(raw)
raw = strings.TrimSpace(raw)
raw = filepath.ToSlash(raw)
raw = strings.TrimPrefix(raw, "/")
if raw == "" || raw == "." {
return "", nil
}
clean := filepath.Clean(raw)
clean = filepath.ToSlash(clean)
if clean == "." {
return "", nil
}
if clean == ".." || strings.HasPrefix(clean, "../") {
return "", errors.New("invalid source path")
}
return clean, nil
}

View File

@@ -0,0 +1,16 @@
package api
import (
"net/http"
"jukebox_maker/internal/dialog"
)
func (s *Server) handlePickFolder(w http.ResponseWriter, r *http.Request) {
path, err := dialog.PickFolder()
if err != nil {
jsonErr(w, http.StatusUnprocessableEntity, err.Error())
return
}
jsonOK(w, map[string]string{"path": path})
}

View File

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

View File

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

View File

@@ -0,0 +1,68 @@
package config
import "testing"
func TestDefaultsAllowedFiles(t *testing.T) {
cfg := defaults()
if cfg.AllowedFilesMode != AllowedFilesByMediaType {
t.Fatalf("allowed files mode = %q, want %q", cfg.AllowedFilesMode, AllowedFilesByMediaType)
}
wantTypes := []string{MediaTypeAudio, MediaTypeVideo}
if len(cfg.EnabledMediaTypes) != len(wantTypes) {
t.Fatalf("enabled media types len = %d, want %d", len(cfg.EnabledMediaTypes), len(wantTypes))
}
for i, want := range wantTypes {
if cfg.EnabledMediaTypes[i] != want {
t.Fatalf("enabled media types[%d] = %q, want %q", i, cfg.EnabledMediaTypes[i], want)
}
}
exts := cfg.EffectiveAllowedExtensions()
if containsString(exts, ".jpg") {
t.Fatalf("default extensions unexpectedly include photo files: %v", exts)
}
if !containsString(exts, ".mp3") || !containsString(exts, ".mp4") {
t.Fatalf("default extensions = %v, want audio/video entries", exts)
}
}
func TestValidateAllowedFilesModeExtensions(t *testing.T) {
cfg := defaults()
cfg.AllowedFilesMode = AllowedFilesByExtensions
cfg.AllowedExtensions = []string{" mp3 ", ".MP4", "*.jpg", ".mp3"}
if err := cfg.Validate(); err != nil {
t.Fatalf("Validate() error = %v", err)
}
want := []string{".mp3", ".mp4", ".jpg"}
if len(cfg.AllowedExtensions) != len(want) {
t.Fatalf("allowed extensions len = %d, want %d", len(cfg.AllowedExtensions), len(want))
}
for i, item := range want {
if cfg.AllowedExtensions[i] != item {
t.Fatalf("allowed extensions[%d] = %q, want %q", i, cfg.AllowedExtensions[i], item)
}
}
}
func TestValidateRejectsEmptyAllowedFiles(t *testing.T) {
cfg := defaults()
cfg.AllowedFilesMode = AllowedFilesByExtensions
cfg.AllowedExtensions = nil
if err := cfg.Validate(); err == nil {
t.Fatal("Validate() error = nil, want non-nil")
}
}
func containsString(items []string, want string) bool {
for _, item := range items {
if item == want {
return true
}
}
return false
}

View File

@@ -2,105 +2,217 @@ package copier
import (
"context"
"encoding/json"
"errors"
"fmt"
"hash/fnv"
"io"
"math/rand/v2"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"jukebox_maker/internal/config"
"jukebox_maker/internal/db"
"jukebox_maker/internal/disk"
"jukebox_maker/internal/task"
"jukebox_maker/internal/transcoder"
)
type Options struct {
DiskID string
MountPath string
MediaPath string
EnabledSources []string
DestFolder string // subfolder on disk, default "media"
SourceRules []config.SourceFolder
AllowedExtensions []string
ReserveFreeGB float64
OverwriteMode config.OverwriteMode
FileSelectMode config.FileSelectMode
Transcode *disk.TranscodeProfile // nil = не транскодировать
// ShuffleDepth: -1=выкл, 0=файлы вразнобой, 1+=группировка по папке на глубине N
ShuffleDepth int
}
type Copier struct {
tasks *task.Store
mu sync.Mutex
cancel context.CancelFunc
cancels map[string]context.CancelFunc
dbMu sync.RWMutex
db *db.DB
dbs map[string]*db.DB
}
func New(tasks *task.Store) *Copier {
return &Copier{tasks: tasks}
return &Copier{
tasks: tasks,
cancels: make(map[string]context.CancelFunc),
dbs: make(map[string]*db.DB),
}
}
// SetDB replaces the active disk database (called when a disk connects or disconnects).
func (c *Copier) SetDB(d *db.DB) {
func (c *Copier) SetDB(diskID string, d *db.DB) {
c.dbMu.Lock()
c.db = d
if d == nil {
delete(c.dbs, diskID)
} else {
c.dbs[diskID] = d
}
c.dbMu.Unlock()
}
func (c *Copier) getDB() *db.DB {
func (c *Copier) getDB(diskID string) *db.DB {
c.dbMu.RLock()
defer c.dbMu.RUnlock()
return c.db
return c.dbs[diskID]
}
func (c *Copier) LastCopiedAt(diskID string) (time.Time, bool, error) {
database := c.getDB(diskID)
if database == nil {
return time.Time{}, false, nil
}
return database.LastCopiedAt(diskID)
}
func (c *Copier) Start(ctx context.Context, opts Options) (string, error) {
return c.startTask(ctx, "", opts)
}
func (c *Copier) Resume(ctx context.Context, taskID string, opts Options) error {
_, err := c.startTask(ctx, taskID, opts)
return err
}
func (c *Copier) startTask(ctx context.Context, existingTaskID string, opts Options) (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
if _, active := c.tasks.ActiveTask(); active {
if _, active := c.cancels[opts.DiskID]; active {
return "", errors.New("copy already running")
}
database := c.getDB()
database := c.getDB(opts.DiskID)
if database == nil {
return "", errors.New("no disk database available")
}
t := c.tasks.Create("copy")
copyCtx, cancel := context.WithCancel(ctx)
c.cancel = cancel
if opts.DestFolder == "" {
opts.DestFolder = config.DefaultDestFolder
}
destFolder, err := config.NormalizeDestFolder(opts.DestFolder)
if err != nil {
destFolder = config.DefaultDestFolder
}
opts.DestFolder = destFolder
go c.run(copyCtx, t.ID, opts, database)
return t.ID, nil
_, free, err := disk.DiskUsage(opts.MountPath)
if err != nil {
return "", err
}
reserveBytes := int64(opts.ReserveFreeGB * 1e9)
if free <= reserveBytes {
return "", errors.New("free space is below reserve threshold")
}
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()
defer c.mu.Unlock()
if c.cancel != nil {
c.cancel()
if cancel, ok := c.cancels[diskID]; ok {
cancel()
}
}
func (c *Copier) run(ctx context.Context, taskID string, opts Options, database *db.DB) {
defer func() {
c.mu.Lock()
delete(c.cancels, opts.DiskID)
c.mu.Unlock()
}()
setStatus := func(s task.Status, msg string, prog int) {
c.tasks.Update(taskID, func(t *task.Task) {
t.Status = s
t.Message = msg
t.Progress = prog
})
if t, ok := c.tasks.Get(taskID); ok {
_ = database.UpdateTask(*t)
}
}
fail := func(err error) {
c.tasks.Update(taskID, func(t *task.Task) {
t.Status = task.StatusFailed
t.Error = err.Error()
})
if t, ok := c.tasks.Get(taskID); ok {
_ = database.UpdateTask(*t)
}
}
setStatus(task.StatusRunning, "Подготовка…", 0)
c.tasks.Update(taskID, func(t *task.Task) {
t.Status = task.StatusRunning
t.Phase = task.PhasePreparing
t.Message = "Preparing..."
t.Progress = 0
t.Error = ""
})
if t, ok := c.tasks.Get(taskID); ok {
_ = database.UpdateTask(*t)
}
destRoot := filepath.Join(opts.MountPath, opts.DestFolder)
if opts.OverwriteMode == config.OverwriteDelete {
setStatus(task.StatusRunning, "Удаление данных с диска…", 0)
if err := deleteOurData(opts.MountPath); err != nil {
c.tasks.Update(taskID, func(t *task.Task) {
t.Status = task.StatusRunning
t.Phase = task.PhaseReplacing
t.Message = "Replacing destination media..."
t.Progress = 0
})
if t, ok := c.tasks.Get(taskID); ok {
_ = database.UpdateTask(*t)
}
if err := os.RemoveAll(destRoot); err != nil {
fail(err)
return
}
@@ -108,7 +220,15 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database
var copiedPaths map[string]struct{}
if opts.FileSelectMode == config.SelectNew {
setStatus(task.StatusRunning, "Загрузка истории…", 0)
c.tasks.Update(taskID, func(t *task.Task) {
t.Status = task.StatusRunning
t.Phase = task.PhaseLoadingHistory
t.Message = "Loading copy history..."
t.Progress = 0
})
if t, ok := c.tasks.Get(taskID); ok {
_ = database.UpdateTask(*t)
}
var err error
copiedPaths, err = database.CopiedPaths(opts.DiskID)
if err != nil {
@@ -117,17 +237,27 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database
}
}
setStatus(task.StatusRunning, "Сканирование источников…", 0)
files, err := buildFileList(opts.MediaPath, opts.EnabledSources, copiedPaths)
c.tasks.Update(taskID, func(t *task.Task) {
t.Status = task.StatusRunning
t.Phase = task.PhaseScanning
t.Message = "Scanning sources..."
t.Progress = 0
})
if t, ok := c.tasks.Get(taskID); ok {
_ = database.UpdateTask(*t)
}
files, err := buildFileList(opts.MediaPath, opts.SourceRules, copiedPaths, opts.AllowedExtensions)
if err != nil {
fail(err)
return
}
if len(files) == 0 {
setStatus(task.StatusSuccess, "Нет новых файлов для копирования.", 100)
setStatus(task.StatusSuccess, "No files to copy.", 100)
return
}
files = applyShuffleDepth(files, opts.ShuffleDepth)
_, free, err := disk.DiskUsage(opts.MountPath)
if err != nil {
fail(err)
@@ -136,19 +266,33 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database
reserveBytes := int64(opts.ReserveFreeGB * 1e9)
available := free - reserveBytes
if available <= 0 {
setStatus(task.StatusSuccess, "Недостаточно свободного места на диске.", 100)
setStatus(task.StatusFailed, "Free space is below the reserved threshold.", 100)
return
}
// суммарный объём для прогресса (всех файлов в списке)
var totalBytes int64
for _, f := range files {
totalBytes += f.size
}
total := len(files)
copied := 0
var doneBytes int64
startTime := time.Now()
for i, f := range files {
select {
case <-ctx.Done():
c.tasks.Update(taskID, func(t *task.Task) {
t.Status = task.StatusCanceled
t.Message = "Отменено"
t.Message = "Canceled"
t.SpeedBPS = 0
t.ETASec = 0
})
if t, ok := c.tasks.Get(taskID); ok {
_ = database.UpdateTask(*t)
}
return
default:
}
@@ -157,23 +301,56 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database
continue
}
msg := fmt.Sprintf("Копирование %s (%d/%d)", filepath.Base(f.srcAbs), i+1, total)
prog := int(float64(i+1) / float64(total) * 100)
setStatus(task.StatusRunning, msg, prog)
elapsed := time.Since(startTime).Seconds()
var speedBPS, etaSec int64
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)
if err := copyFile(ctx, f.srcAbs, dstAbs); err != nil {
if errors.Is(err, context.Canceled) {
prog := int(float64(doneBytes) / float64(totalBytes) * 100)
msg := fmt.Sprintf("Copying %s (%d/%d)", filepath.Base(f.srcAbs), i+1, total)
c.tasks.Update(taskID, func(t *task.Task) {
t.Status = task.StatusRunning
t.Phase = task.PhaseCopying
t.Message = msg
t.Progress = prog
t.SpeedBPS = speedBPS
t.ETASec = int(etaSec)
})
if t, ok := c.tasks.Get(taskID); ok {
_ = database.UpdateTask(*t)
}
dstAbs := filepath.Join(destRoot, f.relPath)
var fileErr error
if opts.Transcode != nil && isVideoFile(f.srcAbs) {
fileErr = c.processVideo(ctx, taskID, database, opts.Transcode, f.srcAbs, dstAbs)
} else {
fileErr = copyFile(ctx, f.srcAbs, dstAbs)
}
if fileErr != nil {
if errors.Is(fileErr, context.Canceled) {
c.tasks.Update(taskID, func(t *task.Task) {
t.Status = task.StatusCanceled
t.Message = "Отменено"
t.Message = "Canceled"
t.SpeedBPS = 0
t.ETASec = 0
})
if t, ok := c.tasks.Get(taskID); ok {
_ = database.UpdateTask(*t)
}
return
}
continue
}
available -= f.size
doneBytes += f.size
copied++
_ = database.RecordCopy(db.CopyRecord{
DiskID: opts.DiskID,
@@ -182,32 +359,135 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database
})
}
setStatus(task.StatusSuccess, fmt.Sprintf("Готово. Скопировано файлов: %d.", copied), 100)
setStatus(task.StatusSuccess, fmt.Sprintf("Done. Copied %d files.", copied), 100)
}
// videoExtensions — расширения видеофайлов из встроенного справочника.
var videoExtensions = func() map[string]struct{} {
exts := config.BuiltInMediaTypeExtensions()[config.MediaTypeVideo]
set := make(map[string]struct{}, len(exts))
for _, e := range exts {
set[e] = struct{}{}
}
return set
}()
func isVideoFile(path string) bool {
_, ok := videoExtensions[strings.ToLower(filepath.Ext(path))]
return ok
}
// processVideo определяет: транскодировать или скопировать файл.
func (c *Copier) processVideo(ctx context.Context, taskID string, database *db.DB, profile *disk.TranscodeProfile, src, dst string) error {
info, err := transcoder.ProbeVideo(src)
if err != nil {
// Не смогли зондировать — просто копируем
return copyFile(ctx, src, dst)
}
if !transcoder.NeedsTranscode(info, profile) {
return copyFile(ctx, src, dst)
}
// Меняем расширение выходного файла под формат контейнера
ext := transcoder.OutputExt(profile.OutputFormat)
dstTranscoded := strings.TrimSuffix(dst, filepath.Ext(dst)) + ext
srcInfo := fmt.Sprintf("%s/%dch/%.0ffps", info.Codec, info.AudioChannels, info.FPS)
dstInfo := fmt.Sprintf("%s/%s/%dfps %s", profile.VideoCodec, profile.AudioCodec, profile.MaxFPS, profile.OutputFormat)
baseMsg := fmt.Sprintf("Transcoding %s (%s → %s)", filepath.Base(src), srcInfo, dstInfo)
c.tasks.Update(taskID, func(t *task.Task) {
t.Phase = task.PhaseTranscoding
t.Message = baseMsg
})
if t, ok := c.tasks.Get(taskID); ok {
_ = database.UpdateTask(*t)
}
progressFn := func(p transcoder.Progress) {
c.tasks.Update(taskID, func(t *task.Task) {
t.Progress = int(p.Pct * 100)
t.SpeedBPS = 0
t.ETASec = 0
if p.EncodeFPS > 0 {
t.Message = fmt.Sprintf("%s @ %.1f fps", baseMsg, p.EncodeFPS)
}
})
}
return transcoder.Transcode(ctx, transcoder.Options{
Input: src,
Output: dstTranscoded,
Profile: profile,
SourceInfo: info,
}, progressFn)
}
type fileEntry struct {
srcAbs string
relPath string
relPath string // relative to /media
size int64
}
func buildFileList(mediaPath string, sources []string, skip map[string]struct{}) ([]fileEntry, error) {
func buildFileList(mediaPath string, rules []config.SourceFolder, skip map[string]struct{}, allowedExtensions []string) ([]fileEntry, error) {
_ = mediaPath
roots, selectedRoots, ruleMap := normalizeSourceRules(rules)
aliases := sourceAliases(roots)
allowedExts := makeAllowedExtensionSet(allowedExtensions)
var result []fileEntry
for _, src := range sources {
dir := filepath.Join(mediaPath, src)
for _, src := range selectedRoots {
root := owningRoot(src, roots)
if root == "" {
root = src
}
alias := aliases[root]
if alias == "" {
alias = filepath.Base(root)
if alias == "." || alias == "" || alias == string(filepath.Separator) {
alias = "source-" + shortHash(root)
}
}
dir := src
err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() {
if err != nil {
return nil
}
rel, _ := filepath.Rel(mediaPath, path)
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
}
if !isPathEnabled(path, ruleMap) {
return nil
}
if !isExtensionAllowed(path, allowedExts) {
return nil
}
rel, _ := filepath.Rel(root, path)
rel = filepath.ToSlash(rel)
destRel := filepath.ToSlash(filepath.Join(alias, rel))
if _, skipped := skip[rel]; skipped {
return nil
}
if _, skipped := skip[destRel]; skipped {
return nil
}
info, err := d.Info()
if err != nil {
return nil
}
result = append(result, fileEntry{srcAbs: path, relPath: rel, size: info.Size()})
result = append(result, fileEntry{srcAbs: path, relPath: destRel, size: info.Size()})
return nil
})
if err != nil {
@@ -217,64 +497,282 @@ func buildFileList(mediaPath string, sources []string, skip map[string]struct{})
return result, nil
}
func deleteOurData(mountPath string) error {
entries, err := os.ReadDir(mountPath)
if err != nil {
return err
func makeAllowedExtensionSet(items []string) map[string]struct{} {
normalized := config.NormalizeExtensions(items)
if len(normalized) == 0 {
normalized = config.DefaultAllowedExtensions()
}
for _, e := range entries {
if e.Name() == ".jukebox" {
result := make(map[string]struct{}, len(normalized))
for _, item := range normalized {
result[item] = struct{}{}
}
return result
}
func isExtensionAllowed(path string, allowed map[string]struct{}) bool {
ext := strings.ToLower(filepath.Ext(path))
if ext == "" {
return false
}
_, ok := allowed[ext]
return ok
}
func normalizeSourceRules(rules []config.SourceFolder) ([]string, []string, map[string]bool) {
ruleMap := make(map[string]bool, len(rules))
rootSet := make(map[string]struct{})
for _, rule := range rules {
src := filepath.Clean(strings.TrimSpace(rule.Path))
if src == "" || src == "." {
continue
}
if err := os.RemoveAll(filepath.Join(mountPath, e.Name())); err != nil {
return err
ruleMap[src] = rule.Enabled
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 {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
in, err := os.Open(src)
srcFile, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
defer srcFile.Close()
tmp := dst + ".juketmp"
out, err := os.Create(tmp)
srcInfo, err := srcFile.Stat()
if err != nil {
return err
}
buf := make([]byte, 512*1024)
for {
select {
case <-ctx.Done():
out.Close()
os.Remove(tmp)
return ctx.Err()
offset := int64(0)
if dstInfo, err := os.Stat(dst); err == nil {
switch {
case dstInfo.Size() < srcInfo.Size():
offset = dstInfo.Size()
case dstInfo.Size() == srcInfo.Size():
return os.Chtimes(dst, srcInfo.ModTime(), srcInfo.ModTime())
default:
}
n, readErr := in.Read(buf)
if n > 0 {
if _, werr := out.Write(buf[:n]); werr != nil {
out.Close()
os.Remove(tmp)
return werr
if err := os.Remove(dst); err != nil {
return err
}
}
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
if offset > 0 {
if _, err := srcFile.Seek(offset, io.SeekStart); err != nil {
return err
}
}
dstFile, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return err
}
defer dstFile.Close()
if offset > 0 {
if _, err := dstFile.Seek(offset, io.SeekStart); err != nil {
return err
}
} else if err := dstFile.Truncate(0); err != nil {
return err
}
buf := make([]byte, 1024*1024)
for {
if err := ctx.Err(); err != nil {
return err
}
nr, readErr := srcFile.Read(buf)
if nr > 0 {
nw, writeErr := dstFile.Write(buf[:nr])
if writeErr != nil {
return writeErr
}
if nw != nr {
return io.ErrShortWrite
}
}
if readErr != nil {
if errors.Is(readErr, io.EOF) {
break
}
if readErr != nil {
out.Close()
os.Remove(tmp)
return readErr
}
}
out.Close()
return os.Rename(tmp, dst)
if err := dstFile.Sync(); err != nil {
return err
}
if err := os.Chtimes(dst, srcInfo.ModTime(), srcInfo.ModTime()); err != nil {
return err
}
dstInfo, err := os.Stat(dst)
if err != nil {
return err
}
if dstInfo.Size() != srcInfo.Size() {
return fmt.Errorf("copied size mismatch for %s", filepath.Base(src))
}
return nil
}
// applyShuffleDepth упорядочивает файлы по заданной глубине шафлера.
// depth < 0 → оригинальный порядок (без шафла)
// depth == 0 → все файлы перемешиваются случайно
// depth >= 1 → файлы группируются по папке на уровне depth от корня /media,
// группы перемешиваются, внутри каждой группы порядок сохраняется.
func applyShuffleDepth(files []fileEntry, depth int) []fileEntry {
if depth < 0 || len(files) == 0 {
return files
}
if depth == 0 {
rand.Shuffle(len(files), func(i, j int) { files[i], files[j] = files[j], files[i] })
return files
}
type group struct {
key string
files []fileEntry
}
groupMap := make(map[string]*group, 64)
var order []string
for _, f := range files {
key := folderKeyAtDepth(f.relPath, depth)
if _, ok := groupMap[key]; !ok {
groupMap[key] = &group{key: key}
order = append(order, key)
}
groupMap[key].files = append(groupMap[key].files, f)
}
rand.Shuffle(len(order), func(i, j int) { order[i], order[j] = order[j], order[i] })
result := make([]fileEntry, 0, len(files))
for _, key := range order {
result = append(result, groupMap[key].files...)
}
return result
}
// folderKeyAtDepth возвращает путь к папке глубины depth из relPath.
// relPath вида "anime/Naruto/Season1/ep01.mkv", depth=2 → "anime/Naruto"
func folderKeyAtDepth(relPath string, depth int) string {
relPath = filepath.ToSlash(relPath)
parts := strings.Split(relPath, "/")
maxDepth := len(parts) - 1 // последний элемент — имя файла
if depth >= maxDepth {
depth = maxDepth
}
return strings.Join(parts[:depth], "/")
}

View File

@@ -0,0 +1,54 @@
package copier
import (
"os"
"path/filepath"
"testing"
"jukebox_maker/internal/config"
)
func TestBuildFileListFiltersAllowedExtensions(t *testing.T) {
root := t.TempDir()
source := filepath.Join(root, "music")
if err := os.MkdirAll(filepath.Join(source, "nested"), 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
files := map[string]string{
filepath.Join(source, "track.mp3"): "audio",
filepath.Join(source, "clip.mp4"): "video",
filepath.Join(source, "cover.jpg"): "photo",
filepath.Join(source, "nested", "note.txt"): "text",
}
for path, body := range files {
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatalf("WriteFile(%q) error = %v", path, err)
}
}
items, err := buildFileList("", []config.SourceFolder{
{Path: source, Enabled: true, Root: true},
}, nil, config.DefaultAllowedExtensions())
if err != nil {
t.Fatalf("buildFileList() error = %v", err)
}
if len(items) != 2 {
t.Fatalf("buildFileList() len = %d, want 2", len(items))
}
got := make(map[string]struct{}, len(items))
for _, item := range items {
got[filepath.Base(item.relPath)] = struct{}{}
}
if _, ok := got["track.mp3"]; !ok {
t.Fatalf("missing mp3 file: %v", got)
}
if _, ok := got["clip.mp4"]; !ok {
t.Fatalf("missing mp4 file: %v", got)
}
if _, ok := got["cover.jpg"]; ok {
t.Fatalf("unexpected jpg file: %v", got)
}
}

View File

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

View File

@@ -0,0 +1,20 @@
package dialog
import (
"fmt"
"os/exec"
"strings"
)
func PickFolder() (string, error) {
cmd := exec.Command("osascript", "-e", `POSIX path of (choose folder with prompt "Select a folder")`)
out, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("folder selection failed: %s", strings.TrimSpace(string(out)))
}
path := strings.TrimSpace(string(out))
if path == "" {
return "", fmt.Errorf("folder selection canceled")
}
return strings.TrimSuffix(path, "/"), nil
}

View File

@@ -0,0 +1,9 @@
//go:build !darwin && !windows
package dialog
import "fmt"
func PickFolder() (string, error) {
return "", fmt.Errorf("native folder picker is not supported on this platform")
}

View File

@@ -0,0 +1,21 @@
package dialog
import (
"fmt"
"os/exec"
"strings"
)
func PickFolder() (string, error) {
script := `Add-Type -AssemblyName System.Windows.Forms; $dialog = New-Object System.Windows.Forms.FolderBrowserDialog; $dialog.ShowNewFolderButton = $false; if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::Write($dialog.SelectedPath) }`
cmd := exec.Command("powershell", "-NoProfile", "-STA", "-Command", script)
out, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("folder selection failed: %s", strings.TrimSpace(string(out)))
}
path := strings.TrimSpace(string(out))
if path == "" {
return "", fmt.Errorf("folder selection canceled")
}
return path, nil
}

View File

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

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

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

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

View File

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

View File

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

View File

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

View File

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

39
ops/build-release.sh Executable file
View File

@@ -0,0 +1,39 @@
#!/bin/sh
set -eu
ROOT_DIR=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
RELEASE_DIR="${ROOT_DIR}/release"
CACHE_DIR="${RELEASE_DIR}/.gocache"
VERSION=${VERSION:-$(git -C "${ROOT_DIR}" describe --tags --always 2>/dev/null || echo dev)}
build_target() {
goos="$1"
goarch="$2"
output_name="$3"
echo "building ${goos}/${goarch} -> ${output_name}"
env \
GOCACHE="${CACHE_DIR}" \
GOOS="${goos}" \
GOARCH="${goarch}" \
go build \
-ldflags "-X main.Version=${VERSION}" \
-o "${RELEASE_DIR}/${output_name}" \
./cmd/jukebox
}
command -v go >/dev/null 2>&1 || {
echo "error: go not found in PATH" >&2
exit 1
}
mkdir -p "${RELEASE_DIR}" "${CACHE_DIR}"
build_target darwin arm64 jukebox-darwin-arm64
build_target windows 386 jukebox-windows-386.exe
echo ""
echo "artifacts:"
echo " ${RELEASE_DIR}/jukebox-darwin-arm64"
echo " ${RELEASE_DIR}/jukebox-windows-386.exe"

View File

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

View File

@@ -1,137 +1,430 @@
{{define "content"}}
<section class="panel">
<h2>Накопитель</h2>
<table class="kv-table">
<tbody>
<tr>
<th>Статус</th>
<td id="diskState"><span class="badge badge-unknown">Не подключён</span></td>
</tr>
<tr id="rowDiskID" class="hidden">
<th>ID диска</th>
<td><span class="mono" id="valDiskID"></span></td>
</tr>
<tr id="rowTotal" class="hidden">
<th>Всего на диске</th>
<td id="valTotal"></td>
</tr>
<tr id="rowFree" class="hidden">
<th>Свободно</th>
<td id="valFree"></td>
</tr>
</tbody>
</table>
</section>
<section class="panel hidden" id="progressPanel">
<h2>Копирование</h2>
<div style="padding:14px 16px">
<div class="progress-bar-bg">
<div class="progress-bar-fill" id="progressFill" style="width:0%"></div>
</div>
<div class="progress-label" id="progressMsg">Подготовка…</div>
<h2>Disks</h2>
<div class="panel-body">
<div id="diskSummary" class="text-muted">Loading disks...</div>
</div>
</section>
<div class="btn-row" style="background:transparent;border:none;padding:0;margin-bottom:24px">
<button class="button-primary" id="btnStart" onclick="startCopy()" disabled>▶ Запустить копирование</button>
<button class="button-danger hidden" id="btnCancel" onclick="cancelCopy()">✕ Отменить</button>
</div>
<div class="disk-grid" id="diskGrid"></div>
<script>
let pollInterval = null;
let activeTaskId = null;
let disks = [];
const taskState = new Map(); // taskID -> task
const taskPollers = new Map(); // taskID -> intervalID
const openPanels = new Set(); // disk key -> panel is open
const renderedDisks = new Map(); // disk key -> { state, disk_id } — what we last fully rendered
// ─── helpers ────────────────────────────────────────────────────────────────
function escapeHTML(v) {
return String(v || '').replace(/[&<>"']/g, c =>
({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
}
function diskKey(disk) { return disk.disk_id || disk.mount_path; }
function badgeClass(s) {
return ({ absent:'badge-unknown', foreign:'badge-warn', known:'badge-ok' })[s] || 'badge-unknown';
}
function badgeLabel(s) {
return ({ absent:'Not connected', foreign:'Uninitialized disk', known:'Ready' })[s] || '—';
}
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 fmtBytes(b) {
if (!b) return '—';
if (b >= 1e12) return (b/1e12).toFixed(1)+' TB';
if (b >= 1e9) return (b/1e9).toFixed(1)+' GB';
if (b >= 1e6) return (b/1e6).toFixed(1)+' MB';
return b+' B';
}
function fmtDateTime(v) {
if (!v) return 'Never';
const d = new Date(v);
if (isNaN(d)) return v;
return d.toLocaleString('en-US', { year:'numeric', month:'short', day:'2-digit', hour:'2-digit', minute:'2-digit' });
}
function taskMeta(t) {
if (!t) return '';
return [fmtSpeed(t.speed_bps), t.eta_sec ? 'ETA: '+fmtETA(t.eta_sec) : ''].filter(Boolean).join(' · ');
}
// ─── progress section (dynamic) ─────────────────────────────────────────────
function progressHTML(disk) {
const task = disk.active_task_id ? taskState.get(disk.active_task_id) : null;
if (!task) return '';
const pct = task.progress || 0;
const message = task.message || 'Preparing...';
const meta = taskMeta(task);
return `
<div class="panel-body progress-wrap">
<div class="progress-bar-bg">
<div class="progress-bar-fill" style="width:${pct}%"></div>
</div>
<div class="progress-label">${escapeHTML(message)}</div>
${meta ? `<div class="progress-label">${escapeHTML(meta)}</div>` : ''}
</div>`;
}
function btnsHTML(disk) {
const task = disk.active_task_id ? taskState.get(disk.active_task_id) : null;
const key = escapeHTML(diskKey(disk));
const busy = !!task;
const isKnown = disk.state === 'known';
const isForeign = disk.state === 'foreign';
return `
${isKnown ? `
<button class="button-danger" data-action="start-copy" data-mode="replace" data-disk-id="${key}" ${busy?'disabled':''}>Replace media</button>
<button class="button-primary" data-action="start-copy" data-mode="add" data-disk-id="${key}" ${busy?'disabled':''}>Add media</button>
<button class="button-danger ${busy?'':'hidden'}" data-action="cancel-copy" data-disk-id="${key}">Cancel</button>
<button class="button-secondary" data-action="disk-settings" data-disk-key="${key}" style="margin-left:auto">⚙ Settings${openPanels.has(diskKey(disk))?' ✕':''}</button>
` : ''}
${isForeign ? `
<button class="button-secondary" data-action="init-disk" data-mount-path="${escapeHTML(disk.mount_path)}">Initialize disk</button>
` : ''}`;
}
// ─── full card render (only on first appearance or state change) ─────────────
function renderProfile(disk) {
const p = disk.profile || {};
const t = p.transcode || null;
const te = !!t;
const k = escapeHTML(diskKey(disk));
const sel = (name, value, opts) => {
const o = opts.map(([v,l]) =>
`<option value="${v}" ${v===value?'selected':''}>${escapeHTML(l)}</option>`
).join('');
return `<select class="form-input" id="prof_${name}_${k}">${o}</select>`;
};
return `
<section class="panel" id="profilePanel_${k}" style="display:${openPanels.has(diskKey(disk))?'':'none'}">
<h2>Профиль диска</h2>
<div class="panel-body">
<h3>Параметры копирования</h3>
<div class="form-group">
<label>Папка назначения</label>
<input class="form-input" type="text" id="prof_dest_folder_${k}" value="${escapeHTML(p.dest_folder||'media')}">
</div>
<div class="form-group">
<label>Режим перезаписи</label>
${sel('overwrite_mode', p.overwrite_mode||'skip', [['skip','Пропускать существующие'],['delete','Заменять всё']])}
</div>
<div class="form-group">
<label>Выбор файлов</label>
${sel('file_select_mode', p.file_select_mode||'new', [['new','Только новые'],['all','Все подходящие']])}
</div>
<div class="form-group">
<label>Порядок копирования</label>
${sel('shuffle_depth', String(p.shuffle_depth??-1), [['-1','По порядку (без перемешивания)'],['0','Случайный порядок файлов'],['1','Случайная папка 1-го уровня (жанр)'],['2','Случайная папка 2-го уровня (сериал)'],['3','Случайная папка 3-го уровня (сезон)']])}
<span class="form-hint">Уровень задаёт глубину вложения: все файлы выбранной папки копируются целиком.</span>
</div>
<div class="form-group">
<label>Резерв свободного места (ГБ)</label>
<input class="form-input" type="number" id="prof_reserve_free_gb_${k}" value="${p.reserve_free_gb??2}" min="0" step="0.5">
</div>
<div class="form-group">
<label><input type="checkbox" id="prof_auto_copy_${k}" ${p.auto_copy?'checked':''}> Автокопирование при подключении</label>
</div>
<h3 style="margin-top:1.5em">Транскодирование видео</h3>
<div class="form-group">
<label>
<input type="checkbox" id="prof_transcode_enabled_${k}" ${te?'checked':''}
onchange="document.getElementById('transcodeFields_${k}').style.display=this.checked?'':'none'">
Ограничить видео под устройство
</label>
</div>
<div id="transcodeFields_${k}" style="${te?'':'display:none'}">
<div class="form-group">
<label>Видеокодек</label>
${sel('video_codec', t?.video_codec||'h264', [['h264','H.264 (AVC)'],['h265','H.265 (HEVC)'],['mpeg4','MPEG-4']])}
</div>
<div class="form-group">
<label>Макс. разрешение</label>
${sel('max_resolution', t?.max_resolution||'720p', [['480p','480p'],['720p','720p (HD)'],['1080p','1080p (Full HD)']])}
</div>
<div class="form-group">
<label>Макс. битрейт видео</label>
${sel('max_video_bitrate', t?.max_video_bitrate||'2000k', [['','Без лимита'],['1000k','1000 кбит/с'],['2000k','2000 кбит/с'],['4000k','4000 кбит/с'],['8000k','8000 кбит/с']])}
</div>
<div class="form-group">
<label>Макс. FPS</label>
${sel('max_fps', String(t?.max_fps??0), [['0','Без лимита'],['24','24'],['25','25'],['30','30']])}
</div>
<hr>
<div class="form-group">
<label>Аудиокодек</label>
${sel('audio_codec', t?.audio_codec||'aac', [['aac','AAC'],['mp3','MP3']])}
</div>
<div class="form-group">
<label>Макс. битрейт аудио</label>
${sel('max_audio_bitrate', t?.max_audio_bitrate||'192k', [['','Без лимита'],['128k','128 кбит/с'],['192k','192 кбит/с'],['320k','320 кбит/с']])}
</div>
<div class="form-group">
<label>Каналы</label>
${sel('max_audio_channels', String(t?.max_audio_channels??0), [['0','Копировать'],['2','Стерео (2.0)'],['6','5.1']])}
</div>
<hr>
<div class="form-group">
<label>Формат контейнера</label>
${sel('output_format', t?.output_format||'mp4', [['mp4','MP4'],['mkv','MKV'],['avi','AVI']])}
</div>
</div>
<div class="btn-row" style="margin-top:1em">
<button class="button-primary" onclick="saveProfile('${escapeHTML(disk.mount_path)}','${k}')">Сохранить профиль</button>
</div>
</div>
</section>`;
}
function diskCardHTML(disk) {
const key = escapeHTML(diskKey(disk));
const hasCapacity = disk.state !== 'absent';
return `
<section class="panel disk-card" id="diskCard_${key}">
<h2>${escapeHTML(disk.mount_path)}</h2>
<table class="kv-table"><tbody>
<tr><th>Status</th><td><span class="badge ${badgeClass(disk.state)}">${badgeLabel(disk.state)}</span></td></tr>
<tr><th>Disk ID</th><td>${disk.disk_id ? `<span class="mono">${escapeHTML(disk.disk_id)}</span>` : '<span class="text-muted">not initialized yet</span>'}</td></tr>
<tr><th>Total capacity</th><td>${hasCapacity ? fmtBytes(disk.total_bytes) : '—'}</td></tr>
<tr><th>Free space</th><td>${hasCapacity ? fmtBytes(disk.free_bytes) : '—'}</td></tr>
<tr><th>Last copied</th><td>${fmtDateTime(disk.last_copied_at)}</td></tr>
</tbody></table>
<div id="diskProgress_${key}">${progressHTML(disk)}</div>
<div class="btn-row" id="diskBtns_${key}">${btnsHTML(disk)}</div>
</section>
${disk.state === 'known' ? renderProfile(disk) : ''}`;
}
// ─── incremental DOM update ──────────────────────────────────────────────────
function renderDisks() {
const grid = document.getElementById('diskGrid');
const summary = document.getElementById('diskSummary');
const knownCount = disks.filter(d => d.state === 'known').length;
summary.textContent = disks.length
? `Disks found: ${disks.length}. Ready to copy: ${knownCount}.`
: 'No disks found.';
const incoming = new Map(disks.map(d => [diskKey(d), d]));
// Remove disappeared disks
for (const [key] of renderedDisks) {
if (!incoming.has(key)) {
if (openPanels.has(key)) {
toast('Диск отключён — несохранённые изменения потеряны', 'error');
openPanels.delete(key);
}
document.getElementById('diskCard_' + key)?.remove();
document.getElementById('profilePanel_' + key)?.remove();
renderedDisks.delete(key);
}
}
// Add or update disks
for (const disk of disks) {
const key = diskKey(disk);
const prev = renderedDisks.get(key);
// Need full re-render: first appearance OR state / disk_id changed
const needFull = !prev || prev.state !== disk.state || prev.disk_id !== disk.disk_id;
if (needFull) {
const existing = document.getElementById('diskCard_' + key);
const tmp = document.createElement('div');
tmp.innerHTML = diskCardHTML(disk);
if (existing) {
// Replace in-place (profile panel follows immediately after)
const panel = document.getElementById('profilePanel_' + key);
existing.replaceWith(...tmp.childNodes);
panel?.remove();
// Profile panel was re-rendered inside tmp; it's already inserted
} else {
// Append new
while (tmp.firstChild) grid.appendChild(tmp.firstChild);
}
} else {
// Partial update: only progress and buttons
const progEl = document.getElementById('diskProgress_' + key);
if (progEl) progEl.innerHTML = progressHTML(disk);
const btnsEl = document.getElementById('diskBtns_' + key);
if (btnsEl) btnsEl.innerHTML = btnsHTML(disk);
}
renderedDisks.set(key, { state: disk.state, disk_id: disk.disk_id });
}
}
// ─── profile save ────────────────────────────────────────────────────────────
async function saveProfile(mountPath, key) {
const g = id => document.getElementById(id);
const te = g(`prof_transcode_enabled_${key}`)?.checked;
const profile = {
dest_folder: g(`prof_dest_folder_${key}`)?.value.trim() || 'media',
overwrite_mode: g(`prof_overwrite_mode_${key}`)?.value || 'skip',
file_select_mode: g(`prof_file_select_mode_${key}`)?.value || 'new',
reserve_free_gb: parseFloat(g(`prof_reserve_free_gb_${key}`)?.value || '2') || 0,
auto_copy: g(`prof_auto_copy_${key}`)?.checked || false,
shuffle_depth: parseInt(g(`prof_shuffle_depth_${key}`)?.value ?? '-1', 10),
};
if (te) {
profile.transcode = {
video_codec: g(`prof_video_codec_${key}`)?.value || 'h264',
max_resolution: g(`prof_max_resolution_${key}`)?.value || '720p',
max_video_bitrate: g(`prof_max_video_bitrate_${key}`)?.value || '',
max_fps: parseInt(g(`prof_max_fps_${key}`)?.value || '0', 10),
audio_codec: g(`prof_audio_codec_${key}`)?.value || 'aac',
max_audio_bitrate: g(`prof_max_audio_bitrate_${key}`)?.value || '',
max_audio_channels: parseInt(g(`prof_max_audio_channels_${key}`)?.value || '0', 10),
output_format: g(`prof_output_format_${key}`)?.value || 'mp4',
};
}
async function refreshDisk() {
try {
const r = await fetch('/api/disk');
const r = await fetch('/api/disks/profile?mount_path=' + encodeURIComponent(mountPath), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(profile),
});
const payload = await r.json();
if (!r.ok) { toast(payload.error || 'Ошибка сохранения профиля', 'error'); return; }
toast('Профиль сохранён', 'ok');
} catch { toast('Ошибка сети', 'error'); }
}
// ─── task polling ─────────────────────────────────────────────────────────────
function stopTaskPoll(id) {
if (!taskPollers.has(id)) return;
clearInterval(taskPollers.get(id));
taskPollers.delete(id);
}
function startTaskPoll(id) {
if (!id || taskPollers.has(id)) return;
taskPollers.set(id, setInterval(() => pollTask(id), 1500));
pollTask(id);
}
async function pollTask(taskID) {
try {
const r = await fetch('/api/tasks/' + taskID);
if (!r.ok) return;
const d = await r.json();
const labels = { absent: 'Не подключён', foreign: 'Незнакомый диск', known: 'Диск подключён' };
const cls = { absent: 'badge-unknown', foreign: 'badge-warn', known: 'badge-ok' };
document.getElementById('diskState').innerHTML =
`<span class="badge ${cls[d.state]||'badge-unknown'}">${labels[d.state]||'—'}</span>`;
const known = d.state === 'known';
['rowDiskID','rowTotal','rowFree'].forEach(id =>
document.getElementById(id).classList.toggle('hidden', !known));
if (known) {
document.getElementById('valDiskID').textContent = d.disk_id;
document.getElementById('valTotal').textContent = fmtBytes(d.total_bytes);
document.getElementById('valFree').textContent = fmtBytes(d.free_bytes);
const task = await r.json();
taskState.set(taskID, task);
renderDisks();
if (['success','failed','canceled'].includes(task.status)) {
stopTaskPoll(taskID);
taskState.delete(taskID);
if (task.status === 'success') toast(task.message || 'Done', 'ok');
if (task.status === 'failed') toast('Error: ' + task.error, 'error');
if (task.status === 'canceled') toast('Copy canceled', 'error');
refreshDisks();
}
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) {}
} catch {}
}
function startTaskPoll(id) { stopTaskPoll(); pollInterval = setInterval(() => pollTask(id), 1500); }
function stopTaskPoll() { if (pollInterval) { clearInterval(pollInterval); pollInterval = null; } }
// ─── disk list refresh ────────────────────────────────────────────────────────
async function pollTask(id) {
async function refreshDisks() {
try {
const r = await fetch('/api/tasks/' + id);
const r = await fetch('/api/disks');
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();
const payload = await r.json();
disks = payload.items || [];
renderDisks();
const active = new Set(disks.map(d => d.active_task_id).filter(Boolean));
for (const id of taskPollers.keys()) {
if (!active.has(id)) { stopTaskPoll(id); taskState.delete(id); }
}
} catch(e) {}
for (const id of active) startTaskPoll(id);
} catch {}
}
async function startCopy() {
document.getElementById('btnStart').disabled = true;
// ─── copy / init actions ─────────────────────────────────────────────────────
async function startCopy(diskID, mode) {
try {
const r = await fetch('/api/copy/start', { method: 'POST' });
const d = await r.json();
if (!r.ok) {
toast(d.error || 'Ошибка запуска', 'error');
document.getElementById('btnStart').disabled = false;
return;
}
activeTaskId = d.task_id;
document.getElementById('btnStart').classList.add('hidden');
document.getElementById('btnCancel').classList.remove('hidden');
document.getElementById('progressPanel').classList.remove('hidden');
document.getElementById('progressFill').style.width = '0%';
document.getElementById('progressMsg').textContent = 'Подготовка…';
startTaskPoll(activeTaskId);
} catch(e) {
toast('Ошибка связи', 'error');
document.getElementById('btnStart').disabled = false;
}
const r = await fetch('/api/disks/' + encodeURIComponent(diskID) + '/copy/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode }),
});
const p = await r.json();
if (!r.ok) { toast(p.error || 'Failed to start copy', 'error'); return; }
startTaskPoll(p.task_id);
refreshDisks();
} catch { toast('Network error', 'error'); }
}
async function cancelCopy() {
try { await fetch('/api/copy/cancel', { method: 'POST' }); toast('Отмена…', 'ok'); } catch(e) {}
async function cancelCopy(diskID) {
try {
await fetch('/api/disks/' + encodeURIComponent(diskID) + '/copy/cancel', { method: 'POST' });
toast('Canceling...', 'ok');
} catch { toast('Network error', 'error'); }
}
refreshDisk();
setInterval(refreshDisk, 5000);
async function initDisk(mountPath) {
try {
const r = await fetch('/api/disks/init', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mount_path: mountPath }),
});
const p = await r.json();
if (!r.ok) { toast(p.error || 'Failed to initialize disk', 'error'); return; }
toast('Disk initialized', 'ok');
refreshDisks();
} catch { toast('Network error', 'error'); }
}
// ─── event delegation ─────────────────────────────────────────────────────────
document.getElementById('diskGrid').addEventListener('click', (event) => {
const btn = event.target.closest('button[data-action]');
if (!btn) return;
const action = btn.dataset.action;
if (action === 'start-copy') startCopy(btn.dataset.diskId, btn.dataset.mode || 'add');
if (action === 'cancel-copy') cancelCopy(btn.dataset.diskId);
if (action === 'init-disk') initDisk(btn.dataset.mountPath);
if (action === 'disk-settings') {
const key = btn.dataset.diskKey;
const panel = document.getElementById('profilePanel_' + key);
if (!panel) return;
const open = panel.style.display !== 'none';
panel.style.display = open ? 'none' : '';
if (open) openPanels.delete(key); else openPanels.add(key);
btn.textContent = open ? '⚙ Settings' : '⚙ Settings ✕';
}
});
// ─── init ─────────────────────────────────────────────────────────────────────
refreshDisks();
setInterval(refreshDisks, 5000);
</script>
{{end}}

View File

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

View File

@@ -2,94 +2,312 @@
<form id="settingsForm" onsubmit="saveSettings(event)">
<section class="panel">
<h2>Источники копирования</h2>
<div class="source-list" id="sourceList">
<div class="text-muted" style="padding:12px 16px">Загрузка…</div>
<h2>Copy Sources</h2>
<div class="panel-body">
<div class="form-hint">Select top-level folders or expand branches and choose individual nested directories.</div>
</div>
<div class="source-list">
<div class="source-tree" id="sourceTree">
<div class="text-muted source-tree-empty">Loading...</div>
</div>
</div>
<div class="btn-row">
<button type="button" class="button-secondary button-sm" onclick="loadSources()">↻ Обновить список</button>
<button type="button" class="button-secondary button-sm" onclick="reloadSourceTree()">Refresh list</button>
</div>
</section>
<section class="panel">
<h2>Параметры копирования</h2>
<h2>Copy Settings</h2>
<div class="form-body">
<div class="form-group">
<label class="form-label" for="reserveGB">Оставить свободным на диске (ГБ)</label>
<label class="form-label" for="reserveGB">Reserved free space on disk (GB)</label>
<input class="form-input" type="number" id="reserveGB" min="0" max="1000" step="0.5" value="2">
<span class="form-hint">Копирование остановится, когда свободного места останется меньше этого значения.</span>
<span class="form-hint">Copying will stop when free space falls below this value.</span>
</div>
<div class="form-group">
<label class="form-label" for="fileSelectMode">Какие файлы копировать</label>
<label class="form-label" for="fileSelectMode">Files to copy</label>
<select class="form-select" id="fileSelectMode" style="width:auto;max-width:420px">
<option value="new">Только новые (не копировавшиеся на этот диск)</option>
<option value="all">Все подряд</option>
<option value="new">Only new files not copied to this disk before</option>
<option value="all">All matching files</option>
</select>
<span class="form-hint">«Только новые» — пропускает файлы, уже скопированные на данный диск, даже если они были удалены с него (считаются просмотренными).</span>
<span class="form-hint">The new-only mode skips files already copied to this disk, even if they were later removed.</span>
</div>
<div class="form-group" id="mediaTypesGroup">
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap">
<label class="form-label" style="margin:0">Allowed file types</label>
<button type="button" class="button-secondary button-sm" id="editAllowedFilesButton" onclick="toggleAllowedFilesEditor()">Edit list</button>
</div>
<div style="display:grid;gap:8px">
<label style="display:flex;align-items:flex-start;gap:8px;cursor:pointer">
<input type="checkbox" id="mediaTypeAudio" style="width:15px;height:15px;accent-color:var(--accent)">
<span>
<strong>Audio</strong>
<span class="form-hint" id="mediaTypeAudioHint" style="display:block"></span>
</span>
</label>
<label style="display:flex;align-items:flex-start;gap:8px;cursor:pointer">
<input type="checkbox" id="mediaTypeVideo" style="width:15px;height:15px;accent-color:var(--accent)">
<span>
<strong>Video</strong>
<span class="form-hint" id="mediaTypeVideoHint" style="display:block"></span>
</span>
</label>
<label style="display:flex;align-items:flex-start;gap:8px;cursor:pointer">
<input type="checkbox" id="mediaTypePhoto" style="width:15px;height:15px;accent-color:var(--accent)">
<span>
<strong>Photo</strong>
<span class="form-hint" id="mediaTypePhotoHint" style="display:block"></span>
</span>
</label>
</div>
<span class="form-hint">Built into the app by default: audio, video, and photo. New installations start with only audio and video enabled.</span>
</div>
<div class="form-group" id="extensionsGroup" style="display:none">
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap">
<label class="form-label" for="allowedExtensions" style="margin:0">Allowed extensions</label>
<button type="button" class="button-secondary button-sm" onclick="toggleAllowedFilesEditor()">Use media types</button>
</div>
<textarea class="form-input" id="allowedExtensions" rows="5" placeholder=".mp3, .flac, .mp4"></textarea>
<span class="form-hint">One extension per line or separated by commas. You can write <code>mp3</code> or <code>.mp3</code>.</span>
</div>
<div class="form-group">
<label class="form-label" for="overwriteMode">Режим записи</label>
<label class="form-label" for="destFolder">Destination folder on disk</label>
<input class="form-input" type="text" id="destFolder" placeholder="media" style="width:200px">
<span class="form-hint">Files will be copied into this subfolder while preserving the selected source structure. The disk root and <code>.jukebox</code> are never allowed here.</span>
</div>
<div class="form-group">
<label class="form-label" for="overwriteMode">Default write mode</label>
<select class="form-select" id="overwriteMode" style="width:auto;max-width:420px">
<option value="skip">Пропустить существующие файлы</option>
<option value="delete">Удалить наши данные с диска и перезаписать заново</option>
<option value="skip">Keep existing files</option>
<option value="delete">Replace destination folder contents</option>
</select>
<span class="form-hint">«Удалить и перезаписать» — удаляет с диска всё кроме папки .jukebox, затем копирует заново.</span>
<span class="form-hint">This is used for automatic copy runs. Manual dashboard actions can override it.</span>
</div>
</div>
</section>
<section class="panel">
<h2>Автоматизация</h2>
<h2>Automation</h2>
<div class="form-body">
<div class="form-group">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="autoCopy" style="width:15px;height:15px;accent-color:var(--accent)">
<span class="form-label" style="margin:0">Автоматическое копирование</span>
<span class="form-label" style="margin:0">Automatic copy</span>
</label>
<span class="form-hint">При обнаружении знакомого накопителя копирование запустится автоматически.</span>
<span class="form-hint">Start copying automatically when a known disk is detected.</span>
</div>
</div>
</section>
<div style="display:flex;gap:8px;margin-bottom:24px">
<button type="submit" class="button-primary">Сохранить настройки</button>
<button type="button" class="button-secondary" onclick="loadSettings()">Сбросить</button>
<button type="submit" class="button-primary">Save settings</button>
<button type="button" class="button-secondary" onclick="loadSettings()">Reset</button>
</div>
</form>
<script>
let allSources = [];
let enabledSources = {};
const sourceTree = new Map();
const expandedNodes = new Set();
const loadingNodes = new Set();
const builtInMediaTypes = {
audio: ['.aac', '.aif', '.aiff', '.alac', '.ape', '.flac', '.m4a', '.mp2', '.mp3', '.ogg', '.opus', '.wav', '.wma'],
video: ['.3gp', '.avi', '.m2ts', '.m4v', '.mkv', '.mov', '.mp4', '.mpeg', '.mpg', '.mts', '.ts', '.webm', '.wmv'],
photo: ['.bmp', '.gif', '.heic', '.heif', '.jpeg', '.jpg', '.png', '.tif', '.tiff', '.webp'],
};
let sourceConfig = {};
let allowedFilesMode = 'media_types';
async function loadSources() {
try {
const r = await fetch('/api/sources');
if (!r.ok) return;
const d = await r.json();
allSources = d.items || [];
function escapeHTML(value) {
return String(value || '').replace(/[&<>"']/g, (char) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[char]));
}
function pathDepth(path) {
return path ? path.split('/').length : 0;
}
function parentPath(path) {
if (!path || !path.includes('/')) return '';
return path.slice(0, path.lastIndexOf('/'));
}
function effectiveSourceState(path) {
let current = path;
while (true) {
if (Object.prototype.hasOwnProperty.call(sourceConfig, current)) {
return sourceConfig[current];
}
if (!current) return true;
current = parentPath(current);
}
}
function collectSourcesForSave() {
const items = [];
const seen = new Set();
const roots = sourceTree.get('') || [];
for (const item of roots) {
items.push({ path: item.path, enabled: effectiveSourceState(item.path) });
seen.add(item.path);
}
Object.entries(sourceConfig).forEach(([path, enabled]) => {
if (seen.has(path)) return;
items.push({ path, enabled });
});
return items.sort((a, b) => a.path.localeCompare(b.path));
}
async function loadSourceChildren(path = '') {
if (loadingNodes.has(path)) return;
loadingNodes.add(path);
renderSources();
} catch(e) {}
try {
const query = path ? '?path=' + encodeURIComponent(path) : '';
const response = await fetch('/api/sources' + query);
if (!response.ok) return;
const payload = await response.json();
sourceTree.set(path, payload.items || []);
} catch (error) {
} finally {
loadingNodes.delete(path);
renderSources();
}
}
async function ensureExpanded(path) {
expandedNodes.add(path);
if (!sourceTree.has(path)) {
await loadSourceChildren(path);
return;
}
renderSources();
}
function toggleSource(path, checked) {
sourceConfig[path] = checked;
renderSources();
}
function renderSourceNodes(parent = '') {
const items = sourceTree.get(parent) || [];
return items.map((item) => {
const checked = effectiveSourceState(item.path);
const expanded = expandedNodes.has(item.path);
const childrenKnown = sourceTree.has(item.path);
const children = childrenKnown ? sourceTree.get(item.path) : [];
const hasChildren = !childrenKnown || children.length > 0;
const pad = 16 + pathDepth(item.path) * 20;
return `
<div class="source-node">
<div class="source-row" style="padding-left:${pad}px">
<button
type="button"
class="source-toggle ${hasChildren ? '' : 'source-toggle-empty'}"
data-action="toggle-expand"
data-path="${escapeHTML(item.path)}"
${hasChildren ? '' : 'tabindex="-1" aria-hidden="true"'}
>${expanded ? '▾' : '▸'}</button>
<input class="source-check" type="checkbox" data-action="toggle-check" data-path="${escapeHTML(item.path)}" ${checked ? 'checked' : ''}>
<div class="source-label">
<span class="source-item-name">${escapeHTML(item.name)}</span>
<span class="source-item-path">/media/${escapeHTML(item.path)}</span>
</div>
</div>
${expanded && loadingNodes.has(item.path) ? '<div class="source-loading">Loading...</div>' : ''}
${expanded && childrenKnown && children.length ? `<div class="source-children">${renderSourceNodes(item.path)}</div>` : ''}
</div>
`;
}).join('');
}
function renderSources() {
const el = document.getElementById('sourceList');
if (!allSources.length) {
el.innerHTML = '<div class="text-muted" style="padding:12px 16px">Папки в /media не найдены.</div>';
const el = document.getElementById('sourceTree');
const roots = sourceTree.get('');
if (loadingNodes.has('') && !roots) {
el.innerHTML = '<div class="text-muted source-tree-empty">Loading...</div>';
return;
}
el.innerHTML = allSources.map(path => {
const checked = enabledSources[path] !== false;
return `<label class="source-item">
<input type="checkbox" data-source="${path}" ${checked ? 'checked' : ''}>
<span class="source-item-name">${path}</span>
<span class="source-item-path">/media/${path}</span>
</label>`;
}).join('');
if (!roots || !roots.length) {
el.innerHTML = '<div class="text-muted source-tree-empty">No folders found in /media.</div>';
return;
}
el.innerHTML = renderSourceNodes('');
}
async function reloadSourceTree() {
sourceTree.clear();
expandedNodes.clear();
await loadSourceChildren('');
}
function defaultAllowedExtensions() {
return [...builtInMediaTypes.audio, ...builtInMediaTypes.video];
}
function parseExtensionsInput(value) {
const items = String(value || '')
.split(/[\n,]+/)
.map((item) => item.trim())
.filter(Boolean);
const result = [];
const seen = new Set();
items.forEach((item) => {
let value = item.toLowerCase().replace(/^\*/, '');
if (!value.startsWith('.')) value = '.' + value;
if (!/^\.[a-z0-9]+$/.test(value)) return;
if (seen.has(value)) return;
seen.add(value);
result.push(value);
});
return result;
}
function formatExtensionsInput(items) {
return (items || []).join('\n');
}
function selectedMediaTypes() {
return ['audio', 'video', 'photo'].filter((name) => {
const id = 'mediaType' + name.charAt(0).toUpperCase() + name.slice(1);
return document.getElementById(id).checked;
});
}
function updateAllowedFilesModeUI() {
document.getElementById('mediaTypesGroup').style.display = allowedFilesMode === 'media_types' ? '' : 'none';
document.getElementById('extensionsGroup').style.display = allowedFilesMode === 'extensions' ? '' : 'none';
}
function toggleAllowedFilesEditor() {
allowedFilesMode = allowedFilesMode === 'extensions' ? 'media_types' : 'extensions';
updateAllowedFilesModeUI();
}
function renderMediaTypeHints() {
document.getElementById('mediaTypeAudioHint').textContent = builtInMediaTypes.audio.join(', ');
document.getElementById('mediaTypeVideoHint').textContent = builtInMediaTypes.video.join(', ');
document.getElementById('mediaTypePhotoHint').textContent = builtInMediaTypes.photo.join(', ');
}
async function loadSettings() {
@@ -98,41 +316,79 @@ async function loadSettings() {
if (!r.ok) return;
const cfg = await r.json();
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('overwriteMode').value = cfg.overwrite_mode || 'skip';
document.getElementById('autoCopy').checked = !!cfg.auto_copy;
enabledSources = {};
(cfg.sources || []).forEach(s => { enabledSources[s.path] = s.enabled; });
allowedFilesMode = cfg.allowed_files_mode || 'media_types';
document.getElementById('mediaTypeAudio').checked = (cfg.enabled_media_types || ['audio', 'video']).includes('audio');
document.getElementById('mediaTypeVideo').checked = (cfg.enabled_media_types || ['audio', 'video']).includes('video');
document.getElementById('mediaTypePhoto').checked = (cfg.enabled_media_types || []).includes('photo');
document.getElementById('allowedExtensions').value = formatExtensionsInput((cfg.allowed_extensions || []).length ? cfg.allowed_extensions : defaultAllowedExtensions());
updateAllowedFilesModeUI();
sourceConfig = {};
(cfg.sources || []).forEach((source) => {
sourceConfig[source.path] = !!source.enabled;
});
renderSources();
} catch(e) {}
} catch (error) {}
}
async function saveSettings(e) {
e.preventDefault();
const checkboxes = document.querySelectorAll('[data-source]');
const sources = Array.from(checkboxes).map(cb => ({ path: cb.dataset.source, enabled: cb.checked }));
Object.keys(enabledSources).forEach(path => {
if (!sources.find(s => s.path === path)) sources.push({ path, enabled: false });
});
async function saveSettings(event) {
event.preventDefault();
const body = {
reserve_free_gb: parseFloat(document.getElementById('reserveGB').value) || 2,
dest_folder: document.getElementById('destFolder').value.trim() || 'media',
file_select_mode: document.getElementById('fileSelectMode').value,
allowed_files_mode: allowedFilesMode,
enabled_media_types: selectedMediaTypes(),
allowed_extensions: parseExtensionsInput(document.getElementById('allowedExtensions').value),
overwrite_mode: document.getElementById('overwriteMode').value,
auto_copy: document.getElementById('autoCopy').checked,
sources,
sources: collectSourcesForSave(),
};
try {
const r = await fetch('/api/config', {
const response = await fetch('/api/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (r.ok) { toast('Настройки сохранены', 'ok'); await loadSettings(); }
else { const d = await r.json(); toast(d.error || 'Ошибка сохранения', 'error'); }
} catch(e) { toast('Ошибка связи', 'error'); }
if (response.ok) {
toast('Settings saved', 'ok');
await loadSettings();
return;
}
const payload = await response.json();
toast(payload.error || 'Failed to save settings', 'error');
} catch (error) {
toast('Network error', 'error');
}
}
document.getElementById('sourceTree').addEventListener('click', async (event) => {
const button = event.target.closest('[data-action="toggle-expand"]');
if (!button) return;
const path = button.dataset.path;
if (expandedNodes.has(path)) {
expandedNodes.delete(path);
renderSources();
return;
}
await ensureExpanded(path);
});
document.getElementById('sourceTree').addEventListener('change', (event) => {
const checkbox = event.target.closest('[data-action="toggle-check"]');
if (!checkbox) return;
toggleSource(checkbox.dataset.path, checkbox.checked);
});
renderMediaTypeHints();
loadSettings();
loadSources();
loadSourceChildren('');
</script>
{{end}}