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:
41
internal/api/handlers_config.go
Normal file
41
internal/api/handlers_config.go
Normal 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)
|
||||
}
|
||||
68
internal/api/handlers_copy.go
Normal file
68
internal/api/handlers_copy.go
Normal 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)
|
||||
}
|
||||
34
internal/api/handlers_disk.go
Normal file
34
internal/api/handlers_disk.go
Normal 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)
|
||||
}
|
||||
26
internal/api/handlers_sources.go
Normal file
26
internal/api/handlers_sources.go
Normal 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
100
internal/api/server.go
Normal 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})
|
||||
}
|
||||
Reference in New Issue
Block a user