Add jukebox_maker web app v1.0

Go web application for filling USB drives with media files.
Runs in Docker on Unraid with /media, /mnt/usb, /config volumes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-23 21:33:43 +03:00
parent eb3f84ea31
commit 29f3ad9576
24 changed files with 1901 additions and 0 deletions

View File

@@ -0,0 +1,41 @@
package api
import (
"encoding/json"
"net/http"
"sync"
"jukebox_maker/internal/config"
)
var cfgMu sync.Mutex
func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) {
cfgMu.Lock()
cfg := *s.deps.Config
cfgMu.Unlock()
jsonOK(w, cfg)
}
func (s *Server) handlePutConfig(w http.ResponseWriter, r *http.Request) {
var incoming config.Config
if err := json.NewDecoder(r.Body).Decode(&incoming); err != nil {
jsonErr(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
if err := incoming.Validate(); err != nil {
jsonErr(w, http.StatusUnprocessableEntity, err.Error())
return
}
cfgMu.Lock()
*s.deps.Config = incoming
cfgMu.Unlock()
if err := config.Save(s.deps.ConfigPath, s.deps.Config); err != nil {
jsonErr(w, http.StatusInternalServerError, "failed to save config: "+err.Error())
return
}
jsonOK(w, incoming)
}

View File

@@ -0,0 +1,68 @@
package api
import (
"context"
"net/http"
"jukebox_maker/internal/copier"
"jukebox_maker/internal/disk"
)
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")
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")
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,
}
taskID, err := s.deps.Copier.Start(context.Background(), opts)
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()
jsonOK(w, map[string]bool{"ok": true})
}
func (s *Server) handleTaskGet(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
t, ok := s.deps.Tasks.Get(id)
if !ok {
jsonErr(w, http.StatusNotFound, "task not found")
return
}
jsonOK(w, t)
}

View File

@@ -0,0 +1,34 @@
package api
import (
"net/http"
"jukebox_maker/internal/disk"
)
func (s *Server) handleDiskStatus(w http.ResponseWriter, r *http.Request) {
info := s.deps.Watcher.CurrentDisk()
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"`
ActiveTaskID string `json:"active_task_id,omitempty"`
}
resp := 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
}
jsonOK(w, resp)
}

View File

@@ -0,0 +1,26 @@
package api
import (
"net/http"
"os"
)
func (s *Server) handleSources(w http.ResponseWriter, r *http.Request) {
entries, err := os.ReadDir(s.deps.MediaPath)
if err != nil {
jsonOK(w, map[string][]string{"items": {}})
return
}
var items []string
for _, e := range entries {
if e.IsDir() && e.Name()[0] != '.' {
items = append(items, e.Name())
}
}
if items == nil {
items = []string{}
}
jsonOK(w, map[string][]string{"items": items})
}

100
internal/api/server.go Normal file
View File

@@ -0,0 +1,100 @@
package api
import (
"encoding/json"
"html/template"
"net/http"
webui "jukebox_maker/web"
"jukebox_maker/internal/config"
"jukebox_maker/internal/copier"
"jukebox_maker/internal/task"
"jukebox_maker/internal/watcher"
)
type Deps struct {
Config *config.Config
ConfigPath string
Watcher *watcher.Watcher
Copier *copier.Copier
Tasks *task.Store
MediaPath string
MountPath string
}
type Server struct {
deps Deps
dashboard *template.Template
settings *template.Template
mux *http.ServeMux
}
func New(deps Deps) (*Server, error) {
dash, err := template.ParseFS(webui.FS, "templates/layout.html", "templates/dashboard.html")
if err != nil {
return nil, err
}
sett, err := template.ParseFS(webui.FS, "templates/layout.html", "templates/settings.html")
if err != nil {
return nil, err
}
s := &Server{deps: deps, dashboard: dash, settings: sett, mux: http.NewServeMux()}
s.routes()
return s, nil
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.mux.ServeHTTP(w, r)
}
func (s *Server) routes() {
s.mux.Handle("GET /static/", http.FileServerFS(webui.FS))
s.mux.HandleFunc("GET /", s.handleDashboard)
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/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("GET /api/tasks/{id}", s.handleTaskGet)
}
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
s.render(w, s.dashboard, map[string]any{"Title": "Dashboard", "Page": "dashboard"})
}
func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
s.render(w, s.settings, map[string]any{"Title": "Настройки", "Page": "settings"})
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
jsonOK(w, map[string]string{"status": "ok"})
}
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 {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func jsonOK(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
func jsonErr(w http.ResponseWriter, code int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}