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:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type OverwriteMode string
|
||||
type FileSelectMode string
|
||||
|
||||
const (
|
||||
OverwriteSkip OverwriteMode = "skip"
|
||||
OverwriteDelete OverwriteMode = "delete"
|
||||
|
||||
SelectNew FileSelectMode = "new"
|
||||
SelectAll FileSelectMode = "all"
|
||||
)
|
||||
|
||||
type SourceFolder struct {
|
||||
Path string `json:"path"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
ReserveFreeGB float64 `json:"reserve_free_gb"`
|
||||
Sources []SourceFolder `json:"sources"`
|
||||
OverwriteMode OverwriteMode `json:"overwrite_mode"`
|
||||
FileSelectMode FileSelectMode `json:"file_select_mode"`
|
||||
AutoCopy bool `json:"auto_copy"`
|
||||
}
|
||||
|
||||
func defaults() Config {
|
||||
return Config{
|
||||
ReserveFreeGB: 2.0,
|
||||
OverwriteMode: OverwriteSkip,
|
||||
FileSelectMode: SelectNew,
|
||||
AutoCopy: false,
|
||||
}
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
cfg := defaults()
|
||||
return &cfg, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg := defaults()
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func Save(path string, cfg *Config) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
func (c *Config) Validate() error {
|
||||
if c.ReserveFreeGB < 0 {
|
||||
return errors.New("reserve_free_gb must be >= 0")
|
||||
}
|
||||
switch c.OverwriteMode {
|
||||
case OverwriteSkip, OverwriteDelete:
|
||||
default:
|
||||
return errors.New("overwrite_mode must be 'skip' or 'delete'")
|
||||
}
|
||||
switch c.FileSelectMode {
|
||||
case SelectNew, SelectAll:
|
||||
default:
|
||||
return errors.New("file_select_mode must be 'new' or 'all'")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package copier
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"jukebox_maker/internal/config"
|
||||
"jukebox_maker/internal/db"
|
||||
"jukebox_maker/internal/disk"
|
||||
"jukebox_maker/internal/task"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
DiskID string
|
||||
MountPath string
|
||||
MediaPath string
|
||||
EnabledSources []string
|
||||
ReserveFreeGB float64
|
||||
OverwriteMode config.OverwriteMode
|
||||
FileSelectMode config.FileSelectMode
|
||||
}
|
||||
|
||||
type Copier struct {
|
||||
tasks *task.Store
|
||||
|
||||
mu sync.Mutex
|
||||
cancel context.CancelFunc
|
||||
|
||||
dbMu sync.RWMutex
|
||||
db *db.DB
|
||||
}
|
||||
|
||||
func New(tasks *task.Store) *Copier {
|
||||
return &Copier{tasks: tasks}
|
||||
}
|
||||
|
||||
// SetDB replaces the active disk database (called when a disk connects or disconnects).
|
||||
func (c *Copier) SetDB(d *db.DB) {
|
||||
c.dbMu.Lock()
|
||||
c.db = d
|
||||
c.dbMu.Unlock()
|
||||
}
|
||||
|
||||
func (c *Copier) getDB() *db.DB {
|
||||
c.dbMu.RLock()
|
||||
defer c.dbMu.RUnlock()
|
||||
return c.db
|
||||
}
|
||||
|
||||
func (c *Copier) Start(ctx context.Context, opts Options) (string, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if _, active := c.tasks.ActiveTask(); active {
|
||||
return "", errors.New("copy already running")
|
||||
}
|
||||
|
||||
database := c.getDB()
|
||||
if database == nil {
|
||||
return "", errors.New("no disk database available")
|
||||
}
|
||||
|
||||
t := c.tasks.Create("copy")
|
||||
copyCtx, cancel := context.WithCancel(ctx)
|
||||
c.cancel = cancel
|
||||
|
||||
go c.run(copyCtx, t.ID, opts, database)
|
||||
return t.ID, nil
|
||||
}
|
||||
|
||||
func (c *Copier) Cancel() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.cancel != nil {
|
||||
c.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Copier) run(ctx context.Context, taskID string, opts Options, database *db.DB) {
|
||||
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
|
||||
})
|
||||
}
|
||||
fail := func(err error) {
|
||||
c.tasks.Update(taskID, func(t *task.Task) {
|
||||
t.Status = task.StatusFailed
|
||||
t.Error = err.Error()
|
||||
})
|
||||
}
|
||||
|
||||
setStatus(task.StatusRunning, "Подготовка…", 0)
|
||||
|
||||
if opts.OverwriteMode == config.OverwriteDelete {
|
||||
setStatus(task.StatusRunning, "Удаление данных с диска…", 0)
|
||||
if err := deleteOurData(opts.MountPath); err != nil {
|
||||
fail(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var copiedPaths map[string]struct{}
|
||||
if opts.FileSelectMode == config.SelectNew {
|
||||
setStatus(task.StatusRunning, "Загрузка истории…", 0)
|
||||
var err error
|
||||
copiedPaths, err = database.CopiedPaths(opts.DiskID)
|
||||
if err != nil {
|
||||
fail(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setStatus(task.StatusRunning, "Сканирование источников…", 0)
|
||||
files, err := buildFileList(opts.MediaPath, opts.EnabledSources, copiedPaths)
|
||||
if err != nil {
|
||||
fail(err)
|
||||
return
|
||||
}
|
||||
if len(files) == 0 {
|
||||
setStatus(task.StatusSuccess, "Нет новых файлов для копирования.", 100)
|
||||
return
|
||||
}
|
||||
|
||||
_, free, err := disk.DiskUsage(opts.MountPath)
|
||||
if err != nil {
|
||||
fail(err)
|
||||
return
|
||||
}
|
||||
reserveBytes := int64(opts.ReserveFreeGB * 1e9)
|
||||
available := free - reserveBytes
|
||||
if available <= 0 {
|
||||
setStatus(task.StatusSuccess, "Недостаточно свободного места на диске.", 100)
|
||||
return
|
||||
}
|
||||
|
||||
total := len(files)
|
||||
copied := 0
|
||||
for i, f := range files {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
c.tasks.Update(taskID, func(t *task.Task) {
|
||||
t.Status = task.StatusCanceled
|
||||
t.Message = "Отменено"
|
||||
})
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
if f.size > available {
|
||||
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)
|
||||
|
||||
dstAbs := filepath.Join(opts.MountPath, f.relPath)
|
||||
if err := copyFile(ctx, f.srcAbs, dstAbs); err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
c.tasks.Update(taskID, func(t *task.Task) {
|
||||
t.Status = task.StatusCanceled
|
||||
t.Message = "Отменено"
|
||||
})
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
available -= f.size
|
||||
copied++
|
||||
_ = database.RecordCopy(db.CopyRecord{
|
||||
DiskID: opts.DiskID,
|
||||
SourcePath: f.relPath,
|
||||
FileSize: f.size,
|
||||
})
|
||||
}
|
||||
|
||||
setStatus(task.StatusSuccess, fmt.Sprintf("Готово. Скопировано файлов: %d.", copied), 100)
|
||||
}
|
||||
|
||||
type fileEntry struct {
|
||||
srcAbs string
|
||||
relPath string
|
||||
size int64
|
||||
}
|
||||
|
||||
func buildFileList(mediaPath string, sources []string, skip map[string]struct{}) ([]fileEntry, error) {
|
||||
var result []fileEntry
|
||||
for _, src := range sources {
|
||||
dir := filepath.Join(mediaPath, src)
|
||||
err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
rel, _ := filepath.Rel(mediaPath, path)
|
||||
if _, skipped := skip[rel]; skipped {
|
||||
return nil
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
result = append(result, fileEntry{srcAbs: path, relPath: rel, size: info.Size()})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func deleteOurData(mountPath string) error {
|
||||
entries, err := os.ReadDir(mountPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.Name() == ".jukebox" {
|
||||
continue
|
||||
}
|
||||
if err := os.RemoveAll(filepath.Join(mountPath, e.Name())); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
tmp := dst + ".juketmp"
|
||||
out, err := os.Create(tmp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
buf := make([]byte, 512*1024)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
out.Close()
|
||||
os.Remove(tmp)
|
||||
return ctx.Err()
|
||||
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 errors.Is(readErr, io.EOF) {
|
||||
break
|
||||
}
|
||||
if readErr != nil {
|
||||
out.Close()
|
||||
os.Remove(tmp)
|
||||
return readErr
|
||||
}
|
||||
}
|
||||
out.Close()
|
||||
return os.Rename(tmp, dst)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
type DB struct {
|
||||
sql *sql.DB
|
||||
}
|
||||
|
||||
type CopyRecord struct {
|
||||
DiskID string
|
||||
SourcePath string
|
||||
FileSize int64
|
||||
CopiedAt time.Time
|
||||
}
|
||||
|
||||
func Open(path string) (*DB, error) {
|
||||
conn, err := sql.Open("sqlite", path+"?_journal=WAL&_timeout=5000")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
conn.SetMaxOpenConns(1)
|
||||
d := &DB{sql: conn}
|
||||
if err := d.migrate(); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (d *DB) Close() error {
|
||||
return d.sql.Close()
|
||||
}
|
||||
|
||||
func (d *DB) migrate() error {
|
||||
_, err := d.sql.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS copy_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
disk_id TEXT NOT NULL,
|
||||
source_path TEXT NOT NULL,
|
||||
file_size INTEGER NOT NULL DEFAULT 0,
|
||||
copied_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_copy_history_disk_path
|
||||
ON copy_history (disk_id, source_path);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) WasCopied(diskID, sourcePath string) (bool, error) {
|
||||
var n int
|
||||
err := d.sql.QueryRow(
|
||||
`SELECT COUNT(*) FROM copy_history WHERE disk_id=? AND source_path=?`,
|
||||
diskID, sourcePath,
|
||||
).Scan(&n)
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
func (d *DB) RecordCopy(rec CopyRecord) error {
|
||||
t := rec.CopiedAt
|
||||
if t.IsZero() {
|
||||
t = time.Now().UTC()
|
||||
}
|
||||
_, err := d.sql.Exec(
|
||||
`INSERT OR IGNORE INTO copy_history (disk_id, source_path, file_size, copied_at) VALUES (?,?,?,?)`,
|
||||
rec.DiskID, rec.SourcePath, rec.FileSize, t.Format(time.RFC3339),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) CopiedPaths(diskID string) (map[string]struct{}, error) {
|
||||
rows, err := d.sql.Query(
|
||||
`SELECT source_path FROM copy_history WHERE disk_id=?`, diskID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
m := make(map[string]struct{})
|
||||
for rows.Next() {
|
||||
var p string
|
||||
if err := rows.Scan(&p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m[p] = struct{}{}
|
||||
}
|
||||
return m, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package disk
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type DiskState string
|
||||
|
||||
const (
|
||||
DiskAbsent DiskState = "absent"
|
||||
DiskForeign DiskState = "foreign"
|
||||
DiskKnown DiskState = "known"
|
||||
)
|
||||
|
||||
type DiskInfo struct {
|
||||
State DiskState `json:"state"`
|
||||
DiskID string `json:"disk_id"`
|
||||
TotalBytes int64 `json:"total_bytes"`
|
||||
FreeBytes int64 `json:"free_bytes"`
|
||||
MountPath string `json:"mount_path"`
|
||||
}
|
||||
|
||||
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 {
|
||||
return info, nil
|
||||
}
|
||||
|
||||
total, free, err := DiskUsage(mountPath)
|
||||
if err != nil {
|
||||
return info, nil
|
||||
}
|
||||
info.TotalBytes = total
|
||||
info.FreeBytes = free
|
||||
|
||||
idPath := filepath.Join(mountPath, markerDir, idFile)
|
||||
data, err := os.ReadFile(idPath)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
info.State = DiskForeign
|
||||
return info, nil
|
||||
}
|
||||
if err != nil {
|
||||
info.State = DiskForeign
|
||||
return info, nil
|
||||
}
|
||||
|
||||
info.DiskID = strings.TrimSpace(string(data))
|
||||
info.State = DiskKnown
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func InitDisk(mountPath string) (string, error) {
|
||||
dir := filepath.Join(mountPath, markerDir)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
id := uuid.New().String()
|
||||
idPath := filepath.Join(dir, idFile)
|
||||
if err := os.WriteFile(idPath, []byte(id), 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusQueued Status = "queued"
|
||||
StatusRunning Status = "running"
|
||||
StatusSuccess Status = "success"
|
||||
StatusFailed Status = "failed"
|
||||
StatusCanceled Status = "canceled"
|
||||
)
|
||||
|
||||
type Task struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Status Status `json:"status"`
|
||||
Progress int `json:"progress"`
|
||||
Message string `json:"message"`
|
||||
Error string `json:"error"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (t *Task) IsTerminal() bool {
|
||||
return t.Status == StatusSuccess || t.Status == StatusFailed || t.Status == StatusCanceled
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
tasks map[string]*Task
|
||||
}
|
||||
|
||||
func NewStore() *Store {
|
||||
return &Store{tasks: make(map[string]*Task)}
|
||||
}
|
||||
|
||||
func (s *Store) Create(taskType string) *Task {
|
||||
t := &Task{
|
||||
ID: uuid.New().String(),
|
||||
Type: taskType,
|
||||
Status: StatusQueued,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.tasks[t.ID] = t
|
||||
s.mu.Unlock()
|
||||
return t
|
||||
}
|
||||
|
||||
func (s *Store) Get(id string) (*Task, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
t, ok := s.tasks[id]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
copy := *t
|
||||
return ©, true
|
||||
}
|
||||
|
||||
func (s *Store) Update(id string, fn func(*Task)) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if t, ok := s.tasks[id]; ok {
|
||||
fn(t)
|
||||
t.UpdatedAt = time.Now().UTC()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) ActiveTask() (*Task, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, t := range s.tasks {
|
||||
if t.Status == StatusQueued || t.Status == StatusRunning {
|
||||
copy := *t
|
||||
return ©, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package watcher
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"jukebox_maker/internal/disk"
|
||||
)
|
||||
|
||||
type DiskEvent struct {
|
||||
Info disk.DiskInfo
|
||||
Prev disk.DiskState
|
||||
}
|
||||
|
||||
type Handler func(event DiskEvent)
|
||||
|
||||
type Watcher struct {
|
||||
mountPath string
|
||||
interval time.Duration
|
||||
handler Handler
|
||||
|
||||
mu sync.RWMutex
|
||||
current disk.DiskInfo
|
||||
}
|
||||
|
||||
func New(mountPath string, interval time.Duration, handler Handler) *Watcher {
|
||||
return &Watcher{
|
||||
mountPath: mountPath,
|
||||
interval: interval,
|
||||
handler: handler,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Watcher) CurrentDisk() disk.DiskInfo {
|
||||
w.mu.RLock()
|
||||
defer w.mu.RUnlock()
|
||||
return w.current
|
||||
}
|
||||
|
||||
func (w *Watcher) Run(ctx context.Context) {
|
||||
ticker := time.NewTicker(w.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
// probe immediately on start
|
||||
w.probe()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
w.probe()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Watcher) probe() {
|
||||
info, _ := disk.Probe(w.mountPath)
|
||||
|
||||
w.mu.Lock()
|
||||
prev := w.current.State
|
||||
changed := prev != info.State
|
||||
w.current = info
|
||||
w.mu.Unlock()
|
||||
|
||||
if changed && w.handler != nil {
|
||||
w.handler(DiskEvent{Info: info, Prev: prev})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user