From 25eee6f76a918f1049c274f43eccf99c8d985805 Mon Sep 17 00:00:00 2001 From: Bryan Bailey Date: Sun, 8 Mar 2026 22:02:24 -0400 Subject: [PATCH] feat(web): add dockerized web UI with comic library browser Adds a `yoink serve` command that starts an HTTP server with a Sonarr/MeTube-inspired dark UI. Features a URL input bar for triggering downloads, a 150x300 cover grid with filter and sort controls, a live download queue strip, and toast notifications. Includes Dockerfile (multi-stage, distroless runtime) and docker-compose.yml for easy deployment. --- .dockerignore | 5 + .gitignore | 13 +- Dockerfile | 37 +++ cli/healthcheck.go | 28 ++ cli/serve.go | 36 +++ docker-compose.yml | 10 + web/server.go | 247 +++++++++++++++ web/static/index.html | 695 ++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 1070 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 cli/healthcheck.go create mode 100644 cli/serve.go create mode 100644 docker-compose.yml create mode 100644 web/server.go create mode 100644 web/static/index.html diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..156054f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.git +.github +*.md +library/ +*_test.go diff --git a/.gitignore b/.gitignore index 8d766bf..154eb7c 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,15 @@ go.work go.work.sum # env file -.env \ No newline at end of file +.env + +# Built binary +yoink +yoink.exe + +# Comic library (downloaded content) +library/ + +# IDE +.vscode/ +.idea/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0d3bd17 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +# ── Build stage ──────────────────────────────────────────────────────────── +FROM mcr.microsoft.com/oss/go/microsoft/golang:1.22-bullseye AS builder + +WORKDIR /app + +# Restore modules in a separate layer so it's cached until go.mod/go.sum change +COPY go.mod go.sum ./ +RUN go mod download && go mod verify + +# Copy source and build a fully static binary +COPY . . +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -ldflags="-s -w" -trimpath -o yoink . + +# ── Runtime stage ────────────────────────────────────────────────────────── +# distroless/base-debian12:nonroot — minimal attack surface, non-root by default +FROM gcr.io/distroless/base-debian12:nonroot + +LABEL org.opencontainers.image.title="yoink" \ + org.opencontainers.image.description="Comic downloader web UI" \ + org.opencontainers.image.source="https://github.com/bryanlundberg/yoink-go" + +WORKDIR /app + +COPY --from=builder --chown=nonroot:nonroot /app/yoink . + +ENV YOINK_LIBRARY=/library + +VOLUME ["/library"] +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ + CMD ["/app/yoink", "healthcheck"] + +USER nonroot + +CMD ["/app/yoink", "serve"] diff --git a/cli/healthcheck.go b/cli/healthcheck.go new file mode 100644 index 0000000..0209d18 --- /dev/null +++ b/cli/healthcheck.go @@ -0,0 +1,28 @@ +package cli + +import ( + "fmt" + "net/http" + "os" + + "github.com/spf13/cobra" +) + +var healthcheckCmd = &cobra.Command{ + Use: "healthcheck", + Short: "Check if the web server is running (used by Docker HEALTHCHECK)", + Args: cobra.NoArgs, + Hidden: true, + Run: func(cmd *cobra.Command, args []string) { + port, _ := cmd.Flags().GetString("port") + resp, err := http.Get(fmt.Sprintf("http://localhost:%s/health", port)) + if err != nil || resp.StatusCode != http.StatusOK { + os.Exit(1) + } + }, +} + +func init() { + healthcheckCmd.Flags().StringP("port", "p", "8080", "Port the server is listening on") + cli.AddCommand(healthcheckCmd) +} diff --git a/cli/serve.go b/cli/serve.go new file mode 100644 index 0000000..419f65c --- /dev/null +++ b/cli/serve.go @@ -0,0 +1,36 @@ +package cli + +import ( + "fmt" + "log" + "os" + "path/filepath" + + "github.com/spf13/cobra" + "yoink/web" +) + +var serveCmd = &cobra.Command{ + Use: "serve", + Short: "Start the Yoink web UI", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + library, ok := os.LookupEnv("YOINK_LIBRARY") + if !ok { + userHome, _ := os.UserHomeDir() + library = filepath.Join(userHome, ".yoink") + } + + port, _ := cmd.Flags().GetString("port") + addr := fmt.Sprintf(":%s", port) + + if err := web.Listen(addr, library); err != nil { + log.Fatal(err) + } + }, +} + +func init() { + serveCmd.Flags().StringP("port", "p", "8080", "Port to listen on") + cli.AddCommand(serveCmd) +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0c9cf0b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,10 @@ +services: + yoink: + build: . + ports: + - "8080:8080" + volumes: + - ./library:/library + environment: + - YOINK_LIBRARY=/library + restart: unless-stopped diff --git a/web/server.go b/web/server.go new file mode 100644 index 0000000..6415ebd --- /dev/null +++ b/web/server.go @@ -0,0 +1,247 @@ +package web + +import ( + "embed" + "encoding/json" + "fmt" + "io/fs" + "net/http" + "net/url" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + "github.com/PuerkitoBio/goquery" + "yoink/comic" +) + +//go:embed static +var staticFiles embed.FS + +type JobStatus string + +const ( + StatusPending JobStatus = "pending" + StatusRunning JobStatus = "running" + StatusComplete JobStatus = "complete" + StatusError JobStatus = "error" +) + +type Job struct { + ID string `json:"id"` + URL string `json:"url"` + Title string `json:"title"` + Status JobStatus `json:"status"` + Error string `json:"error,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type ComicEntry struct { + Title string `json:"title"` + CoverURL string `json:"cover_url"` + FileURL string `json:"file_url"` + DownloadedAt time.Time `json:"downloaded_at"` +} + +type Server struct { + libraryPath string + jobs map[string]*Job + mu sync.RWMutex +} + +func NewServer(libraryPath string) *Server { + return &Server{ + libraryPath: libraryPath, + jobs: make(map[string]*Job), + } +} + +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + + // Embedded static assets + staticFS, _ := fs.Sub(staticFiles, "static") + mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS)))) + + // Library files: covers (inline) and cbz downloads (attachment) + mux.Handle("/covers/", http.StripPrefix("/covers/", http.FileServer(http.Dir(s.libraryPath)))) + mux.Handle("/files/", http.StripPrefix("/files/", s.downloadHandler())) + + // API + mux.HandleFunc("/api/download", s.handleDownload) + mux.HandleFunc("/api/comics", s.handleComics) + mux.HandleFunc("/api/jobs", s.handleJobs) + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + // SPA root + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + data, _ := staticFiles.ReadFile("static/index.html") + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write(data) + }) + + return mux +} + +// downloadHandler wraps the library file server to force Content-Disposition: attachment. +func (s *Server) downloadHandler() http.Handler { + fs := http.FileServer(http.Dir(s.libraryPath)) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Disposition", "attachment") + fs.ServeHTTP(w, r) + }) +} + +func (s *Server) handleDownload(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + URL string `json:"url"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.URL) == "" { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + + job := &Job{ + ID: fmt.Sprintf("%d", time.Now().UnixNano()), + URL: req.URL, + Status: StatusPending, + CreatedAt: time.Now(), + } + + s.mu.Lock() + s.jobs[job.ID] = job + s.mu.Unlock() + + go s.runJob(job) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(job) +} + +func (s *Server) runJob(job *Job) { + s.mu.Lock() + job.Status = StatusRunning + s.mu.Unlock() + + markupCh := make(chan *goquery.Document) + imageCh := make(chan []string) + + c := comic.NewComic(job.URL, s.libraryPath, imageCh, markupCh) + + s.mu.Lock() + job.Title = c.Title + s.mu.Unlock() + + errs := c.Download(len(c.Filelist)) + if len(errs) > 0 { + s.mu.Lock() + job.Status = StatusError + job.Error = errs[0].Error() + s.mu.Unlock() + return + } + + if err := c.Archive(); err != nil { + s.mu.Lock() + job.Status = StatusError + job.Error = err.Error() + s.mu.Unlock() + return + } + + c.Cleanup() + + s.mu.Lock() + job.Status = StatusComplete + s.mu.Unlock() +} + +func (s *Server) handleComics(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + entries := []ComicEntry{} + + dirs, err := os.ReadDir(s.libraryPath) + if err != nil { + json.NewEncoder(w).Encode(entries) + return + } + + for _, dir := range dirs { + if !dir.IsDir() { + continue + } + + title := dir.Name() + dirPath := filepath.Join(s.libraryPath, title) + + var coverURL, fileURL string + var downloadedAt time.Time + + files, _ := os.ReadDir(dirPath) + for _, f := range files { + name := f.Name() + + if strings.HasSuffix(name, ".cbz") { + fileURL = "/files/" + url.PathEscape(title) + "/" + url.PathEscape(name) + if info, err := f.Info(); err == nil { + downloadedAt = info.ModTime() + } + } + + // Cover kept by Cleanup: " 001.jpg" + stripped := strings.TrimSpace(strings.TrimPrefix(name, title)) + if strings.HasPrefix(strings.ToLower(stripped), "001") { + coverURL = "/covers/" + url.PathEscape(title) + "/" + url.PathEscape(name) + } + } + + if fileURL != "" { + entries = append(entries, ComicEntry{ + Title: title, + CoverURL: coverURL, + FileURL: fileURL, + DownloadedAt: downloadedAt, + }) + } + } + + // Default: newest first + sort.Slice(entries, func(i, j int) bool { + return entries[i].DownloadedAt.After(entries[j].DownloadedAt) + }) + + json.NewEncoder(w).Encode(entries) +} + +func (s *Server) handleJobs(w http.ResponseWriter, r *http.Request) { + s.mu.RLock() + jobs := make([]*Job, 0, len(s.jobs)) + for _, j := range s.jobs { + jobs = append(jobs, j) + } + s.mu.RUnlock() + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(jobs) +} + +func Listen(addr string, libraryPath string) error { + srv := NewServer(libraryPath) + fmt.Printf("Yoink web server listening on %s\n", addr) + return http.ListenAndServe(addr, srv.Handler()) +} diff --git a/web/static/index.html b/web/static/index.html new file mode 100644 index 0000000..9a9c5ff --- /dev/null +++ b/web/static/index.html @@ -0,0 +1,695 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Yoink + + + + +
+ +
+ + +
+
+ +
+ +
+
+
Library
+ +
+ + + + +
+
+
+
+ + + + +

No comics yet — paste a URL above to get started.

+
+ + +
+ + + +