Compare commits
2
Commits
6027cfabb9
...
68c7dd76f2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68c7dd76f2 | ||
|
|
db4e3c9611 |
No files matched your search
@@ -8,6 +8,8 @@ SESSION_TTL_HOURS=12
|
|||||||
TRUST_PROXY_HEADERS=true
|
TRUST_PROXY_HEADERS=true
|
||||||
|
|
||||||
LISTEN_ADDR=:8080
|
LISTEN_ADDR=:8080
|
||||||
|
LOG_LEVEL=info
|
||||||
|
LOG_FORMAT=text
|
||||||
DOWNLOAD_DIR=/data/downloads
|
DOWNLOAD_DIR=/data/downloads
|
||||||
|
|
||||||
DOWNLOAD_HOST_DIR=./data/downloads
|
DOWNLOAD_HOST_DIR=./data/downloads
|
||||||
|
|||||||
@@ -64,7 +64,14 @@ Every first level folder inside these roots is a move destination:
|
|||||||
| `DOWNLOAD_DIR` | `/data/downloads` | Temporary download directory |
|
| `DOWNLOAD_DIR` | `/data/downloads` | Temporary download directory |
|
||||||
| `MIN_FREE_GB` | `10` | Initial value: free space that must always remain |
|
| `MIN_FREE_GB` | `10` | Initial value: free space that must always remain |
|
||||||
| `MAX_CONCURRENT_DOWNLOADS` | `2` | Initial value: parallel downloads, others wait in queue |
|
| `MAX_CONCURRENT_DOWNLOADS` | `2` | Initial value: parallel downloads, others wait in queue |
|
||||||
|
| `CONNECTIONS_PER_DOWNLOAD` | `4` | Initial value: parallel connections for one download when the server allows ranges |
|
||||||
| `ALLOWED_PORTS` | `80,443` | Initial value: allowed remote ports, `*` for any |
|
| `ALLOWED_PORTS` | `80,443` | Initial value: allowed remote ports, `*` for any |
|
||||||
|
| `LOG_LEVEL` | `info` | `debug`, `info`, `warn` or `error`. `debug` also logs every polling request |
|
||||||
|
| `LOG_FORMAT` | `text` | `text` or `json` |
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
Logs go to stdout (`docker compose logs -f`). Every request gets an `X-Request-ID`. When the server hits an internal error, the page shows a reference ID and the log line for that request carries the same `request_id`, so the two can be matched. Download, extraction and move steps are logged with a short `download` ID.
|
||||||
|
|
||||||
## Web Settings
|
## Web Settings
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ type Settings struct {
|
|||||||
TrustProxyHeaders bool
|
TrustProxyHeaders bool
|
||||||
DownloadDir string
|
DownloadDir string
|
||||||
LibraryRoots []string
|
LibraryRoots []string
|
||||||
|
LogLevel string
|
||||||
|
LogFormat string
|
||||||
Defaults preferences.Preferences
|
Defaults preferences.Preferences
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +38,8 @@ func Load() (Settings, error) {
|
|||||||
SessionTTL: time.Duration(reader.integer("SESSION_TTL_HOURS", 12)) * time.Hour,
|
SessionTTL: time.Duration(reader.integer("SESSION_TTL_HOURS", 12)) * time.Hour,
|
||||||
TrustProxyHeaders: reader.boolean("TRUST_PROXY_HEADERS", false),
|
TrustProxyHeaders: reader.boolean("TRUST_PROXY_HEADERS", false),
|
||||||
DownloadDir: filepath.Clean(reader.text("DOWNLOAD_DIR", "/data/downloads")),
|
DownloadDir: filepath.Clean(reader.text("DOWNLOAD_DIR", "/data/downloads")),
|
||||||
|
LogLevel: reader.text("LOG_LEVEL", "info"),
|
||||||
|
LogFormat: reader.text("LOG_FORMAT", "text"),
|
||||||
Defaults: preferences.Preferences{
|
Defaults: preferences.Preferences{
|
||||||
MaxConcurrentDownloads: reader.integer("MAX_CONCURRENT_DOWNLOADS", 2),
|
MaxConcurrentDownloads: reader.integer("MAX_CONCURRENT_DOWNLOADS", 2),
|
||||||
ConnectionsPerDownload: reader.integer("CONNECTIONS_PER_DOWNLOAD", 4),
|
ConnectionsPerDownload: reader.integer("CONNECTIONS_PER_DOWNLOAD", 4),
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package explain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"net"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"ddc/internal/guard"
|
||||||
|
)
|
||||||
|
|
||||||
|
var keptAsIs = []error{
|
||||||
|
guard.ErrBlockedAddress,
|
||||||
|
guard.ErrBlockedPort,
|
||||||
|
guard.ErrTooManyRedirects,
|
||||||
|
guard.ErrUnsupportedScheme,
|
||||||
|
}
|
||||||
|
|
||||||
|
func Error(err error) string {
|
||||||
|
if err == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, known := range keptAsIs {
|
||||||
|
if errors.Is(err, known) {
|
||||||
|
return capitalize(known.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if message := networkMessage(err); message != "" {
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
if message := fileSystemMessage(err); message != "" {
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
return capitalize(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
func networkMessage(err error) string {
|
||||||
|
var dnsError *net.DNSError
|
||||||
|
var certificateError *tls.CertificateVerificationError
|
||||||
|
var unknownAuthority x509.UnknownAuthorityError
|
||||||
|
var hostnameError x509.HostnameError
|
||||||
|
var timeoutError net.Error
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case errors.As(err, &dnsError):
|
||||||
|
return fmt.Sprintf("Could not find the server %q. Check the link.", dnsError.Name)
|
||||||
|
case errors.As(err, &certificateError), errors.As(err, &unknownAuthority), errors.As(err, &hostnameError):
|
||||||
|
return "The server's security certificate is not valid."
|
||||||
|
case errors.Is(err, syscall.ECONNREFUSED):
|
||||||
|
return "The server refused the connection."
|
||||||
|
case errors.Is(err, syscall.ECONNRESET), errors.Is(err, io.ErrUnexpectedEOF):
|
||||||
|
return "The server closed the connection before the file was complete. Use Retry to continue."
|
||||||
|
case errors.Is(err, syscall.ENETUNREACH), errors.Is(err, syscall.EHOSTUNREACH):
|
||||||
|
return "The server could not be reached from this network."
|
||||||
|
case errors.As(err, &timeoutError) && timeoutError.Timeout():
|
||||||
|
return "The server took too long to respond."
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileSystemMessage(err error) string {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, syscall.ENOSPC):
|
||||||
|
return "The disk is full."
|
||||||
|
case errors.Is(err, fs.ErrPermission):
|
||||||
|
return "The server does not have permission to write there. Check the folder owner and permissions."
|
||||||
|
case errors.Is(err, syscall.EROFS):
|
||||||
|
return "The destination is on a read-only file system."
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func capitalize(message string) string {
|
||||||
|
if message == "" {
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
first := message[0]
|
||||||
|
if first >= 'a' && first <= 'z' {
|
||||||
|
return string(first-'a'+'A') + message[1:]
|
||||||
|
}
|
||||||
|
return message
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package fetch
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -34,6 +35,14 @@ type Request struct {
|
|||||||
State *State
|
State *State
|
||||||
Progress Progress
|
Progress Progress
|
||||||
Checkpoint func(State)
|
Checkpoint func(State)
|
||||||
|
Logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func (request Request) logger() *slog.Logger {
|
||||||
|
if request.Logger != nil {
|
||||||
|
return request.Logger
|
||||||
|
}
|
||||||
|
return slog.Default()
|
||||||
}
|
}
|
||||||
|
|
||||||
type Downloader struct {
|
type Downloader struct {
|
||||||
@@ -90,12 +99,20 @@ func (downloader *Downloader) Download(ctx context.Context, request Request) (st
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (downloader *Downloader) prepare(ctx context.Context, source *remoteFile, request Request, partPath string) (State, *http.Response, error) {
|
func (downloader *Downloader) prepare(ctx context.Context, source *remoteFile, request Request, partPath string) (State, *http.Response, error) {
|
||||||
if request.State != nil && request.State.valid() && fileExists(partPath) {
|
if request.State != nil {
|
||||||
|
if request.State.valid() && fileExists(partPath) {
|
||||||
state := *request.State
|
state := *request.State
|
||||||
source.validator = state.Validator
|
source.validator = state.Validator
|
||||||
source.totalBytes = state.TotalBytes
|
source.totalBytes = state.TotalBytes
|
||||||
|
request.logger().Info("resuming from saved progress",
|
||||||
|
"bytes_done", state.DoneBytes(),
|
||||||
|
"bytes_total", state.TotalBytes,
|
||||||
|
"parts", len(state.Segments),
|
||||||
|
)
|
||||||
return state, nil, nil
|
return state, nil, nil
|
||||||
}
|
}
|
||||||
|
request.logger().Warn("saved progress is unusable, starting over", "part_file_exists", fileExists(partPath))
|
||||||
|
}
|
||||||
|
|
||||||
probe, err := downloader.open(ctx, source, 0, 0)
|
probe, err := downloader.open(ctx, source, 0, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -113,6 +130,13 @@ func (downloader *Downloader) prepare(ctx context.Context, source *remoteFile, r
|
|||||||
TotalBytes: source.totalBytes,
|
TotalBytes: source.totalBytes,
|
||||||
Segments: planSegments(source.totalBytes, connections),
|
Segments: planSegments(source.totalBytes, connections),
|
||||||
}
|
}
|
||||||
|
request.logger().Info("connected to server",
|
||||||
|
"file", state.FileName,
|
||||||
|
"bytes_total", state.TotalBytes,
|
||||||
|
"supports_ranges", probe.Header.Get("Accept-Ranges") == "bytes",
|
||||||
|
"has_validator", state.Validator != "",
|
||||||
|
"parts", len(state.Segments),
|
||||||
|
)
|
||||||
return state, probe, nil
|
return state, probe, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -123,6 +123,8 @@ func (session *session) saveCheckpoint() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
state := session.snapshot()
|
state := session.snapshot()
|
||||||
session.file.Sync()
|
if err := session.file.Sync(); err != nil {
|
||||||
|
session.request.logger().Warn("could not flush partial file to disk", "error", err)
|
||||||
|
}
|
||||||
session.request.Checkpoint(state)
|
session.request.Checkpoint(state)
|
||||||
}
|
}
|
||||||
@@ -68,11 +68,26 @@ func (worker *segmentWorker) record(count int64) error {
|
|||||||
|
|
||||||
func (worker *segmentWorker) reconnect(ctx context.Context, cause error) (*http.Response, error) {
|
func (worker *segmentWorker) reconnect(ctx context.Context, cause error) (*http.Response, error) {
|
||||||
progress := worker.session.request.Progress
|
progress := worker.session.request.Progress
|
||||||
|
logger := worker.session.request.logger().With("part_start", worker.segment.start)
|
||||||
for {
|
for {
|
||||||
worker.failedAttempts++
|
worker.failedAttempts++
|
||||||
if ctx.Err() != nil || !isRetryable(cause) || worker.failedAttempts > maxRetriesWithoutProgress {
|
if ctx.Err() != nil {
|
||||||
return nil, cause
|
return nil, cause
|
||||||
}
|
}
|
||||||
|
if !isRetryable(cause) {
|
||||||
|
logger.Debug("error cannot be retried", "error", cause)
|
||||||
|
return nil, cause
|
||||||
|
}
|
||||||
|
if worker.failedAttempts > maxRetriesWithoutProgress {
|
||||||
|
logger.Error("giving up after repeated connection failures", "attempts", maxRetriesWithoutProgress, "error", cause)
|
||||||
|
return nil, fmt.Errorf("gave up after %d reconnect attempts: %w", maxRetriesWithoutProgress, cause)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Warn("connection lost, reconnecting",
|
||||||
|
"attempt", worker.failedAttempts,
|
||||||
|
"position", worker.segment.position(),
|
||||||
|
"error", cause,
|
||||||
|
)
|
||||||
progress.SetNotice(fmt.Sprintf("Connection lost, reconnecting (attempt %d of %d)", worker.failedAttempts, maxRetriesWithoutProgress))
|
progress.SetNotice(fmt.Sprintf("Connection lost, reconnecting (attempt %d of %d)", worker.failedAttempts, maxRetriesWithoutProgress))
|
||||||
if err := waitBeforeRetry(ctx, worker.failedAttempts); err != nil {
|
if err := waitBeforeRetry(ctx, worker.failedAttempts); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -80,6 +95,7 @@ func (worker *segmentWorker) reconnect(ctx context.Context, cause error) (*http.
|
|||||||
|
|
||||||
response, err := worker.open(ctx)
|
response, err := worker.open(ctx)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
logger.Info("reconnected", "position", worker.segment.position())
|
||||||
progress.SetNotice("")
|
progress.SetNotice("")
|
||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"ddc/internal/explain"
|
||||||
"ddc/internal/extract"
|
"ddc/internal/extract"
|
||||||
"ddc/internal/fetch"
|
"ddc/internal/fetch"
|
||||||
)
|
)
|
||||||
@@ -17,6 +19,7 @@ func (manager *Manager) Pause(id string) error {
|
|||||||
if !job.pause() {
|
if !job.pause() {
|
||||||
return ErrJobBusy
|
return ErrJobBusy
|
||||||
}
|
}
|
||||||
|
job.logger().Info("pause requested")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,6 +31,7 @@ func (manager *Manager) Resume(id string) error {
|
|||||||
if !job.begin(StatusQueued, StatusPaused, StatusFailed) {
|
if !job.begin(StatusQueued, StatusPaused, StatusFailed) {
|
||||||
return ErrJobBusy
|
return ErrJobBusy
|
||||||
}
|
}
|
||||||
|
job.logger().Info("download resumed", "has_saved_progress", job.currentDownloadState() != nil)
|
||||||
job.saveRecord()
|
job.saveRecord()
|
||||||
manager.launchDownload(job)
|
manager.launchDownload(job)
|
||||||
return nil
|
return nil
|
||||||
@@ -44,17 +48,17 @@ func (manager *Manager) Reorder(id, direction string) error {
|
|||||||
if job.currentStatus() != StatusQueued || !manager.slots.reorder(id, direction) {
|
if job.currentStatus() != StatusQueued || !manager.slots.reorder(id, direction) {
|
||||||
return ErrJobBusy
|
return ErrJobBusy
|
||||||
}
|
}
|
||||||
|
job.logger().Debug("queue position changed", "direction", direction, "position", manager.slots.position(id))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (manager *Manager) launchDownload(job *Job) {
|
func (manager *Manager) launchDownload(job *Job) {
|
||||||
runContext, cancelRun := job.startRun()
|
runContext, cancelRun := job.startRun()
|
||||||
manager.slots.enqueue(job.id)
|
manager.slots.enqueue(job.id)
|
||||||
go func() {
|
manager.runInBackground(job, "download", func() {
|
||||||
defer job.workers.Done()
|
|
||||||
defer cancelRun(nil)
|
defer cancelRun(nil)
|
||||||
manager.download(job, runContext)
|
manager.download(job, runContext)
|
||||||
}()
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (manager *Manager) download(job *Job, runContext context.Context) {
|
func (manager *Manager) download(job *Job, runContext context.Context) {
|
||||||
@@ -65,18 +69,25 @@ func (manager *Manager) download(job *Job, runContext context.Context) {
|
|||||||
defer manager.slots.release()
|
defer manager.slots.release()
|
||||||
|
|
||||||
job.setStatus(StatusDownloading, "")
|
job.setStatus(StatusDownloading, "")
|
||||||
|
started := time.Now()
|
||||||
|
job.logger().Info("download started", "url", job.displayURL)
|
||||||
filePath, err := manager.downloader.Download(runContext, manager.fetchRequest(job))
|
filePath, err := manager.downloader.Download(runContext, manager.fetchRequest(job))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
manager.handleDownloadError(job, runContext, err)
|
manager.handleDownloadError(job, runContext, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
job.markDownloaded(filepath.Base(filePath))
|
job.markDownloaded(filepath.Base(filePath))
|
||||||
|
job.logger().Info("download finished",
|
||||||
|
"file", filepath.Base(filePath),
|
||||||
|
"bytes", job.bytesDone.Load(),
|
||||||
|
"duration", time.Since(started).Round(time.Second).String(),
|
||||||
|
)
|
||||||
|
|
||||||
if job.autoExtract && extract.IsArchive(filePath) {
|
if job.autoExtract && extract.IsArchive(filePath) {
|
||||||
job.setStatus(StatusExtracting, "")
|
job.setStatus(StatusExtracting, "")
|
||||||
destination, err := manager.extractArchive(job, filePath)
|
destination, err := manager.extractArchive(job, filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
manager.finishWithError(job, StatusReady, "Extraction failed: ", err)
|
manager.failExtraction(job, err)
|
||||||
job.saveRecord()
|
job.saveRecord()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -98,6 +109,7 @@ func (manager *Manager) fetchRequest(job *Job) fetch.Request {
|
|||||||
Connections: current.ConnectionsPerDownload,
|
Connections: current.ConnectionsPerDownload,
|
||||||
State: job.currentDownloadState(),
|
State: job.currentDownloadState(),
|
||||||
Progress: job,
|
Progress: job,
|
||||||
|
Logger: job.logger(),
|
||||||
Checkpoint: func(state fetch.State) {
|
Checkpoint: func(state fetch.State) {
|
||||||
job.setDownloadState(&state)
|
job.setDownloadState(&state)
|
||||||
job.saveRecord()
|
job.saveRecord()
|
||||||
@@ -109,14 +121,22 @@ func (manager *Manager) handleDownloadError(job *Job, runContext context.Context
|
|||||||
switch {
|
switch {
|
||||||
case errors.Is(context.Cause(runContext), errPaused):
|
case errors.Is(context.Cause(runContext), errPaused):
|
||||||
job.setStatus(StatusPaused, "")
|
job.setStatus(StatusPaused, "")
|
||||||
|
job.logger().Info("download paused", "bytes_done", job.bytesDone.Load())
|
||||||
case job.jobContext.Err() != nil:
|
case job.jobContext.Err() != nil:
|
||||||
job.setStatus(StatusCancelled, "")
|
job.setStatus(StatusCancelled, "")
|
||||||
|
job.logger().Info("download stopped", "bytes_done", job.bytesDone.Load())
|
||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
if errors.Is(err, fetch.ErrSourceChanged) || errors.Is(err, fetch.ErrResumeUnsupported) {
|
if errors.Is(err, fetch.ErrSourceChanged) || errors.Is(err, fetch.ErrResumeUnsupported) {
|
||||||
job.setDownloadState(nil)
|
job.setDownloadState(nil)
|
||||||
}
|
}
|
||||||
job.setStatus(StatusFailed, err.Error())
|
job.setStatus(StatusFailed, explain.Error(err))
|
||||||
|
job.logger().Error("download failed",
|
||||||
|
"url", job.displayURL,
|
||||||
|
"bytes_done", job.bytesDone.Load(),
|
||||||
|
"bytes_total", job.bytesTotal.Load(),
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
job.saveRecord()
|
job.saveRecord()
|
||||||
}
|
}
|
||||||
@@ -4,8 +4,10 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
"ddc/internal/diskspace"
|
"ddc/internal/diskspace"
|
||||||
|
"ddc/internal/explain"
|
||||||
"ddc/internal/extract"
|
"ddc/internal/extract"
|
||||||
"ddc/internal/files"
|
"ddc/internal/files"
|
||||||
)
|
)
|
||||||
@@ -32,14 +34,13 @@ func (manager *Manager) Extract(id, relativePath string) error {
|
|||||||
if !job.begin(StatusExtracting, StatusReady) {
|
if !job.begin(StatusExtracting, StatusReady) {
|
||||||
return ErrJobBusy
|
return ErrJobBusy
|
||||||
}
|
}
|
||||||
go func() {
|
manager.runInBackground(job, "extraction", func() {
|
||||||
defer job.workers.Done()
|
|
||||||
if _, err := manager.extractArchive(job, archivePath); err != nil {
|
if _, err := manager.extractArchive(job, archivePath); err != nil {
|
||||||
manager.finishWithError(job, StatusReady, "Extraction failed: ", err)
|
manager.failExtraction(job, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
job.setStatus(StatusReady, "")
|
job.setStatus(StatusReady, "")
|
||||||
}()
|
})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,16 +59,27 @@ func (manager *Manager) extractArchive(job *Job, archivePath string) (string, er
|
|||||||
limits := extract.Limits{MaxBytes: limit, MaxEntries: maxArchiveEntries}
|
limits := extract.Limits{MaxBytes: limit, MaxEntries: maxArchiveEntries}
|
||||||
|
|
||||||
job.resetProgress()
|
job.resetProgress()
|
||||||
|
started := time.Now()
|
||||||
|
job.logger().Info("extraction started", "archive", filepath.Base(archivePath))
|
||||||
if err := extract.Archive(job.jobContext, archivePath, destination, limits, job); err != nil {
|
if err := extract.Archive(job.jobContext, archivePath, destination, limits, job); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return destination, os.Remove(archivePath)
|
job.logger().Info("extraction finished",
|
||||||
|
"archive", filepath.Base(archivePath),
|
||||||
|
"folder", filepath.Base(destination),
|
||||||
|
"duration", time.Since(started).Round(time.Second).String(),
|
||||||
|
)
|
||||||
|
if err := os.Remove(archivePath); err != nil {
|
||||||
|
job.logger().Warn("could not remove archive after extraction", "archive", archivePath, "error", err)
|
||||||
|
}
|
||||||
|
return destination, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (manager *Manager) finishWithError(job *Job, status Status, prefix string, err error) {
|
func (manager *Manager) failExtraction(job *Job, err error) {
|
||||||
if job.jobContext.Err() != nil {
|
if job.jobContext.Err() != nil {
|
||||||
job.setStatus(StatusCancelled, "")
|
job.setStatus(StatusCancelled, "")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
job.setStatus(status, prefix+err.Error())
|
job.logger().Error("extraction failed", "error", err)
|
||||||
|
job.setStatus(StatusReady, "Extraction failed: "+explain.Error(err))
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package jobs
|
package jobs
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"ddc/internal/history"
|
"ddc/internal/history"
|
||||||
@@ -28,6 +27,6 @@ func (manager *Manager) recordHistory(job *Job, destination string, movedNames [
|
|||||||
MovedAt: time.Now(),
|
MovedAt: time.Now(),
|
||||||
}
|
}
|
||||||
if err := manager.history.Add(entry); err != nil {
|
if err := manager.history.Add(entry); err != nil {
|
||||||
log.Printf("could not save history: %v", err)
|
job.logger().Error("could not save history", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -76,6 +77,12 @@ func (manager *Manager) UpdatePreferences(next preferences.Preferences) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
manager.policy.SetAllowedPorts(next.AllowedPorts)
|
manager.policy.SetAllowedPorts(next.AllowedPorts)
|
||||||
|
slog.Debug("preferences saved",
|
||||||
|
"parallel_downloads", next.MaxConcurrentDownloads,
|
||||||
|
"connections_per_download", next.ConnectionsPerDownload,
|
||||||
|
"min_free_gb", next.MinFreeGB,
|
||||||
|
"download_paths", len(next.DownloadPaths),
|
||||||
|
)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,6 +144,11 @@ func (manager *Manager) Create(request CreateRequest) (Snapshot, error) {
|
|||||||
|
|
||||||
manager.store(job)
|
manager.store(job)
|
||||||
job.saveRecord()
|
job.saveRecord()
|
||||||
|
job.logger().Info("download added",
|
||||||
|
"url", job.displayURL,
|
||||||
|
"file_name", requestedName,
|
||||||
|
"target", job.snapshot().MoveTarget,
|
||||||
|
)
|
||||||
manager.launchDownload(job)
|
manager.launchDownload(job)
|
||||||
return job.snapshot(), nil
|
return job.snapshot(), nil
|
||||||
}
|
}
|
||||||
@@ -186,7 +198,12 @@ func (manager *Manager) Delete(id string) error {
|
|||||||
delete(manager.jobs, id)
|
delete(manager.jobs, id)
|
||||||
manager.mutex.Unlock()
|
manager.mutex.Unlock()
|
||||||
|
|
||||||
return os.RemoveAll(job.directory)
|
if err := os.RemoveAll(job.directory); err != nil {
|
||||||
|
job.logger().Error("could not remove download files", "directory", job.directory, "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
job.logger().Info("download removed")
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (manager *Manager) Shutdown(ctx context.Context) {
|
func (manager *Manager) Shutdown(ctx context.Context) {
|
||||||
@@ -208,6 +225,7 @@ func (manager *Manager) Shutdown(ctx context.Context) {
|
|||||||
select {
|
select {
|
||||||
case <-finished:
|
case <-finished:
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
|
slog.Warn("stopped waiting for downloads to save progress", "error", ctx.Err())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+20
-5
@@ -5,7 +5,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"ddc/internal/explain"
|
||||||
"ddc/internal/files"
|
"ddc/internal/files"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -50,10 +52,9 @@ func (manager *Manager) Move(id string, request MoveRequest) error {
|
|||||||
if !job.begin(StatusMoving, StatusReady) {
|
if !job.begin(StatusMoving, StatusReady) {
|
||||||
return ErrJobBusy
|
return ErrJobBusy
|
||||||
}
|
}
|
||||||
go func() {
|
manager.runInBackground(job, "move", func() {
|
||||||
defer job.workers.Done()
|
|
||||||
manager.moveFiles(job, sources, destination, request.Cleanup)
|
manager.moveFiles(job, sources, destination, request.Cleanup)
|
||||||
}()
|
})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,13 +110,26 @@ func (manager *Manager) moveFiles(job *Job, sources []string, destination string
|
|||||||
|
|
||||||
movedNames := make([]string, 0, len(sources))
|
movedNames := make([]string, 0, len(sources))
|
||||||
defer func() { manager.recordHistory(job, destination, movedNames) }()
|
defer func() { manager.recordHistory(job, destination, movedNames) }()
|
||||||
|
started := time.Now()
|
||||||
for _, source := range sources {
|
for _, source := range sources {
|
||||||
if err := files.MoveInto(source, destination, manager.reservedBytes(), job.Add); err != nil {
|
if err := files.MoveInto(source, destination, manager.reservedBytes(), job.Add); err != nil {
|
||||||
job.setStatus(StatusReady, "Move failed: "+err.Error())
|
job.logger().Error("move failed",
|
||||||
|
"item", filepath.Base(source),
|
||||||
|
"destination", destination,
|
||||||
|
"moved_before_failure", len(movedNames),
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
job.setStatus(StatusReady, "Move failed: "+explain.Error(err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
movedNames = append(movedNames, filepath.Base(source))
|
movedNames = append(movedNames, filepath.Base(source))
|
||||||
}
|
}
|
||||||
|
job.logger().Info("move finished",
|
||||||
|
"items", len(movedNames),
|
||||||
|
"bytes", totalBytes,
|
||||||
|
"destination", destination,
|
||||||
|
"duration", time.Since(started).Round(time.Second).String(),
|
||||||
|
)
|
||||||
|
|
||||||
message := fmt.Sprintf("Moved %d item(s) to %s", len(sources), destination)
|
message := fmt.Sprintf("Moved %d item(s) to %s", len(sources), destination)
|
||||||
if !cleanup {
|
if !cleanup {
|
||||||
@@ -123,7 +137,8 @@ func (manager *Manager) moveFiles(job *Job, sources []string, destination string
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := os.RemoveAll(job.directory); err != nil {
|
if err := os.RemoveAll(job.directory); err != nil {
|
||||||
message += ", cleanup failed: " + err.Error()
|
job.logger().Warn("could not remove temporary files after move", "directory", job.directory, "error", err)
|
||||||
|
message += ", cleanup failed: " + explain.Error(err)
|
||||||
}
|
}
|
||||||
job.setStatus(StatusCompleted, message)
|
job.setStatus(StatusCompleted, message)
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@ package jobs
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
@@ -69,23 +69,28 @@ func phaseFor(status Status, downloaded bool) string {
|
|||||||
func (job *Job) saveRecord() {
|
func (job *Job) saveRecord() {
|
||||||
data, err := json.MarshalIndent(job.record(), "", " ")
|
data, err := json.MarshalIndent(job.record(), "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("could not encode download %s: %v", job.id, err)
|
job.logger().Error("could not encode download state", "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
job.recordMutex.Lock()
|
job.recordMutex.Lock()
|
||||||
defer job.recordMutex.Unlock()
|
defer job.recordMutex.Unlock()
|
||||||
if err := files.WriteAtomically(filepath.Join(job.directory, files.JobFileName), data); err != nil && !os.IsNotExist(err) {
|
if err := files.WriteAtomically(filepath.Join(job.directory, files.JobFileName), data); err != nil && !os.IsNotExist(err) {
|
||||||
log.Printf("could not save download %s: %v", job.id, err)
|
job.logger().Error("could not save download state", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func readRecord(directory string) (jobRecord, bool) {
|
func readRecord(directory string) (jobRecord, bool) {
|
||||||
data, err := os.ReadFile(filepath.Join(directory, files.JobFileName))
|
recordPath := filepath.Join(directory, files.JobFileName)
|
||||||
|
data, err := os.ReadFile(recordPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
slog.Warn("could not read saved download state", "path", recordPath, "error", err)
|
||||||
|
}
|
||||||
return jobRecord{}, false
|
return jobRecord{}, false
|
||||||
}
|
}
|
||||||
var record jobRecord
|
var record jobRecord
|
||||||
if err := json.Unmarshal(data, &record); err != nil {
|
if err := json.Unmarshal(data, &record); err != nil {
|
||||||
|
slog.Warn("saved download state is damaged, treating the download as finished", "path", recordPath, "error", err)
|
||||||
return jobRecord{}, false
|
return jobRecord{}, false
|
||||||
}
|
}
|
||||||
return record, true
|
return record, true
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package jobs
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
@@ -20,12 +21,14 @@ func (manager *Manager) RestoreExisting() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var queuedJobs []*Job
|
var queuedJobs []*Job
|
||||||
|
restoredCount := 0
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if !entry.IsDir() || !jobIDPattern.MatchString(entry.Name()) {
|
if !entry.IsDir() || !jobIDPattern.MatchString(entry.Name()) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
job := manager.restoreJob(entry)
|
job := manager.restoreJob(entry)
|
||||||
manager.store(job)
|
manager.store(job)
|
||||||
|
restoredCount++
|
||||||
if job.status == StatusQueued {
|
if job.status == StatusQueued {
|
||||||
queuedJobs = append(queuedJobs, job)
|
queuedJobs = append(queuedJobs, job)
|
||||||
}
|
}
|
||||||
@@ -38,6 +41,7 @@ func (manager *Manager) RestoreExisting() error {
|
|||||||
job.workers.Add(1)
|
job.workers.Add(1)
|
||||||
manager.launchDownload(job)
|
manager.launchDownload(job)
|
||||||
}
|
}
|
||||||
|
slog.Info("restored downloads", "total", restoredCount, "resuming", len(queuedJobs))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package jobs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"runtime/debug"
|
||||||
|
)
|
||||||
|
|
||||||
|
const shortIDLength = 8
|
||||||
|
|
||||||
|
func (job *Job) logger() *slog.Logger {
|
||||||
|
return slog.With("download", job.id[:min(len(job.id), shortIDLength)])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (job *Job) isDownloaded() bool {
|
||||||
|
job.mutex.Lock()
|
||||||
|
defer job.mutex.Unlock()
|
||||||
|
return job.downloaded
|
||||||
|
}
|
||||||
|
|
||||||
|
func (manager *Manager) runInBackground(job *Job, task string, work func()) {
|
||||||
|
go func() {
|
||||||
|
defer job.workers.Done()
|
||||||
|
defer func() {
|
||||||
|
recovered := recover()
|
||||||
|
if recovered == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
job.logger().Error("background task crashed",
|
||||||
|
"task", task,
|
||||||
|
"panic", recovered,
|
||||||
|
"stack", string(debug.Stack()),
|
||||||
|
)
|
||||||
|
status := StatusFailed
|
||||||
|
if job.isDownloaded() {
|
||||||
|
status = StatusReady
|
||||||
|
}
|
||||||
|
job.setStatus(status, "Internal error during "+task+". The server log has the details.")
|
||||||
|
job.saveRecord()
|
||||||
|
}()
|
||||||
|
work()
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"ddc/internal/explain"
|
||||||
"ddc/internal/files"
|
"ddc/internal/files"
|
||||||
"ddc/internal/libraries"
|
"ddc/internal/libraries"
|
||||||
"ddc/internal/preferences"
|
"ddc/internal/preferences"
|
||||||
@@ -82,6 +83,7 @@ func (manager *Manager) moveToTarget(job *Job) {
|
|||||||
}
|
}
|
||||||
names, err := topLevelNames(job.directory)
|
names, err := topLevelNames(job.directory)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
job.logger().Info("moving to chosen folder", "destination", job.target.displayPath(), "items", len(names))
|
||||||
err = manager.Move(job.id, MoveRequest{
|
err = manager.Move(job.id, MoveRequest{
|
||||||
Paths: names,
|
Paths: names,
|
||||||
Destination: job.target.destination,
|
Destination: job.target.destination,
|
||||||
@@ -90,7 +92,8 @@ func (manager *Manager) moveToTarget(job *Job) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
job.setStatus(StatusReady, "Move failed: "+err.Error())
|
job.logger().Error("automatic move failed", "destination", job.target.displayPath(), "error", err)
|
||||||
|
job.setStatus(StatusReady, "Move failed: "+explain.Error(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package logging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Setup(level, format string) error {
|
||||||
|
var parsedLevel slog.Level
|
||||||
|
if err := parsedLevel.UnmarshalText([]byte(level)); err != nil {
|
||||||
|
return fmt.Errorf("LOG_LEVEL must be debug, info, warn or error: %w", err)
|
||||||
|
}
|
||||||
|
options := &slog.HandlerOptions{Level: parsedLevel}
|
||||||
|
|
||||||
|
var handler slog.Handler
|
||||||
|
switch strings.ToLower(format) {
|
||||||
|
case "text":
|
||||||
|
handler = slog.NewTextHandler(os.Stdout, options)
|
||||||
|
case "json":
|
||||||
|
handler = slog.NewJSONHandler(os.Stdout, options)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("LOG_FORMAT must be text or json, got %q", format)
|
||||||
|
}
|
||||||
|
slog.SetDefault(slog.New(handler))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Fatal(message string, err error, attributes ...any) {
|
||||||
|
slog.Error(message, append(attributes, "error", err)...)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -20,17 +22,18 @@ type sessionResponse struct {
|
|||||||
func (server *Server) handleLogin(writer http.ResponseWriter, request *http.Request) {
|
func (server *Server) handleLogin(writer http.ResponseWriter, request *http.Request) {
|
||||||
client := server.clientAddress(request)
|
client := server.clientAddress(request)
|
||||||
if !server.limiter.Allowed(client) {
|
if !server.limiter.Allowed(client) {
|
||||||
|
slog.Warn("login blocked after too many failures", "client", client, "request_id", requestIDFrom(request))
|
||||||
writeError(writer, http.StatusTooManyRequests, "too many failed attempts, try again later")
|
writeError(writer, http.StatusTooManyRequests, "too many failed attempts, try again later")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var body loginRequest
|
var body loginRequest
|
||||||
if err := decodeJSON(request, &body); err != nil {
|
if !decodeBody(writer, request, &body) {
|
||||||
writeError(writer, http.StatusBadRequest, "invalid request body")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !auth.CredentialsMatch(server.settings.Username, server.settings.Password, body.Username, body.Password) {
|
if !auth.CredentialsMatch(server.settings.Username, server.settings.Password, body.Username, body.Password) {
|
||||||
server.limiter.RecordFailure(client)
|
server.limiter.RecordFailure(client)
|
||||||
|
slog.Warn("login failed", "client", client, "username", body.Username, "request_id", requestIDFrom(request))
|
||||||
writeError(writer, http.StatusUnauthorized, "invalid username or password")
|
writeError(writer, http.StatusUnauthorized, "invalid username or password")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -38,9 +41,10 @@ func (server *Server) handleLogin(writer http.ResponseWriter, request *http.Requ
|
|||||||
server.limiter.Reset(client)
|
server.limiter.Reset(client)
|
||||||
token, err := server.sessions.Create()
|
token, err := server.sessions.Create()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(writer, http.StatusInternalServerError, "could not create session")
|
writeFailure(writer, request, fmt.Errorf("could not create session: %w", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
slog.Info("login succeeded", "client", client, "request_id", requestIDFrom(request))
|
||||||
http.SetCookie(writer, server.sessionCookie(token, int(server.settings.SessionTTL.Seconds())))
|
http.SetCookie(writer, server.sessionCookie(token, int(server.settings.SessionTTL.Seconds())))
|
||||||
writeJSON(writer, http.StatusOK, sessionResponse{Authenticated: true})
|
writeJSON(writer, http.StatusOK, sessionResponse{Authenticated: true})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,13 @@ package server
|
|||||||
|
|
||||||
import "net/http"
|
import "net/http"
|
||||||
|
|
||||||
|
type reorderRequest struct {
|
||||||
|
Direction string `json:"direction"`
|
||||||
|
}
|
||||||
|
|
||||||
func (server *Server) handlePause(writer http.ResponseWriter, request *http.Request) {
|
func (server *Server) handlePause(writer http.ResponseWriter, request *http.Request) {
|
||||||
if err := server.manager.Pause(request.PathValue("id")); err != nil {
|
if err := server.manager.Pause(request.PathValue("id")); err != nil {
|
||||||
writeFailure(writer, err)
|
writeFailure(writer, request, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(writer, http.StatusAccepted, startedResponse{Started: true})
|
writeJSON(writer, http.StatusAccepted, startedResponse{Started: true})
|
||||||
@@ -12,24 +16,19 @@ func (server *Server) handlePause(writer http.ResponseWriter, request *http.Requ
|
|||||||
|
|
||||||
func (server *Server) handleResume(writer http.ResponseWriter, request *http.Request) {
|
func (server *Server) handleResume(writer http.ResponseWriter, request *http.Request) {
|
||||||
if err := server.manager.Resume(request.PathValue("id")); err != nil {
|
if err := server.manager.Resume(request.PathValue("id")); err != nil {
|
||||||
writeFailure(writer, err)
|
writeFailure(writer, request, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(writer, http.StatusAccepted, startedResponse{Started: true})
|
writeJSON(writer, http.StatusAccepted, startedResponse{Started: true})
|
||||||
}
|
}
|
||||||
|
|
||||||
type reorderRequest struct {
|
|
||||||
Direction string `json:"direction"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (server *Server) handleReorder(writer http.ResponseWriter, request *http.Request) {
|
func (server *Server) handleReorder(writer http.ResponseWriter, request *http.Request) {
|
||||||
var body reorderRequest
|
var body reorderRequest
|
||||||
if err := decodeJSON(request, &body); err != nil {
|
if !decodeBody(writer, request, &body) {
|
||||||
writeError(writer, http.StatusBadRequest, "invalid request body")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := server.manager.Reorder(request.PathValue("id"), body.Direction); err != nil {
|
if err := server.manager.Reorder(request.PathValue("id"), body.Direction); err != nil {
|
||||||
writeFailure(writer, err)
|
writeFailure(writer, request, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(writer, http.StatusOK, startedResponse{Started: true})
|
writeJSON(writer, http.StatusOK, startedResponse{Started: true})
|
||||||
@@ -39,9 +38,9 @@ func (server *Server) handleHistory(writer http.ResponseWriter, _ *http.Request)
|
|||||||
writeJSON(writer, http.StatusOK, server.manager.History())
|
writeJSON(writer, http.StatusOK, server.manager.History())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (server *Server) handleClearHistory(writer http.ResponseWriter, _ *http.Request) {
|
func (server *Server) handleClearHistory(writer http.ResponseWriter, request *http.Request) {
|
||||||
if err := server.manager.ClearHistory(); err != nil {
|
if err := server.manager.ClearHistory(); err != nil {
|
||||||
writeFailure(writer, err)
|
writeFailure(writer, request, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writer.WriteHeader(http.StatusNoContent)
|
writer.WriteHeader(http.StatusNoContent)
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ func (server *Server) handleLibraries(writer http.ResponseWriter, _ *http.Reques
|
|||||||
writeJSON(writer, http.StatusOK, server.manager.Destinations())
|
writeJSON(writer, http.StatusOK, server.manager.Destinations())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (server *Server) handleDisk(writer http.ResponseWriter, _ *http.Request) {
|
func (server *Server) handleDisk(writer http.ResponseWriter, request *http.Request) {
|
||||||
available, err := server.manager.AvailableBytes()
|
available, err := server.manager.AvailableBytes()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeFailure(writer, err)
|
writeFailure(writer, request, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(writer, http.StatusOK, diskResponse{AvailableBytes: available, ReservedBytes: server.manager.Preferences().MinFreeBytes()})
|
writeJSON(writer, http.StatusOK, diskResponse{AvailableBytes: available, ReservedBytes: server.manager.Preferences().MinFreeBytes()})
|
||||||
@@ -38,13 +38,12 @@ func (server *Server) handleListJobs(writer http.ResponseWriter, _ *http.Request
|
|||||||
|
|
||||||
func (server *Server) handleCreateJob(writer http.ResponseWriter, request *http.Request) {
|
func (server *Server) handleCreateJob(writer http.ResponseWriter, request *http.Request) {
|
||||||
var body jobs.CreateRequest
|
var body jobs.CreateRequest
|
||||||
if err := decodeJSON(request, &body); err != nil {
|
if !decodeBody(writer, request, &body) {
|
||||||
writeError(writer, http.StatusBadRequest, "invalid request body")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
snapshot, err := server.manager.Create(body)
|
snapshot, err := server.manager.Create(body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeFailure(writer, err)
|
writeFailure(writer, request, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(writer, http.StatusCreated, snapshot)
|
writeJSON(writer, http.StatusCreated, snapshot)
|
||||||
@@ -53,7 +52,7 @@ func (server *Server) handleCreateJob(writer http.ResponseWriter, request *http.
|
|||||||
func (server *Server) handleJobFiles(writer http.ResponseWriter, request *http.Request) {
|
func (server *Server) handleJobFiles(writer http.ResponseWriter, request *http.Request) {
|
||||||
tree, err := server.manager.Files(request.PathValue("id"))
|
tree, err := server.manager.Files(request.PathValue("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeFailure(writer, err)
|
writeFailure(writer, request, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(writer, http.StatusOK, tree)
|
writeJSON(writer, http.StatusOK, tree)
|
||||||
@@ -61,12 +60,11 @@ func (server *Server) handleJobFiles(writer http.ResponseWriter, request *http.R
|
|||||||
|
|
||||||
func (server *Server) handleExtract(writer http.ResponseWriter, request *http.Request) {
|
func (server *Server) handleExtract(writer http.ResponseWriter, request *http.Request) {
|
||||||
var body extractRequest
|
var body extractRequest
|
||||||
if err := decodeJSON(request, &body); err != nil {
|
if !decodeBody(writer, request, &body) {
|
||||||
writeError(writer, http.StatusBadRequest, "invalid request body")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := server.manager.Extract(request.PathValue("id"), body.Path); err != nil {
|
if err := server.manager.Extract(request.PathValue("id"), body.Path); err != nil {
|
||||||
writeFailure(writer, err)
|
writeFailure(writer, request, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(writer, http.StatusAccepted, startedResponse{Started: true})
|
writeJSON(writer, http.StatusAccepted, startedResponse{Started: true})
|
||||||
@@ -74,12 +72,11 @@ func (server *Server) handleExtract(writer http.ResponseWriter, request *http.Re
|
|||||||
|
|
||||||
func (server *Server) handleMove(writer http.ResponseWriter, request *http.Request) {
|
func (server *Server) handleMove(writer http.ResponseWriter, request *http.Request) {
|
||||||
var body jobs.MoveRequest
|
var body jobs.MoveRequest
|
||||||
if err := decodeJSON(request, &body); err != nil {
|
if !decodeBody(writer, request, &body) {
|
||||||
writeError(writer, http.StatusBadRequest, "invalid request body")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := server.manager.Move(request.PathValue("id"), body); err != nil {
|
if err := server.manager.Move(request.PathValue("id"), body); err != nil {
|
||||||
writeFailure(writer, err)
|
writeFailure(writer, request, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(writer, http.StatusAccepted, startedResponse{Started: true})
|
writeJSON(writer, http.StatusAccepted, startedResponse{Started: true})
|
||||||
@@ -87,7 +84,7 @@ func (server *Server) handleMove(writer http.ResponseWriter, request *http.Reque
|
|||||||
|
|
||||||
func (server *Server) handleDeleteJob(writer http.ResponseWriter, request *http.Request) {
|
func (server *Server) handleDeleteJob(writer http.ResponseWriter, request *http.Request) {
|
||||||
if err := server.manager.Delete(request.PathValue("id")); err != nil {
|
if err := server.manager.Delete(request.PathValue("id")); err != nil {
|
||||||
writeFailure(writer, err)
|
writeFailure(writer, request, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writer.WriteHeader(http.StatusNoContent)
|
writer.WriteHeader(http.StatusNoContent)
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"runtime/debug"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type contextKey int
|
||||||
|
|
||||||
|
const requestIDKey contextKey = iota
|
||||||
|
|
||||||
|
type statusRecorder struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
status int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (recorder *statusRecorder) WriteHeader(status int) {
|
||||||
|
if recorder.status == 0 {
|
||||||
|
recorder.status = status
|
||||||
|
}
|
||||||
|
recorder.ResponseWriter.WriteHeader(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (recorder *statusRecorder) Write(data []byte) (int, error) {
|
||||||
|
if recorder.status == 0 {
|
||||||
|
recorder.status = http.StatusOK
|
||||||
|
}
|
||||||
|
return recorder.ResponseWriter.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (recorder *statusRecorder) Unwrap() http.ResponseWriter {
|
||||||
|
return recorder.ResponseWriter
|
||||||
|
}
|
||||||
|
|
||||||
|
func (server *Server) observe(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
started := time.Now()
|
||||||
|
requestID := newRequestID()
|
||||||
|
writer.Header().Set("X-Request-ID", requestID)
|
||||||
|
request = request.WithContext(context.WithValue(request.Context(), requestIDKey, requestID))
|
||||||
|
recorder := &statusRecorder{ResponseWriter: writer}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if recovered := recover(); recovered != nil {
|
||||||
|
if recovered == http.ErrAbortHandler {
|
||||||
|
panic(recovered)
|
||||||
|
}
|
||||||
|
slog.Error("request handler crashed",
|
||||||
|
"request_id", requestID,
|
||||||
|
"method", request.Method,
|
||||||
|
"path", request.URL.Path,
|
||||||
|
"panic", recovered,
|
||||||
|
"stack", string(debug.Stack()),
|
||||||
|
)
|
||||||
|
if recorder.status == 0 {
|
||||||
|
writeError(recorder, http.StatusInternalServerError, internalErrorMessage(requestID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
server.logRequest(request, recorder.status, time.Since(started))
|
||||||
|
}()
|
||||||
|
|
||||||
|
next.ServeHTTP(recorder, request)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (server *Server) logRequest(request *http.Request, status int, duration time.Duration) {
|
||||||
|
if status == 0 {
|
||||||
|
status = http.StatusOK
|
||||||
|
}
|
||||||
|
slog.Log(request.Context(), requestLogLevel(request.Method, status), "request",
|
||||||
|
"request_id", requestIDFrom(request),
|
||||||
|
"method", request.Method,
|
||||||
|
"path", request.URL.Path,
|
||||||
|
"status", status,
|
||||||
|
"duration_ms", duration.Milliseconds(),
|
||||||
|
"client", server.clientAddress(request),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestLogLevel(method string, status int) slog.Level {
|
||||||
|
switch {
|
||||||
|
case status >= http.StatusInternalServerError:
|
||||||
|
return slog.LevelError
|
||||||
|
case status >= http.StatusBadRequest && status != http.StatusUnauthorized:
|
||||||
|
return slog.LevelWarn
|
||||||
|
case method != http.MethodGet && method != http.MethodHead:
|
||||||
|
return slog.LevelInfo
|
||||||
|
default:
|
||||||
|
return slog.LevelDebug
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestIDFrom(request *http.Request) string {
|
||||||
|
if requestID, ok := request.Context().Value(requestIDKey).(string); ok {
|
||||||
|
return requestID
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRequestID() string {
|
||||||
|
buffer := make([]byte, 6)
|
||||||
|
if _, err := rand.Read(buffer); err != nil {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func internalErrorMessage(requestID string) string {
|
||||||
|
return "Something went wrong on the server (reference " + requestID + "). The server log has the details."
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package server
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"ddc/internal/diskspace"
|
"ddc/internal/diskspace"
|
||||||
@@ -11,24 +12,55 @@ import (
|
|||||||
"ddc/internal/preferences"
|
"ddc/internal/preferences"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type errorResponse struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
RequestID string `json:"requestId,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
func writeJSON(writer http.ResponseWriter, status int, payload any) {
|
func writeJSON(writer http.ResponseWriter, status int, payload any) {
|
||||||
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
|
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
writer.WriteHeader(status)
|
writer.WriteHeader(status)
|
||||||
json.NewEncoder(writer).Encode(payload)
|
if err := json.NewEncoder(writer).Encode(payload); err != nil {
|
||||||
|
slog.Debug("could not write response", "error", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeError(writer http.ResponseWriter, status int, message string) {
|
func writeError(writer http.ResponseWriter, status int, message string) {
|
||||||
writeJSON(writer, status, map[string]string{"error": message})
|
writeJSON(writer, status, errorResponse{Error: message, RequestID: writer.Header().Get("X-Request-ID")})
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeFailure(writer http.ResponseWriter, err error) {
|
func writeFailure(writer http.ResponseWriter, request *http.Request, err error) {
|
||||||
writeError(writer, statusFor(err), err.Error())
|
status := statusFor(err)
|
||||||
|
if status != http.StatusInternalServerError {
|
||||||
|
writeError(writer, status, err.Error())
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeJSON(request *http.Request, target any) error {
|
requestID := requestIDFrom(request)
|
||||||
|
slog.Error("request failed",
|
||||||
|
"request_id", requestID,
|
||||||
|
"method", request.Method,
|
||||||
|
"path", request.URL.Path,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
writeError(writer, status, internalErrorMessage(requestID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeBody(writer http.ResponseWriter, request *http.Request, target any) bool {
|
||||||
decoder := json.NewDecoder(request.Body)
|
decoder := json.NewDecoder(request.Body)
|
||||||
decoder.DisallowUnknownFields()
|
decoder.DisallowUnknownFields()
|
||||||
return decoder.Decode(target)
|
err := decoder.Decode(target)
|
||||||
|
if err == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
var tooLarge *http.MaxBytesError
|
||||||
|
if errors.As(err, &tooLarge) {
|
||||||
|
writeError(writer, http.StatusRequestEntityTooLarge, "request body is too large")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
writeError(writer, http.StatusBadRequest, "invalid request body: "+err.Error())
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func statusFor(err error) int {
|
func statusFor(err error) int {
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ func (server *Server) routes() http.Handler {
|
|||||||
root.Handle("/api/", server.requireSession(api))
|
root.Handle("/api/", server.requireSession(api))
|
||||||
root.Handle("/", staticHandler())
|
root.Handle("/", staticHandler())
|
||||||
|
|
||||||
return securityHeaders(protectStateChanges(root))
|
return server.observe(securityHeaders(protectStateChanges(root)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func staticHandler() http.Handler {
|
func staticHandler() http.Handler {
|
||||||
@@ -69,6 +69,7 @@ func staticHandler() http.Handler {
|
|||||||
http.NotFound(writer, request)
|
http.NotFound(writer, request)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
writer.Header().Set("Cache-Control", "no-cache")
|
||||||
fileServer.ServeHTTP(writer, request)
|
fileServer.ServeHTTP(writer, request)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"ddc/internal/preferences"
|
"ddc/internal/preferences"
|
||||||
@@ -18,14 +19,14 @@ func (server *Server) handleGetSettings(writer http.ResponseWriter, _ *http.Requ
|
|||||||
|
|
||||||
func (server *Server) handleUpdateSettings(writer http.ResponseWriter, request *http.Request) {
|
func (server *Server) handleUpdateSettings(writer http.ResponseWriter, request *http.Request) {
|
||||||
var body preferences.Preferences
|
var body preferences.Preferences
|
||||||
if err := decodeJSON(request, &body); err != nil {
|
if !decodeBody(writer, request, &body) {
|
||||||
writeError(writer, http.StatusBadRequest, "invalid request body")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := server.manager.UpdatePreferences(body); err != nil {
|
if err := server.manager.UpdatePreferences(body); err != nil {
|
||||||
writeFailure(writer, err)
|
writeFailure(writer, request, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
slog.Info("settings updated", "request_id", requestIDFrom(request))
|
||||||
writeJSON(writer, http.StatusOK, server.settingsSnapshot())
|
writeJSON(writer, http.StatusOK, server.settingsSnapshot())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"log"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
@@ -15,6 +16,7 @@ import (
|
|||||||
"ddc/internal/config"
|
"ddc/internal/config"
|
||||||
"ddc/internal/history"
|
"ddc/internal/history"
|
||||||
"ddc/internal/jobs"
|
"ddc/internal/jobs"
|
||||||
|
"ddc/internal/logging"
|
||||||
"ddc/internal/preferences"
|
"ddc/internal/preferences"
|
||||||
"ddc/internal/server"
|
"ddc/internal/server"
|
||||||
)
|
)
|
||||||
@@ -22,45 +24,41 @@ import (
|
|||||||
const (
|
const (
|
||||||
settingsFileName = ".settings.json"
|
settingsFileName = ".settings.json"
|
||||||
historyFileName = ".history.json"
|
historyFileName = ".history.json"
|
||||||
|
shutdownTimeout = 10 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
settings, err := config.Load()
|
settings, err := config.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("configuration error: %v", err)
|
logging.Fatal("configuration error", err)
|
||||||
|
}
|
||||||
|
if err := logging.Setup(settings.LogLevel, settings.LogFormat); err != nil {
|
||||||
|
logging.Fatal("logging configuration error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.MkdirAll(settings.DownloadDir, 0o755); err != nil {
|
if err := prepareDownloadDir(&settings); err != nil {
|
||||||
log.Fatalf("download directory error: %v", err)
|
logging.Fatal("download directory error", err, "path", settings.DownloadDir)
|
||||||
}
|
|
||||||
resolvedDownloadDir, err := filepath.EvalSymlinks(settings.DownloadDir)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("download directory error: %v", err)
|
|
||||||
}
|
|
||||||
settings.DownloadDir = resolvedDownloadDir
|
|
||||||
if err := ensureWritable(settings.DownloadDir); err != nil {
|
|
||||||
log.Fatalf("download directory %s is not writable by uid %d gid %d: %v", settings.DownloadDir, os.Getuid(), os.Getgid(), err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
store, err := preferences.Open(filepath.Join(settings.DownloadDir, settingsFileName), settings.Defaults)
|
store, err := preferences.Open(filepath.Join(settings.DownloadDir, settingsFileName), settings.Defaults)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("settings error: %v", err)
|
logging.Fatal("could not load settings", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
historyStore, err := history.Open(filepath.Join(settings.DownloadDir, historyFileName))
|
historyStore, err := history.Open(filepath.Join(settings.DownloadDir, historyFileName))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("history error: %v", err)
|
logging.Fatal("could not load history", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
manager := jobs.NewManager(settings, store, historyStore)
|
manager := jobs.NewManager(settings, store, historyStore)
|
||||||
if err := manager.RestoreExisting(); err != nil {
|
if err := manager.RestoreExisting(); err != nil {
|
||||||
log.Printf("could not restore existing downloads: %v", err)
|
slog.Error("could not restore existing downloads", "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
sessions := auth.NewSessionStore(settings.SessionTTL)
|
sessions := auth.NewSessionStore(settings.SessionTTL)
|
||||||
httpServer := &http.Server{
|
httpServer := &http.Server{
|
||||||
Addr: settings.ListenAddr,
|
Addr: settings.ListenAddr,
|
||||||
Handler: server.New(settings, sessions, manager),
|
Handler: server.New(settings, sessions, manager),
|
||||||
|
ErrorLog: slog.NewLogLogger(slog.Default().Handler(), slog.LevelWarn),
|
||||||
ReadHeaderTimeout: 10 * time.Second,
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
ReadTimeout: 30 * time.Second,
|
ReadTimeout: 30 * time.Second,
|
||||||
WriteTimeout: 60 * time.Second,
|
WriteTimeout: 60 * time.Second,
|
||||||
@@ -71,20 +69,41 @@ func main() {
|
|||||||
defer stopListening()
|
defer stopListening()
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
log.Printf("listening on %s", settings.ListenAddr)
|
slog.Info("server started",
|
||||||
|
"listen", settings.ListenAddr,
|
||||||
|
"download_dir", settings.DownloadDir,
|
||||||
|
"library_roots", settings.LibraryRoots,
|
||||||
|
"log_level", settings.LogLevel,
|
||||||
|
)
|
||||||
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
log.Fatalf("server error: %v", err)
|
logging.Fatal("server stopped unexpectedly", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
<-shutdownSignal.Done()
|
<-shutdownSignal.Done()
|
||||||
log.Printf("shutting down")
|
slog.Info("shutting down, saving download progress")
|
||||||
shutdownContext, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
shutdownContext, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
manager.Shutdown(shutdownContext)
|
manager.Shutdown(shutdownContext)
|
||||||
if err := httpServer.Shutdown(shutdownContext); err != nil {
|
if err := httpServer.Shutdown(shutdownContext); err != nil {
|
||||||
log.Printf("shutdown error: %v", err)
|
slog.Error("shutdown did not finish cleanly", "error", err)
|
||||||
}
|
}
|
||||||
|
slog.Info("stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareDownloadDir(settings *config.Settings) error {
|
||||||
|
if err := os.MkdirAll(settings.DownloadDir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resolved, err := filepath.EvalSymlinks(settings.DownloadDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
settings.DownloadDir = resolved
|
||||||
|
if err := ensureWritable(resolved); err != nil {
|
||||||
|
return fmt.Errorf("not writable by uid %d gid %d: %w", os.Getuid(), os.Getgid(), err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ensureWritable(directory string) error {
|
func ensureWritable(directory string) error {
|
||||||
|
|||||||
+58
-23
@@ -42,6 +42,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<div id="connection-banner" class="banner" role="status" hidden></div>
|
||||||
|
|
||||||
<main class="content">
|
<main class="content">
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h2>New download</h2>
|
<h2>New download</h2>
|
||||||
@@ -127,7 +129,7 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<dialog id="settings-dialog" class="sheet">
|
<dialog id="settings-dialog" class="sheet sheet-wide">
|
||||||
<form id="settings-form" class="sheet-form">
|
<form id="settings-form" class="sheet-form">
|
||||||
<header class="sheet-header">
|
<header class="sheet-header">
|
||||||
<h2>Settings</h2>
|
<h2>Settings</h2>
|
||||||
@@ -135,30 +137,50 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="sheet-body">
|
<div class="sheet-body">
|
||||||
<fieldset class="settings-group">
|
<section class="settings-section">
|
||||||
<legend>Downloads</legend>
|
<div class="settings-intro">
|
||||||
|
<h3>Downloads</h3>
|
||||||
|
<p class="muted">How many files run at once and how fast each one downloads.</p>
|
||||||
|
</div>
|
||||||
<div class="settings-grid">
|
<div class="settings-grid">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Parallel downloads</span>
|
<span>Parallel downloads</span>
|
||||||
<input id="setting-concurrent" type="number" inputmode="numeric" min="1" max="10" required>
|
<input id="setting-concurrent" type="number" inputmode="numeric" min="1" max="10" required>
|
||||||
|
<small class="field-hint">Downloads running at the same time (1 to 10).</small>
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Connections per download</span>
|
<span>Connections per download</span>
|
||||||
<input id="setting-connections" type="number" inputmode="numeric" min="1" max="16" required>
|
<input id="setting-connections" type="number" inputmode="numeric" min="1" max="16" required>
|
||||||
|
<small class="field-hint">Parts fetched at once when the server allows it (1 to 16).</small>
|
||||||
</label>
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-section">
|
||||||
|
<div class="settings-intro">
|
||||||
|
<h3>Safety</h3>
|
||||||
|
<p class="muted">Limits that protect the server.</p>
|
||||||
|
</div>
|
||||||
|
<div class="settings-grid">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Keep free on disk (GB)</span>
|
<span>Keep free on disk (GB)</span>
|
||||||
<input id="setting-min-free" type="number" inputmode="numeric" min="0" required>
|
<input id="setting-min-free" type="number" inputmode="numeric" min="0" required>
|
||||||
|
<small class="field-hint">Downloads stop before free space drops below this.</small>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Allowed ports</span>
|
<span>Allowed ports</span>
|
||||||
<input id="setting-ports" placeholder="80, 443 or * for any" autocomplete="off">
|
<input id="setting-ports" placeholder="80, 443" autocomplete="off">
|
||||||
|
<small class="field-hint">Comma separated, or * for any port.</small>
|
||||||
</label>
|
</label>
|
||||||
</fieldset>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<fieldset class="settings-group">
|
<section class="settings-section">
|
||||||
<legend>Defaults</legend>
|
<div class="settings-intro">
|
||||||
|
<h3>After download</h3>
|
||||||
|
<p class="muted">Default choices for new downloads and moves.</p>
|
||||||
|
</div>
|
||||||
|
<div class="settings-stack">
|
||||||
<label class="checkbox">
|
<label class="checkbox">
|
||||||
<input id="setting-auto-extract" type="checkbox">
|
<input id="setting-auto-extract" type="checkbox">
|
||||||
<span>Extract archives automatically</span>
|
<span>Extract archives automatically</span>
|
||||||
@@ -167,20 +189,29 @@
|
|||||||
<input id="setting-cleanup" type="checkbox">
|
<input id="setting-cleanup" type="checkbox">
|
||||||
<span>Delete temporary files after a move</span>
|
<span>Delete temporary files after a move</span>
|
||||||
</label>
|
</label>
|
||||||
</fieldset>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<fieldset class="settings-group">
|
<section class="settings-section">
|
||||||
<legend>Download paths</legend>
|
<div class="settings-intro">
|
||||||
|
<h3>Download paths</h3>
|
||||||
|
<p class="muted">Folders listed under "Saved paths" when you pick where a download goes.</p>
|
||||||
|
</div>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>One folder per line, inside a destination root. Missing folders are created on first use.</span>
|
<span>Folders, one per line</span>
|
||||||
<textarea id="setting-download-paths" rows="4" placeholder="/media/blob/TeaNet/Plex/Movies/Kids" autocomplete="off" autocapitalize="off" spellcheck="false"></textarea>
|
<textarea id="setting-download-paths" rows="5" placeholder="/media/blob/TeaNet/Plex/Movies/Kids" autocomplete="off" autocapitalize="off" spellcheck="false"></textarea>
|
||||||
|
<small class="field-hint">Must be inside a destination root. Missing folders are created on first use.</small>
|
||||||
</label>
|
</label>
|
||||||
</fieldset>
|
</section>
|
||||||
|
|
||||||
<fieldset class="settings-group">
|
<section class="settings-section">
|
||||||
<legend>Appearance</legend>
|
<div class="settings-intro">
|
||||||
<label class="field">
|
<h3>This browser</h3>
|
||||||
<span>Theme (this browser)</span>
|
<p class="muted">Saved only on this device.</p>
|
||||||
|
</div>
|
||||||
|
<div class="settings-stack">
|
||||||
|
<label class="field settings-narrow">
|
||||||
|
<span>Theme</span>
|
||||||
<select id="setting-theme">
|
<select id="setting-theme">
|
||||||
<option value="system">System</option>
|
<option value="system">System</option>
|
||||||
<option value="light">Light</option>
|
<option value="light">Light</option>
|
||||||
@@ -189,19 +220,23 @@
|
|||||||
</label>
|
</label>
|
||||||
<label class="checkbox">
|
<label class="checkbox">
|
||||||
<input id="setting-notifications" type="checkbox">
|
<input id="setting-notifications" type="checkbox">
|
||||||
<span>Notify when downloads finish (this browser)</span>
|
<span>Notify when downloads finish</span>
|
||||||
</label>
|
</label>
|
||||||
</fieldset>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<fieldset class="settings-group">
|
<section class="settings-section">
|
||||||
<legend>Server</legend>
|
<div class="settings-intro">
|
||||||
|
<h3>Server</h3>
|
||||||
|
<p class="muted">Set in the server environment, read only.</p>
|
||||||
|
</div>
|
||||||
<dl class="info-list">
|
<dl class="info-list">
|
||||||
<dt>Download folder</dt>
|
<dt>Download folder</dt>
|
||||||
<dd id="info-download-dir" class="mono"></dd>
|
<dd id="info-download-dir" class="mono"></dd>
|
||||||
<dt>Destination roots</dt>
|
<dt>Destination roots</dt>
|
||||||
<dd id="info-roots"></dd>
|
<dd id="info-roots"></dd>
|
||||||
</dl>
|
</dl>
|
||||||
</fieldset>
|
</section>
|
||||||
|
|
||||||
<p id="settings-error" class="error" role="alert"></p>
|
<p id="settings-error" class="error" role="alert"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+42
-5
@@ -1,8 +1,22 @@
|
|||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
constructor(status, message) {
|
constructor(status, message, requestId = "") {
|
||||||
super(message);
|
super(message);
|
||||||
this.status = status;
|
this.status = status;
|
||||||
|
this.requestId = requestId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get isNetworkError() {
|
||||||
|
return this.status === 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function describeError(error) {
|
||||||
|
const message = error && error.message ? error.message : "Something went wrong.";
|
||||||
|
const alreadyReferenced = message.includes("reference");
|
||||||
|
if (error instanceof ApiError && error.requestId && error.status >= 500 && !alreadyReferenced) {
|
||||||
|
return `${message} (reference ${error.requestId})`;
|
||||||
|
}
|
||||||
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
const unauthorizedListeners = new Set();
|
const unauthorizedListeners = new Set();
|
||||||
@@ -20,6 +34,22 @@ function parseBody(text) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fallbackMessage(status) {
|
||||||
|
if (status === 502 || status === 503 || status === 504) {
|
||||||
|
return `The server is not responding right now (${status}). Try again in a moment.`;
|
||||||
|
}
|
||||||
|
if (status === 413) return "The request was too large.";
|
||||||
|
return `Request failed (${status}).`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function send(path, options) {
|
||||||
|
try {
|
||||||
|
return await fetch(path, options);
|
||||||
|
} catch {
|
||||||
|
throw new ApiError(0, "Cannot reach the server. Check your connection.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function request(method, path, body) {
|
async function request(method, path, body) {
|
||||||
const options = { method, credentials: "same-origin", headers: {} };
|
const options = { method, credentials: "same-origin", headers: {} };
|
||||||
if (body !== undefined) {
|
if (body !== undefined) {
|
||||||
@@ -27,15 +57,22 @@ async function request(method, path, body) {
|
|||||||
options.body = JSON.stringify(body);
|
options.body = JSON.stringify(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(path, options);
|
const response = await send(path, options);
|
||||||
const data = parseBody(await response.text());
|
let text = "";
|
||||||
|
try {
|
||||||
|
text = await response.text();
|
||||||
|
} catch {
|
||||||
|
throw new ApiError(0, "The connection dropped while reading the server response.");
|
||||||
|
}
|
||||||
|
const data = parseBody(text);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
if (response.status === 401 && path !== "/api/login") {
|
if (response.status === 401 && path !== "/api/login") {
|
||||||
unauthorizedListeners.forEach((listener) => listener());
|
unauthorizedListeners.forEach((listener) => listener());
|
||||||
}
|
}
|
||||||
const message = data && data.error ? data.error : `Request failed (${response.status})`;
|
const message = data && data.error ? data.error : fallbackMessage(response.status);
|
||||||
throw new ApiError(response.status, message);
|
const requestId = (data && data.requestId) || response.headers.get("X-Request-ID") || "";
|
||||||
|
throw new ApiError(response.status, message, requestId);
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-10
@@ -1,4 +1,4 @@
|
|||||||
import { api, onUnauthorized } from "./api.js";
|
import { api, describeError, onUnauthorized } from "./api.js";
|
||||||
import { element } from "./dom.js";
|
import { element } from "./dom.js";
|
||||||
import { destinationSelect, setAutoExtractDefault, setupDownloadForm, updateSubfolderState } from "./download-form.js";
|
import { destinationSelect, setAutoExtractDefault, setupDownloadForm, updateSubfolderState } from "./download-form.js";
|
||||||
import { formatBytes } from "./format.js";
|
import { formatBytes } from "./format.js";
|
||||||
@@ -12,6 +12,7 @@ import { renderTree, topLevelSelection } from "./tree.js";
|
|||||||
|
|
||||||
const pollIntervalMs = 1500;
|
const pollIntervalMs = 1500;
|
||||||
const filterStorageKey = "ddc-job-filter";
|
const filterStorageKey = "ddc-job-filter";
|
||||||
|
const failuresBeforeBanner = 2;
|
||||||
|
|
||||||
const elements = {
|
const elements = {
|
||||||
loginView: document.getElementById("login-view"),
|
loginView: document.getElementById("login-view"),
|
||||||
@@ -21,6 +22,7 @@ const elements = {
|
|||||||
logoutButton: document.getElementById("logout-button"),
|
logoutButton: document.getElementById("logout-button"),
|
||||||
settingsButton: document.getElementById("settings-button"),
|
settingsButton: document.getElementById("settings-button"),
|
||||||
diskStatus: document.getElementById("disk-status"),
|
diskStatus: document.getElementById("disk-status"),
|
||||||
|
connectionBanner: document.getElementById("connection-banner"),
|
||||||
jobFilter: document.getElementById("job-filter"),
|
jobFilter: document.getElementById("job-filter"),
|
||||||
clearCompletedButton: document.getElementById("clear-completed-button"),
|
clearCompletedButton: document.getElementById("clear-completed-button"),
|
||||||
jobList: document.getElementById("job-list"),
|
jobList: document.getElementById("job-list"),
|
||||||
@@ -53,10 +55,12 @@ const state = {
|
|||||||
selection: new Set(),
|
selection: new Set(),
|
||||||
pollTimer: null,
|
pollTimer: null,
|
||||||
refreshing: false,
|
refreshing: false,
|
||||||
|
failedRefreshes: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
function showLogin() {
|
function showLogin() {
|
||||||
stopPolling();
|
stopPolling();
|
||||||
|
setConnectionProblem(null);
|
||||||
closeDetail();
|
closeDetail();
|
||||||
elements.appView.hidden = true;
|
elements.appView.hidden = true;
|
||||||
elements.loginView.hidden = false;
|
elements.loginView.hidden = false;
|
||||||
@@ -82,7 +86,7 @@ async function showSettings() {
|
|||||||
try {
|
try {
|
||||||
await openSettings();
|
await openSettings();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
window.alert(error.message);
|
window.alert(describeError(error));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,15 +131,31 @@ async function refresh() {
|
|||||||
updateSpeeds(state.jobs);
|
updateSpeeds(state.jobs);
|
||||||
handleTransitions(collectTransitions(state.jobs));
|
handleTransitions(collectTransitions(state.jobs));
|
||||||
elements.diskStatus.textContent = `${formatBytes(disk.availableBytes)} free`;
|
elements.diskStatus.textContent = `${formatBytes(disk.availableBytes)} free`;
|
||||||
|
setConnectionProblem(null);
|
||||||
renderJobList();
|
renderJobList();
|
||||||
await syncDetail();
|
await syncDetail();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.status !== 401) console.error(error);
|
if (error.status !== 401) {
|
||||||
|
console.error(error);
|
||||||
|
setConnectionProblem(error);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
state.refreshing = false;
|
state.refreshing = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setConnectionProblem(error) {
|
||||||
|
if (!error) {
|
||||||
|
state.failedRefreshes = 0;
|
||||||
|
elements.connectionBanner.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.failedRefreshes += 1;
|
||||||
|
if (state.failedRefreshes < failuresBeforeBanner) return;
|
||||||
|
elements.connectionBanner.textContent = `${describeError(error)} Retrying automatically.`;
|
||||||
|
elements.connectionBanner.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
function handleTransitions(transitions) {
|
function handleTransitions(transitions) {
|
||||||
notifyTransitions(transitions);
|
notifyTransitions(transitions);
|
||||||
if (transitions.some((transition) => transition.previous === "moving")) loadHistory();
|
if (transitions.some((transition) => transition.previous === "moving")) loadHistory();
|
||||||
@@ -166,7 +186,7 @@ async function runJobAction(action) {
|
|||||||
await action();
|
await action();
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
window.alert(error.message);
|
window.alert(describeError(error));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,7 +203,7 @@ function openDetail(job) {
|
|||||||
renderJobList();
|
renderJobList();
|
||||||
syncDetail();
|
syncDetail();
|
||||||
loadLibraries().catch((error) => {
|
loadLibraries().catch((error) => {
|
||||||
elements.actionError.textContent = error.message;
|
elements.actionError.textContent = describeError(error);
|
||||||
});
|
});
|
||||||
elements.detail.scrollIntoView({ behavior: "smooth", block: "start" });
|
elements.detail.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
}
|
}
|
||||||
@@ -230,7 +250,7 @@ async function loadTree(jobId) {
|
|||||||
});
|
});
|
||||||
updateSelectionSummary();
|
updateSelectionSummary();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
elements.actionError.textContent = error.message;
|
elements.actionError.textContent = describeError(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,7 +267,7 @@ async function extractArchive(path) {
|
|||||||
await api.extract(state.selectedJobId, path);
|
await api.extract(state.selectedJobId, path);
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
elements.actionError.textContent = error.message;
|
elements.actionError.textContent = describeError(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,7 +286,7 @@ async function moveSelection() {
|
|||||||
elements.subfolderInput.value = "";
|
elements.subfolderInput.value = "";
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
elements.actionError.textContent = error.message;
|
elements.actionError.textContent = describeError(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,7 +301,7 @@ async function deleteJob(job) {
|
|||||||
if (job.id === state.selectedJobId) closeDetail();
|
if (job.id === state.selectedJobId) closeDetail();
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
window.alert(error.message);
|
window.alert(describeError(error));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,7 +324,7 @@ async function clearHistory() {
|
|||||||
await api.clearHistory();
|
await api.clearHistory();
|
||||||
await loadHistory();
|
await loadHistory();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
window.alert(error.message);
|
window.alert(describeError(error));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,6 +356,7 @@ async function logout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
|
window.addEventListener("unhandledrejection", (event) => console.error("Unhandled error", event.reason));
|
||||||
onUnauthorized(showLogin);
|
onUnauthorized(showLogin);
|
||||||
setupLogin(elements.loginForm, elements.loginError, showApp);
|
setupLogin(elements.loginForm, elements.loginError, showApp);
|
||||||
setupDownloadForm(refresh);
|
setupDownloadForm(refresh);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { api } from "./api.js";
|
import { api, describeError } from "./api.js";
|
||||||
|
|
||||||
const fields = {
|
const fields = {
|
||||||
form: document.getElementById("download-form"),
|
form: document.getElementById("download-form"),
|
||||||
@@ -61,7 +61,7 @@ async function submitDownloads(event, onCreated) {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failedUrls.push(url);
|
failedUrls.push(url);
|
||||||
if (!firstError) firstError = error.message;
|
if (!firstError) firstError = describeError(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { api } from "./api.js";
|
import { api, describeError } from "./api.js";
|
||||||
import { element } from "./dom.js";
|
import { element } from "./dom.js";
|
||||||
import { notificationsEnabled, setNotificationsEnabled } from "./notify.js";
|
import { notificationsEnabled, setNotificationsEnabled } from "./notify.js";
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ async function saveSettings(event, onSaved) {
|
|||||||
fields.dialog.close();
|
fields.dialog.close();
|
||||||
onSaved(data.settings);
|
onSaved(data.settings);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fields.error.textContent = error.message;
|
fields.error.textContent = describeError(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+334
-209
@@ -1,69 +1,76 @@
|
|||||||
:root {
|
:root {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
--background: #f2f3f6;
|
--background: #eef0f3;
|
||||||
--surface: #ffffff;
|
--surface: #ffffff;
|
||||||
--surface-alt: #f7f8fa;
|
--surface-alt: #f5f6f8;
|
||||||
--surface-hover: #eef0f4;
|
--surface-hover: #e8ebf0;
|
||||||
--border: #dde0e6;
|
--border: #d8dce3;
|
||||||
--text: #191c22;
|
--text: #15181e;
|
||||||
--muted: #687080;
|
--muted: #626a77;
|
||||||
--accent: #3b6ef5;
|
--accent: #2f6bed;
|
||||||
--accent-hover: #2f5ddb;
|
--accent-hover: #2459cc;
|
||||||
--accent-soft: rgba(59, 110, 245, 0.12);
|
--accent-soft: #dde7fc;
|
||||||
--accent-text: #ffffff;
|
--accent-text: #ffffff;
|
||||||
--danger: #d03c32;
|
--danger: #c7372c;
|
||||||
--danger-soft: rgba(208, 60, 50, 0.1);
|
--danger-soft: #fae2df;
|
||||||
--success: #1f9d62;
|
--success: #17855a;
|
||||||
--success-soft: rgba(31, 157, 98, 0.12);
|
--success-soft: #d8f1e5;
|
||||||
--info: #3b6ef5;
|
--info: #2f6bed;
|
||||||
--info-soft: rgba(59, 110, 245, 0.12);
|
--info-soft: #dde7fc;
|
||||||
--shadow: 0 1px 2px rgba(16, 24, 40, 0.05), 0 2px 8px rgba(16, 24, 40, 0.04);
|
--warning: #a86400;
|
||||||
--radius: 12px;
|
--warning-soft: #fcebc9;
|
||||||
--radius-small: 8px;
|
--track: #e1e4ea;
|
||||||
|
--overlay: rgba(10, 12, 16, 0.6);
|
||||||
|
--radius: 10px;
|
||||||
|
--radius-small: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root:not([data-theme="light"]) {
|
:root:not([data-theme="light"]) {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
--background: #0f1115;
|
--background: #101216;
|
||||||
--surface: #171a20;
|
--surface: #171a1f;
|
||||||
--surface-alt: #1d2128;
|
--surface-alt: #1e2228;
|
||||||
--surface-hover: #242933;
|
--surface-hover: #262b33;
|
||||||
--border: #2a2f39;
|
--border: #2c313a;
|
||||||
--text: #e7e9ee;
|
--text: #e6e8ec;
|
||||||
--muted: #949cab;
|
--muted: #949ba7;
|
||||||
--accent: #5b87ff;
|
--accent: #4f7cff;
|
||||||
--accent-hover: #7298ff;
|
--accent-hover: #6a90ff;
|
||||||
--accent-soft: rgba(91, 135, 255, 0.16);
|
--accent-soft: #1f2a45;
|
||||||
--danger: #f06a5f;
|
--danger: #ef6a5e;
|
||||||
--danger-soft: rgba(240, 106, 95, 0.14);
|
--danger-soft: #3a201e;
|
||||||
--success: #3fc486;
|
--success: #3bbf80;
|
||||||
--success-soft: rgba(63, 196, 134, 0.14);
|
--success-soft: #17332a;
|
||||||
--info: #5b87ff;
|
--info: #4f7cff;
|
||||||
--info-soft: rgba(91, 135, 255, 0.16);
|
--info-soft: #1f2a45;
|
||||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
--warning: #e2a63c;
|
||||||
|
--warning-soft: #3a2e17;
|
||||||
|
--track: #262b33;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="dark"] {
|
:root[data-theme="dark"] {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
--background: #0f1115;
|
--background: #101216;
|
||||||
--surface: #171a20;
|
--surface: #171a1f;
|
||||||
--surface-alt: #1d2128;
|
--surface-alt: #1e2228;
|
||||||
--surface-hover: #242933;
|
--surface-hover: #262b33;
|
||||||
--border: #2a2f39;
|
--border: #2c313a;
|
||||||
--text: #e7e9ee;
|
--text: #e6e8ec;
|
||||||
--muted: #949cab;
|
--muted: #949ba7;
|
||||||
--accent: #5b87ff;
|
--accent: #4f7cff;
|
||||||
--accent-hover: #7298ff;
|
--accent-hover: #6a90ff;
|
||||||
--accent-soft: rgba(91, 135, 255, 0.16);
|
--accent-soft: #1f2a45;
|
||||||
--danger: #f06a5f;
|
--danger: #ef6a5e;
|
||||||
--danger-soft: rgba(240, 106, 95, 0.14);
|
--danger-soft: #3a201e;
|
||||||
--success: #3fc486;
|
--success: #3bbf80;
|
||||||
--success-soft: rgba(63, 196, 134, 0.14);
|
--success-soft: #17332a;
|
||||||
--info: #5b87ff;
|
--info: #4f7cff;
|
||||||
--info-soft: rgba(91, 135, 255, 0.16);
|
--info-soft: #1f2a45;
|
||||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
--warning: #e2a63c;
|
||||||
|
--warning-soft: #3a2e17;
|
||||||
|
--track: #262b33;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -89,7 +96,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
font-size: 1.05rem;
|
font-size: 1rem;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
margin: 0 0 14px;
|
margin: 0 0 14px;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
@@ -107,16 +114,29 @@ input:not([type="checkbox"]),
|
|||||||
select,
|
select,
|
||||||
textarea {
|
textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 42px;
|
min-height: 40px;
|
||||||
padding: 9px 12px;
|
padding: 8px 12px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-small);
|
border-radius: var(--radius-small);
|
||||||
background: var(--surface-alt);
|
background: var(--surface-alt);
|
||||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
color: var(--text);
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
textarea {
|
textarea {
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
input::placeholder,
|
||||||
|
textarea::placeholder {
|
||||||
|
color: var(--muted);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:not([type="checkbox"]):disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
input:not([type="checkbox"]):focus,
|
input:not([type="checkbox"]):focus,
|
||||||
@@ -124,7 +144,6 @@ select:focus,
|
|||||||
textarea:focus {
|
textarea:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="checkbox"] {
|
input[type="checkbox"] {
|
||||||
@@ -144,12 +163,12 @@ button {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
transition: background 0.15s ease, border-color 0.15s ease, opacity 0.15s ease;
|
transition: background 0.15s ease, border-color 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
button:focus-visible {
|
button:focus-visible {
|
||||||
outline: none;
|
outline: 2px solid var(--accent);
|
||||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
button:disabled {
|
button:disabled {
|
||||||
@@ -168,22 +187,22 @@ button.primary:hover:not(:disabled) {
|
|||||||
|
|
||||||
button.ghost,
|
button.ghost,
|
||||||
button.secondary {
|
button.secondary {
|
||||||
background: transparent;
|
background: var(--surface);
|
||||||
border-color: var(--border);
|
border-color: var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
button.ghost:hover,
|
button.ghost:hover:not(:disabled),
|
||||||
button.secondary:hover {
|
button.secondary:hover:not(:disabled) {
|
||||||
background: var(--surface-hover);
|
background: var(--surface-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
button.danger {
|
button.danger {
|
||||||
background: transparent;
|
background: var(--surface);
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
border-color: var(--danger);
|
border-color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
button.danger:hover {
|
button.danger:hover:not(:disabled) {
|
||||||
background: var(--danger-soft);
|
background: var(--danger-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,7 +244,6 @@ button.block {
|
|||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
box-shadow: var(--shadow);
|
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
@@ -234,29 +252,31 @@ button.block {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
font-size: 0.85rem;
|
min-width: 0;
|
||||||
font-weight: 550;
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.field input,
|
.field input,
|
||||||
.field select {
|
.field select,
|
||||||
|
.field textarea {
|
||||||
|
font-size: 0.95rem;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: var(--text);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox {
|
.checkbox {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin: 14px 0;
|
margin: 14px 0 0;
|
||||||
min-height: 32px;
|
min-height: 32px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-mark {
|
.brand-mark {
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
font-size: 1.15rem;
|
font-size: 1.1rem;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
@@ -309,9 +329,7 @@ button.block {
|
|||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
background: color-mix(in srgb, var(--surface) 88%, transparent);
|
background: var(--surface);
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
-webkit-backdrop-filter: blur(10px);
|
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
padding-top: env(safe-area-inset-top);
|
padding-top: env(safe-area-inset-top);
|
||||||
}
|
}
|
||||||
@@ -331,6 +349,16 @@ button.block {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.banner {
|
||||||
|
background: var(--warning-soft);
|
||||||
|
color: var(--warning);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 10px 16px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
max-width: 980px;
|
max-width: 980px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@@ -339,13 +367,56 @@ button.block {
|
|||||||
|
|
||||||
.download-form {
|
.download-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.download-form textarea {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 76px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-form button {
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-options {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-toolbar h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-toolbar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-toolbar-actions select {
|
||||||
|
width: auto;
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
padding: 24px 8px;
|
padding: 28px 8px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
border: 1px dashed var(--border);
|
border: 1px dashed var(--border);
|
||||||
border-radius: var(--radius-small);
|
border-radius: var(--radius-small);
|
||||||
@@ -370,7 +441,6 @@ button.block {
|
|||||||
|
|
||||||
.job.selected {
|
.job.selected {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.job-header {
|
.job-header {
|
||||||
@@ -413,7 +483,7 @@ button.block {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
padding: 3px 9px;
|
padding: 3px 9px;
|
||||||
border-radius: 999px;
|
border-radius: 4px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
@@ -438,10 +508,15 @@ button.block {
|
|||||||
background: var(--info-soft);
|
background: var(--info-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.badge-paused {
|
||||||
|
color: var(--warning);
|
||||||
|
background: var(--warning-soft);
|
||||||
|
}
|
||||||
|
|
||||||
.progress {
|
.progress {
|
||||||
height: 8px;
|
height: 6px;
|
||||||
background: var(--border);
|
background: var(--track);
|
||||||
border-radius: 999px;
|
border-radius: 3px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -449,7 +524,6 @@ button.block {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
width: 0;
|
width: 0;
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
border-radius: 999px;
|
|
||||||
transition: width 0.4s ease;
|
transition: width 0.4s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -513,7 +587,7 @@ button.block {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
min-height: 40px;
|
min-height: 40px;
|
||||||
border-radius: 6px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-row:hover {
|
.tree-row:hover {
|
||||||
@@ -546,11 +620,97 @@ button.block {
|
|||||||
|
|
||||||
.move-controls {
|
.move-controls {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#job-detail .checkbox {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-card {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-card summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 650;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-card summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-card summary::before {
|
||||||
|
content: "+";
|
||||||
|
width: 18px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-card[open] summary::before {
|
||||||
|
content: "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-card[open] summary {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-card > :not(summary) {
|
||||||
|
margin-left: 20px;
|
||||||
|
margin-right: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-card > :last-child {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin-top: 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
max-height: 420px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-list:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-small);
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--surface-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-name {
|
||||||
|
font-weight: 600;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-meta {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin: 4px 0 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-card .empty-state {
|
||||||
|
margin-top: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.sheet {
|
.sheet {
|
||||||
width: min(560px, calc(100% - 32px));
|
width: min(560px, calc(100% - 32px));
|
||||||
max-height: calc(100dvh - 48px);
|
max-height: calc(100dvh - 48px);
|
||||||
@@ -559,11 +719,10 @@ button.block {
|
|||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.25);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sheet::backdrop {
|
.sheet::backdrop {
|
||||||
background: rgba(10, 12, 16, 0.55);
|
background: var(--overlay);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sheet-form {
|
.sheet-form {
|
||||||
@@ -595,38 +754,62 @@ button.block {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sheet-body {
|
.sheet-body {
|
||||||
padding: 4px 20px 16px;
|
padding: 0 24px 8px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-group {
|
.sheet-wide {
|
||||||
border: none;
|
width: min(860px, calc(100% - 32px));
|
||||||
padding: 0;
|
}
|
||||||
margin: 16px 0 0;
|
|
||||||
|
.settings-section {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 220px minmax(0, 1fr);
|
||||||
|
gap: 24px;
|
||||||
|
padding: 20px 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section:last-of-type {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-intro h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-intro p {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-stack {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-group legend {
|
.settings-stack .checkbox {
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
color: var(--muted);
|
|
||||||
padding: 0;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-group .checkbox {
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-narrow {
|
||||||
|
max-width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-grid {
|
.settings-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 12px;
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-hint {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-list {
|
.info-list {
|
||||||
@@ -636,15 +819,29 @@ button.block {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.info-list dt {
|
.info-list dt {
|
||||||
font-size: 0.85rem;
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
margin-top: 6px;
|
}
|
||||||
|
|
||||||
|
.info-list dd + dt {
|
||||||
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-list dd {
|
.info-list dd {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.download-options {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-options .field:first-child {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
body {
|
body {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
@@ -652,7 +849,19 @@ button.block {
|
|||||||
|
|
||||||
.card {
|
.card {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
border-radius: 10px;
|
}
|
||||||
|
|
||||||
|
.history-card {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-card summary {
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-card > :not(summary) {
|
||||||
|
margin-left: 16px;
|
||||||
|
margin-right: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
@@ -674,17 +883,32 @@ button.block {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.download-form textarea {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.download-form button,
|
.download-form button,
|
||||||
button.block-mobile {
|
button.block-mobile {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 46px;
|
min-height: 46px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.download-options,
|
||||||
.move-controls,
|
.move-controls,
|
||||||
.settings-grid {
|
.settings-grid,
|
||||||
|
.settings-section {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-section {
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-narrow {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
.job-actions button {
|
.job-actions button {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 40px;
|
min-height: 40px;
|
||||||
@@ -708,7 +932,7 @@ button.block {
|
|||||||
max-width: none;
|
max-width: none;
|
||||||
max-height: 92dvh;
|
max-height: 92dvh;
|
||||||
margin: auto 0 0;
|
margin: auto 0 0;
|
||||||
border-radius: 16px 16px 0 0;
|
border-radius: 12px 12px 0 0;
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -731,102 +955,3 @@ button.block {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-paused {
|
|
||||||
color: var(--muted);
|
|
||||||
background: var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.download-form {
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.download-form textarea {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-height: 64px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.download-options {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: 12px;
|
|
||||||
margin-top: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-toolbar {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 10px;
|
|
||||||
margin-bottom: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-toolbar h2 {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-toolbar-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-toolbar-actions select {
|
|
||||||
width: auto;
|
|
||||||
min-height: 36px;
|
|
||||||
padding: 6px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-card summary {
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 1.05rem;
|
|
||||||
font-weight: 650;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-list {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 14px 0 12px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
max-height: 420px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-list:empty {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-item {
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-small);
|
|
||||||
padding: 10px 12px;
|
|
||||||
background: var(--surface-alt);
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-name {
|
|
||||||
font-weight: 600;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-meta {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
margin: 4px 0 0;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-card .empty-state {
|
|
||||||
margin: 14px 0 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.download-form textarea {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.download-options {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in new issue
Block a user