TTS: parallel workers for CLI conversion + intra-op thread cap; Apple Silicon stays CPU (decision 003).
Chased down why a 3× smaller book took ~3× longer on the M5 Pro (1h44m) than the bigger book on
fedya's GTX 1070 (35 min): mac5 runs Piper CPU-only — CoreML can't run VITS's data-dependent output
length (decision 003) — while fedya uses its real
GPU, and fb2-to-wav.sh ran a single daemon on a 15-core machine.
main.rs: optionalSOPDS_TTS_THREADSenv caps the ORT intra-op thread count per session, so several daemons can run in parallel without oversubscribing.fb2-to-wav.sh: newWORKERS(default 4) runs N resident daemons over round-robin shards, each capped to ~cores/WORKERS threads, with aggregate progress/ETA by polling produced WAVs. (Also fixed apipefail+ls-on-empty-glob bug that silently aborted the run before the first WAV existed.)- Benchmarked on mac5 (M5 Pro, 5 P + 10 E cores), 40 real ru chunks: 27 s → 14 s at 4 workers, flat at 5/8/10 — ~2× is the ceiling, set by the performance-core count. So the ~50 h book drops from ~1h44m to ~52 min on mac5; fedya (GPU) stays faster for big books.
- Decision doc 003 records (sourced) that Apple-Silicon GPU/ANE for Piper is a dead-end — the VITS duration-predictor length dynamism forces CPU fallback, and ANE would be a wash even if it ran.
Files: sopds-tts-rs/src/main.rs, sopds-tts-rs/fb2-to-wav.sh,
docs/decisions/003-apple-silicon-piper-stays-cpu.md, README.md.
fb2-to-wav.sh: output format by extension (default MP3), 4 GB-WAV guard, chunk size in chars.
Two issues surfaced converting a ~50 h book on mac5:
- The concatenated audio was 8 GB, but WAV's 32-bit size fields cap a file at 4 GB →
ffmpeg wrote a broken header. Fix: output format now follows the extension
(
mp3/m4b/m4a/opus/oggare encoded at a speech bitrate;wavis copied), the default is.mp3, and a.wavthat would exceed 4 GB auto-switches to.mp3so you never get a broken file. (An already-broken WAV recovers withffmpeg -ignore_length 1 -i big.wav -c:a libmp3lame -b:a 64k out.mp3.) MAXBYTESwas a misnomer — gawklength()counts characters, so it always limited chars, not bytes (900 → ~1750 bytes for Cyrillic, near the GPU OOM cliff, and the reason the same-title library book split into 12455 byte-based chunks while the script made 1827 char-based ones). Renamed toMAXCHARS(legacyMAXBYTESstill honored); default lowered 900 → 600 chars (GPU-safe — char count tracks phoneme count, which drives attention memory). Files:sopds-tts-rs/fb2-to-wav.sh.
fb2-to-wav.sh: live progress % + elapsed + ETA.
The synth loop now prints N/M (pct%) <elapsed> ETA <dur> on one refreshing line (ANSI
clear-to-EOL), extrapolating ETA from the average per-chunk rate so far, plus a final
done in <dur> summary. Durations format as 1h02m / 3m05s / 12s. Files:
sopds-tts-rs/fb2-to-wav.sh.
sopds-tts-rs: a phoneme-less chunk no longer kills the whole book (silence + no crash).
A 12455-chunk Russian book reached chunk 12454/12455, then the job was marked failed and the
UI reverted to "Audio Not Generated" — 12454 good WAVs thrown away. Cause: chunk 909's text had
no pronounceable phonemes (a "* * *"-style scene break / bare separator), so synth returned
no phoneme IDs generated. On the one-shot fallback that error propagated via ?, which
unwind-dropped the ORT Session → the CUDA-teardown core dump (Rev 79's exit_ok guarded only the
success path). Go saw the non-zero exit and failed the entire job.
Two fixes in main.rs:
synthnow returns ~0.25 s of silence for a phoneme-less chunk instead of an error — a scene break becomes a short pause, and one dud chunk can't fail a whole book. (The daemon path already tolerated synth errors; this makes the result correct too.)- The one-shot arm runs synth+write in a
matchand callsexit_ok()/exit(1)without dropping the Session on either path, so a genuine synth error exits cleanly (exit 1) instead of core-dumping.
Verified on mac5: empty/whitespace text → ok, 5512-sample (0.25 s) silent WAV; real text unchanged;
empty stdin → clean exit 1, no core dump. Files: sopds-tts-rs/src/main.rs (synth, one-shot arm).
Follow-up (not yet done): harden processJob to tolerate any single failed chunk — write a
silence placeholder and complete the job rather than discarding all completed work.
sopds-tts-rs/fb2-to-wav.sh — one-command FB2 → single WAV (manual/CLI conversion).
A standalone converter for one-off files (the web UI already chunks; this is the manual path
Dmitry asked for). ./fb2-to-wav.sh <book.fb2> <model.onnx> [out.wav]:
- extracts the main
<body>text (skips<body name="notes">footnotes) viaxmllint, - splits it into ~900-byte sentence-grouped chunks (
MAXBYTESoverridable) — big chunks OOM the GPU (Rev 81), - synthesizes every chunk through one resident daemon (model loaded once, NDJSON protocol),
- concatenates the chunk WAVs into one file via
ffmpeg -c copy.
Self-bootstraps its tools (espeak-ng, ffmpeg, jq, xmllint, GNU sed/awk) through
nix shell when nix is present, so nothing needs preinstalling; degrades to whatever's on PATH
otherwise. Tested on mac5: single- and multi-chunk paths, footnote exclusion, Cyrillic; 2-chunk
vs 1-chunk output durations match (~13 s). Files: sopds-tts-rs/fb2-to-wav.sh.
TTS: default chunk_size 5000 → 1000 bytes — the old default OOMs the GPU (real cause of "slow").
A live fedya generation (a large Russian book, ru_RU-irina-medium) ran at ~2.3 s/chunk, ETA 1h42m.
The log showed why: every chunk failed on the daemon with a CUDA OOM and fell back to the slow
one-shot path (fresh process + CUDA init + 114 MB model reload each time):
BFCArena … Failed to allocate memory for requested buffer of size 3035899904 (~3 GB).
Root cause: Piper/VITS attention/upsampling is ~O(n²) in text length (the intermediate is roughly
text_len × predicted-audio-frames), and chunk_size defaulted to 5000 bytes. Measured threshold
on the GTX 1070 (8 GB, 7.85 GB free): chunks of 576 / 1152 bytes synthesize fine (175 / 535 ms), a
2304-byte chunk requests >7.8 GB and OOMs. So it is not GPU contention — a single chunk's tensor
simply doesn't fit.
Fix: lower the default chunk_size to 1000 bytes (config.go default + extractor.go fallback).
chunk_size is measured in bytes, and Cyrillic is ~2 bytes/char, so 1000 ≈ 500 ru / ~1000 en chars
— a few sentences, ~20–40 s of audio. Confirmed on fedya: realistic EN (362 B → 919 ms) and RU
(436 B → 484 ms) chunks run on the daemon with no OOM. Also set chunk_size: 1000 in the live
/opt/sopds/config.yaml and restarted. Bigger GPUs can raise it; the O(n²) wall is the only limit.
Note: total synth time is bounded by GPU throughput (audio length is fixed), so the win here is
(a) the daemon actually engaging instead of the OOM→one-shot fallback, and (b) better prosody from
sentence-scale chunks. Files: internal/config/config.go, internal/tts/extractor.go.
Future option: set the CUDA EP arena to a memory limit / kSameAsRequested, or auto-split a chunk
that still OOMs, instead of failing that chunk.
Deploy: sopds-tts-rs live on fedya (CUDA GPU); sopds server rebuilt to Rev 79 for daemon mode.
Completed the fedya (dvg-fedya, GTX 1070 / Pascal) TTS deployment that Rev 79 left as a TODO. The
TTS binary is CUDA and the sopds service runs as User=sopds with a minimal PATH and no nix.
Recipe (all on dvg-fedya):
- Build the CUDA binary via the flake:
cd sopds-tts-rs && nix develop -c cargo build --release(buildscudaPkgs.onnxruntimefor sm_61 — heavy the first time, cached after). - Copy it where the service user can exec it (home dir is
0700):sudo install -m755 target/release/sopds-tts-rs /opt/sopds/sopds-tts-rs-bin. - Wrapper — install the file
getTTSBinaryPath()looks for, next to/opt/sopds/sopds:/opt/sopds/sopds-tts, ashscript that puts espeak-ng (nix store) on PATH and execs the binary.libonnxruntime+ cuDNN/cuBLAS resolve via the binary's baked RPATH;libcudafrom/run/opengl-driver/lib(NixOS default loader path). NoLD_LIBRARY_PATHneeded. - Pin those store paths against nix-GC:
nix develop sopds-tts-rs --profile ~/.local/state/sopds-tts-devshell -c true(indirect gcroot). - Server — rebuild the Go server from Rev 79 (has the daemon integration) and swap it in.
The server needs CGO:
nix shell nixpkgs#gcc -c go build -o /tmp/sopds-new ./cmd/sopds, thensudo systemctl stop sopds && sudo cp -a /opt/sopds/sopds /opt/sopds/sopds.bak-pre-rev78 && sudo install -m755 /tmp/sopds-new /opt/sopds/sopds && sudo systemctl start sopds.
Regenerate steps 1–4 together if sopds-tts-rs/flake.lock changes (espeak path in the wrapper +
the gcroot). Verified: binary runs on the GPU (nvidia-smi → 368 MiB); one-shot and daemon
both exit 0 (no more CUDA-teardown core dump, Rev 79); the wrapper works under sudo -u sopds
with a systemd-minimal env; the redeployed server logs TTS: Starting 2 workers on boot. Rollback:
/opt/sopds/sopds.bak-pre-rev78. Left for the operator: a UI-triggered generate to confirm the
resident daemon end-to-end (TTS routes are session-authed).
sopds-tts-rs: fix the fedya "TTS starts and dies" crash — exit before the ORT Session drops:
Root cause of fedya's TTS dying immediately: the binary synthesized the WAV correctly, then
core-dumped (corrupted double-linked list) the instant the Tts struct — holding the ONNX
Runtime CUDA Session — was dropped at end of scope. ORT's CUDA-provider static teardown corrupts
the heap on this box (onnxruntime-1.22 CUDA build, cuDNN 9.8, GTX 1070 / Pascal). In one-shot
mode that non-zero exit (134, core dumped) made sopds-go treat a perfectly good chunk as a
failure — hence "starts and dies very quickly".
Fix: added exit_ok() (flush stdout, std::process::exit(0)) and call it while the Session is
still alive — at the end of the one-shot arm and at daemon EOF — so the Session's Drop (the
crashing CUDA teardown) never runs. The WAV is already finalized on disk and any daemon responses
already sent/flushed, so nothing is lost. No-op on the clean-exiting macOS/CPU path.
Verified on fedya (CUDA): one-shot now exits 0 with a valid WAV; daemon mode answers 3 chunks
(492 / 215 / 115 ms) and exits 0 on EOF. Files: sopds-tts-rs/src/main.rs (main, new
exit_ok, one-shot arm, serve EOF).
Still TODO for fedya (deploy, not code): the sopds service (User=sopds, /opt/sopds/sopds)
finds the TTS binary via getTTSBinaryPath() = sopds-tts next to the exe or on PATH — and
/opt/sopds/ has no such binary, so spawns fail. Needs a sopds-tts wrapper on that box that
provides the ORT/cuDNN/espeak-ng runtime env (the service PATH has no nix).
sopds-go TTS: use the resident daemon (Rev 77) instead of one process per chunk:
Wired the daemon protocol into internal/tts — this is what turns Rev 77 into a UI-visible
speedup. New daemon.go: a daemonPool keeps up to Workers live sopds-tts <model>
processes per model, reused across chunks and jobs. generateChunk now prefers the pool
(NDJSON request → WAV) and transparently falls back to the old one-shot subprocess
(generateChunkOneShot) when a daemon can't start — an old binary without daemon mode, or
espeak-ng missing. The failure is detected once per model (a broken flag on the
model's pool), so there's no per-chunk retry storm; a daemon that dies mid-stream is
discarded and respawned. The pool is created in Start() (sized to Workers) and shut down
when the workers stop.
Integration test daemon_test.go (skipped unless SOPDS_TTS_BIN/SOPDS_TTS_MODEL are set
and espeak-ng is on PATH) drives 4 chunks through the pool in parallel: 0.52 s for 4
chunks with 2 daemons (model loaded once each) vs ~1.6 s as 4 separate one-shot processes.
For a full book (dozens of chunks) the two model loads amortize to near-zero, so per-chunk
cost approaches the ~20–90 ms measured in Rev 77.
Files: internal/tts/daemon.go (new), internal/tts/daemon_test.go (new),
internal/tts/generator.go (pool field; Start/shutdown wiring; generateChunk daemon-first
with one-shot fallback, old body renamed generateChunkOneShot).
sopds-tts-rs: daemon mode — load the model once, then ~20–90 ms per chunk (no GPU):
The one-shot contract sopds-tts-rs <model> <output> reloads the ~60 MB ONNX model on every
call. Benchmarking showed that reload (~0.34 s) — not synthesis — is the dominant cost: actual
inference is ~0.02–0.09 s per sentence on the M5 Pro CPU. So the win isn't the GPU (CoreML can't
run this VITS model anyway, see Rev 76); it's a resident model.
Added a backward-compatible daemon mode:
sopds-tts-rs <model> <output>— unchanged one-shot (text on stdin, WAV out).sopds-tts-rs <model>(no output arg) — loads model + session once, printsready: …to stderr, then reads NDJSON requests on stdin, one per line:{"text":"…","output":"/p.wav"}. For each it synthesizes + writes the WAV and emits one NDJSON response:{"ok":true,"samples":N,"elapsed_ms":M,"output":"…"}(or{"ok":false,"error":"…"}). EOF → exit.
Refactored load/synth into a Tts struct (load() once → synth(text) -> Vec<i16>), shared by
both modes. Measured on mac5, 5 sentences in one daemon session: 86 / 76 / 19 / 60 / 70 ms each,
vs ~340 ms per separate one-shot call — sub-0.1 s per chunk, ×4–15 faster, CPU only.
Files: sopds-tts-rs/src/main.rs (Tts struct, serve() daemon, run() dispatch on argc).
TODO (next rev): internal/tts/generator.go should keep one daemon per model alive and speak
this NDJSON protocol instead of spawning a process per chunk — that's what turns this into a
UI-visible speedup.
sopds-tts-rs: build + run on macOS (Apple Silicon) — one repo, auto per platform:
The Rust CUDA TTS port (sopds-tts-rs/) now also builds and runs on macOS. Cargo.toml
selects the ONNX Runtime execution provider per target via [target.'cfg(...)'.dependencies]
(CUDA on Linux — unchanged, lib via ORT_LIB_LOCATION/run-gpu.sh; download-binaries on
macOS), and main.rs picks the provider with cfg(target_os). cargo build --release now
Just Works on both: CUDA binary on fedya, CPU binary on mac5.
- macOS runs on CPU. CoreML — the only ORT GPU path on Apple Silicon — can't run this
Piper/VITS model: its inputs have dynamic (unbounded) dimensions that CoreML rejects
(NeuralNetwork format segfaults; MLProgram floods
unbounded dimensionerrors and falls back to CPU anyway). Not worth chasing — Apple-Silicon CPU is ~13× real-time (13.8 s of audio in 1.08 s on an M5 Pro). Re-add thecoremlfeature only if the model is re-exported with bounded shapes. - No change to the Linux/CUDA path.
v1.3.3 patch — resend-verification + SMTP password_env + docs cleanup:
Bundle of features and fixes since v1.3.2 (Rev 70). New user-visible behavior plus the usual archive-rebuild for any users who pull from GoReleaser/Homebrew/GHCR.
Features (vs. v1.3.2):
- Rev 73 —
/web/resend-verificationflow with 60s per-user cooldown, login-page link, email-enumeration-safe responses. - Rev 74 —
smtp.password_env:for secret-store integration (sops/k8s/vault/docker-env).
Docs/fixes (vs. v1.3.2):
- Rev 71-72 — docker-compose recipe + Taskfile helpers for the typical
~/dockers/sopds/deployment pattern. - Migration 013 —
verify_token_sent_atcolumn for resend cooldown state. - Various config.yaml.example fixes: SMTP block, JWT secret, auth.users
[]placeholder, port aligned to 8081, library.root container-vs-host distinction documented in README. init.sqlremoved from compose recipe (POSTGRES_DB env var handles bootstrap; init.sql is for standalone PG only).- PG volume mount path corrected for PG18+ layout (
/var/lib/postgresqlinstead of/.../data). - PostgreSQL bumped 16 → 18 in Homebrew formula dep.
Migration note: Existing v1.3.x installations need sopds migrate (or task migrate in the docker stack) on first start with v1.3.3 to apply migration 013. Users running first-time install just get the column for free at bootstrap.
Auto-triggered on tag push:
- GoReleaser builds 4 archives (linux/macos × x86_64/arm64) + checksums.txt.
- Docker images republished as
ghcr.io/dimgord/sopds-go:1.3.3and:latest. - Homebrew formula auto-updated in
dimgord/homebrew-tapto v1.3.3.
SMTP password_env: env-var indirection — keep secrets out of config files:
smtp.password: in config.yaml lands secrets on disk (and into any committed-or-mounted version of that file). Not OK for production deployments where the SMTP credential should come from sops, k8s secrets, Vault, or docker env. Added generic env-var indirection:
smtp:
password: "" # leave blank
password_env: "SOPDS_SMTP_PASSWORD" # name of env var holding the secretAt startup sopds calls SMTPConfig.ResolvedPassword() which checks os.Getenv(c.PasswordEnv) first, falls back to c.Password if the env var is unset/empty. Existing deployments using password: continue to work unchanged.
Files Modified:
internal/config/config.go: newPasswordEnv stringfield onSMTPConfig; new methodResolvedPassword() string(env-lookup with fallback).internal/server/email.go: one-line change —e.config.Password→e.config.ResolvedPassword()in the auth setup.config.yaml.example: SMTP block documents both options (a) literal password and (b)password_envindirection, with the rationale and fallback semantics.docker-compose.example.yml: addedenvironment: SOPDS_SMTP_PASSWORD: ${SOPDS_SMTP_PASSWORD:-}passthrough on the sopds service, with comment showing thesops exec-env … docker compose up -ddeploy pattern.
Deploy pattern with sops-nix secrets:
cd ~/dockers/sopds
sops exec-env ~/.nixfiles/secrets/secrets.yaml \
'SOPDS_SMTP_PASSWORD="$email_app_password" docker compose up -d'sops exec-env decrypts secrets.yaml in-memory and exports each key as an env var for the wrapped command; docker compose's ${SOPDS_SMTP_PASSWORD} interpolation then forwards it into the container's env; sopds reads it on startup via os.Getenv. Plaintext secret never touches disk.
Works equally well with .env file (compose auto-loads), k8s secrets: mount → envFrom:, AWS ECS task secrets, etc. — env-var is the lowest common denominator.
Verified: go build ./... clean; go test ./... all 14 packages pass.
Resend-verification-email flow:
SendVerificationEmail was already wired into the signup path (auth.go:378 since Rev ), but there was no way for a user to request a new email if the first one was lost or expired. Added a complete resend flow with per-user rate-limiting and email-enumeration protection.
Domain layer (internal/domain/user/user.go):
- New field
VerifyTokenSentAt *time.Timeon User — tracks when the most recent verification email was sent. - New method
RegenerateVerifyToken() error— replaces the existing token with a fresh value (new 24h expiry), invalidating any previous email link. - New method
CanResendVerification() (allowed bool, remaining time.Duration)— returns whether the cooldown has elapsed and how long to wait. Returns(true, 0)for users without a sent-at timestamp (pre-migration accounts). - New const
VerifyResendCooldown = 60 * time.Second— short enough to be user-friendly when an email is lost, long enough to make burst-spam unattractive. NewUsernow setsVerifyTokenSentAt = &nowso the cooldown clock starts at signup.VerifyEmailclearsVerifyTokenSentAtto nil (irrelevant once verified).
Persistence (internal/infrastructure/persistence/user_repository.go):
- New column
VerifyTokenSentAt *time.Time(GORM tagcolumn:verify_token_sent_at). - Updated
userToModelandmodelToUserconverters to round-trip the new field.
Migration (migrations/013_verify_resend.sql):
ALTER TABLE users ADD COLUMN IF NOT EXISTS verify_token_sent_at TIMESTAMP.- Index
idx_users_verify_sent_at(partial,WHERE email_verified = FALSE) for future nag-cron jobs. - Existing users get NULL, treated as "no recent send" → can resend immediately.
Handler (internal/server/auth.go::HandleResendVerification):
- GET → renders form asking for email.
- POST → looks up user by email; if exists + not verified + cooldown OK → regenerate token, save, send email.
- Email-enumeration safe: every outcome below the form-validation level renders the same generic "if an account exists, a new link was sent" message. Doesn't matter whether the email exists, is already verified, or the SMTP send itself failed — no information disclosure.
- Per-user cooldown rejection IS shown to the caller (form-error with seconds remaining) — there's no enumeration risk because cooldown only triggers when a real account was found, but the cooldown response only fires AFTER the lookup; we accept that minor info-leak in exchange for a sane UX. (An attacker can still discover this by sending two requests in quick succession to the same email and observing the response delta — but at that point they've also gotten two genuine SMTP sends.)
Routes (internal/server/server.go):
r.Get(WebPrefix+"/resend-verification", HandleResendVerification)— show form.r.With(RateLimitMiddleware(checkRateLimiter)).Post(WebPrefix+"/resend-verification", HandleResendVerification)— process. The HTTP rate limiter (per-IP) layers on top of the per-user 60s cooldown.
Template (internal/server/auth_templates.go):
- New
resend-verificationtemplate, modelled on theforgot-passwordform. - Login page now links to it:
Forgot password? · Resend verification emailnext to the existing forgot-password link, so users who hit the "Please verify your email first" error have a one-click path forward.
Files Modified:
internal/domain/user/user.go— new field + 2 methods + 1 const.internal/infrastructure/persistence/user_repository.go— column + converters.internal/infrastructure/persistence/migrations/013_verify_resend.sql— new file.internal/server/auth.go—HandleResendVerification(~70 lines),renderResendVerificationPage,fmtimport.internal/server/auth_templates.go— new template + login-page link.internal/server/server.go— 2 new route lines.
Verified: go build ./... clean; go test ./... all 14 packages pass.
Taskfile.docker.example.yml — task-runner recipes for the docker-compose stack:
Sister file to docker-compose.example.yml (Rev 71). Bundles common operational commands so end-users don't need to memorize docker compose ... invocations or docker exec -it sopds ... -c /etc/sopds/config.yaml boilerplate.
Task groups:
- Stack lifecycle:
up,down,nuke(withprompt:confirmation since it deletes the data volume),restart(sopds only — keeps PG warm),restart-all,pull,update(pull + up = rolling upgrade). - Observability:
ps,logs(sopds),logs-pg,logs-all. - sopds CLI (via
docker exec; image is distroless so calls are direct to/usr/local/bin/sopdsrather than through a shell):status,version,scan,migrate. - Postgres:
psql(interactive shell into the DB),psql-exec -- "<SQL>"(one-shot),db-size(DB size + top-20 table row counts),shell-pg(debug shell into PG container). - Backup / restore:
backup(pg_dump custom-format →./backups/sopds_<ts>.dump),backup-sql(plain SQL, human-readable),restore -- backups/<file>.dump(pg_restore with --clean --if-exists).
Why ship in the repo as *.example.yml:
Same logic as docker-compose.example.yml — these are starter templates users curl down alongside their stack. Naming with .example.yml keeps it from being confused with the repo's own dev Taskfile.yml (which has go build / go test / etc. for sopds-go development, irrelevant to a runtime deployment).
README docker recipe updated to curl this third file alongside the compose + config + (mv it to Taskfile.yml), with a one-line pointer to the task list.
Files Modified:
Taskfile.docker.example.yml(new, ~100 lines including section comments and prompt'd nuke).README.md: +3 lines in the Docker recipe block + a paragraph about task discoverability.
docker-compose.example.yml + README compose-stack instructions:
The single-container docker run example in README assumes the user already has PostgreSQL running somewhere — true for some deployments but not the common "spin up a fresh stack in ~/dockers/sopds/" pattern. The README didn't cover the latter explicitly; this Rev fills that gap.
This Rev adds:
-
docker-compose.example.ymlat repo root: two services (sopds + postgres:18-alpine), named volume for PG data, healthcheck-gateddepends_on, init.sql mounted into/docker-entrypoint-initdb.d/, configurable PG password viaSOPDS_PG_PASSWORDenv var. Comments document required edits before firstup -d(library path, password sync between compose env and config.yaml). -
README Docker section expansion: split into "Single container" (existing curl-like example) and "Compose stack" (new — three-curl bootstrap of compose+config+init.sql, plus the first-scan command). End-user gets a 5-command path from zero to running stack.
Why postgres:18-alpine specifically:
- Matches the bumped
recommendeddep from Rev 69 (Homebrew formula'spostgresql@18). - Alpine variant is lean (~80MB vs ~150MB Debian-based) — appropriate since we don't need OS-level tooling, just
postgres. - PostgreSQL 18 is current stable as of 2026-05.
Compose semantics:
depends_on.postgres.condition: service_healthymakes sopds wait forpg_isreadybefore starting, eliminating the "first start fails because DB isn't up yet" footgun common in naive sopds+pg stacks.- Named volume
sopds-pg-data(not bind-mount) — Postgres data lives in Docker-managed storage; survivescompose down, nuked bycompose down -v. restart: unless-stoppedfor both services — survives host reboot but respects manualdocker stop.
Pre-publication checklist update:
- Add
docker-compose.example.ymlto GoReleaser archive bundles? Not done in this Rev — the file is small and easily curl-able from raw.githubusercontent. Could be added later if download-traffic patterns justify it.
Files Modified:
docker-compose.example.yml(new, ~55 lines including comments).README.md: Docker section expanded ~15 lines.
v1.3.2 patch — push Rev 68/69 fixes through to Homebrew formula:
The Homebrew formula at dimgord/homebrew-tap/Formula/sopds-go.rb is auto-generated by GoReleaser on each v* tag. v1.3.1 was tagged before Rev 69 (PostgreSQL bump 16→18) landed, so users running brew install sopds-go are still pulling postgresql@16 as a dependency.
This Rev re-tags as v1.3.2 to push the formula update through the same release pipeline. No code changes since v1.3.1; the only delta is:
- Homebrew dep:
postgresql@16→postgresql@18(Rev 69) nix run github:dimgord/sopds-goworks for end users now (Rev 68 — the v1.3.1 tag has the brokenpath:./sopds-tts-rsinput). Tagging v1.3.2 makesnix run github:dimgord/sopds-go/v1.3.2 -- startwork; the unversionednix run github:dimgord/sopds-goalready worked since Rev 68 went on main.
End-user upgrade path:
brew update && brew upgrade sopds-go # picks up the new postgresql@18 dep
docker pull ghcr.io/dimgord/sopds-go:1.3.2
nix run github:dimgord/sopds-go/v1.3.2 -- versionPipeline verification (post-tag):
- GoReleaser regenerates Formula/sopds-go.rb with
version "1.3.2"and four new download URLs/SHA-256 pairs. - Docker images
:1.3.2-amd64,:1.3.2-arm64,:1.3.2,:latestpublished. - GitHub Release page lists 4 archives + checksums.txt.
PostgreSQL bump 16 → 18 across all configs:
PG 18 was released 2025-09-25 and is the current stable major as of 2026-05. PG 16 is still supported upstream (until Nov 2028) but trails three majors behind; for an OSS project being published today, recommending the current major is the right default. Nixpkgs and Homebrew both have postgresql_18 / postgresql@18 available.
Changes:
flake.nixdevShell:postgresql_16→postgresql_18(client tools — psql, pg_dump, pg_restore — for migrations/backups). Verified vianix develop --command psql --version→psql (PostgreSQL) 18.3..goreleaser.yamlbrews block:dependencies.postgresql@16→postgresql@18(recommended dep installed alongside sopds-go via brew).caveatsstep 1:brew services start postgresql@16→postgresql@18.
README.mdRequirements:PostgreSQL 14+→PostgreSQL 16+(raised the lower bound — features used by sopds-go don't strictly require >14, but no point shipping with an unsupported floor); explicit "18 recommended" note pointing at the brew dep.CLAUDE.mdRequirements:Go 1.21+→Go 1.25+(catches Rev 62 toolchain bump that was missed in the dev-doc);PostgreSQL 12+→PostgreSQL 16+with the same "18 recommended" note.
Compat notes:
- Schema in
init.sqland migrations works on PG 16+ — the only PG-15-or-newer feature used is "Grant schema permissions" (per the comment in init.sql:13). PG 12-14 would need additional GRANTs which we never tested; raising the floor to 16 makes the support story honest. - No code changes needed in Go —
gorm.io/driver/postgresautodetects server version.
For users on existing PG 14/15/16 installs: keep using your current Postgres; no schema migration needed. The bump is just the recommended default for fresh installs.
Files Modified:
flake.nix: 1 line (postgres pkg name)..goreleaser.yaml: 2 lines (dep name + caveat).README.md: 1 line (Requirements bullet).CLAUDE.md: 2 lines (Go + PG bullets).
Drop sopds-tts-rs from root flake.nix inputs — fixes nix run github:dimgord/sopds-go for end users:
Symptom: nix run github:dimgord/sopds-go -- status failed for an end-user invocation with:
error: cannot write modified lock file of flake 'github:dimgord/sopds-go'
(use '--no-write-lock-file' to ignore)
Root cause: Rev 60 composed sopds-tts-rs into the root flake via a path:./sopds-tts-rs input. path: inputs in flakes lock to a specific narHash + lastModified pair tied to the parent flake's working-tree position. When the root flake is fetched from GitHub (read-only), Nix tries to re-resolve the path: to the new fetch location and discovers the narHash drifts — wants to update the lock, but can't write to a remote read-only flake. Fail.
Fix: drop the input entirely. The tts-rs devShell that re-exported sopds-tts-rs.devShells.${system}.default is removed; users who want the Rust + CUDA workflow now cd sopds-tts-rs && nix develop ./ from a local clone (the sopds-tts-rs flake remains self-contained and works fine — the issue is only with composing it under a different parent flake that's then consumed remotely).
Trade-off: nix develop from repo root no longer gives you the Rust toolchain in one shot. Have to nest. Acceptable — the Rust+CUDA stack is heavy (cuDNN 9.8 pin from Jun-2025 nixpkgs, ~15-30min source build of ORT) and most users of sopds-go don't need it. Those who do are already cd-ing into the subproject.
Verified after fix:
nix flake updateremovedsopds-tts-rsand its 4 transitive inputs (nixpkgs,nixpkgs-cuda,rust-overlay,rust-overlay/nixpkgs) fromflake.lock.nix build .#sopds --no-linkbuilds clean (only depends onnixpkgs+flake-utilsnow).nix run github:dimgord/sopds-go -- versionwill work for end users once this Rev is onmain.
Files Modified:
flake.nix: -25 lines (input declaration + devShells..tts-rs entry + thepkgs.lib.optionalAttrsLinux gate). Updated comment block at top of inputs explaining the rationale so future-me doesn't re-introduce the same trap.flake.lock: 5 inputs auto-removed bynix flake update.PROGRESS.md: this entry.
Fix flake.nix vendorHash — nix run github:dimgord/sopds-go now actually works:
Rev 60 introduced the root flake.nix but with vendorHash = "sha256-AAAAA…AAAAA" (lib.fakeHash placeholder), so any nix build would fail until the real Go-modules vendor hash was substituted. Resolved this Rev.
Mechanism:
nix build .#sopds --no-linkprinted the expected hash:hash mismatch in fixed-output derivation '…sopds-go-modules.drv': specified: sha256-AAAAA… got: sha256-/Bws/W3fmXls7i+4GN26S4kJt/+cpf9sKIqLaWhIImA=- Substituted into
commonGoArgs.vendorHash. Re-rannix build .#sopds→ built clean. - Smoke test:
nix run .#sopds -- version→SOPDS dev-dirty (Go rewrite)(the "dev-dirty" suffix is correct — it'sself.dirtyRevbecause the working tree was uncommitted at build time).
nix run github:dimgord/sopds-go from any user is now functional. The version string in their build will reflect the git rev they're tagging from, e.g. v1.3.1 users get SOPDS v1.3.1 (per the ${version} substitution in commonGoArgs.ldflags).
Caveat — vendorHash drift:
- Hash is computed from
go.sum+go.mod. Any dep change → must bump this hash again. - CI does not currently catch hash drift; would need a
nix flake checkstep in.github/workflows/ci.yml. Filed as future work — when it surfaces (e.g.dependabotopens a Go-deps PR andnix buildbreaks for downstream users), add the CI step. - For human bumps:
nix build .#sopds 2>&1 | grep got:extracts the new hash; copy it intoflake.nixand commit.
Files Modified:
flake.nix: 4-line comment block + 1-line hash. Net +1 line.
Pre-publication checklist update:
- Nix flake fully working (vendorHash filled in)
v1.3.1 patch — re-publish release archives with Rev 64+65 fixes baked in:
v1.3.0 release archives (linux/macos × x86_64/arm64 tar.gz) shipped with the Rev-54/55 mis-attribution in the bundled NOTICE.md and an unhelpful sopds status error message. Both fixed in Rev 64+65 on main; this Rev re-tags as v1.3.1 to push corrected docs and the status UX fix into the GitHub Release / Docker image / Homebrew formula.
What's different vs. v1.3.0:
- NOTICE.md: correct upstream attribution (Dmitry V. Shelepnev, © 2014, admin@sopds.ru) instead of hallucinated "V.A. Onishchenko"; dead
www.sopds.ruandsergey-dryabzhinsky/sopdslinks removed. - README.md: same attribution fixes + new "Related projects" section linking to fbe-go.
sopdsbinary:statuscommand distinguishes ESRCH/EPERM and shows PID file path (Rev 65).- No new features. Pure patch-level release.
Auto-triggered on tag push:
- GoReleaser builds 4 archives + sha256 checksums.
- Docker image republished as
ghcr.io/dimgord/sopds-go:1.3.1and:latest. - Homebrew formula auto-updated in
dimgord/homebrew-tap/Formula/sopds-go.rb.
sopds status UX fix — distinguish ESRCH from EPERM, show PID file path:
Symptom: ps -ef | grep sopds on a deployment shows sopds 2260 1 0 … /opt/sopds/sopds start (process running for days). But /opt/sopds/sopds status reports "SOPDS server is not running (process not found)" — even with explicit -c <config.yaml>.
Root causes (two separate issues, both surfacing through the same symptom):
-
Process is running under a daemon user; status invoked from a different account lacks permission to signal it.
os.FindProcesson Linux always succeeds (it just constructs aos.Processstruct); the actual liveness check isprocess.Signal(syscall.Signal(0)), which on Linux returnsEPERM(permission denied) when the process exists but is owned by another user, andESRCH(no such process) when the PID is stale. The pre-Rev-65 code lumped both into "process not found", erasing the diagnostic distinction. -
PID file path differs between configs. When
sopds statusis invoked without-c, the binary loads default config which has emptyScanner.PIDFile→ falls back to/tmp/sopds.pid. A production sopds started under a service account with its own config writes its PID file elsewhere (e.g./opt/sopds/sopds.pid). The two-config situation is correct architecture; the bug is that the error message gives no hint which PID file path was being read, leaving the user to guess.
Fix in cmd/sopds/main.go::runStatus:
- Distinguish
ESRCH(stale PID) vsEPERM(running but not yours) vs other errors viaerrors.Is(err, syscall.ESRCH/EPERM). - Print the PID file path in every "not running" branch so the user immediately sees where status was looking.
strings.TrimSpacethe PID file content beforestrconv.Atoi— some pid files have trailing newline.
New status outputs:
| Condition | Pre-Rev-65 message | Post-Rev-65 message |
|---|---|---|
| PID file absent | not running (no PID file) |
not running (no PID file at <path>) |
| PID file unparseable | status unknown (invalid PID file) |
status unknown (invalid PID file content: %q) |
| PID file has stale PID | not running (process not found) |
not running (stale PID N in <path>) |
| Process exists but owned by other user | not running (process not found) |
running (PID: N) — owned by another user; run as that user or root to control it |
| Process exists, signaled OK | running (PID: N) |
running (PID: N) (unchanged) |
| Other syscall error | not running (process not found) |
status check failed: <err> |
Files Modified:
cmd/sopds/main.go: importserrors(new);runStatusrewritten as switch onsignal(0)error class. ~25 lines net.
Fix incorrect upstream attribution + drop dead links:
In Rev 54 (README) and Rev 55 (NOTICE.md) I attributed the original Simple OPDS Python project to "V.A. Onishchenko" — that was a hallucination. The actual author per the LICENSE header (Russian text, sourced from a local copy of the upstream Python source) is Dmitry V. Shelepnev (Дмитрий Шелепнёв), © 2014, contact admin@sopds.ru, version 0.23.
I also linked to a sergey-dryabzhinsky/sopds GitHub repo as an "active fork" — that link returns 404 (verified with curl). The upstream homepage www.sopds.ru referenced in the LICENSE / README headers also doesn't resolve in 2026. Both removed from sopds-go's docs to avoid promising users dead resources.
License clause noted: original Python SOPDS is under "GPL-3.0 or, at your option, any later version" — the "any later version" clause is what permits an AGPL-3.0 downstream (per GNU's license compatibility matrix). Worth documenting because if it were strict GPL-3.0-only, our AGPL upgrade would be more delicate.
Files Modified:
README.md: opening paragraph attribution corrected (English transliterationDmitry V. Shelepnev+ CyrillicДмитрий Шелепнёв+ version 0.23 + email). Dead links to sopds.ru and the GitHub fork removed; replaced with "appears to be offline as of 2026" note. License section attribution corrected likewise.NOTICE.md: "Original SOPDS — Python implementation" section rewritten with correct author block, the "any later version" license-clause note, and a list of upstream conventions carried over (OPDS feed structure, MySQL schema for import-mysql tool, sopds.conf-style ini-to-YAML mapping, CLI shape).README.md: new "Related projects" section before License — links to fbe-go (sister project, same author, complementary FB2 editor role: edit metadata in fbe-go, serve from sopds-go).PROGRESS.md: this entry.
No code changes — pure metadata correction. v1.3.0 release artifacts still ship the original (mis-attributed) NOTICE.md; users who already downloaded v1.3.0 archives have the wrong text baked in. If bundled-doc accuracy matters, re-tag v1.3.1 to push corrected archives. Decision deferred — GitHub-Release-page README/NOTICE will reflect the fix immediately as part of the next push.
v1.3.0 milestone — OSS-publication ready:
Bump version 1.2.0 → 1.3.0 marking the first publicly-installable release. Nothing functional changed since v1.2.0 (Rust ports remained); this Rev wraps up the OSS-publication track that was Revs 53-62.
What's new for end-users at v1.3.0:
- License clarity — AGPL-3.0 with a README section explaining what it means for self-hosters vs. forks-deployed-as-services (Rev 53).
- NOTICE.md attribution — clean third-party-licenses audit, including the AGPL-compatibility verdict for each dep (Rev 55).
- Module path —
github.com/dimgord/sopds-gomatches the repo URL, sogo install github.com/dimgord/sopds-go/cmd/sopds@latestactually works (Rev 56). - CI — every push to main runs lint + test + build on Linux & macOS (Rev 57).
- GitHub Releases — cross-platform binaries (linux/darwin × amd64/arm64) auto-built via GoReleaser on each
v*tag, with SHA-256 checksums and conventional-commits-grouped changelog (Rev 58). - Docker images —
ghcr.io/dimgord/sopds-go:latest(multi-arch via manifest list, distroless base, ~75MB) auto-published on each release (Rev 59). - Nix —
nix run github:dimgord/sopds-go -- startworks directly;nix develop .#tts-rsfor the CUDA Rust workflow (Rev 60). - Homebrew —
brew tap dimgord/tap && brew install sopds-goafter the first release ships the formula auto-update (Rev 61). - Go 1.25 — toolchain bumped to current Go stable (Rev 62).
Install matrix (post-v1.3.0):
# Pre-built binary
curl -LO https://github.com/dimgord/sopds-go/releases/download/v1.3.0/sopds-go_1.3.0_linux_x86_64.tar.gz
# Docker
docker run -d ghcr.io/dimgord/sopds-go:latest
# Nix
nix run github:dimgord/sopds-go -- start
# Homebrew
brew tap dimgord/tap && brew install sopds-go
# Source
go install github.com/dimgord/sopds-go/cmd/sopds@v1.3.0v1.3.0 release pipeline test plan:
Tag v1.3.0 will trigger .github/workflows/release.yml. First release reveals what's brittle in the pipeline. Known pre-flight risks:
- GoReleaser docker step needs amd64 + arm64 cross-compile — should work but no prior validation.
- Homebrew formula push —
HOMEBREW_TAP_GITHUB_TOKENset as repo secret; first auto-formula commit goes todimgord/homebrew-tap. - GHCR multi-arch manifest — first push to
ghcr.io/dimgord/sopds-go; package needs to be created (likely auto on first push underdimgordnamespace). - Changelog generation — pulls from git commits since previous tag (v1.2.0); should pick up Rev 53-62 commits and group by Conventional Commits prefix.
If any step fails, that's diagnostic data for follow-up Revs. Either re-run with gh workflow run release.yml -f tag=v1.3.0 after the fix, or move forward with v1.3.1.
Files Modified:
PROGRESS.md: headerCurrent Version1.2.0 → 1.3.0; Rev 63 entry.
Go bump 1.24 → 1.25 + golangci-lint version unpinned (CI hotfix):
First push of Rev 57 (CI workflow) failed in the lint job with:
Error: can't load config: the Go language version (go1.23) used to build
golangci-lint is lower than the targeted Go version (1.24.0)
golangci-lint v1.61 (which I pinned in Rev 57) was itself built with Go 1.23, and golangci-lint's parser refuses to analyze code targeting a Go version newer than its own build version. So pinning the linter while the project tracks current-Go is a self-defeating maintenance trap — every Go bump silently breaks lint until the pin is bumped too.
Fix is twofold:
- Bump Go target 1.24 → 1.25.0 in
go.modto align with current stable Go. Also dropped thetoolchain go1.24.10line — Go's auto-downloader will fetch 1.25 on hosts without it. - Switch CI lint version from
v1.61→latestso the action grabs whatever golangci-lint release is current at run time. This eliminates the version-skew failure mode for good. Comment block in ci.yml documents the rationale so this doesn't get re-pinned by reflex on a future PR.
Other files touched to stay in sync:
.github/workflows/ci.yml—GO_VERSION: 1.24.x→1.25.x; golangci-lint pin →latest..github/workflows/release.yml—go-version: 1.24.x→1.25.x(otherwise GoReleaser builds release binaries with stale Go).flake.nix— devShellgo_1_24→go_1_25sonix developmatches CI.go.mod— direct + indirect deps re-tidied viago mod tidy(added a few transitively-needed packages:go-internal,objx,pretty,mousetrap,randomstring).
Verified locally:
go versionreports 1.26.1 (newer than the go.mod floor — fine, Go is forward-compatible).go build ./...clean.go test ./...all 14 packages pass.
Files Modified:
go.mod:go 1.24.0 / toolchain go1.24.10→go 1.25.0; tidy added indirect entries..github/workflows/ci.yml: 2 lines..github/workflows/release.yml: 1 line.flake.nix: 1 line.
Homebrew tap — dimgord/homebrew-tap + GoReleaser brews integration (Phase 9):
End-user macOS install path closed: brew tap dimgord/tap && brew install sopds-go. Each v* tag automatically pushes a formula update to dimgord/homebrew-tap via GoReleaser's brews: integration — no manual formula maintenance.
1. New repo: dimgord/homebrew-tap
Created public via gh repo create dimgord/homebrew-tap --public --add-readme. Default branch: master. README explains the auto-update flow and lists available formulae (currently just sopds-go); the Formula/ directory will be populated by GoReleaser on the first release tag — no formula committed manually. Brew's tap-name convention (homebrew- prefix on the repo name, dropped in brew tap user-side) means users get the friendly dimgord/tap shorthand.
2. .goreleaser.yaml — brews: block:
repository.{owner,name,branch}→dimgord/homebrew-taponmaster. Auth viaHOMEBREW_TAP_GITHUB_TOKENenv var (PAT or fine-grained token withcontents: writeon the tap repo).directory: Formula— Homebrew's standard formula location (resolves toFormula/sopds-go.rb).commit_author—dimgord-botfor tap-side audit clarity (vs. mixing with personal commits).installblock — drops all three binaries tobin/,config.yaml.exampleandinit.sqltopkgshare/so users have reference materials in$(brew --prefix)/share/sopds-go/.testblock —system "#{bin}/sopds", "version"is the smoke test thatbrew test sopds-goruns. Confirms the binary links cleanly and the version-injection ldflags from Rev 58 took effect.dependencies—postgresql@16as recommended (not required, since users may have a separate Postgres install).caveats— multi-step setup runbook shown afterbrew install. Mentions optional runtime deps (calibre,espeak-ng) and points at sopds-tts-rs for CUDA TTS.
3. .github/workflows/release.yml — passes HOMEBREW_TAP_GITHUB_TOKEN to GoReleaser env:
Added HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} next to existing GITHUB_TOKEN. Comment block in workflow file documents how to set the secret.
Manual step required from the maintainer — set the secret:
The auto-built GITHUB_TOKEN for sopds-go's workflow runs only has access to sopds-go itself; cross-repo writes (to homebrew-tap) need a separate token. Two options:
- Classic PAT (simplest): https://github.com/settings/tokens/new —
reposcope. Set:gh secret set HOMEBREW_TAP_GITHUB_TOKEN --repo dimgord/sopds-go # paste the PAT when prompted
- Fine-grained token (recommended for least-privilege): https://github.com/settings/personal-access-tokens/new — Repository access: Only select repositories →
dimgord/homebrew-tap. Repository permissions: Contents: Read and write. Samegh secret setcommand.
Once the secret is set, the next v* tag push will auto-publish the formula. No re-trigger of v1.2.0 is needed; just bump to v1.3.0 (or whatever) when convenient.
Pre-publication checklist update:
- Optional: Homebrew tap
Files Modified:
dimgord/homebrew-tap(new repo): polished README pushed (1 commit, 33 lines)..goreleaser.yaml: +60 lines (brews:block)..github/workflows/release.yml: +5 lines (HOMEBREW_TAP_GITHUB_TOKEN env + comment).
Root flake.nix — nix run github:dimgord/sopds-go (Phase 8):
Composite Nix flake at repo root that packages all three Go binaries (sopds, sopds-tts, zipdupes) plus re-exports sopds-tts-rs/'s CUDA-aware Rust dev shell. Lets Nix users install/run sopds-go without ever touching go build directly, and gives a single nix develop entry point covering both languages.
Flake structure:
-
Inputs:
nixpkgs(nixos-unstable) — Go toolchain + system tools.flake-utils—eachDefaultSystemboilerplate for the 4-system matrix (x86/arm × linux/darwin).sopds-tts-rs—path:./sopds-tts-rsso the Rust subproject's existing flake (with CUDA + cuDNN 9.8 pinning for Pascal sm_61, see Rev 52) is composed in.inputs.nixpkgs.follows = "nixpkgs"keeps the Go-side and Rust-side nixpkgs identical to avoid double-eval at the cost of using whatever sopds-tts-rs's own pinned cuDNN dictates for cuda-related tools (nixpkgs-cudais a separate sopds-tts-rs input — that one stays pinned).
-
packages.<system>.{sopds,sopds-tts,zipdupes,default}— onebuildGoModuleper CLI, with sharedcommonGoArgs(vendorHash, ldflags, doCheck = false).defaultaliasessopds. Version computed fromself.rev(short git rev) or "dev"/"dev-dirty" fallback. Linker flags match GoReleaser's tagged-release flags (-s -w -X main.version=${version}). -
apps.<system>.{sopds,sopds-tts,zipdupes,default}—nix run github:dimgord/sopds-go [-- args]invokes the corresponding binary'sbin/<name>entry. Default =sopds. Lets a curious user spin up the server in one command without cloning. -
devShells.<system>.default— Go dev shell:go_1_24,gopls,golangci-lint,delve,go-task,postgresql_16(client tools — psql / pg_dump / pg_restore for migrations + backups). Shell hook prints versions + quick task references. -
devShells.x86_64-linux.tts-rs— re-export ofsopds-tts-rs.devShells.x86_64-linux.default. Linux-only because the upstream flake hardcodes x86_64-linux + cudaSupport. Users donix develop .#tts-rsfrom repo root for the CUDA + Rust toolchain in one go.
Known caveat — vendorHash is fakeHash:
vendorHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";First nix build will fail with a "hash mismatch — expected: AAA…, got: " error. Copy the real hash into commonGoArgs.vendorHash and re-run. Going through the flake.lock + actually running nix build .#sopds once on a Linux box is the only way to know the right hash; CI will do this on first release-tag push, but for now it's a placeholder. Future improvement: gomod2nix derivative tooling to auto-generate hashes from go.mod, eliminating the manual step.
Verified evaluation:
nix flake metadataresolves all four inputs (nixpkgs at 2026-05-05, sopds-tts-rs path-input, flake-utils, transitive rust-overlay + nixpkgs-cuda from Jun 2025).nix flake show --no-update-lock-fileenumerates all 4 systems × 4 apps + 4 packages + 1 devShell (default) + 1 extra devShell on x86_64-linux (tts-rs). No errors.- Actual
nix builddeferred until a Linux test box and the vendorHash is filled in — the structure is sound.
Files Modified:
flake.nix(new): ~140 lines.flake.lock(new): generated bynix flake metadata. Committed for reproducibility.
Pre-publication checklist update:
- Root
flake.nixpackaging the binary fornix run github:dimgord/sopds-go
Docker image build + GHCR publish on tag (Phase 7):
Multi-arch (linux/amd64 + linux/arm64) Docker image automatically built and pushed to ghcr.io/dimgord/sopds-go on each v* tag, integrated into the existing GoReleaser pipeline (Rev 58). Users can now docker run -d ghcr.io/dimgord/sopds-go:latest for self-hosting without compiling anything.
1. Dockerfile at repo root, ~30 lines:
- Base:
gcr.io/distroless/base-debian12:nonroot— minimal Google distroless image with glibc + CA certs +nonrootuser, no shell, no package manager. ~25MB before binaries; ~75MB after. - Strategy: GoReleaser builds the binaries outside the image (cross-platform via Go's native cross-compile, way faster than QEMU-emulated
go buildinside arm64 image), then COPYs them into the runtime image. Dockerfile has no Go toolchain — pure runtime. - COPYs
sopds,sopds-tts,zipdupesto/usr/local/bin/. Bundlesconfig.yaml.exampleto/etc/sopds/config.yaml.exampleas reference; users mount their actual config over/etc/sopds/config.yaml. - Exposes 8081, runs as
nonroot:nonroot(no privileged daemon needed for the OPDS server). - ENTRYPOINT/CMD:
sopds start -c /etc/sopds/config.yaml. Override CMD for one-shot ops (migrate,scan,import-mysql). - Comments explicitly call out what's NOT included: PostgreSQL (run separately as a sibling container), Calibre (~500MB, build a derivative if MOBI conversion needed), espeak-ng + Piper voices (same logic — build a derivative for TTS). README will document this for users.
2. .dockerignore — excludes .git/, .github/, .task/, editor files, build artifacts, the Rust subprojects, fb2converters/, fetch/, test files, plus historical noise (*.lst, tags, sopds_backup.dump*). Keeps the build context tight.
3. .goreleaser.yaml extended with two dockers: blocks (one per arch) and docker_manifests: (manifest list aliasing both archs under :{{ .Version }} and :latest):
- Image tags:
ghcr.io/dimgord/sopds-go:<arch>for arch-specific, plus manifest list atghcr.io/dimgord/sopds-go:{{ .Version }}andghcr.io/dimgord/sopds-go:latest. - OCI labels:
org.opencontainers.image.{title,description,url,source,version,created,revision,licenses}populated from GoReleaser context — registries that read these (Docker Hub, GHCR's own UI) display them as image metadata.
4. .github/workflows/release.yml extended with three new steps before goreleaser:
docker/setup-qemu-action@v3— registers binfmt for arm64 emulation on the amd64 GitHub runner. Needed only because GoReleaser still does adocker build(even though the binary is pre-cross-compiled, the FROM base layer needs to be unpacked for the target arch).docker/setup-buildx-action@v3— buildx is required for--platform=linux/arm64flag GoReleaser passes todocker build.docker/login-action@v3toghcr.iousing the workflow's auto-providedGITHUB_TOKEN. Permissions extended:packages: writeadded next to existingcontents: writeso the token can push to GHCR.
Pre-publication checklist update:
- Dockerfile + GHCR publish
Files Modified:
Dockerfile(new): ~30 lines..dockerignore(new): ~30 lines..goreleaser.yaml: +75 lines (dockers× 2 +docker_manifests)..github/workflows/release.yml: +14 lines (qemu + buildx + login + permissions: packages:write).
First Docker image will be built and pushed automatically on the next v* tag. No tag needs to be bumped; current v1.2.0 was pre-Docker. Either re-trigger the existing tag via workflow_dispatch (Actions tab → Release → Run workflow → tag: v1.2.0) or wait for the next semver tag (v1.3.0 when Phase 8/9 ship).
Release automation: GoReleaser + .github/workflows/release.yml:
End-to-end release pipeline that fires on v* tag push: cross-compiles all three CLI binaries (sopds, sopds-tts, zipdupes) for linux/darwin × amd64/arm64, packages them with assets, computes SHA-256 checksums, generates a changelog from git commits, and publishes a GitHub Release.
1. .goreleaser.yaml at repo root:
version: 2— modern GoReleaser config schema.- 3
buildsentries — one per CLI. All static (CGO_ENABLED=0) so the binaries run on any glibc/musl distro.-trimpathfor reproducible builds (no local-path leak);-s -wstrips DWARF debug info;-X main.version={{ .Version }}injects the tag into thesopdsbinary's version command (refactored to use a package var — see code change below). - Archive naming:
sopds-go_<version>_<os>_<arch>.tar.gzwith macOS aliased tomacosand arm64/amd64 normalized toarm64/x86_64for human readability. Each archive bundles both binaries plusLICENSE,NOTICE.md,README.md,PROGRESS.md,config.yaml.example,init.sql. - Checksums:
checksums.txtin SHA-256. - Changelog from GitHub commits: uses
Conventional Commitsregex grouping —feat:→ "Features",fix:/bugfix:→ "Bug fixes", everything else → "Other". Filters out^docs:,^test:,^chore:,^ci:, andtypofrom the changelog (signal noise). - Release header/footer: header points to README + PROGRESS; footer has a curl-tar-install one-liner for Linux x86_64 + sha256sum verification reminder + AGPL-3.0 disclosure.
prerelease: auto— tags with-rc,-beta,-alphaautomatically flagged as pre-release on GitHub.
2. .github/workflows/release.yml — single job, ~50 lines:
- Triggered on
pushofv*tag, plusworkflow_dispatchfor manual rebuilds of an existing tag. permissions: contents: write— minimum scope, lets GoReleaser create the Release; no other writes.fetch-depth: 0— GoReleaser needs full git history for the changelog.goreleaser/goreleaser-action@v6withversion: ~> v2.GITHUB_TOKENfrom default action secrets — no PAT needed; default token has the necessaryreleases: writescope.
3. Code change — cmd/sopds/main.go: replaced the hardcoded fmt.Println("SOPDS v1.2.0 (Go rewrite)") with fmt.Printf("SOPDS %s (Go rewrite)\n", version) where version is a package-level var version = "dev" overridable via -ldflags. So:
go install github.com/dimgord/sopds-go/cmd/sopds@latest→ version reportsdevgo build -ldflags '-X main.version=v1.2.0' …→ reportsv1.2.0- GoReleaser-built binaries → reports the tag name from
{{ .Version }} - Verified:
/tmp/sopds_test version→SOPDS v1.2.0 (Go rewrite). ✓
4. Future-Phase wiring: the GoReleaser config includes hooks for Docker (Phase 7) and Homebrew tap (Phase 9) but they're commented out / not yet enabled — will come in subsequent Revs.
Pre-publication checklist update:
- Release automation
Files Modified:
.goreleaser.yaml(new): ~120 lines..github/workflows/release.yml(new): ~50 lines.cmd/sopds/main.go: 4 lines changed (addedversionpackage var, switched Println → Printf).
CI workflow — .github/workflows/ci.yml:
Added GitHub Actions CI that runs on push to main/dev and PRs to main. Patterns adapted from fbe-go's CI but trimmed — sopds-go has no frontend, no Wails, no XSD-tag matrix.
Three jobs:
-
lint(Linux only):golangci-lint v1.61with default linter set (govet, ineffassign, staticcheck, errcheck, unused). 5-min timeout. No.golangci.ymlin repo yet — using action's defaults; can tune later. -
test(Linux + macOS matrix):go vet ./...thengo test -race -timeout 5m ./.... Hermetic — no PostgreSQL service container needed becauseinternal/database/*_test.goonly tests model structs (struct-roundtrip, constants, pagination math), not actual SQL queries. The-racedetector catches goroutine misuse early. -
build(Linux + macOS matrix): builds all three CLI binaries —sopds,sopds-tts,zipdupes— to confirm they link on each target. Catches build-tag / CGo issues that pure-Go tests don't (e.g. if a future change adds a Linux-only syscall import that breaks macOS).
Why no PostgreSQL service: verified by inspection — find internal -name '*_test.go' -exec grep -l 'sql\|gorm\|database/sql' {} only matches internal/config/config_test.go (uses gorm types but doesn't open a connection), internal/database/models_test.go (tests model structs), and a couple of domain-layer tests (use repository interfaces, no real connection). If actual integration tests are added later, add a services.postgres block to the test job.
Cancel-in-progress is on for the same workflow+ref so successive pushes don't pile up runs. permissions: contents: read — minimum scope, avoids CodeQL warning, follows the principle that CI is read-only (only reads code, doesn't comment back or push).
Files Modified:
.github/workflows/ci.yml(new): 3 jobs, ~85 lines.
Pre-publication checklist update:
- CI workflow
Rename Go module path: github.com/sopds/sopds-go → github.com/dimgord/sopds-go:
The original module path declared in go.mod referenced a non-existent GitHub organization (sopds/), while the actual repo lives at github.com/dimgord/sopds-go. This mismatch wouldn't matter for go build but would break go install github.com/dimgord/sopds-go/cmd/sopds@latest for users — Go's module proxy expects the path to match the hosted location.
Why rename instead of creating a sopds/ org:
- sopds-go is an application, not a library — nobody is importing it as a Go package, so the rename has zero external blast radius.
- Renaming aligns the module path with the actual repo location, making
go installwork for users who want to build from source without cloning. - Creating an empty
sopdsorg just to host one repo is administrative overhead with no upside.
Mechanism:
find . -type f \( -name '*.go' -o -name 'go.mod' \) ! -path './.git/*' ! -path '*/target/*' \
-exec sed -i '' 's|github.com/sopds/sopds-go|github.com/dimgord/sopds-go|g' {} +Replaced 78 occurrences across 38 files (37 Go files + go.mod). PROGRESS.md was deliberately excluded from sed — historical entries that mention the old path remain as factual snapshots.
Verification:
go build ./...— clean (no errors).go test ./...— all 14 internal packages pass (converter, database, domain/{author,book,catalog,genre,repository,series,user}, i18n, opds, scanner, tts).
Files Modified: 37 .go files + go.mod — all internal imports rewritten consistently.
NOTICE.md — third-party attributions audit:
Added NOTICE.md at the repo root listing every third-party work that ships with sopds-go (bundled), is linked at build time (Go modules / Rust crates), or is invoked as a subprocess at runtime, with license + AGPL-compatibility verified for each.
Sections:
- Original SOPDS attribution — V.A. Onishchenko's Python implementation, GPL-licensed, source for sopds-go's architecture / config schema / MySQL DB layout.
- Bundled converters (
fb2converters/fb2mobi/,fb2converters/fb2epub/) — both MIT-licensed, called as subprocesses (not linked). Originally based on dnkorpushov'sfb2conv+ eBook .NET respectively. - Go module direct deps — 14 modules: chi (MIT), gorm (MIT), cobra (Apache-2.0), pgx via driver/postgres (MIT), mysql driver (MPL-2.0), golang.org/x/* (BSD-3), yaml.v3 (MIT/Apache-2.0), etc. AGPL-compatibility verified for each. Notable: MPL-2.0 (mysql driver) is AGPL-compatible per FSF list because MPL §3.3 explicitly permits combination.
- Rust crates for sopds-tts-rs + zipdupes-rs —
ort,serde,serde_json,hound,clap,walkdir. All Apache-2.0/MIT dual or compatible. - CUDA / cuDNN — proprietary NVIDIA libraries linked dynamically by ORT at build time on GPU systems. Not redistributed — sourced from user's NVIDIA installation. Binary remains AGPL-distributable.
- External subprocess tools — Calibre (GPL-3.0), espeak-ng (GPL-3.0), Piper TTS (MIT), PostgreSQL (BSD-like). GPL-3.0 tools called via
exec.Commanddo not impose GPL on sopds-go. - Voice models — NOT bundled, user-downloaded from rhasspy/piper-voices. Mostly MIT/CC-BY per model author.
- Web UI assets — Font Awesome via CDN (SIL OFL / CC BY / MIT), not bundled.
Why this matters at OSS publication time:
- AGPL-3.0 places strict requirements on derivative works and combined works. NOTICE.md documents that each link/bundle/subprocess relationship has been classified correctly (linked vs. invoked) so license obligations transfer cleanly.
- Apache-2.0 deps (cobra, hound, ort, serde) require attribution preservation under §4(c); listing them here satisfies that.
- Subprocess invocation (Calibre, espeak-ng, ebook-convert) does not propagate GPL to sopds-go — but users need to know these are GPL tools they're installing alongside, especially if they redistribute prebuilt sopds-go bundles with these tools included (Docker image needs this disclaimer).
- Voice models are user-provided, no bundling concern, but README and NOTICE both clarify each model has its own license.
Files Modified:
NOTICE.md(new): ~140 lines covering all categories above.
README polish for OSS publication:
Significant rewrite preparing the repo for public release on GitHub. Goals: orient new readers, surface the Rust subprojects, replace placeholder <repository> references with real install paths, sync version + Go-version data with reality.
Changes:
-
Header: replaced bare
**Version: 1.1.0**line with four shields.io badges — latest release, license (AGPL-3.0), Go version (read from go.mod), CI build status. Badges link to relevant pages so a curious visitor can land on any of them in one click. -
Description rewrite: opening paragraph now positions sopds-go as a Go rewrite of Onishchenko's Python SOPDS, with concrete value-prop language — "self-hosted home libraries", "scans nested ZIP archives", "even produces audiobooks via local TTS". Replaces the previous one-liner that didn't tell a stranger why they'd care.
-
Features section grouped into four subsections: Catalog & Browsing / Library Management / Conversion & TTS / Operations. Adds previously-missing items: TTS (Piper), GPU-accelerated TTS (sopds-tts-rs), Docker image, Nix flake, prebuilt binaries.
-
Requirements bumped:
Go 1.21+ → Go 1.24+(go.mod actually pins 1.24.0). Added optional dependencies for TTS (espeak-ng, Piper) and CUDA. PostgreSQL 12+ → 14+ (more reasonable lower bound). -
Installation section restructured — four subsections in priority order:
- Pre-built binaries (curl + tar + install) — for users
- Docker (
docker runwith library + config volumes) — for self-hosters - Nix flake (
nix run github:dimgord/sopds-go) — for Nix users - From source (
go build) — for hackers Replaces the previous singlegit clone <repository>placeholder.
-
New
## Rust subprojectssection — explainssopds-tts-rs/(CUDA-accelerated, Pascal sm_61 supported, ORT 2.0 + cuDNN 9.8 dual-nixpkgs flake) andzipdupes-rs/(rip-grep-style speed for corpus scans). Both presented as CLI-drop-in replacements; Go originals retained as fallbacks. References Rev 52 for the engineering trail. -
Project Structure refreshed to reflect actual directory tree (added
internal/tts/,internal/infrastructure/, both Rust subdirs,fb2converters/,Taskfile.yml). -
Web Interface section trimmed and renamed
## Web UI(was## Web Interface Screenshotsbut had no actual screenshots — false promise).
Files Modified:
README.md: ~120 lines added, ~30 removed; net +90 lines.
Pre-publication checklist (work in progress; not blocking this Rev):
- Rev 53 — license switch to AGPL-3.0
- Rev 54 — README polish
- Rev 55 — NOTICE.md third-party attributions audit
- Rev 56 — Module-path rename to
github.com/dimgord/sopds-go - Rev 57 — CI workflow (
.github/workflows/ci.yml) - Rev 58 — Release automation (
.github/workflows/release.yml) + GoReleaser config - Rev 59 — Dockerfile + GHCR multi-arch publish
- Rev 60 — Root
flake.nix(Go binaries + composed Rust devShell) - Rev 61 — Homebrew tap (
dimgord/homebrew-tap) + GoReleaserbrews:integration - Optional: Homebrew tap (
dimgord/homebrew-tap)
License switch: GPL-3.0 → AGPL-3.0:
Replaced the LICENSE file with the canonical AGPL-3.0 text from gnu.org. README's License section rewritten to explain the rationale and the practical difference for network-deployed instances.
Why AGPL specifically (not staying on GPL):
- sopds-go is inherently a network service — its primary interface is the OPDS XML feed served over HTTP, plus the HTML web UI. Most users will deploy it as a public/intranet catalog, not run it as a local CLI.
- GPL-3.0 has the well-known "SaaS loophole": a hosting provider can take GPL code, modify it, run it as a public service, and never release the modifications because the software isn't distributed to users in the GPL sense — only its output (XML, HTML) is.
- AGPL-3.0 closes this loophole via §13: "if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network ... an opportunity to receive the Corresponding Source".
- For an OPDS catalog, this is the correct ethical default: someone running a forked sopds-go for their book community should share their improvements back, not silently keep them.
- Self-hosting for personal use remains unrestricted under both GPL and AGPL.
Practical impact for users:
- Personal home server: zero change — you already have the source.
- Forking + deploying as a community service: must publish your fork's source.
- Embedding sopds-go logic in another network application: that application must be AGPL-licensed (strong copyleft).
Practical impact for contributors:
- Patches submitted via PR are accepted under AGPL-3.0 (CONTRIBUTING.md will spell this out when added).
- Contributors retain copyright; sopds-go does not require CLA assignment.
Files Modified:
LICENSE: replaced GPL-3.0 text with AGPL-3.0 (canonical from https://www.gnu.org/licenses/agpl-3.0.txt; sha1 4c665f87b5dc2e7d26279c4b48968d085e1ace32, 661 lines).README.md: License section rewritten — explicit AGPL-3.0 link, network-deployment note, attribution to original SOPDS project (V.A. Onishchenko's Python implementation, also GPL-family).
Rust ports of sopds-tts and zipdupes for performance + GPU acceleration:
Two CLI utilities rewritten in Rust as drop-in replacements for the Go versions, primarily to recover GPU acceleration on Pascal-class hardware (sm_61, GTX 1070-era) and squeeze more throughput out of the audiobook-generation pipeline. Both Rust subprojects ship as separate crates under the sopds-go monorepo and remain CLI-compatible with their Go predecessors so callers don't change.
1. sopds-tts-rs/ — CUDA-accelerated TTS (drop-in for sopds-tts):
- Same CLI:
sopds-tts-rs <model> <output>, text on stdin, WAV on<output>. Identical interface to Go version, so the audiobook generator's subprocess plumbing stays unchanged. - Stack:
ort 2.0.0-rc.10(ONNX Runtime Rust binding, CUDA execution provider) +serde(model metadata) +hound(WAV encoding) +espeak-ng(IPA phonemizer for non-"text" Piper models). - Why Rust: Go's
onnxruntime-gohas limited CUDA-EP support and trails upstream. ORT-rs binds against the C API directly, gets the full CUDA EP, and the executable is dramatically faster than the Go path for typical chapter-length inputs. - Pascal + Nix bring-up was the hard part — most of the engineering hours on this Rev. Current nixpkgs-unstable bundles cuDNN 9.x but not in a sm_61-compatible build (newer cuDNN dropped Pascal compute capability). Solution:
flake.nixpulls TWO nixpkgs inputs:nixpkgs(latest) — Rust toolchain (rustc,cargo,pkg-config).nixpkgs-pinned(June 2025 snapshot) —cudaPackages.cudnn = 9.8which still ships sm_61. Linked at build time viaLD_LIBRARY_PATHoverlay.
- ORT itself is built from source (CUDA-EP enabled, sm_61 added explicitly to
CMAKE_CUDA_ARCHITECTURES). Firstnix developis 15-30 min on cold cache; subsequent builds are trivial. - Files:
sopds-tts-rs/src/main.rs(291 lines: ONNX inference + speaker-id + phoneme map + WAV write),sopds-tts-rs/Cargo.toml,sopds-tts-rs/Cargo.lock,sopds-tts-rs/flake.nix,sopds-tts-rs/flake.lock,sopds-tts-rs/run-gpu.sh.
2. zipdupes-rs/ — Rust port of the FB2 archive de-duplicator:
- Same CLI as Go
zipdupes. Walks a directory tree of.fb2.ziparchives, hashes their entries, identifies duplicates across archives. - Why Rust: rip-grep-style speed for I/O-heavy walking + hashing on the 1TB book corpus on
/1TB. Go version was acceptable but visibly slow on cold-cache full corpus scans. - Files:
zipdupes-rs/src/main.rs(455 lines),zipdupes-rs/Cargo.toml,zipdupes-rs/Cargo.lock.
Status:
- Both ports landed in production at this Rev —
sopds-tts-rsis the active TTS backend whenever GPU TTS is needed;zipdupes-rsreplaces the Gozipdupesfor batch corpus scans. Neither has been heavily exercised since (no major book imports / TTS jobs in the recent month), so any subtle bugs are still latent. The Go versions remain in-tree as fallbacks (not deleted).
Files Modified:
sopds-tts-rs/(new directory, 7 files):Cargo.toml,Cargo.lock,flake.nix,flake.lock,run-gpu.sh,src/main.rs. Total: 1547 lines.zipdupes-rs/(new directory, 3 files):Cargo.toml,Cargo.lock,src/main.rs. Total: 682 lines.go.mod,go.sum: dependency churn from co-landed Go-side cleanups (49 + 322 lines added)..gitignore: added Rust target/ patterns.CLAUDE.md(commit7e0c83d, follow-up): documents the Rust subprojects in the architecture tree and adds a "Rust TTS alternative" section under "Audiobook Support" describing the Nix CUDA build flow.
Commits covered (retroactively documented 2026-05-09 — original landing skipped PROGRESS.md):
9f6e1e5Add rust versions of zipdupes and sopds-tts.7e0c83dAdd Rust TTS (sopds-tts-rs) documentation to CLAUDE.md
Bugfix: Ukrainian TTS - Multi-Speaker Model & Language Code Matching:
Three issues fixed for Ukrainian book TTS generation:
-
Multi-speaker ONNX model support (
Missing Input: siderror)uk_UA-ukrainian_tts-mediumhas 3 speakers (lada, mykyta, tetiana)- ONNX model requires
sidinput tensor but we only sent 3 inputs - Added
sidtensor torunInference()whennum_speakers > 1 - Added
SpeakerIDMapandspeakerIDfield to Piper struct - ONNX session now includes "sid" in input names for multi-speaker models
-
Text phoneme type support (model uses raw text, not IPA)
uk_UA-ukrainian_tts-mediumhasphoneme_type: "text"- Its
phoneme_id_mapcontains Ukrainian letters directly, not IPA symbols textToPhonemes()now skips espeak-ng for "text" type models- Returns lowercase text directly for phoneme ID mapping
-
Language code normalization (
uk-UAnot matching config keyuk)- Book 211236 had lang
uk-UAin FB2 metadata, config key wasuk GetVoice()now tries base language code (strips-UA,-USetc.)- Ukrainian books no longer fall back to English voice
- Book 211236 had lang
Files Modified:
internal/tts/piper.go:- Added
PhonemeType,SpeakerIDMapto PiperConfig - Added
speakerIDfield to Piper struct NewPiper()configures "sid" input name and default speaker ID for multi-speaker modelstextToPhonemes()returns raw lowercase text for "text" phoneme typerunInference()addssidtensor whennum_speakers > 1
- Added
internal/tts/generator.go:GetVoice()tries base language code when exact match fails
Bugfix: Text Chunk Splitting UTF-8 Bug (Root Cause of OOM):
- Found root cause: chunk 7 was 16,551 chars instead of 5,000 due to UTF-8 split bug
- Bug in
findSplitPoint(): when UTF-8 boundary search backed up to 0, returnedlen(text)(entire remaining text) - Fix: Instead of returning entire text, find first valid rune start after original position
- This was causing ONNX to allocate massive memory for oversized chunks, leading to OOM
Files Modified:
internal/tts/extractor.go:- Fixed
findSplitPoint()to not return entire text length on UTF-8 boundary failure - Now finds next valid rune start instead of returning all remaining text
- Fixed
internal/tts/generator.go:- Reverted to normal chunk ordering
- Re-enabled parallel processing
TTS Debug Logging and ONNX Isolation:
- Added debug logging to generateChunk to trace subprocess execution
- Removed ONNX initialization from main sopds process entirely
- IsAvailable() now just checks for sopds-tts binary and models dir
- Main sopds process has zero ONNX/native TTS code loaded
Files Modified:
internal/tts/generator.go:- Added log statements in generateChunk for subprocess tracking
- IsAvailable() no longer calls initORT()
TTS Subprocess Architecture (Memory Leak Fix):
- Created separate
sopds-ttsbinary for TTS chunk generation - Each chunk runs in its own subprocess, guaranteeing complete memory release
- Re-enabled parallel chunk processing (subprocesses are independent)
- Supports multiple simultaneous users (each request spawns separate processes)
New Files:
cmd/sopds-tts/main.go: Standalone TTS binary- Takes model path and output path as args, text via stdin
- Creates Piper, generates audio, exits (releasing all memory)
Files Modified:
internal/tts/generator.go:- Added
getTTSBinaryPath()to locate sopds-tts binary generateChunk()now spawns sopds-tts subprocess instead of native Piper- Re-enabled parallel processing with configurable workers
- Added
Taskfile.yml: Build task now builds both sopds and sopds-tts
TTS Memory Leak Investigation:
- Reverted parallel chunk processing to single-threaded (numWorkers=1)
- Added
debug.FreeOSMemory()for aggressive memory release - Added explicit nil assignments in
Piper.Close()to help GC - Issue: ONNX runtime (onnxruntime_go) appears to leak native memory
- TODO: If memory issues persist, implement subprocess-based TTS worker
Files Modified:
internal/tts/generator.go: Single worker, added debug.FreeOSMemory()internal/tts/piper.go: Clear all references in Close()
Bugfix: TTS Status Transition Not Updating UI:
- Fixed page not updating when TTS status changes from "queued" to "processing"
- Added
initialStatusvariable to track page's initial state - Page now reloads when polled status differs from initial status
Files Modified:
internal/server/web.go: Added status change detection in TTS polling JavaScript
Enhancement: Parallel TTS Chunk Generation:
- Chunk generation now runs in parallel using worker pool pattern
- Number of parallel workers controlled by
tts.workersconfig (default: 2) - Uses atomic counter for thread-safe progress tracking
- Graceful cancellation support - workers check context and exit early
- First error stops all workers and reports failure
Files Modified:
internal/tts/generator.go:- Added
sync/atomicimport - Replaced sequential chunk loop with parallel worker pool
- Workers pull from task channel, process independently
- Progress updated atomically as each chunk completes
- Added
Bugfix: TTS Memory Leak (OOM Kill):
- Fixed massive memory leak causing OOM kill after ~7 chunks (58GB memory usage)
- Root cause: Cached ONNX/Piper sessions accumulate memory across inference runs
- Solution: Create fresh Piper instance for each chunk, close immediately after use
- Added explicit
runtime.GC()call after closing each Piper to release ONNX memory - Removed unused Piper caching code (
getPiper,closePipers, pipers map)
Files Modified:
internal/tts/generator.go:- Removed
pipersmap andpipersMumutex from Generator struct - Removed
getPiper()andclosePipers()methods generateChunk()now creates fresh Piper per chunk with Close() + GC after each
- Removed
Bugfix: TTS Player JavaScript Errors:
- Fixed "style is undefined" errors in TTS player page
- Root cause: JavaScript runs unconditionally but DOM elements only exist in specific template branches
- Fix 1: Added null checks for
genProgress/genTextin status polling (queued→processing transition) - Fix 2: Wrapped audio event listeners in
{{if .IsComplete}}template conditiontimeupdate,loadedmetadata,endedlisteners now only register when player exists
Files Modified:
internal/server/web.go:- Added element existence checks in TTS polling JavaScript
- Wrapped audio event listeners in IsComplete template condition
Enhancement: TTS Player Progress Display:
- Added chunk progress display to TTS generation status bar
- Shows "X/Y chunks" alongside percentage during generation
- Added ETA calculation based on average chunk processing time
- ETA displays minutes or hours format depending on remaining time
Files Modified:
internal/server/web.go:- Added
ChunksDone,ChunksTotalfields to TTS player template data - Updated HTML template to show initial chunk counts
- Enhanced JavaScript polling to update chunks and calculate ETA
- Added
Feature: TTS (Text-to-Speech) with Piper:
- Added text-to-speech capability for FB2 ebooks using piper TTS engine
- Background generation with configurable worker pool
- Cached audio files per book for instant playback
New Package: internal/tts/
extractor.go- Extract plain text from FB2 XML for TTS input- Strips markup, preserves paragraph breaks
- Splits text into chunks at sentence/paragraph boundaries
cache.go- Manage cached audio files per book- Directory structure:
{cache_dir}/{book_id}/chunk_NNN.wav - Metadata JSON tracks voice, language, chunk count, status
- Directory structure:
queue.go- Job queue for background generation- In-memory queue with book lookup
- Status tracking: queued, processing, completed, failed
- Progress tracking per chunk
generator.go- Piper integration and worker pool- Configurable number of workers
- Language-specific voice selection
- Graceful start/stop
New Routes:
POST /web/book/{id}/tts/generate- Queue TTS generationGET /web/book/{id}/tts/status- Get generation status (JSON)GET /web/book/{id}/tts- TTS player pageGET /web/book/{id}/tts/chunk/{idx}- Stream audio chunk
TTS Player Features:
- Chapter navigation with clickable list
- Play/pause, prev/next track controls
- Progress bar with seek
- Dark mode toggle
- Auto-play next chunk
- Status polling during generation
Configuration (config.yaml):
tts:
enabled: true
piper_path: "/usr/bin/piper"
models_dir: "/var/lib/piper/models"
voices:
en: "en_US-lessac-medium"
uk: "uk_UA-lada-x_low"
default_voice: "en_US-lessac-medium"
cache_dir: "/var/lib/sopds/tts_cache"
workers: 2
chunk_size: 5000Files Created:
internal/tts/extractor.go- FB2 text extractioninternal/tts/cache.go- Cache managementinternal/tts/queue.go- Job queueinternal/tts/generator.go- Piper integrationinternal/tts/tts_test.go- Unit tests (15 tests)
Files Modified:
internal/config/config.go- Added TTSConfig structinternal/server/server.go- TTS generator init, routes, book data callbackinternal/server/web.go- TTS handlers and player templateinternal/i18n/locales/en.yaml- English TTS translationsinternal/i18n/locales/uk.yaml- Ukrainian TTS translations
i18n Keys Added:
tts.listen,tts.generate,tts.generatingtts.queued,tts.queued_desc,tts.waitingtts.not_generated,tts.not_generated_desctts.chapters,tts.chapter,tts.progresstts.play,tts.pause,tts.downloadtts.not_available,tts.failed
Requirements:
- piper binary (https://github.com/rhasspy/piper)
- Voice model files (.onnx) in models_dir
DRY Refactoring - Server Helpers:
- Created
internal/server/helpers.go(208 lines) with consolidated helper functions - Eliminated ~150 lines of duplicated code across handlers.go and web.go
New Helper Functions:
BookPathInfostruct - parsed path info (archive/regular file detection)getBookPath()- full filesystem path for book filegetBookDir()- directory containing the bookparseBookPath()- parses archive info (ZIP/7z detection, internal path)getTrackPath()- audiobook track path constructionisAudioExtension(),isArchiveExtension()- format detectionreadFromArchive()- unified ZIP/7z reading (replaces separate functions)readFileFromZip(),readFileFrom7z()- low-level archive readingBookLinksstruct - authors, genres, series for a bookgetBookLinks()- fetches all book links in one callgetTotalPages()- pagination calculation
Code Consolidated:
- 20+
filepath.Join(s.config.Library.Root, ...)patterns → helper methods - 12 occurrences of
GetBookAuthors/Genres/Seriestriplet →getBookLinks() - 8 pagination calculations →
getTotalPages() readFromZip()andreadFrom7z()→readFromArchive()serveFromZip()simplified usingparseBookPath()
Bug Fixed:
- web.go:2132 was reading from
book.Pathinstead of full path with filename
Comprehensive Test Suite:
- Created comprehensive tests before DRY refactoring (99 test functions total)
- Tests document actual behavior for regression detection
- Baseline saved to
test_baseline.txt
New Test Files Created:
internal/config/config_test.go(405 lines, 11 tests)- DefaultConfig, DSN, Load, Save, validation, struct fields
internal/converter/converter_test.go(464 lines, 17 tests)- FB2→EPUB conversion, cover handling, BOM support, ZIP structure
internal/converter/reader_test.go(411 lines, 18 tests)- FB2→ReaderHTML, TOC extraction, nested sections, heading levels
internal/scanner/fb2parser_test.go(251 lines, 8 tests)- FB2 parsing, authors, genres, series, BOM, partial parsing
internal/scanner/audioparser_test.go(401 lines, 19 tests)- Audio format detection, MIME types, author extraction, encoding fixes
internal/scanner/audiobookgrouper_test.go(279 lines, 12 tests)- Folder name parsing, year suffix removal, duration formatting
internal/scanner/duration_test.go(269 lines, 14 tests)- Duration extraction for MP4, MP3, FLAC, OGG formats
internal/scanner/inxparser_test.go(231 lines, updated)- Nokia INX parser structs, total duration, file detection
Known Implementation Issues Documented:
FormatDuration()only shows single digits (uses modulo 10)parseTitleFromFolderName()doesn't handle trailing separator- Tests document actual behavior for safe refactoring
Feature: Nokia AWB Audiobook Support:
- Added support for Nokia audiobooks (AWB format from Nokia Audiobook Manager ~2008)
- AWB is AMR-WB (Adaptive Multi-Rate Wideband) audio codec
- INX index files provide accurate durations (AWB files have no metadata tags)
- AWB audiobooks treated as regular folder audiobooks (format="folder")
Files Created:
internal/scanner/inxparser.go- Nokia INX index file parserNokiaAudiobookstruct with name, tracks, chapters, version, codec infoNokiaTrackstruct with filename and duration (seconds)ParseINX()parses UTF-16 LE/BE INX files with BOM detectionFindINXFile()finds INX file in a directory
internal/scanner/inxparser_test.go- Unit tests for INX parser
Files Modified:
internal/scanner/scanner.go- Added
.awbtoaudioExtSetfor audio file detection - Modified
processAudioGroup()to check for INX files and read durations - Modified
countFiles()to count audio files from audioExtSet
- Added
internal/scanner/audioparser.go- Added AWB format handling - returns basic metadata (no tags to parse)
internal/server/web.go- Added
serveTrackFromAWB()for AWB→MP3 streaming conversion via ffmpeg - Modified folder audiobook handling to detect AWB tracks by extension
- Added
internal/config/config.go- Added
FFmpegfield toConvertersConfig(default: "ffmpeg") - Added
.awbto default library formats
- Added
Requirements:
- ffmpeg with libmp3lame for AWB→MP3 conversion
- Conversion runs at ~430x realtime speed
Feature: Web-Based FB2 Reader:
- Implemented in-browser ebook reader for FB2, EPUB, and MOBI formats
- Non-FB2 formats converted to FB2 using Calibre's ebook-convert
- Route:
GET /web/read/{id}- opens book in reader
Reader Features:
- Full book content display with proper typography
- Table of Contents navigation (collapsible sidebar)
- Font size controls (+/-)
- Light/dark mode toggle
- Reading position saved in localStorage
- Images embedded as data URIs
- Responsive design (mobile-friendly TOC)
Files Created:
internal/converter/reader.go- FB2 to reader HTML conversionReaderContentstruct with title, authors, TOC, HTML, coverFB2ToReaderHTML()parses FB2 and generates reader-friendly HTMLConvertToFB2()converts EPUB/MOBI to FB2 via Calibre
Files Modified:
internal/server/web.go- Added
ReaderDatastruct for reader template - Added
handleWebReader()handler - Added
readFrom7z()for reading books from 7z archives - Added
renderReaderTemplate()with reader template - Added
readerTemplateconstant with full HTML/CSS/JS - Added "Read" button to book list template for FB2/EPUB/MOBI
- Added "Read" button to bookshelf template
- Added
internal/server/server.go- Registered/web/read/{id}routeinternal/i18n/locales/*.yaml- Added reader translations (all 5 languages)
i18n Keys Added:
reader.read- Read button labelreader.toc- Table of Contentsreader.font_smaller/reader.font_larger- Font controlsreader.dark_mode- Dark mode togglereader.close- Close buttonreader.not_supported- Error for unsupported formatsreader.conversion_failed- Conversion error messagereader.loading- Loading indicator
Bug Fix: Audiobook player link for all formats:
- Changed condition from
Format=="FOLDER"toIsAudiobook - Now 7z/zip single-file audiobooks properly link to audio player page
- Single-track audiobooks show "Audiobook" instead of "1 Tracks"
Feature: Favicon and Placeholder Covers:
- Added SVG favicon with book icon in app theme colors
- Routes:
/favicon.icoand/favicon.svg - Added
<link rel="icon">to HTML templates
- Routes:
Feature: Placeholder Covers for Missing Book/Audiobook Covers:
- Added fallback placeholder SVG images for books/audiobooks without covers
- Books use book icon placeholder (3 book spines)
- Audiobooks use headphones icon placeholder
- Replaces 404 responses with 200 + placeholder image
- Nordic-themed design (matching app colors) with book icon
- Aspect ratio 2:3 (200x300) - standard book cover dimensions
- "No Cover" text label
- Created
placeholderCoverSVGconstant in handlers.go - Created
servePlaceholderCover()function for serving the placeholder - Updated
handleCover()to return placeholder instead of 404 for:- Invalid book ID (non-numeric)
- Non-existent book ID
- Non-FB2 formats without embedded covers
- FB2 files without cover images
- File not found errors
- Updated
serveAudioCover()to return placeholder for all error cases - Updated
handleAudioTrackCover()to return placeholder for all error cases:- Invalid audiobook ID
- Missing file parameter
- Audiobook not found
- Not an audiobook
- Cover not found in track
- Bug fix: Fixed nil pointer panic when GetBook returns nil book without error
- GetBook returns
nil, nilfor non-existent books (not an error) - Added
book == nilcheck alongside error check
- GetBook returns
Bug Fix: UI language switching when filtering books:
- The
langURL parameter was used for both UI language AND book filtering - Filtering books by language (e.g., Japanese) would switch UI if it matched a supported language
- Fix: UI language now only set via cookie (JavaScript switcher)
- URL
langparameter is now exclusively for book language filtering - Removed
setLangCookie()function and all 18 calls to it
Bug Fix: FOLDER button 404:
- Folder-based audiobooks showed "FOLDER" as download button, which caused 404
- Fix: For folder format, show headphones button with track count linking to audio detail page
- Applied to both book list template and bookshelf template
Bug Fix: Subfolder track playback in archive audiobooks:
- Tracks inside subfolders within ZIP/7z archives couldn't be played
- Two issues fixed:
- Archive path construction when
path == filename(data bug workaround) - Track matching now uses multiple strategies:
- Exact path match
- Suffix match for relative paths
- Basename match for just filenames
- Archive path construction when
- Created
serveFileFromZip()andserveFileFrom7z()helper functions - Handles cases where tracks don't have full path stored (legacy data)
Bug Fix: Empty track path in audiobook template:
- Legacy audiobook data has empty
pathfield in tracks (onlynamestored) - Template was generating
?file=(empty) causing 400 errors - Fix: Use
{{or $track.Path $track.Name}}to fallback to filename - Applied to both collection tracks and flat track list templates
Feature: Password Confirmation with Show/Hide Toggle:
- Added password confirmation field to registration form
- Both password fields have eye icon toggle for show/hide
- Real-time validation checks passwords match before enabling submit
- When password changes, confirm field re-validates automatically
Feature: Centralized i18n with Language Resource Files:
- Created
internal/i18n/package for internationalization - Language files stored in
internal/i18n/locales/*.yaml - To add a new language: copy
en.yaml, translate, add code tosupportedLanguages - All translations consolidated from web.go and server.go into YAML files
- Uses Go's
embedto compile translations into binary - Supported languages (5): English, Ukrainian, French, Spanish, German
Files Created:
internal/i18n/i18n.go- i18n package with YAML loadinginternal/i18n/locales/en.yaml- English translationsinternal/i18n/locales/uk.yaml- Ukrainian translations (Українська)internal/i18n/locales/fr.yaml- French translations (Français)internal/i18n/locales/es.yaml- Spanish translations (Español)internal/i18n/locales/de.yaml- German translations (Deutsch)
Files Modified:
internal/server/web.go- Uses i18n package instead of inline translationsinternal/server/server.go- Uses i18n package for auth translationsinternal/server/auth_templates.go- Password confirmation with toggle
Files Created:
internal/server/favicon.go- SVG favicon with book icon
Files Modified:
internal/server/handlers.go- Added placeholderCoverSVG, servePlaceholderCover(), updated handleCover() and serveAudioCover()internal/server/server.go- Added favicon routes (/favicon.ico, /favicon.svg)internal/server/web.go- Added favicon link to HTML templates, updated handleAudioTrackCover() to use placeholder
Feature: SMTP Email Sending for Authentication:
- Added
SMTPConfigto configuration (internal/config/config.go)- Supports SMTP with STARTTLS (port 587) or implicit TLS (port 465)
- Configurable: host, port, username, password, from address
- Created
EmailService(internal/server/email.go)SendVerificationEmail()- sends email verification linksSendPasswordResetEmail()- sends password reset links- Falls back to logging tokens when SMTP is disabled (dev mode)
- Updated
auth.goto use EmailService instead of just logging - Fixed auth template rendering (was returning empty page)
- Templates define
{{define "content"}}blocks - Now properly clones base template and executes "auth" template
- Templates define
- Fixed user dropdown hover issue (menu disappeared on hover)
- Changed from margin-top gap to padding-top with transparent outer
- Added primary color background on hover for visibility
- Added users table migration (
012_users.sql) to schema
Configuration (config.yaml):
smtp:
enabled: false
host: smtp.gmail.com
port: 587
username: ""
password: ""
from: "SOPDS Library <noreply@example.com>"
use_tls: false
use_starttls: trueFiles Modified:
internal/config/config.go- AddedSMTPConfiginternal/server/email.go- New file, email sending serviceinternal/server/server.go- AddedemailServiceto Server, template fixinternal/server/auth.go- Use emailService for verification/reset emailsinternal/server/web.go- Fixed dropdown hover CSS, audiodetail PageDataconfig.yaml- Added smtp section, fixed site.url
Fix: Folder-Based Audiobook Path Calculation & M4B Grouping:
- Fixed bug where folder audiobooks were stored with incorrect path
- Problem:
relPathincluded the folder name itself (e.g., "Music/Author - Title") - Fix: Now uses parent directory as path (e.g., "Music"), folder name as filename
- This matches the pattern used by regular files
- Problem:
- M4B files now included in folder grouping
- Folders with multiple M4B files are grouped as audiobook collections
- Single M4B in a folder still processed as individual audiobook
- Use case: collection folders like "Author - short stories (1)" with multiple M4B files
- Added cleanup of old individual audio files when creating grouped audiobook
MarkAudioFilesInFolderDeleted()marks old individual entries as unavailable- Affects all audio formats: mp3, m4a, m4b, flac, ogg, opus
- Prevents duplicate entries when rescanning after grouping was added
- Fixed audio track serving for folder audiobooks
serveTrackFromFolder()now handles multiple path formats:- Full filesystem paths (starts with /)
- Paths relative to library root (FolderName/file.mp3)
- Just filenames
- Constructs correct full path using book.Path and trackPath
- Added debug logging for path resolution issues
- Added cover extraction support for folder audiobooks
extractTrackCoverFromFolder()reads cover from audio file metadata
- Per-track covers for folder audiobooks
- Audio player updates cover image when switching tracks
- Each M4B/audio file can have its own embedded cover art
- Uses preloading to avoid flicker when switching tracks
- Fixed main book cover endpoint for folder audiobooks
serveAudioCover()now correctly constructs folder path withbook.Filename- Iterates through ALL tracks to find one with embedded cover (not just first)
- Previously returned 404 when first track had no cover
- Now serves cover from any track that has embedded art
- Header now shows current track info
- Added "Now Playing" indicator with pulsing speaker icon
- Shows current track name in header when playing
- Cover image always visible (placeholder if none)
- Cover updates per-track for folder audiobooks
- Header remains sticky at top when scrolling
Files Modified:
internal/scanner/scanner.go- FixedprocessAudioGroup(), include M4B in groupinginternal/infrastructure/persistence/service.go- AddedMarkAudioFilesInFolderDeleted()with M4Binternal/server/web.go- FixedserveTrackFromFolder(), addedextractTrackCoverFromFolder(), added "Now Playing" header indicator, cover placeholder, per-track cover updatesinternal/server/handlers.go- FixedserveAudioCover()folder path and track iteration
Taskfile for Project Automation:
- Added Taskfile.yml for common project tasks
- Commands: build, init, migrate, start, stop, restart, status, scan
- Systemd: service-start, service-stop, service-restart, service-status, service-logs
- Database: db-backup, db-restore, db-vacuum, db-stats
- Duplicates: dupes, dupes-clear
- Development: dev, test, vet, fmt, lint, clean
- Import: import-mysql, version
Files Created:
Taskfile.yml- Task runner configuration
User Authentication System:
- Added complete user authentication with JWT tokens
- Registration with real-time validation (username/email availability check)
- Login by email OR username
- Logout functionality
- Password reset with token (logged to console, no SMTP)
- Email verification required before login
- Rate limiting for security
- Username/email check: 150 requests/minute (prevents enumeration)
- Forgot password: 5 requests/hour (prevents abuse)
- Password requirements with real-time feedback
- Minimum 8 characters
- At least 1 lowercase, 1 uppercase, 1 digit
- Color-coded validation in registration form
- Username validation
- 3-30 characters
- Alphanumeric and underscore only
- Trimmed (no leading/trailing spaces)
- Anonymous guest mode
- "Continue as guest" option on landing page
- Basic auth credentials work for guest mode (if configured)
- Warning banner for anonymous users (bookshelf not saved)
- User dropdown in navigation
- Shows username for logged-in users
- Shows "Guest" for anonymous users
- Login/Register links for unauthenticated
- Logout link for authenticated
- Bookshelf migration on login
- Anonymous bookshelf items copied to user account
- Existing items not overwritten
- JWT-based sessions
- HTTP-only cookie for security
- 24-hour expiration
- Optional jwt_secret in config.yaml
Files Created:
internal/infrastructure/persistence/migrations/012_users.sql- Users table, bookshelf user_idinternal/domain/user/user.go- User entity with validationinternal/domain/repository/user_repository.go- Repository interfaceinternal/infrastructure/persistence/user_repository.go- GORM implementationinternal/server/auth.go- JWT utilities, rate limiting, auth handlersinternal/server/auth_templates.go- Auth page templates (landing, login, register, etc.)
Files Modified:
internal/infrastructure/persistence/models.go- Added UserID to BookshelfModelinternal/infrastructure/persistence/repositories.go- Added Users repositoryinternal/infrastructure/persistence/service.go- Added MigrateAnonBookshelf methodinternal/config/config.go- Added JWTSecret to ServerConfiginternal/server/server.go- Added userRepo, authHandlers, auth routes, authMiddlewareinternal/server/web.go- Added Auth field to PageData, auth translations, user dropdown, guest warning
New Dependencies:
github.com/golang-jwt/jwt/v5- JWT token handling
Auth Page Templates:
- Landing page with login/register/guest options
- Login form with email/username support
- Registration form with real-time validation
- Forgot password form
- Reset password form with password strength indicator
- Message page for verification errors
Folder-Based Audiobook Grouping:
- Audio files in same folder now grouped as single audiobook entry
- Uses existing
AudiobookGrouperfor grouping logic - Creates "folder" format audiobooks (similar to archive-based)
- Track structure stored in
chaptersJSONB field - Properly links authors, narrators, and genres from metadata
- Uses existing
- @eaDir directories skipped during scan (Synology metadata folders)
- M4B files processed individually (single-file audiobooks with chapters)
Cyrillic Encoding Fix:
- Fixed Windows-1251 text incorrectly read as Latin-1 in ID3 tags
- Added
fixCyrillicEncoding()to detect and convert mojibake - Uses
golang.org/x/text/encoding/charmapfor Windows-1251 decoding
- Added
- Fixed UTF-16 metadata misread as UTF-8 (common in OGG Vorbis comments)
- Detects "Б . А к у н и н" pattern (null bytes appearing as spaces)
- Strips null bytes and removes alternating space pattern
- Prevents PostgreSQL "invalid byte sequence for encoding UTF8: 0x00" error
OGG Duration Fix:
- Fixed Vorbis sample rate detection (was reading wrong offset)
- After 8-byte codec header, skip 4 bytes (remaining version + channels)
- Previously skipped 5 bytes, reading sample rate from wrong position
- Fixed duration calculation: 163M granules / 44100 Hz = 3697s (was calculating 947881s)
Folder Audiobook Playback Fix:
- Added track endpoint support for "folder" format audiobooks
handleAudioTrackDownloadnow handles "folder" format (was only zip/7z)- New
serveTrackFromFolder()function serves files directly from disk - Uses
http.ServeContent()for proper HTTP range request support (seeking) - Security check ensures file path is within library root
Folder Audiobook Author/Title Parsing:
- Always parse author and title from folder name (format: "Author - Title")
- Metadata "artist" field often contains narrator, not author
- Metadata artists now treated as narrators for folder audiobooks
parseTitleFromFolderName()extracts title after separator- Handles " - ", " – " (en-dash), and "-" separators
- Removes year suffixes like "(2020)" or "[2020]"
Folder Audiobook Cover Fix:
- Fixed cover detection for folder-based audiobooks
serveAudioCovernow handles "folder" format correctly- Uses first track path from chapters JSON for @eaDir lookup
- Added
getFolderCoverInDir()helper for folder cover detection
- Added SYNOAUDIO patterns to @eaDir cover lookup
SYNOAUDIO_01APIC_03.jpg(Audio Station album art)SYNOAUDIO_01APIC_00/01/02.jpgvariants
Files Modified:
internal/scanner/duration.go:- Fixed OGG Vorbis sample rate offset (skip 4 bytes, not 5)
internal/scanner/scanner.go:- Added
audioGrouperto Scanner struct - Modified walk to collect audio files separately, skip @eaDir
- Added
processAudioFolders()to group and process collected audio files - Added
processAudioGroup()to create single audiobook entry from folder - Author linking now always parses folder name first, treats metadata as narrators
- Added
internal/scanner/audioparser.go:- Added
fixCyrillicEncoding()for Windows-1251 detection and fix - Added
looksLikeMojibake(),hasCyrillic()helper functions - Added
looksLikeUTF16AsUTF8(),fixUTF16AsUTF8()for UTF-16 fix - All metadata fields now passed through encoding fix
- Added
internal/scanner/audiobookgrouper.go:createGroup()now parses title from folder name- Added
parseTitleFromFolderName()function - Added
removeYearSuffix()andisDigits()helpers - Metadata authors treated as narrators
internal/server/handlers.go:- Added SYNOAUDIO patterns to
getEaDirCover() serveAudioCover()now handles "folder" format- Added
getFolderCoverInDir()helper function - Added
encoding/jsonimport
- Added SYNOAUDIO patterns to
internal/server/web.go:- Added
serveTrackFromFolder()for folder audiobook playback
- Added
New Dependencies:
golang.org/x/text/encoding/charmap- Character set conversion
@eaDir and Folder Cover Support:
- Added Synology @eaDir thumbnail support for audiobook covers
getEaDirCover()checks for SYNOPHOTO_THUMB_*.jpg patterns- Looks in
@eaDir/{filename}/and@eaDir/directories - Fast cover serving without parsing audio files
- Added folder cover support (cover.jpg, folder.jpg)
getFolderCover()checks for common cover image names- Supports both .jpg and .png formats
- Scanner detects folder covers during scan
- Cover priority: @eaDir → folder cover → embedded in audio → archive extraction
Duration Extraction Improvements:
- Fixed memory issues with large M4B files in archives
- Only read first 10MB for M4B/M4A (moov atom often at beginning)
- Prevents memory exhaustion for 700MB+ files
- Improved fallback estimation bitrate (96kbps for AAC, more accurate)
- Disabled track-specific cover loading (caused 502 errors for large files)
Bug Fixes:
- Fixed nil pointer panic when accessing deleted audiobook
- Added null check for book in handleWebAudioDetail
Files Modified:
internal/server/handlers.go:getEaDirCover()- Synology thumbnail lookupgetFolderCover()- Folder cover lookup- Updated
serveAudioCover()to check @eaDir and folder covers first
internal/scanner/scanner.go:hasEaDirCover()- Check @eaDir during scanhasFolderCover()- Check folder covers during scan- Limited archive file reading to 10MB for M4B duration extraction
internal/server/web.go:- Disabled track-specific cover fetching
- Added nil check for book
Audiobook Player Enhancements:
- Extracted real audio duration from files during scan
- New
duration.gowith native Go parsers for MP4, MP3, FLAC, OGG duration extraction - MP4: Parse mvhd atom for timescale and duration
- MP3: Parse Xing/Info header for frame count, fallback to bitrate estimation
- FLAC: Parse STREAMINFO block for sample rate and total samples
- OGG: Read last granule position from end of file
- Tested accuracy: MP3 within 0.01s, M4B exact match with ffprobe
- New
- Made track rows clickable to switch playback
- Added
onclick="player.onTrackClick(this, event)"to track<li>elements - Added
event.stopPropagation()on checkbox, play button, download link to prevent row click - Added cursor:pointer and hover effect to track list items
- Added
- Implemented per-track position persistence
trackPositionsmap stores position for each track by pathsaveCurrentTrackPosition()saves position before switching trackssaveState()now includestrackPositionsin cookieloadState()restores per-track positions from cookieloadTrack()restores position for specific track being loaded
- Made header with cover sticky (always visible)
- Added
position: sticky; top: 0; z-index: 100;to.audio-header - Header stays visible while scrolling through track list
- Added
- Added track-specific cover images support
- New endpoint
/web/audio/{id}/cover?file={trackPath}for per-track covers handleAudioTrackCover()extracts cover from specific audio file in archiveextractTrackCoverFromZip()andextractTrackCoverFrom7z()helpersextractCoverFromAudioData()uses dhowden/tag to extract embedded cover- Player attempts to load track-specific cover when switching tracks
- Falls back to book cover if track has no embedded cover
- New endpoint
Files Created:
internal/scanner/duration.go- Audio duration extraction for MP4, MP3, FLAC, OGG
Files Modified:
internal/scanner/audioparser.go- UsesGetAudioDurationFromReader()for real durationinternal/server/web.go- Audiodetail template changes:- Track rows clickable with onclick handler
- Per-track position storage in AudioPlayer JS class
- Sticky header CSS
- Track cover update on switch
- New
handleAudioTrackCover()handler
internal/server/server.go- Added/audio/{id}/coverroute
Bug Fixes:
- Fixed bookshelf "Added" state not persisting after navigation
- Added
OnBookshelffield toBookViewstruct - Load user's bookshelf IDs and check when building book views
- Template now shows "Added" button if book is already on shelf
- Added
- Fixed bookshelf button not working on mobile/audiodetail page
- Changed audiodetail bookshelf link from
<a href>to JavaScriptonclick - Now uses same
bookshelfAction()function as regular book list
- Changed audiodetail bookshelf link from
- Fixed pagination showing "Next" when books count equals page size
- Changed
hasMorecalculation fromlen(books) >= pageSizeto usepagination.TotalCount - Now correctly shows "Next" only when more pages exist
- Changed
- Fixed audio cover not displaying for 7z audiobooks
- Check
book.IsAudiobookflag, not just audio format extension - Handle standalone 7z/zip archive files as archives (not audio files)
- Correctly build archive path from book.Path + book.Filename
- Check
Files Modified:
internal/server/web.go- Added OnBookshelf to BookView, updated handlersinternal/server/handlers.go- Fixed audio cover extraction for 7z archivesinternal/domain/repository/bookshelf_repository.go- Added GetBookIDs interfaceinternal/infrastructure/persistence/bookshelf_repository.go- Implemented GetBookIDsinternal/infrastructure/persistence/service.go- Added GetBookShelfIDs service method
Changes:
- Added missing file detection for regular files (cat_type=0)
- Scanner now detects when standalone files (e.g., 7z audiobooks) are deleted/renamed
- Prompts for confirmation before marking as unavailable (respects
auto_cleanconfig) - Similar to existing ZIP archive cleanup logic
- Added audio cover display for audiobook detail pages
- Set
HasCover: truefor audiobooks in handleWebAudioDetail - Cover extraction from audio files (MP3, M4B, M4A, FLAC, OGG, OPUS)
- Supports covers embedded in files inside ZIP and 7z archives
- Template has onerror fallback to hide image if no cover found
- Set
- Fixed ZIP cover extraction to buffer data for tag.ReadFrom (requires io.ReadSeeker)
- Fixed duplicate detection to separate audiobooks from text books
- Audiobooks only match other audiobooks
- Non-audiobooks only match other non-audiobooks
- Prevents audiobook being marked as duplicate of FB2 with same title
Files Modified:
internal/scanner/scanner.go- Added checkMissingRegularFiles() methodinternal/infrastructure/persistence/service.go- Added GetRegularFileBooks(), MarkBooksUnavailable()internal/infrastructure/persistence/book_repository.go- Added is_audiobook to duplicate detection queriesinternal/server/web.go- Set HasCover for audiobooks in handleWebAudioDetailinternal/server/handlers.go- Fixed ZIP audio cover extraction buffering
New Dependencies:
github.com/saracen/go7z- 7z archive reading for cover extraction
Changes:
- Optimized duplicate detection performance
- Added functional indexes on
LOWER(title)for case-insensitive matching - New index
idx_books_dup_strongfor strong mode (title + format + filesize) - New index
idx_books_dup_normalfor normal mode (title)
- Added functional indexes on
- Implemented incremental duplicate detection
- Only checks newly added books against existing ones
- O(n) for n new books instead of O(N log N) for N total books
- Tracks new book IDs during scan for targeted duplicate checking
- Added progress reporting during duplicate detection
- Logs progress every 500 books processed
- Shows percentage completion
Files Modified:
internal/infrastructure/persistence/migrations/011_duplicate_detection_index.sql- New indexesinternal/domain/repository/book_repository.go- Added MarkDuplicatesIncremental interfaceinternal/infrastructure/persistence/book_repository.go- Implemented incremental methodinternal/infrastructure/persistence/service.go- Added service methodinternal/scanner/scanner.go- Track new book IDs, use incremental detection
Changes:
- Implemented full-featured audiobook player UI
- Sticky player bar at bottom of page with gradient design
- Progress bar with seek functionality (click or drag to seek)
- Time display: current time / total duration
- Playback controls: prev, -15s, play/pause, +15s, next
- Speed control: 0.5x, 0.75x, 1x, 1.25x, 1.5x, 1.75x, 2x (saved in cookie)
- Volume toggle (mute/unmute)
- Buffering indicator on progress bar
- Auto-play next track when current ends
- Track list enhancements:
- Now-playing highlight with pulsing icon animation
- Auto-scroll to current track
- Auto-expand collapsed folder when track plays
- Track icon changes from music note to speaker when playing
- Cookie-based progress persistence:
- Saves current track index and position per book (30 days)
- Restores position when returning to audiobook page
- Playback speed saved globally (365 days)
- Keyboard shortcuts:
- Space: play/pause
- Left/Right arrows: seek -5s/+5s (Shift: -30s/+30s)
- Up/Down arrows: volume +/-10%
- M: toggle mute
- N: next track
- P: previous track (or restart if >3s played)
- Mobile responsive design:
- Simplified controls on small screens
- Touch-friendly seek bar
- Player bar positioned below track list (sticky, not fixed at viewport bottom)
Files Modified:
internal/server/web.go- Complete rewrite of audiodetail template with AudioPlayer class
Changes:
- Added more progress phases during scan
- "Loading catalogs from database..." - when loading ZIP catalog cache
- "Detecting duplicates..." - when running duplicate detection
- Previously these phases showed nothing, making scan appear stuck
- Fixed progress not displaying due to buffering and throttling
- Added
os.Stdout.Sync()to flush output immediately - Only throttle "scanning" phase, always show phase changes
- Added
- Skip duplicate detection if no new books added (major performance fix)
- Duplicate detection on 568K books was taking 3+ minutes
- Now skips entirely when BooksAdded == 0
Files Modified:
internal/scanner/scanner.go- Added phase reports, fixed throttle, skip duplicates if no new bookscmd/sopds/main.go- Handle new phases in printProgress, add stdout sync
Changes:
- Added
scanner.auto_cleanconfig option for missing archivesask(default) - prompt user for confirmationyes- auto-delete without askingno- skip check entirely, never delete
- Fixed DeleteInCatalogs not deleting catalog entries
- Was only deleting books, leaving catalog entries in DB
- Now deletes both books AND catalog entries
- Fixes repeated prompts about missing archives on every scan
Files Modified:
internal/config/config.go- Added AutoClean field to ScannerConfiginternal/scanner/scanner.go- Check auto_clean config before promptinginternal/infrastructure/persistence/book_repository.go- DeleteInCatalogs now also deletes catalogsconfig.yaml- Added auto_clean: yes setting
Changes:
- Added play buttons for audio tracks in audiobook detail page
- Green play button next to each track's download button
- Click to play/pause, icon toggles between play/pause
- Hidden HTML5 audio element for streaming
- Auto-stops previous track when playing new one
- Resets icon when track ends
Files Modified:
internal/server/web.go- Added track-play button, playTrack() JS function, audio element, CSS styles
Changes:
- Fixed audiobook filter showing all authors instead of only audiobook authors
GetFilterOptionswas missingAudioOnlyfilter application- Added
query.Filters.WithAudioOnly()whenopts.AudioOnlyis true - Fixes Chrome crash on mobile when opening author filter dropdown
Files Modified:
internal/infrastructure/persistence/service.go- Added AudioOnly filter to GetFilterOptions
Changes:
- Implemented file logging from config with rolling support
logging.file- log file pathlogging.max_size- max size in MB before rotation (default 10)logging.max_backups- number of old log files to keep (default 3)rollingLogWriterstruct implements io.Writer with automatic rotation- Rotated files named
.1,.2,.3etc (newest first) - Falls back to stderr with warning if file/rotation fails
Files Modified:
cmd/sopds/main.go- AddedrollingLogWritertype,setupLogging()functioninternal/config/config.go- AddedMaxSize,MaxBackupsto LoggingConfigconfig.yaml- Addedmax_size,max_backupssettings
Changes:
- Fixed audiobook archive path bug
Pathwas incorrectly storing full path including filename instead of directory- Changed to
Path: filepath.Dir(relPath)in bothprocessAudioZipandprocessAudio7z
- Added individual track download from archives
- New endpoint
/web/audio/{id}/track?file=pathserves individual files serveTrackFromZip()andserveTrackFrom7z()helper functions- Tracks now store full path inside archive for download
- New endpoint
- Audiobook detail page track selection UI
- Select all checkbox with indeterminate state support
- Part-level checkboxes for collections (multi-directory audiobooks)
- Individual track checkboxes
- "Download Selected" button triggers sequential downloads
- Download link for each individual track
- Added
Pathfield toAudiobookTrackstruct for storing full path inside archive - New translations:
audio.selectall,audio.downloadsel(English and Ukrainian) - CSS styles for select controls, checkboxes, and track download buttons
- Track count in book list now clickable - links to audiobook detail page
/web/audio/{id}- Changed from
<span>to<a>withmeta-linkclass - Uses i18n
audio.trackstranslation instead of hardcoded "tracks"
- Changed from
Files Modified:
internal/scanner/scanner.go- Added Path to AudiobookTrack, fixed archive path bug, updated trackInfo structinternal/server/server.go- Added/audio/{id}/trackrouteinternal/server/web.go- Added handleAudioTrackDownload handler, serveTrackFromZip, serveTrackFrom7z, Path field to AudioTrack, checkboxes/JS/CSS in audiodetail template, new translations, clickable track count in book list
Changes:
- Fixed book ID not returned after GORM upsert in
Save()method- GORM populates
model.IDvia RETURNING clause, but domain book was not updated - Caused
book_id=0to be passed toAddBookAuthor, resulting in FK constraint violations - Added
b.SetID(book.ID(model.ID))after Create to propagate ID back to domain object
- GORM populates
- Added audio formats to config.yaml (mp3, m4b, m4a, flac, ogg, opus)
- Audiobook titles now use folder name instead of filename (better metadata)
- Added "Audiobooks" menu item in web UI to browse all audiobooks
- New
/web/audioroute with full filter support (language, author, genre) - Headphones icon in both upper navigation and menu grid
- Translations for English and Ukrainian ("nav.audio", "main.audiobooks")
- New
- Audiobook author parsing from topmost folder name
- Parses "Author - Title" or "Author_-_Title" format
- Splits author name into first_name/last_name
- Strips year suffixes like "[2007]" or "(2007)" from title
- Falls back to Unknown Author if no separator found
- ZIP audiobook grouping: ZIP with audio files = ONE audiobook entry
isAudioZip()detects ZIPs containing audio filesprocessAudioZip()creates single book entry with aggregated metadata- Author/title parsed from top-level folder inside ZIP
- Total duration estimated from file sizes
- Track count stored
- Audiobook detail page with tree view
/web/audio/{id}route shows audiobook structure- Displays parts/chapters for collections (multi-directory ZIPs)
- Displays flat track list for simple audiobooks
- Shows duration for each track/part
- Download ZIP button, add to bookshelf
- Modern collapsible UI with Font Awesome icons
- Scanner stores audiobook structure in
chaptersJSONB fieldAudiobookStructure,AudiobookPart,AudiobookTracktypes in scanner- Type: "book" for flat structure, "collection" for multi-directory
- Each track stores name, duration, size
- Each part stores name, total duration, list of tracks
- Fixed audiobook ZIP parsing to use top-level folder inside ZIP for author/title
- Expected structure:
ZIP / TopFolder (Author - Title) / [SubFolders] / tracks - Subfolders under top-level folder become parts/chapters
- Files directly under top-level folder become tracks (simple audiobook)
- Expected structure:
- Added 7z archive support
- New dependency:
github.com/bodgit/sevenzip process7z()- processes 7z archives same as ZIPisAudio7z()- detects audio 7z filesprocessAudio7z()- handles audiobook 7z files- Same audiobook structure parsing as ZIP (top-level folder, parts, tracks)
- New dependency:
Files Modified:
internal/infrastructure/persistence/book_repository.go- Fixed Save() to update domain book IDinternal/scanner/scanner.go- Parse author from topmost folder, AudioMeta struct, parseAudioFolderName(), parseAuthorFullName(), isAudioZip(), processAudioZip(), process7z(), isAudio7z(), processAudio7z(), AudiobookStructure/Part/Track typesinternal/server/web.go- Added handleWebAudio handler, handleWebAudioDetail handler, audiodetail template, AudiobookStructure/AudiobookPart/AudioTrack/AudioDetailData types, formatDuration template function, audio translationsinternal/server/server.go- Added /web/audio and /web/audio/{id} routesinternal/infrastructure/persistence/service.go- Added AudioOnly to SearchOptionsinternal/infrastructure/persistence/scopes.go- Added OnlyAudiobooks scopeinternal/domain/repository/filters.go- Added AudioOnly filter and WithAudioOnly methodconfig.yaml- Added audio formats to library.formatsgo.mod,go.sum- Added github.com/bodgit/sevenzip dependency
Changes:
- Fixed author race condition in concurrent scanner workers
- Added unique constraint on
authors(first_name, last_name) - Updated
GetOrCreateto useON CONFLICT DO NOTHINGwith re-fetch pattern - Fixed PostgreSQL sequence not incrementing after explicit ID insert in migration
- Added unique constraint on
- Increased
series.sercolumn from 64 to 128 characters (some series names exceed 64) - All FB2 metadata (authors, genres, series) now properly linked to books
Files Created:
internal/infrastructure/persistence/migrations/009_author_unique_constraint.sqlinternal/infrastructure/persistence/migrations/010_increase_series_name.sql
Files Modified:
internal/infrastructure/persistence/author_repository.go- Race-safe GetOrCreate with ON CONFLICTinternal/infrastructure/persistence/models.go- SeriesModel.Name size:128
Changes:
- Added audiobook support with full metadata extraction
- Supports audio formats: MP3, M4B, M4A, FLAC, OGG, OPUS
- Audio metadata extraction using dhowden/tag library (ID3, MP4 atoms, Vorbis comments)
- Narrator tracking via author role ('author' vs 'narrator') in bauthors table
- Duration display for audiobooks (formatted as "Xh Ym")
- Track count display for multi-file audiobooks
- Web UI shows headphones icon badge for audiobooks
- OPDS feeds include proper audio MIME types
- Fixed missing unique constraint on (filename, path) for ON CONFLICT
- Fixed JSONB chapters column: changed from string to *string to allow NULL
- Added audiobook fields to domain Book and all conversion layers
- Fixed slow scan performance with multiple optimizations:
- Enabled
SET LOCAL synchronous_commit = offwithin transactions - All ZIP operations wrapped in single transaction (one commit per ZIP)
- Batch availability updates for existing books
- Added
GetBookMapByCatalog()for 1-query existence check (replaces N queries per ZIP) - Added
Service.Transaction()method for transactional batch operations - Recommended: keep
scanner.workersat 4-8 to avoid I/O saturation
- Enabled
- Fixed bauthors FK constraint violation: filter out zero IDs from failed GORM upserts before linking authors
- Fixed missing FB2 author/genre/series linking (never implemented in Go version)
- Added
bookWithMetastruct to store parsed FB2 metadata alongside book records - ZIP batch processing now properly extracts and links authors, genres, and series
- Individual file processing (
processFile) now also links FB2 metadata - Added
parseFB2MetadataFull()function that returns full metadata for linking - Falls back to Unknown Author only when no authors found in FB2 metadata
- Added
New Database Fields (books table):
duration_seconds- Total duration in secondsbitrate- Audio bitrateis_audiobook- Boolean flagtrack_count- Number of tracks (for multi-file audiobooks)chapters- JSONB column for chapter data
New Database Fields (bauthors table):
role- Author role ('author' or 'narrator')
Files Created:
internal/infrastructure/persistence/migrations/006_audiobook_support.sql- Database migration for audio fieldsinternal/infrastructure/persistence/migrations/007_unique_filename_path.sql- Unique constraint for upsertinternal/infrastructure/persistence/migrations/008_fix_search_trigger.sql- Recreate trigger with UPDATE OF conditioninternal/scanner/audioparser.go- Audio metadata extractioninternal/scanner/audiobookgrouper.go- Multi-file audiobook grouping logic
Files Modified:
internal/database/models.go- Added audio fields to Book structinternal/infrastructure/persistence/models.go- Added GORM audio fields (*string for chapters), Role to BookAuthorModelinternal/domain/book/book.go- Added audiobook fields, updated Reconstruct(), added gettersinternal/domain/book/value_objects.go- Added audio Format constants, IsAudio() methodinternal/domain/repository/book_repository.go- Added narrator methodsinternal/infrastructure/persistence/book_repository.go- Implemented narrator repository methodsinternal/infrastructure/persistence/mappers.go- Added audiobook fields to BookToModel/BookToDomaininternal/infrastructure/persistence/adapters.go- Added audiobook fields to BookToLegacyinternal/infrastructure/persistence/database.go- Added SetAsyncCommit(), SetSyncCommit() methodsinternal/infrastructure/persistence/service.go- Added narrator service methods, audiobook fields in Reconstruct calls, UpdateBookAvailBatch, Transaction() with SET LOCAL, GetBookMapByCatalog()internal/infrastructure/persistence/book_repository.go- Added UpdateAvailabilityBatch, GetBookMapByCatalog for batch operationsinternal/domain/repository/book_repository.go- Added UpdateAvailabilityBatch, GetBookMapByCatalog interface methodsinternal/opds/feed.go- Added audio MIME types and FormatDuration helperinternal/scanner/scanner.go- Added audio file detection, metadata parsing, optimized ZIP scanning with GetBookMapByCatalog (1 query per ZIP), transaction batchinginternal/server/web.go- Updated BookView struct and templates for audiobook displayinternal/config/config.go- Added audio formats to defaults
Web UI Changes:
- Audiobooks display headphones icon next to title
- Duration shown instead of file size for audiobooks
- Narrators displayed with microphone icon
- Track count shown for multi-file audiobooks
Changes:
- Fixed field shadowing bug in i18n:
BooksData.Languagesrenamed toFilterLangs - The old field shadowed
PageData.Languagescausing "can't evaluate field Code in type string" error on search page - Fixed page size, pagination and filters losing URL parameters
- Converted page size buttons to use JavaScript
setPageSize()function - Converted pagination links to use JavaScript
goToPage()function - Converted "Clear all" filters to use JavaScript
clearFilters()function - All navigation now properly preserves search queries and filter parameters
- Fixed fname/lname filters not working (were parsed but never applied)
- Added
FirstNameFilterandLastNameFilterto SearchOptions - Added
WithAuthorFirstNameExactandWithAuthorLastNameExactscopes - Added filters to all book listing pages (genre, author, series, language, new books)
- All book lists now support filtering by: language, author first/last name, genre
- Added
NewPeriodto SearchOptions for new books page - Updated CLAUDE.md with systemd service management commands
- Fixed filter dropdowns showing language codes instead of human-readable names
- Fixed filter dropdowns only showing options from current page (now shows ALL options in scope)
- Added
GetFilterOptionsmethod to query distinct filter values for entire scope - Added
LangOptionstruct with Code and Name fields for proper language display - Added
langsToOptions()andgenresToLinkedItems()helper functions
Filters by page type:
- Genre page: lang, fname, lname filters (genre already scoped)
- Author page: lang, genre filters (author already scoped, fname/lname not applicable)
- Series page: lang, fname, lname, genre filters
- Language page: fname, lname, genre filters (lang already scoped)
- New books page: lang, fname, lname, genre filters
Files Modified:
internal/server/web.go- Updated all book listing handlers to use GetFilterOptions, LangOption struct, helper functionsinternal/infrastructure/persistence/service.go- Added GetFilterOptions method, FilterOptions structinternal/infrastructure/persistence/scopes.go- Added exact name filter scopesinternal/domain/repository/filters.go- Added AuthorFirstName/AuthorLastName fields and methodsCLAUDE.md- Added systemd service commands (start/stop/restart/status)
Changes:
- Added internationalization (i18n) system for Web UI
- Supports English (en) and Ukrainian (uk) languages
- Extensible design - add new languages by editing supportedLanguages and translations map
- Language switcher in navigation bar (JavaScript-based, preserves current URL)
- Cookie-based language preference persistence (1 year)
- Full translation of all templates: main, search, books, bookshelf, authors, genres, series, catalogs, languages, error pages
- Help page with full translations
Translated UI Elements:
- Navigation menu
- Statistics labels
- Browse menu items
- Book listings (Show, Filters, All Languages, Previous, Next, No books found, Add to Shelf, Duplicates, Remove)
- Empty state messages (No authors/genres/series/languages/catalogs found)
- Bookshelf (title, empty message)
i18n Architecture:
Languagestruct with Code and Name fieldssupportedLanguagesslice - add new languages heretranslationsmap[string]map[string]string - key → lang → textT(lang, key)function for translation lookups- Template function
{{t "key"}}for inline translations newPageData()andaddI18n()helpers initialize Lang and Languages fields- All handlers call
setLangCookie()andaddI18n()for consistent language support
Adding a New Language:
- Add to supportedLanguages:
{Code: "de", Name: "Deutsch"} - Add translations for all keys in translations map
Files Modified:
internal/server/web.go- i18n system, all templates translated, addI18n helper, handlers updatedinternal/server/server.go- Added /web/help route
Changes:
- Enhanced search with separate title and author name fields
- Added pattern filters for language, genre, and series (ILIKE matching)
- Added language scope for searching within a language context
- Schema migration: renamed
doublicattoduplicate_of,favoriteINT to BOOL - Fixed duplicate count display (only show when count > 1)
- Added scoped search (search within current author/genre/series/catalog/language)
New Search Features:
- Separate title search (q=) and author search (author=) fields
- Both can be combined with AND logic
- Pattern filters via URL parameters:
lang_pattern=uk- filter by language patterngenre_pattern=comedy- filter by genre nameseries_pattern=Silo- filter by series name
- Language scope: when browsing by language, searches stay within that language
Files Modified:
internal/infrastructure/persistence/service.go- SearchOptions with TitleQuery/AuthorQueryinternal/infrastructure/persistence/scopes.go- Added WithAuthorName, WithLangPattern, WithGenrePattern, WithSeriesPatterninternal/domain/repository/filters.go- Added AuthorNameQuery and pattern filter fieldsinternal/server/web.go- Updated search form with author field, language scopeinternal/server/handlers.go- Updated OPDS search to use TitleQueryinternal/database/models.go- Changed Duplicate/Favorite field types
Migration:
005_schema_cleanup.sql- Renames doublicat to duplicate_of, converts favorite to boolean
Changes:
- Added PostgreSQL full-text search (FTS) for book searches
- Search is now 440x faster (4.75ms vs 2099ms)
- Uses tsvector column with GIN index
- Title weighted higher than annotation in search results
- Trigger auto-updates search vector on book insert/update
Files Created:
internal/infrastructure/persistence/migrations/004_fulltext_search.sql
Files Modified:
internal/infrastructure/persistence/scopes.go- WithKeywords uses FTSinternal/infrastructure/persistence/models.go- Added SearchVector fieldinternal/infrastructure/persistence/database.go- Multi-statement migrations
Changes:
- Migrated from raw pgx queries to GORM ORM with Domain-Driven Design (DDD)
- Created domain layer with entities: Book, Author, Genre, Series, Catalog
- Created repository interfaces in
internal/domain/repository/ - Implemented GORM repositories in
internal/infrastructure/persistence/ - Added Service layer as bridge between handlers and repositories
- Moved SQL migrations to persistence package
- Removed 1,500+ lines of raw SQL queries
Architecture:
internal/
├── domain/ # Pure business logic
│ ├── book/ # Book aggregate with value objects
│ ├── author/ # Author entity
│ ├── genre/ # Genre entity
│ ├── series/ # Series entity
│ ├── catalog/ # Catalog entity
│ └── repository/ # Repository interfaces
│
├── infrastructure/persistence/ # GORM implementations
│ ├── database.go # GORM connection + migrations
│ ├── models.go # GORM models with tags
│ ├── mappers.go # Domain <-> Model conversion
│ ├── scopes.go # Reusable query scopes
│ ├── *_repository.go # Repository implementations
│ ├── adapters.go # Domain <-> Legacy type conversion
│ └── service.go # Bridge layer for handlers
│
└── database/ # Legacy (models only)
└── models.go # Legacy types for handlers
Files Created:
internal/domain/book/book.go- Book aggregate rootinternal/domain/book/value_objects.go- Format, Language, Availability, Coverinternal/domain/author/author.go- Author entityinternal/domain/genre/genre.go- Genre entityinternal/domain/series/series.go- Series entityinternal/domain/catalog/catalog.go- Catalog entityinternal/domain/repository/*.go- Repository interfacesinternal/infrastructure/persistence/*.go- GORM implementations
Files Removed:
internal/database/postgres.go- Replaced by persistence/database.gointernal/database/queries.go- Replaced by repositories
Files Modified:
cmd/sopds/main.go- Uses persistence.NewDB and persistence.NewServiceinternal/server/server.go- Uses persistence.Serviceinternal/server/handlers.go- Uses service methodsinternal/server/web.go- Uses service methodsinternal/scanner/scanner.go- Uses persistence.Service
Changes:
- Added "See duplicates" feature to view all versions of a book
- Books with duplicates show a "Duplicates (N)" button linking to duplicates page
- Duplicate books show "Duplicates" button to find original and other copies
- Duplicates page displays original book and all its duplicates for easy comparison
Files Modified:
internal/database/queries.go- Added GetDuplicateCount, GetBookDuplicates functionsinternal/server/web.go- Added DuplicateOf/DuplicateCount to BookView, handleWebDuplicates handler, updated templatesinternal/server/server.go- Added /web/duplicates/{id} routeCLAUDE.md- Updated documentation with new features and endpoints
Changes:
- Implemented cover image extraction from FB2 files (base64 decoding)
- Implemented bookshelf feature (user reading lists)
- Added web bookshelf page with add/remove functionality
- Added "Add to Bookshelf" button on all book listings
- Cover images served from
/book/{id}/coverendpoint
Files Modified:
internal/server/handlers.go- Added cover extraction, bookshelf handlersinternal/server/web.go- Added bookshelf template, TotalCount field, button stylesinternal/database/queries.go- Added bookshelf database functionsinternal/server/server.go- Added bookshelf routes
Changes:
- Updated all documentation files (CLAUDE.md, README.md, PROGRESS.md)
- Added comprehensive documentation for all features
Files Modified:
CLAUDE.md- Full rewrite with architecture, commands, API endpointsREADME.md- Updated with all new features and configuration optionsPROGRESS.md- Added all revision history
Changes:
- Implemented FB2 to EPUB converter in pure Go (
internal/converter/) - Implemented FB2 to MOBI conversion using Calibre's ebook-convert
- Wired converters to server handlers (
/book/{id}/epub,/book/{id}/mobi) - EPUB conversion includes: metadata, body content, images, TOC, stylesheet
Files Created:
internal/converter/converter.go
Files Modified:
internal/server/server.go- Added converter initializationinternal/server/handlers.go- Implemented handleConvertEPUB, handleConvertMOBIgo.mod,go.sum- Added github.com/google/uuid dependency
Changes:
- Complete Web UI rewrite with modern design
- Fixed download 404 error (OPDSPrefix in templates)
- Fixed pagination 404 error (WebPrefix in URLs)
- Added EPUB/MOBI download buttons (conditional on config)
- Implemented hierarchical letter navigation (1→2→3 char drill-down)
- Added cloud-style prefix buttons for author/series navigation
- Modern CSS with gradients, Font Awesome 6 icons, responsive design
- Fixed template field names (Stats.BooksCount instead of Stats.Books)
Files Modified:
internal/server/web.go- Complete rewriteinternal/database/queries.go- Added GetAuthorsByPrefix, GetSeriesByPrefix
Changes:
- Added MySQL to PostgreSQL migration command (
import-mysql) - Fixed MySQL connection timeout during large imports (read all data to memory first)
- Fixed PostgreSQL constraint violations (drop/recreate FK constraints during import)
- Added
--clearflag to clear PostgreSQL tables before import - Added progress reporting during migration
Files Modified:
cmd/sopds/main.go- Added import-mysql command with all migration functions
Changes:
- Added progress tracking and ETA to library scanner
- Added
ProgressInfostruct with: TotalFiles, ProcessedFiles, BooksAdded, BooksSkipped, Elapsed, Rate, ETA, Phase - Added
ProgressCallbackfor CLI progress display - Pre-count phase before scanning for accurate progress
- Progress bar display with spinner animation
Files Modified:
internal/scanner/scanner.go- Added progress trackingcmd/sopds/main.go- Added progress display for scan command
Changes:
- Added
sopds-go/README.mdwith full documentation- Quick start guide
- Configuration reference
- API endpoints
- Project structure
Files Modified:
sopds-go/README.md(new)
Changes:
- Added
init.sqlfor PostgreSQL database initialization- Creates database and user
- Grants schema permissions (required for PostgreSQL 15+)
- Fixed
002_genres.sql- escaped single quotes in Russian text
Files Modified:
sopds-go/init.sql(new)sopds-go/internal/database/migrations/002_genres.sql(fixed)
Changes:
- Complete rewrite of SOPDS from Python to Go
- New technology stack:
- Go 1.21+ with PostgreSQL (replacing MySQL)
- YAML configuration format (replacing INI)
- chi router for HTTP
- pgx for PostgreSQL
- encoding/xml for OPDS feeds
New Files Created (sopds-go/):
cmd/sopds/main.go- CLI with cobra (start/stop/status/scan/migrate/init)internal/config/config.go- YAML configuration parserinternal/database/postgres.go- PostgreSQL connection poolinternal/database/models.go- Data models (Book, Author, Genre, Series, etc.)internal/database/queries.go- All database queriesinternal/database/migrations/001_initial.sql- PostgreSQL schemainternal/database/migrations/002_genres.sql- Genre datainternal/scanner/scanner.go- Parallel directory scanner with goroutinesinternal/scanner/fb2parser.go- FB2 metadata extractioninternal/server/server.go- HTTP server with chi routerinternal/server/handlers.go- OPDS/Web request handlersinternal/opds/feed.go- OPDS Atom feed generationconfig.yaml- Default configuration templatego.mod,go.sum- Go module filesCLAUDE.md- Project documentation for Claude CodePROGRESS.md- Development progress tracking
Key Improvements over Python version:
- Parallel scanning with configurable worker count
- RESTful routes instead of
?id=parameter encoding - Proper graceful shutdown with signal handling
- Embedded SQL migrations
- Type-safe YAML configuration
- Modern Web UI with responsive design
- Built-in FB2 to EPUB conversion (pure Go)
- MySQL migration tool for existing databases
| Feature | Status |
|---|---|
| OPDS 1.2 Catalog | Done |
| Web Interface | Done |
| FB2 Metadata Extraction | Done |
| ZIP Archive Scanning | Done |
| Parallel Scanning | Done |
| Progress Tracking | Done |
| Basic Auth | Done |
| Scheduled Scanning | Done |
| FB2 to EPUB | Done |
| FB2 to MOBI | Done |
| MySQL Migration | Done |
| PostgreSQL Backup/Restore | Done |
| Cover Images | Done |
| Duplicate Detection | Done |
| Duplicates Viewer | Done |
| Bookshelf | Done |
| GORM ORM | Done |
| Domain-Driven Design | Done |
| Full-Text Search | Done |
| Advanced Search (title+author) | Done |
| Pattern Filters | Done |
| Scoped Search | Done |
| Internationalization (i18n) | Done |
| Help Page | Done |
| Audiobook Support | Done |
| User Authentication | Done |
| Table | Description |
|---|---|
| books | Book metadata and file info |
| authors | Author names |
| bauthors | Book-author relationships |
| genres | Genre definitions |
| bgenres | Book-genre relationships |
| series | Series names |
| bseries | Book-series relationships |
| catalogs | Directory structure |
| bookshelf | User reading lists |
| users | User accounts |