diff --git a/docker-compose.yml b/docker-compose.yml
index da50956..e08957b 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -82,6 +82,9 @@ services:
build:
context: ./scraper
dockerfile: Dockerfile
+ args:
+ VERSION: "${GIT_TAG:-dev}"
+ COMMIT: "${GIT_COMMIT:-unknown}"
#container_name: libnovel-scraper
restart: unless-stopped
depends_on:
@@ -131,6 +134,9 @@ services:
build:
context: ./ui
dockerfile: Dockerfile
+ args:
+ BUILD_VERSION: "${GIT_TAG:-dev}"
+ BUILD_COMMIT: "${GIT_COMMIT:-unknown}"
# container_name: libnovel-ui
restart: unless-stopped
depends_on:
diff --git a/scraper/Dockerfile b/scraper/Dockerfile
index 561c518..c596bd4 100644
--- a/scraper/Dockerfile
+++ b/scraper/Dockerfile
@@ -9,8 +9,14 @@ RUN go mod download
COPY . .
+# Build-time version info — injected by docker-compose or CI via --build-arg.
+ARG VERSION=dev
+ARG COMMIT=unknown
+
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
- go build -ldflags="-s -w" -o /scraper ./cmd/scraper
+ go build \
+ -ldflags="-s -w -X main.Version=${VERSION} -X main.Commit=${COMMIT}" \
+ -o /scraper ./cmd/scraper
# ── Runtime stage ──────────────────────────────────────────────────────────────
FROM alpine:3.21
diff --git a/scraper/cmd/scraper/main.go b/scraper/cmd/scraper/main.go
index 9216878..090e22f 100644
--- a/scraper/cmd/scraper/main.go
+++ b/scraper/cmd/scraper/main.go
@@ -47,6 +47,13 @@ import (
"github.com/libnovel/scraper/internal/storage"
)
+// Build-time version info — injected via -ldflags during docker build.
+// Falls back to "dev" / "unknown" when built without -ldflags (local dev).
+var (
+ Version = "dev"
+ Commit = "unknown"
+)
+
func main() {
logLevel := slog.LevelInfo
if v := os.Getenv("LOG_LEVEL"); v != "" {
@@ -183,7 +190,7 @@ func run(log *slog.Logger) error {
"pocketbase_url", pbCfg.BaseURL,
"pocketbase_email", pbCfg.AdminEmail,
)
- srv := server.New(addr, oCfg, nf, log, store, kokoroURL, kokoroVoice)
+ srv := server.New(addr, oCfg, nf, log, store, kokoroURL, kokoroVoice, Version, Commit)
return srv.ListenAndServe(ctx)
case "save-browse":
diff --git a/scraper/internal/server/server.go b/scraper/internal/server/server.go
index 24f38bb..878c9e5 100644
--- a/scraper/internal/server/server.go
+++ b/scraper/internal/server/server.go
@@ -43,6 +43,8 @@ type Server struct {
running bool
kokoroURL string // Kokoro-FastAPI base URL, e.g. http://kokoro:8880
kokoroVoice string // default voice, e.g. af_bella
+ version string // semver tag, e.g. "v1.2.3" (set via ldflags)
+ commit string // short git SHA (set via ldflags)
// voiceMu guards cachedVoices.
voiceMu sync.RWMutex
@@ -74,7 +76,7 @@ type browseCacheEntry struct {
}
// New creates a new Server.
-func New(addr string, oCfg orchestrator.Config, novel scraper.NovelScraper, log *slog.Logger, store storage.Store, kokoroURL, kokoroVoice string) *Server {
+func New(addr string, oCfg orchestrator.Config, novel scraper.NovelScraper, log *slog.Logger, store storage.Store, kokoroURL, kokoroVoice, version, commit string) *Server {
return &Server{
addr: addr,
oCfg: oCfg,
@@ -83,6 +85,8 @@ func New(addr string, oCfg orchestrator.Config, novel scraper.NovelScraper, log
store: store,
kokoroURL: kokoroURL,
kokoroVoice: kokoroVoice,
+ version: version,
+ commit: commit,
audioJobIDs: make(map[string]string),
browseInFlight: make(map[string]struct{}),
browseMemCache: make(map[string]browseCacheEntry),
@@ -138,6 +142,7 @@ func (s *Server) voices() []string {
func (s *Server) ListenAndServe(ctx context.Context) error {
mux := http.NewServeMux()
mux.HandleFunc("GET /health", s.handleHealth)
+ mux.HandleFunc("GET /api/version", s.handleVersion)
mux.HandleFunc("POST /scrape", s.handleScrapeCatalogue)
mux.HandleFunc("POST /scrape/book", s.handleScrapeBook)
mux.HandleFunc("POST /scrape/book/range", s.handleScrapeBookRange)
@@ -222,7 +227,19 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
- _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
+ _ = json.NewEncoder(w).Encode(map[string]string{
+ "status": "ok",
+ "version": s.version,
+ "commit": s.commit,
+ })
+}
+
+func (s *Server) handleVersion(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]string{
+ "version": s.version,
+ "commit": s.commit,
+ })
}
// ─── Session cookie helpers ───────────────────────────────────────────────────
diff --git a/ui/Dockerfile b/ui/Dockerfile
index fd24108..c5bfba9 100644
--- a/ui/Dockerfile
+++ b/ui/Dockerfile
@@ -5,6 +5,15 @@ COPY package.json package-lock.json ./
RUN npm ci
COPY . .
+
+# Build-time version info — injected by docker-compose or CI via --build-arg.
+ARG BUILD_VERSION=dev
+ARG BUILD_COMMIT=unknown
+
+# Expose as PUBLIC_ env vars so SvelteKit's $env/dynamic/public can read them.
+ENV PUBLIC_BUILD_VERSION=$BUILD_VERSION
+ENV PUBLIC_BUILD_COMMIT=$BUILD_COMMIT
+
RUN npm run build
# ── Runtime image ──────────────────────────────────────────────────────────────
diff --git a/ui/src/routes/+layout.svelte b/ui/src/routes/+layout.svelte
index ce3a278..b4e93cc 100644
--- a/ui/src/routes/+layout.svelte
+++ b/ui/src/routes/+layout.svelte
@@ -5,6 +5,7 @@
import type { Snippet } from 'svelte';
import type { LayoutData } from './$types';
import { audioStore } from '$lib/audio.svelte';
+ import { env } from '$env/dynamic/public';
let { children, data }: { children: Snippet; data: LayoutData } = $props();
@@ -395,6 +396,9 @@
Privacy
DMCA
© {new Date().getFullYear()} libnovel
+ {#if env.PUBLIC_BUILD_VERSION && env.PUBLIC_BUILD_VERSION !== 'dev'}
+ {env.PUBLIC_BUILD_VERSION}+{env.PUBLIC_BUILD_COMMIT?.slice(0, 7)}
+ {/if}