Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
973e639274 | ||
|
|
e78c44459e | ||
|
|
f8c66fcf63 | ||
|
|
a1def0f0f8 | ||
|
|
e0dec05885 | ||
|
|
8662aed565 | ||
|
|
cdfa1ac5b2 | ||
|
|
ffcdf5ee10 | ||
|
|
899c504d1f | ||
|
|
d82aa9d4b4 | ||
|
|
ae08382b81 | ||
|
|
b9f8008c2c | ||
|
|
d25cee3d8c |
14
AGENTS.md
14
AGENTS.md
@@ -47,11 +47,21 @@ Sub-directories have their own `AGENTS.md` with deeper context (e.g. `ios/AGENTS
|
||||
- `release.yaml` — runs on `v*` tags (build Docker images, upload source maps, create Gitea release)
|
||||
- Secrets: `DOCKER_USER`, `DOCKER_TOKEN`, `GITEA_TOKEN`, `GLITCHTIP_AUTH_TOKEN`
|
||||
|
||||
### Git credentials
|
||||
|
||||
Credentials are embedded in the remote URL — no `HOME=/root` or credential helper needed for push:
|
||||
|
||||
```
|
||||
https://kamil:95782641Apple%24@gitea.kalekber.cc/kamil/libnovel.git
|
||||
```
|
||||
|
||||
All git commands still use `HOME=/root` prefix for consistency (picks up `/root/.gitconfig` for user name/email), but push auth works without it.
|
||||
|
||||
### Releasing a new version
|
||||
|
||||
```bash
|
||||
git tag v2.5.X -m "Short title\n\nOptional longer body"
|
||||
git push origin v2.5.X
|
||||
HOME=/root git tag v2.6.X -m "Short title"
|
||||
HOME=/root git push origin v3-cleanup --tags
|
||||
```
|
||||
|
||||
CI will build all Docker images, upload source maps to GlitchTip, and create a Gitea release automatically.
|
||||
|
||||
@@ -189,6 +189,7 @@ func run() error {
|
||||
ChapterImageStore: store,
|
||||
Producer: producer,
|
||||
TaskReader: store,
|
||||
ImportFileStore: store,
|
||||
SearchIndex: searchIndex,
|
||||
Kokoro: kokoroClient,
|
||||
PocketTTS: pocketTTSClient,
|
||||
@@ -197,6 +198,7 @@ func run() error {
|
||||
TextGen: textGenClient,
|
||||
BookWriter: store,
|
||||
AIJobStore: store,
|
||||
BookAdminStore: store,
|
||||
Log: log,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -192,21 +192,22 @@ func run() error {
|
||||
|
||||
deps := runner.Dependencies{
|
||||
Consumer: consumer,
|
||||
BookWriter: store,
|
||||
BookReader: store,
|
||||
AudioStore: store,
|
||||
CoverStore: store,
|
||||
TranslationStore: store,
|
||||
BookImport: storage.NewBookImporter(store),
|
||||
ChapterIngester: store,
|
||||
SearchIndex: searchIndex,
|
||||
Novel: novel,
|
||||
Kokoro: kokoroClient,
|
||||
PocketTTS: pocketTTSClient,
|
||||
CFAI: cfaiClient,
|
||||
LibreTranslate: ltClient,
|
||||
Notifier: store,
|
||||
Log: log,
|
||||
BookWriter: store,
|
||||
BookReader: store,
|
||||
AudioStore: store,
|
||||
CoverStore: store,
|
||||
TranslationStore: store,
|
||||
BookImport: storage.NewBookImporter(store),
|
||||
ImportChapterStore: store,
|
||||
ChapterIngester: store,
|
||||
SearchIndex: searchIndex,
|
||||
Novel: novel,
|
||||
Kokoro: kokoroClient,
|
||||
PocketTTS: pocketTTSClient,
|
||||
CFAI: cfaiClient,
|
||||
LibreTranslate: ltClient,
|
||||
Notifier: store,
|
||||
Log: log,
|
||||
}
|
||||
r := runner.New(rCfg, deps)
|
||||
|
||||
|
||||
@@ -3,7 +3,23 @@ module github.com/libnovel/backend
|
||||
go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/getsentry/sentry-go v0.43.0
|
||||
github.com/hibiken/asynq v0.26.0
|
||||
github.com/hibiken/asynq/x v0.0.0-20260203063626-d704b68a426d
|
||||
github.com/meilisearch/meilisearch-go v0.36.1
|
||||
github.com/minio/minio-go/v7 v7.0.98
|
||||
github.com/pdfcpu/pdfcpu v0.11.1
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/redis/go-redis/v9 v9.18.0
|
||||
github.com/yuin/goldmark v1.8.2
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.17.0
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0
|
||||
go.opentelemetry.io/otel v1.42.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0
|
||||
go.opentelemetry.io/otel/log v0.18.0
|
||||
go.opentelemetry.io/otel/sdk v1.42.0
|
||||
go.opentelemetry.io/otel/sdk/log v0.18.0
|
||||
golang.org/x/net v0.51.0
|
||||
)
|
||||
|
||||
@@ -13,12 +29,9 @@ require (
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.2.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/dslipak/pdf v0.0.2 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/getsentry/sentry-go v0.43.0 // indirect
|
||||
github.com/go-ini/ini v1.67.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
@@ -28,40 +41,25 @@ require (
|
||||
github.com/hhrutter/lzw v1.0.0 // indirect
|
||||
github.com/hhrutter/pkcs7 v0.2.0 // indirect
|
||||
github.com/hhrutter/tiff v1.0.2 // indirect
|
||||
github.com/hibiken/asynq v0.26.0 // indirect
|
||||
github.com/hibiken/asynq/x v0.0.0-20260203063626-d704b68a426d // indirect
|
||||
github.com/klauspost/compress v1.18.2 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
|
||||
github.com/klauspost/crc32 v1.3.0 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/meilisearch/meilisearch-go v0.36.1 // indirect
|
||||
github.com/minio/crc64nvme v1.1.1 // indirect
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.66.1 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/redis/go-redis/v9 v9.18.0 // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/tinylib/msgp v1.6.1 // indirect
|
||||
github.com/yuin/goldmark v1.8.2 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.17.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
|
||||
go.opentelemetry.io/otel v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/log v0.18.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/log v0.18.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.42.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
@@ -77,5 +75,4 @@ require (
|
||||
google.golang.org/grpc v1.79.2 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -2,6 +2,10 @@ github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7X
|
||||
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
@@ -12,14 +16,16 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/dslipak/pdf v0.0.2 h1:djAvcM5neg9Ush+zR6QXB+VMJzR6TdnX766HPIg1JmI=
|
||||
github.com/dslipak/pdf v0.0.2/go.mod h1:2L3SnkI9cQwnAS9gfPz2iUoLC0rUZwbucpbKi5R1mUo=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/getsentry/sentry-go v0.43.0 h1:XbXLpFicpo8HmBDaInk7dum18G9KSLcjZiyUKS+hLW4=
|
||||
github.com/getsentry/sentry-go v0.43.0/go.mod h1:XDotiNZbgf5U8bPDUAfvcFmOnMQQceESxyKaObSssW0=
|
||||
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
||||
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
|
||||
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
@@ -29,6 +35,10 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
|
||||
@@ -50,6 +60,12 @@ github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4O
|
||||
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
|
||||
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/meilisearch/meilisearch-go v0.36.1 h1:mJTCJE5g7tRvaqKco6DfqOuJEjX+rRltDEnkEC02Y0M=
|
||||
@@ -66,42 +82,40 @@ github.com/pdfcpu/pdfcpu v0.11.1 h1:htHBSkGH5jMKWC6e0sihBFbcKZ8vG1M67c8/dJxhjas=
|
||||
github.com/pdfcpu/pdfcpu v0.11.1/go.mod h1:pP3aGga7pRvwFWAm9WwFvo+V68DfANi9kxSQYioNYcw=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
|
||||
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
|
||||
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
|
||||
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
|
||||
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
|
||||
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
||||
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
|
||||
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
|
||||
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
|
||||
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.17.0 h1:NFIS6x7wyObQ7cR84x7bt1sr8nYBx89s3x3GwRjw40k=
|
||||
@@ -124,12 +138,18 @@ go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXY
|
||||
go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
|
||||
go.opentelemetry.io/otel/sdk/log v0.18.0 h1:n8OyZr7t7otkeTnPTbDNom6rW16TBYGtvyy2Gk6buQw=
|
||||
go.opentelemetry.io/otel/sdk/log v0.18.0/go.mod h1:C0+wxkTwKpOCZLrlJ3pewPiiQwpzycPI/u6W0Z9fuYk=
|
||||
go.opentelemetry.io/otel/sdk/log/logtest v0.18.0 h1:l3mYuPsuBx6UKE47BVcPrZoZ0q/KER57vbj2qkgDLXA=
|
||||
go.opentelemetry.io/otel/sdk/log/logtest v0.18.0/go.mod h1:7cHtiVJpZebB3wybTa4NG+FUo5NPe3PROz1FqB0+qdw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
|
||||
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
|
||||
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
@@ -146,6 +166,8 @@ golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac=
|
||||
@@ -154,9 +176,9 @@ google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
|
||||
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
@@ -96,11 +96,12 @@ func (p *Producer) CreateImportTask(ctx context.Context, task domain.ImportTask)
|
||||
}
|
||||
|
||||
payload := ImportPayload{
|
||||
PBTaskID: id,
|
||||
Slug: task.Slug,
|
||||
Title: task.Title,
|
||||
FileType: task.FileType,
|
||||
ObjectKey: task.ObjectKey,
|
||||
PBTaskID: id,
|
||||
Slug: task.Slug,
|
||||
Title: task.Title,
|
||||
FileType: task.FileType,
|
||||
ObjectKey: task.ObjectKey,
|
||||
ChaptersKey: task.ChaptersKey,
|
||||
}
|
||||
if err := p.enqueue(ctx, TypeImportBook, payload); err != nil {
|
||||
// Non-fatal: PB record exists; runner will pick it up on next poll.
|
||||
|
||||
@@ -48,9 +48,10 @@ type ScrapePayload struct {
|
||||
|
||||
// ImportPayload is the Asynq job payload for PDF/EPUB import tasks.
|
||||
type ImportPayload struct {
|
||||
PBTaskID string `json:"pb_task_id"`
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
FileType string `json:"file_type"` // "pdf" or "epub"
|
||||
ObjectKey string `json:"object_key"` // MinIO path to uploaded file
|
||||
PBTaskID string `json:"pb_task_id"`
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
FileType string `json:"file_type"` // "pdf" or "epub"
|
||||
ObjectKey string `json:"object_key"` // MinIO path to uploaded file
|
||||
ChaptersKey string `json:"chapters_key"` // MinIO path to pre-parsed chapters JSON
|
||||
}
|
||||
|
||||
117
backend/internal/backend/handlers_books_admin.go
Normal file
117
backend/internal/backend/handlers_books_admin.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/libnovel/backend/internal/storage"
|
||||
)
|
||||
|
||||
// handleAdminArchiveBook handles PATCH /api/admin/books/{slug}/archive.
|
||||
// Soft-deletes a book by setting archived=true in PocketBase and updating the
|
||||
// Meilisearch document so it is excluded from all public search results.
|
||||
// The book data is preserved and can be restored with the unarchive endpoint.
|
||||
func (s *Server) handleAdminArchiveBook(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
if slug == "" {
|
||||
jsonError(w, http.StatusBadRequest, "missing slug")
|
||||
return
|
||||
}
|
||||
if s.deps.BookAdminStore == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "book admin store not configured")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.deps.BookAdminStore.ArchiveBook(r.Context(), slug); err != nil {
|
||||
if errors.Is(err, storage.ErrNotFound) {
|
||||
jsonError(w, http.StatusNotFound, "book not found")
|
||||
return
|
||||
}
|
||||
s.deps.Log.Error("archive book failed", "slug", slug, "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Update the Meilisearch document so the archived flag takes effect
|
||||
// immediately in search/catalogue results.
|
||||
if meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), slug); err == nil && ok {
|
||||
if upsertErr := s.deps.SearchIndex.UpsertBook(r.Context(), meta); upsertErr != nil {
|
||||
s.deps.Log.Warn("archive book: meili upsert failed", "slug", slug, "err", upsertErr)
|
||||
}
|
||||
}
|
||||
|
||||
s.deps.Log.Info("book archived", "slug", slug)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"slug": slug, "status": "archived"})
|
||||
}
|
||||
|
||||
// handleAdminUnarchiveBook handles PATCH /api/admin/books/{slug}/unarchive.
|
||||
// Restores a previously archived book by clearing the archived flag, making it
|
||||
// publicly visible in search and catalogue results again.
|
||||
func (s *Server) handleAdminUnarchiveBook(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
if slug == "" {
|
||||
jsonError(w, http.StatusBadRequest, "missing slug")
|
||||
return
|
||||
}
|
||||
if s.deps.BookAdminStore == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "book admin store not configured")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.deps.BookAdminStore.UnarchiveBook(r.Context(), slug); err != nil {
|
||||
if errors.Is(err, storage.ErrNotFound) {
|
||||
jsonError(w, http.StatusNotFound, "book not found")
|
||||
return
|
||||
}
|
||||
s.deps.Log.Error("unarchive book failed", "slug", slug, "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Sync the updated archived=false state back to Meilisearch.
|
||||
if meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), slug); err == nil && ok {
|
||||
if upsertErr := s.deps.SearchIndex.UpsertBook(r.Context(), meta); upsertErr != nil {
|
||||
s.deps.Log.Warn("unarchive book: meili upsert failed", "slug", slug, "err", upsertErr)
|
||||
}
|
||||
}
|
||||
|
||||
s.deps.Log.Info("book unarchived", "slug", slug)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"slug": slug, "status": "active"})
|
||||
}
|
||||
|
||||
// handleAdminDeleteBook handles DELETE /api/admin/books/{slug}.
|
||||
// Permanently removes all data for a book:
|
||||
// - PocketBase books record and all chapters_idx records
|
||||
// - All MinIO chapter markdown objects and the cover image
|
||||
// - Meilisearch document
|
||||
//
|
||||
// This operation is irreversible. Use the archive endpoint for soft-deletion.
|
||||
func (s *Server) handleAdminDeleteBook(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
if slug == "" {
|
||||
jsonError(w, http.StatusBadRequest, "missing slug")
|
||||
return
|
||||
}
|
||||
if s.deps.BookAdminStore == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "book admin store not configured")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.deps.BookAdminStore.DeleteBook(r.Context(), slug); err != nil {
|
||||
if errors.Is(err, storage.ErrNotFound) {
|
||||
jsonError(w, http.StatusNotFound, "book not found")
|
||||
return
|
||||
}
|
||||
s.deps.Log.Error("delete book failed", "slug", slug, "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Remove from Meilisearch — best-effort (log on failure, don't fail request).
|
||||
if err := s.deps.SearchIndex.DeleteBook(r.Context(), slug); err != nil {
|
||||
s.deps.Log.Warn("delete book: meili delete failed", "slug", slug, "err", err)
|
||||
}
|
||||
|
||||
s.deps.Log.Info("book deleted", "slug", slug)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"slug": slug, "status": "deleted"})
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -45,6 +46,8 @@ func (s *Server) handleAdminImport(w http.ResponseWriter, r *http.Request) {
|
||||
ct := r.Header.Get("Content-Type")
|
||||
var req importRequest
|
||||
var objectKey string
|
||||
var chaptersKey string
|
||||
var chapterCount int
|
||||
|
||||
if strings.HasPrefix(ct, "multipart/form-data") {
|
||||
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||
@@ -96,17 +99,38 @@ func (s *Server) handleAdminImport(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Upload to MinIO for actual import
|
||||
// Parse PDF/EPUB on the backend (with timeout) and store chapters as JSON.
|
||||
// The runner only needs to ingest pre-parsed chapters — no PDF parsing on runner.
|
||||
parseCtx, parseCancel := context.WithTimeout(r.Context(), 3*time.Minute)
|
||||
defer parseCancel()
|
||||
chapters, parseErr := storage.ParseImportFile(parseCtx, data, req.FileType)
|
||||
if parseErr != nil || len(chapters) == 0 {
|
||||
jsonError(w, http.StatusUnprocessableEntity, "could not parse file: "+func() string {
|
||||
if parseErr != nil { return parseErr.Error() }
|
||||
return "no chapters found"
|
||||
}())
|
||||
return
|
||||
}
|
||||
|
||||
// Store raw file in MinIO (for reference/re-import).
|
||||
objectKey = fmt.Sprintf("imports/%d_%s", time.Now().Unix(), header.Filename)
|
||||
store, ok := s.deps.Producer.(*storage.Store)
|
||||
if !ok {
|
||||
if s.deps.ImportFileStore == nil {
|
||||
jsonError(w, http.StatusInternalServerError, "storage not available")
|
||||
return
|
||||
}
|
||||
if err := store.PutImportFile(r.Context(), objectKey, data); err != nil {
|
||||
if err := s.deps.ImportFileStore.PutImportFile(r.Context(), objectKey, data); err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "upload file: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Store pre-parsed chapters JSON in MinIO so runner can ingest without re-parsing.
|
||||
chaptersJSON, _ := json.Marshal(chapters)
|
||||
chaptersKey = fmt.Sprintf("imports/%d_%s_chapters.json", time.Now().Unix(), strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename)))
|
||||
if err := s.deps.ImportFileStore.PutImportChapters(r.Context(), chaptersKey, chaptersJSON); err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "store chapters: "+err.Error())
|
||||
return
|
||||
}
|
||||
chapterCount = len(chapters)
|
||||
} else {
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
|
||||
@@ -142,6 +166,8 @@ func (s *Server) handleAdminImport(w http.ResponseWriter, r *http.Request) {
|
||||
BookStatus: req.BookStatus,
|
||||
FileType: req.FileType,
|
||||
ObjectKey: objectKey,
|
||||
ChaptersKey: chaptersKey,
|
||||
ChaptersTotal: chapterCount,
|
||||
InitiatorUserID: "",
|
||||
})
|
||||
if err != nil {
|
||||
@@ -150,8 +176,9 @@ func (s *Server) handleAdminImport(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
writeJSON(w, 0, importResponse{
|
||||
TaskID: taskID,
|
||||
Slug: slug,
|
||||
TaskID: taskID,
|
||||
Slug: slug,
|
||||
Preview: &importPreview{Chapters: chapterCount},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
141
backend/internal/backend/handlers_split.go
Normal file
141
backend/internal/backend/handlers_split.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/libnovel/backend/internal/bookstore"
|
||||
"github.com/libnovel/backend/internal/domain"
|
||||
)
|
||||
|
||||
// handleAdminSplitChapters handles POST /api/admin/books/{slug}/split-chapters.
|
||||
//
|
||||
// Request body (JSON):
|
||||
//
|
||||
// { "text": "<full text with --- dividers and optional ## Title lines>" }
|
||||
//
|
||||
// The text is split on lines containing only "---". Each segment may start with
|
||||
// a "## Title" line which becomes the chapter title; remaining lines are the
|
||||
// chapter content. Sequential chapter numbers 1..N are assigned.
|
||||
//
|
||||
// All existing chapters for the book are replaced: WriteChapter is called for
|
||||
// each new chapter (upsert by number), so chapters beyond N are not deleted —
|
||||
// use the dedup endpoint afterwards if needed.
|
||||
func (s *Server) handleAdminSplitChapters(w http.ResponseWriter, r *http.Request) {
|
||||
if s.deps.BookWriter == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "book writer not configured")
|
||||
return
|
||||
}
|
||||
|
||||
slug := r.PathValue("slug")
|
||||
if slug == "" {
|
||||
jsonError(w, http.StatusBadRequest, "slug is required")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Text) == "" {
|
||||
jsonError(w, http.StatusBadRequest, "text is required")
|
||||
return
|
||||
}
|
||||
|
||||
chapters := splitChapterText(req.Text)
|
||||
if len(chapters) == 0 {
|
||||
jsonError(w, http.StatusUnprocessableEntity, "no chapters produced from text")
|
||||
return
|
||||
}
|
||||
|
||||
for _, ch := range chapters {
|
||||
var mdContent string
|
||||
if ch.Title != "" && ch.Title != fmt.Sprintf("Chapter %d", ch.Number) {
|
||||
mdContent = fmt.Sprintf("# %s\n\n%s", ch.Title, ch.Content)
|
||||
} else {
|
||||
mdContent = fmt.Sprintf("# Chapter %d\n\n%s", ch.Number, ch.Content)
|
||||
}
|
||||
domainCh := domain.Chapter{
|
||||
Ref: domain.ChapterRef{Number: ch.Number, Title: ch.Title},
|
||||
Text: mdContent,
|
||||
}
|
||||
if err := s.deps.BookWriter.WriteChapter(r.Context(), slug, domainCh); err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, fmt.Sprintf("write chapter %d: %s", ch.Number, err.Error()))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, 0, map[string]any{
|
||||
"chapters": len(chapters),
|
||||
"slug": slug,
|
||||
})
|
||||
}
|
||||
|
||||
// splitChapterText splits text on "---" divider lines into bookstore.Chapter
|
||||
// slices. Each segment may optionally start with a "## Title" header line.
|
||||
func splitChapterText(text string) []bookstore.Chapter {
|
||||
lines := strings.Split(text, "\n")
|
||||
|
||||
// Collect raw segments split on "---" dividers.
|
||||
var segments [][]string
|
||||
cur := []string{}
|
||||
for _, line := range lines {
|
||||
if strings.TrimSpace(line) == "---" {
|
||||
segments = append(segments, cur)
|
||||
cur = []string{}
|
||||
} else {
|
||||
cur = append(cur, line)
|
||||
}
|
||||
}
|
||||
segments = append(segments, cur) // last segment
|
||||
|
||||
var chapters []bookstore.Chapter
|
||||
chNum := 0
|
||||
for _, seg := range segments {
|
||||
// Trim leading/trailing blank lines from the segment.
|
||||
start, end := 0, len(seg)
|
||||
for start < end && strings.TrimSpace(seg[start]) == "" {
|
||||
start++
|
||||
}
|
||||
for end > start && strings.TrimSpace(seg[end-1]) == "" {
|
||||
end--
|
||||
}
|
||||
seg = seg[start:end]
|
||||
if len(seg) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for a "## Title" header on the first line.
|
||||
title := ""
|
||||
contentStart := 0
|
||||
if strings.HasPrefix(strings.TrimSpace(seg[0]), "## ") {
|
||||
title = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(seg[0]), "## "))
|
||||
contentStart = 1
|
||||
// Skip blank lines after the title.
|
||||
for contentStart < len(seg) && strings.TrimSpace(seg[contentStart]) == "" {
|
||||
contentStart++
|
||||
}
|
||||
}
|
||||
|
||||
content := strings.TrimSpace(strings.Join(seg[contentStart:], "\n"))
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
chNum++
|
||||
if title == "" {
|
||||
title = fmt.Sprintf("Chapter %d", chNum)
|
||||
}
|
||||
chapters = append(chapters, bookstore.Chapter{
|
||||
Number: chNum,
|
||||
Title: title,
|
||||
Content: content,
|
||||
})
|
||||
}
|
||||
return chapters
|
||||
}
|
||||
@@ -85,9 +85,15 @@ type Dependencies struct {
|
||||
// BookWriter writes book metadata and chapter refs to PocketBase.
|
||||
// Used by admin text-gen apply endpoints.
|
||||
BookWriter bookstore.BookWriter
|
||||
// ImportFileStore uploads raw PDF/EPUB files to MinIO for the runner to process.
|
||||
// Always wired to the concrete *storage.Store (not the Asynq wrapper).
|
||||
ImportFileStore bookstore.ImportFileStore
|
||||
// AIJobStore tracks long-running AI generation jobs in PocketBase.
|
||||
// If nil, job persistence is disabled (jobs still run but are not recorded).
|
||||
AIJobStore bookstore.AIJobStore
|
||||
// BookAdminStore provides admin-only operations: archive, unarchive, hard-delete.
|
||||
// If nil, the admin book management endpoints return 503.
|
||||
BookAdminStore bookstore.BookAdminStore
|
||||
// Log is the structured logger.
|
||||
Log *slog.Logger
|
||||
}
|
||||
@@ -244,6 +250,14 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
// Admin data repair endpoints
|
||||
mux.HandleFunc("POST /api/admin/dedup-chapters/{slug}", s.handleDedupChapters)
|
||||
|
||||
// Admin book management (soft-delete / hard-delete)
|
||||
mux.HandleFunc("PATCH /api/admin/books/{slug}/archive", s.handleAdminArchiveBook)
|
||||
mux.HandleFunc("PATCH /api/admin/books/{slug}/unarchive", s.handleAdminUnarchiveBook)
|
||||
mux.HandleFunc("DELETE /api/admin/books/{slug}", s.handleAdminDeleteBook)
|
||||
|
||||
// Admin chapter split (imported books)
|
||||
mux.HandleFunc("POST /api/admin/books/{slug}/split-chapters", s.handleAdminSplitChapters)
|
||||
|
||||
// Import (PDF/EPUB)
|
||||
mux.HandleFunc("POST /api/admin/import", s.handleAdminImport)
|
||||
mux.HandleFunc("GET /api/admin/import", s.handleAdminImportList)
|
||||
|
||||
@@ -215,3 +215,35 @@ type BookImporter interface {
|
||||
// Returns the extracted chapters or an error.
|
||||
Import(ctx context.Context, objectKey, fileType string) ([]Chapter, error)
|
||||
}
|
||||
|
||||
// BookAdminStore covers admin-only operations for managing books in the catalogue.
|
||||
// All methods require admin authorisation at the HTTP handler level.
|
||||
type BookAdminStore interface {
|
||||
// ArchiveBook sets archived=true on a book record, hiding it from all
|
||||
// public search and catalogue responses. Returns ErrNotFound when the
|
||||
// slug does not exist.
|
||||
ArchiveBook(ctx context.Context, slug string) error
|
||||
|
||||
// UnarchiveBook clears archived on a book record, making it publicly
|
||||
// visible again. Returns ErrNotFound when the slug does not exist.
|
||||
UnarchiveBook(ctx context.Context, slug string) error
|
||||
|
||||
// DeleteBook permanently removes all data for a book:
|
||||
// - PocketBase books record
|
||||
// - All PocketBase chapters_idx records
|
||||
// - All MinIO chapter markdown objects ({slug}/chapter-*.md)
|
||||
// - MinIO cover image (covers/{slug}.jpg)
|
||||
// The caller is responsible for also deleting the Meilisearch document.
|
||||
DeleteBook(ctx context.Context, slug string) error
|
||||
}
|
||||
|
||||
// ImportFileStore uploads raw import files to object storage.
|
||||
// Kept separate from BookImporter so the HTTP handler can upload the file
|
||||
// without a concrete type assertion, regardless of which Producer is wired.
|
||||
type ImportFileStore interface {
|
||||
PutImportFile(ctx context.Context, objectKey string, data []byte) error
|
||||
// PutImportChapters stores the pre-parsed chapters JSON under the given key.
|
||||
PutImportChapters(ctx context.Context, key string, data []byte) error
|
||||
// GetImportChapters retrieves the pre-parsed chapters JSON.
|
||||
GetImportChapters(ctx context.Context, key string) ([]byte, error)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,9 @@ type BookMeta struct {
|
||||
// updated in PocketBase. Populated on read; not sent on write (PocketBase
|
||||
// manages its own updated field).
|
||||
MetaUpdated int64 `json:"meta_updated,omitempty"`
|
||||
// Archived is true when the book has been soft-deleted by an admin.
|
||||
// Archived books are excluded from all public search and catalogue responses.
|
||||
Archived bool `json:"archived,omitempty"`
|
||||
}
|
||||
|
||||
// CatalogueEntry is a lightweight book reference returned by catalogue pages.
|
||||
@@ -178,6 +181,7 @@ type ImportTask struct {
|
||||
FileName string `json:"file_name"`
|
||||
FileType string `json:"file_type"` // "pdf" or "epub"
|
||||
ObjectKey string `json:"object_key,omitempty"` // MinIO path to uploaded file
|
||||
ChaptersKey string `json:"chapters_key,omitempty"` // MinIO path to pre-parsed chapters JSON
|
||||
Author string `json:"author,omitempty"`
|
||||
CoverURL string `json:"cover_url,omitempty"`
|
||||
Genres []string `json:"genres,omitempty"`
|
||||
|
||||
@@ -32,11 +32,15 @@ type Client interface {
|
||||
// BookExists reports whether a book with the given slug is already in the
|
||||
// index. Used by the catalogue refresh to skip re-indexing known books.
|
||||
BookExists(ctx context.Context, slug string) bool
|
||||
// DeleteBook removes a book document from the search index by slug.
|
||||
DeleteBook(ctx context.Context, slug string) error
|
||||
// Search returns up to limit books matching query.
|
||||
// Archived books are always excluded.
|
||||
Search(ctx context.Context, query string, limit int) ([]domain.BookMeta, error)
|
||||
// Catalogue queries books with optional filters, sort, and pagination.
|
||||
// Returns books, the total hit count for pagination, and a FacetResult
|
||||
// with available genre and status values from the index.
|
||||
// Archived books are always excluded.
|
||||
Catalogue(ctx context.Context, q CatalogueQuery) ([]domain.BookMeta, int64, FacetResult, error)
|
||||
}
|
||||
|
||||
@@ -99,7 +103,7 @@ func Configure(host, apiKey string) error {
|
||||
return fmt.Errorf("meili: update searchable attributes: %w", err)
|
||||
}
|
||||
|
||||
filterable := []interface{}{"status", "genres"}
|
||||
filterable := []interface{}{"status", "genres", "archived"}
|
||||
if _, err := idx.UpdateFilterableAttributes(&filterable); err != nil {
|
||||
return fmt.Errorf("meili: update filterable attributes: %w", err)
|
||||
}
|
||||
@@ -128,6 +132,9 @@ type bookDoc struct {
|
||||
// MetaUpdated is the Unix timestamp (seconds) of the last PocketBase update.
|
||||
// Used for sort=update ("recently updated" ordering).
|
||||
MetaUpdated int64 `json:"meta_updated"`
|
||||
// Archived is true when the book has been soft-deleted by an admin.
|
||||
// Used as a filter to exclude archived books from all search results.
|
||||
Archived bool `json:"archived"`
|
||||
}
|
||||
|
||||
func toDoc(b domain.BookMeta) bookDoc {
|
||||
@@ -144,6 +151,7 @@ func toDoc(b domain.BookMeta) bookDoc {
|
||||
Rank: b.Ranking,
|
||||
Rating: b.Rating,
|
||||
MetaUpdated: b.MetaUpdated,
|
||||
Archived: b.Archived,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,6 +169,7 @@ func fromDoc(d bookDoc) domain.BookMeta {
|
||||
Ranking: d.Rank,
|
||||
Rating: d.Rating,
|
||||
MetaUpdated: d.MetaUpdated,
|
||||
Archived: d.Archived,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,13 +193,24 @@ func (c *MeiliClient) BookExists(_ context.Context, slug string) bool {
|
||||
return err == nil && doc.Slug != ""
|
||||
}
|
||||
|
||||
// DeleteBook removes a book document from the index by slug.
|
||||
// The operation is fire-and-forget (Meilisearch processes tasks asynchronously).
|
||||
func (c *MeiliClient) DeleteBook(_ context.Context, slug string) error {
|
||||
if _, err := c.idx.DeleteDocument(slug, nil); err != nil {
|
||||
return fmt.Errorf("meili: delete book %q: %w", slug, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Search returns books matching query, up to limit results.
|
||||
// Archived books are always excluded.
|
||||
func (c *MeiliClient) Search(_ context.Context, query string, limit int) ([]domain.BookMeta, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
res, err := c.idx.Search(query, &meilisearch.SearchRequest{
|
||||
Limit: int64(limit),
|
||||
Limit: int64(limit),
|
||||
Filter: "archived = false",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("meili: search %q: %w", query, err)
|
||||
@@ -231,17 +251,15 @@ func (c *MeiliClient) Catalogue(_ context.Context, q CatalogueQuery) ([]domain.B
|
||||
Facets: []string{"genres", "status"},
|
||||
}
|
||||
|
||||
// Build filter
|
||||
var filters []string
|
||||
// Build filter — always exclude archived books
|
||||
filters := []string{"archived = false"}
|
||||
if q.Genre != "" && q.Genre != "all" {
|
||||
filters = append(filters, fmt.Sprintf("genres = %q", q.Genre))
|
||||
}
|
||||
if q.Status != "" && q.Status != "all" {
|
||||
filters = append(filters, fmt.Sprintf("status = %q", q.Status))
|
||||
}
|
||||
if len(filters) > 0 {
|
||||
req.Filter = strings.Join(filters, " AND ")
|
||||
}
|
||||
req.Filter = strings.Join(filters, " AND ")
|
||||
|
||||
// Map UI sort tokens to Meilisearch sort expressions.
|
||||
switch q.Sort {
|
||||
@@ -318,7 +336,8 @@ func sortStrings(s []string) {
|
||||
type NoopClient struct{}
|
||||
|
||||
func (NoopClient) UpsertBook(_ context.Context, _ domain.BookMeta) error { return nil }
|
||||
func (NoopClient) BookExists(_ context.Context, _ string) bool { return false }
|
||||
func (NoopClient) BookExists(_ context.Context, _ string) bool { return false }
|
||||
func (NoopClient) DeleteBook(_ context.Context, _ string) error { return nil }
|
||||
func (NoopClient) Search(_ context.Context, _ string, _ int) ([]domain.BookMeta, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -199,10 +199,11 @@ func (r *Runner) handleImportTask(ctx context.Context, t *asynq.Task) error {
|
||||
return fmt.Errorf("unmarshal import payload: %w", err)
|
||||
}
|
||||
task := domain.ImportTask{
|
||||
ID: p.PBTaskID,
|
||||
Slug: p.Slug,
|
||||
Title: p.Title,
|
||||
FileType: p.FileType,
|
||||
ID: p.PBTaskID,
|
||||
Slug: p.Slug,
|
||||
Title: p.Title,
|
||||
FileType: p.FileType,
|
||||
ChaptersKey: p.ChaptersKey,
|
||||
}
|
||||
r.tasksRunning.Add(1)
|
||||
defer r.tasksRunning.Add(-1)
|
||||
|
||||
@@ -19,3 +19,53 @@ func stripMarkdown(src string) string {
|
||||
src = regexp.MustCompile(`\n{3,}`).ReplaceAllString(src, "\n\n")
|
||||
return strings.TrimSpace(src)
|
||||
}
|
||||
|
||||
// chunkText splits text into chunks of at most maxChars characters, breaking
|
||||
// at sentence boundaries (". ", "! ", "? ", "\n") so that the TTS service
|
||||
// receives natural prose fragments rather than mid-sentence cuts.
|
||||
//
|
||||
// If a single sentence exceeds maxChars it is included as its own chunk —
|
||||
// never silently truncated.
|
||||
func chunkText(text string, maxChars int) []string {
|
||||
if len(text) <= maxChars {
|
||||
return []string{text}
|
||||
}
|
||||
|
||||
// Sentence-boundary delimiters — we split AFTER these sequences.
|
||||
// Order matters: longer sequences first.
|
||||
delimiters := []string{".\n", "!\n", "?\n", ". ", "! ", "? ", "\n\n", "\n"}
|
||||
|
||||
var chunks []string
|
||||
remaining := text
|
||||
|
||||
for len(remaining) > 0 {
|
||||
if len(remaining) <= maxChars {
|
||||
chunks = append(chunks, strings.TrimSpace(remaining))
|
||||
break
|
||||
}
|
||||
|
||||
// Find the last sentence boundary within the maxChars window.
|
||||
window := remaining[:maxChars]
|
||||
cutAt := -1
|
||||
for _, delim := range delimiters {
|
||||
idx := strings.LastIndex(window, delim)
|
||||
if idx > 0 && idx+len(delim) > cutAt {
|
||||
cutAt = idx + len(delim)
|
||||
}
|
||||
}
|
||||
|
||||
if cutAt <= 0 {
|
||||
// No boundary found — hard-break at maxChars to avoid infinite loop.
|
||||
cutAt = maxChars
|
||||
}
|
||||
|
||||
chunk := strings.TrimSpace(remaining[:cutAt])
|
||||
if chunk != "" {
|
||||
chunks = append(chunks, chunk)
|
||||
}
|
||||
remaining = strings.TrimSpace(remaining[cutAt:])
|
||||
}
|
||||
|
||||
return chunks
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
@@ -49,6 +50,11 @@ type ChapterIngester interface {
|
||||
IngestChapters(ctx context.Context, slug string, chapters []bookstore.Chapter) error
|
||||
}
|
||||
|
||||
// ImportChapterStore retrieves pre-parsed chapter JSON blobs from object storage.
|
||||
type ImportChapterStore interface {
|
||||
GetImportChapters(ctx context.Context, key string) ([]byte, error)
|
||||
}
|
||||
|
||||
// Config tunes the runner behaviour.
|
||||
type Config struct {
|
||||
// WorkerID uniquely identifies this runner instance in PocketBase records.
|
||||
@@ -114,7 +120,12 @@ type Dependencies struct {
|
||||
// CoverStore stores book cover images in MinIO.
|
||||
CoverStore bookstore.CoverStore
|
||||
// BookImport handles PDF/EPUB file parsing and chapter extraction.
|
||||
// Kept for backward compatibility when ChaptersKey is not set.
|
||||
BookImport bookstore.BookImporter
|
||||
// ImportChapterStore retrieves pre-parsed chapter JSON blobs from MinIO.
|
||||
// When set and the task has a ChaptersKey, the runner reads from here
|
||||
// instead of calling BookImport.Import() (the new preferred path).
|
||||
ImportChapterStore ImportChapterStore
|
||||
// ChapterIngester persists extracted chapters into MinIO/PocketBase.
|
||||
ChapterIngester ChapterIngester
|
||||
// Notifier creates notifications for users.
|
||||
@@ -645,7 +656,7 @@ func (r *Runner) runAudioTask(ctx context.Context, task domain.AudioTask) {
|
||||
return
|
||||
}
|
||||
var genErr error
|
||||
audioData, genErr = r.deps.Kokoro.GenerateAudio(ctx, text, task.Voice)
|
||||
audioData, genErr = kokoroGenerateChunked(ctx, r.deps.Kokoro, text, task.Voice, log)
|
||||
if genErr != nil {
|
||||
fail(fmt.Sprintf("kokoro generate: %v", genErr))
|
||||
return
|
||||
@@ -674,7 +685,36 @@ func (r *Runner) runAudioTask(ctx context.Context, task domain.AudioTask) {
|
||||
log.Info("runner: audio task finished", "key", key)
|
||||
}
|
||||
|
||||
// kokoroGenerateChunked splits text into ~1 000-character sentence-boundary
|
||||
// chunks, calls Kokoro.GenerateAudio for each, and concatenates the raw MP3
|
||||
// bytes. This avoids EOF / timeout failures that occur when the Kokoro
|
||||
// FastAPI server receives very large inputs (e.g. a full imported PDF chapter).
|
||||
//
|
||||
// Concatenating raw MP3 frames is valid — MP3 is a frame-based format and
|
||||
// standard players handle multi-segment files correctly.
|
||||
func kokoroGenerateChunked(ctx context.Context, k kokoro.Client, text, voice string, log *slog.Logger) ([]byte, error) {
|
||||
const chunkSize = 1000
|
||||
|
||||
chunks := chunkText(text, chunkSize)
|
||||
log.Info("runner: kokoro chunked generation", "chunks", len(chunks), "total_chars", len(text))
|
||||
|
||||
var combined []byte
|
||||
for i, chunk := range chunks {
|
||||
data, err := k.GenerateAudio(ctx, chunk, voice)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("chunk %d/%d: %w", i+1, len(chunks), err)
|
||||
}
|
||||
combined = append(combined, data...)
|
||||
log.Info("runner: kokoro chunk done", "chunk", i+1, "of", len(chunks), "bytes", len(data))
|
||||
}
|
||||
return combined, nil
|
||||
}
|
||||
|
||||
// runImportTask executes one PDF/EPUB import task.
|
||||
// Preferred path: when task.ChaptersKey is set, it reads pre-parsed chapters
|
||||
// JSON from MinIO (written by the backend at upload time) and ingests them.
|
||||
// Fallback path: when ChaptersKey is empty, calls BookImport.Import() to
|
||||
// parse the raw file on the runner (legacy behaviour, not used for new tasks).
|
||||
func (r *Runner) runImportTask(ctx context.Context, task domain.ImportTask, objectKey string) {
|
||||
ctx, span := otel.Tracer("runner").Start(ctx, "runner.import_task")
|
||||
defer span.End()
|
||||
@@ -682,10 +722,11 @@ func (r *Runner) runImportTask(ctx context.Context, task domain.ImportTask, obje
|
||||
attribute.String("task.id", task.ID),
|
||||
attribute.String("book.slug", task.Slug),
|
||||
attribute.String("file.type", task.FileType),
|
||||
attribute.String("chapters_key", task.ChaptersKey),
|
||||
)
|
||||
|
||||
log := r.deps.Log.With("task_id", task.ID, "slug", task.Slug, "file_type", task.FileType)
|
||||
log.Info("runner: import task starting")
|
||||
log.Info("runner: import task starting", "chapters_key", task.ChaptersKey)
|
||||
|
||||
hbCtx, hbCancel := context.WithCancel(ctx)
|
||||
defer hbCancel()
|
||||
@@ -714,15 +755,33 @@ func (r *Runner) runImportTask(ctx context.Context, task domain.ImportTask, obje
|
||||
}
|
||||
}
|
||||
|
||||
if r.deps.BookImport == nil {
|
||||
fail("book import not configured (BookImport dependency missing)")
|
||||
return
|
||||
}
|
||||
var chapters []bookstore.Chapter
|
||||
|
||||
chapters, err := r.deps.BookImport.Import(ctx, objectKey, task.FileType)
|
||||
if err != nil {
|
||||
fail(fmt.Sprintf("import file: %v", err))
|
||||
return
|
||||
if task.ChaptersKey != "" && r.deps.ImportChapterStore != nil {
|
||||
// New path: read pre-parsed chapters JSON uploaded by the backend.
|
||||
raw, err := r.deps.ImportChapterStore.GetImportChapters(ctx, task.ChaptersKey)
|
||||
if err != nil {
|
||||
fail(fmt.Sprintf("get chapters JSON: %v", err))
|
||||
return
|
||||
}
|
||||
if err := json.Unmarshal(raw, &chapters); err != nil {
|
||||
fail(fmt.Sprintf("unmarshal chapters JSON: %v", err))
|
||||
return
|
||||
}
|
||||
log.Info("runner: loaded pre-parsed chapters", "count", len(chapters))
|
||||
} else {
|
||||
// Legacy path: parse the raw file on the runner.
|
||||
if r.deps.BookImport == nil {
|
||||
fail("book import not configured (BookImport dependency missing)")
|
||||
return
|
||||
}
|
||||
var err error
|
||||
chapters, err = r.deps.BookImport.Import(ctx, objectKey, task.FileType)
|
||||
if err != nil {
|
||||
fail(fmt.Sprintf("import file: %v", err))
|
||||
return
|
||||
}
|
||||
log.Info("runner: parsed chapters from file (legacy path)", "count", len(chapters))
|
||||
}
|
||||
|
||||
if len(chapters) == 0 {
|
||||
@@ -730,23 +789,12 @@ func (r *Runner) runImportTask(ctx context.Context, task domain.ImportTask, obje
|
||||
return
|
||||
}
|
||||
|
||||
// Store chapters via BookWriter
|
||||
// Note: BookWriter.WriteChapters expects domain.Chapter, need conversion
|
||||
var domainChapters []bookstore.Chapter
|
||||
for _, ch := range chapters {
|
||||
domainChapters = append(domainChapters, bookstore.Chapter{
|
||||
Number: ch.Number,
|
||||
Title: ch.Title,
|
||||
Content: ch.Content,
|
||||
})
|
||||
}
|
||||
|
||||
// Store chapters via ChapterIngester
|
||||
// Persist chapters via ChapterIngester.
|
||||
if r.deps.ChapterIngester == nil {
|
||||
fail("chapter ingester not configured")
|
||||
return
|
||||
}
|
||||
if err := r.deps.ChapterIngester.IngestChapters(ctx, task.Slug, domainChapters); err != nil {
|
||||
if err := r.deps.ChapterIngester.IngestChapters(ctx, task.Slug, chapters); err != nil {
|
||||
fail(fmt.Sprintf("store chapters: %v", err))
|
||||
return
|
||||
}
|
||||
@@ -786,7 +834,7 @@ func (r *Runner) runImportTask(ctx context.Context, task domain.ImportTask, obje
|
||||
log.Error("runner: FinishImportTask failed", "err", err)
|
||||
}
|
||||
|
||||
// Create notification for the user who initiated the import
|
||||
// Notify the user who initiated the import.
|
||||
if r.deps.Notifier != nil {
|
||||
msg := fmt.Sprintf("Import completed: %d chapters from %s", len(chapters), task.Title)
|
||||
targetUser := task.InitiatorUserID
|
||||
|
||||
@@ -6,23 +6,19 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/dslipak/pdf"
|
||||
"github.com/libnovel/backend/internal/bookstore"
|
||||
"github.com/libnovel/backend/internal/domain"
|
||||
minio "github.com/minio/minio-go/v7"
|
||||
"github.com/pdfcpu/pdfcpu/pkg/api"
|
||||
"github.com/pdfcpu/pdfcpu/pkg/pdfcpu/model"
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// chapterHeadingRE matches common chapter heading patterns:
|
||||
// "Chapter 1", "Chapter 1:", "Chapter 1 -", "CHAPTER ONE", "1.", "Part 1", etc.
|
||||
var chapterHeadingRE = regexp.MustCompile(
|
||||
`(?i)^(?:chapter|ch\.?|part|episode|book)\s+(\d+|[ivxlcdm]+)\b|^\d{1,4}[\.\)]\s+\S`)
|
||||
|
||||
type importer struct {
|
||||
mc *minioClient
|
||||
}
|
||||
@@ -58,6 +54,7 @@ func (i *importer) Import(ctx context.Context, objectKey, fileType string) ([]bo
|
||||
// chapter count and up to 3 preview lines (first non-empty line of each of
|
||||
// the first 3 chapters). It is used by the analyze-only endpoint so users
|
||||
// can preview chapter count before committing the import.
|
||||
// Note: uses parsePDF which is backed by pdfcpu ExtractContent — fast, no hang risk.
|
||||
func AnalyzeFile(data []byte, fileType string) (chapterCount int, firstLines []string, err error) {
|
||||
var chapters []bookstore.Chapter
|
||||
switch fileType {
|
||||
@@ -90,35 +87,398 @@ func AnalyzeFile(data []byte, fileType string) (chapterCount int, firstLines []s
|
||||
|
||||
|
||||
|
||||
func parsePDF(data []byte) ([]bookstore.Chapter, error) {
|
||||
r, err := pdf.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
// decryptPDF strips encryption from a PDF using an empty user password.
|
||||
// Returns the decrypted bytes, or an error if decryption is not possible.
|
||||
// This handles the common case of "owner-only" encrypted PDFs (copy/print
|
||||
// restrictions) which use an empty user password and open normally in readers.
|
||||
func decryptPDF(data []byte) ([]byte, error) {
|
||||
conf := model.NewDefaultConfiguration()
|
||||
conf.UserPW = ""
|
||||
conf.OwnerPW = ""
|
||||
|
||||
var out bytes.Buffer
|
||||
err := api.Decrypt(bytes.NewReader(data), &out, conf)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open PDF: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
// ParseImportFile parses a PDF or EPUB and returns chapters.
|
||||
// Unlike AnalyzeFile it respects ctx cancellation so callers can apply a timeout.
|
||||
// For PDFs it first attempts to strip encryption with an empty password.
|
||||
func ParseImportFile(ctx context.Context, data []byte, fileType string) ([]bookstore.Chapter, error) {
|
||||
type result struct {
|
||||
chapters []bookstore.Chapter
|
||||
err error
|
||||
}
|
||||
ch := make(chan result, 1)
|
||||
go func() {
|
||||
var chapters []bookstore.Chapter
|
||||
var err error
|
||||
switch fileType {
|
||||
case "pdf":
|
||||
chapters, err = parsePDF(data)
|
||||
case "epub":
|
||||
chapters, err = parseEPUB(data)
|
||||
default:
|
||||
err = fmt.Errorf("unsupported file type: %s", fileType)
|
||||
}
|
||||
ch <- result{chapters, err}
|
||||
}()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, fmt.Errorf("parse timed out: %w", ctx.Err())
|
||||
case r := <-ch:
|
||||
return r.chapters, r.err
|
||||
}
|
||||
}
|
||||
|
||||
// pdfSkipBookmarks lists bookmark titles that are front/back matter, not story chapters.
|
||||
// These are skipped when building the chapter list.
|
||||
var pdfSkipBookmarks = map[string]bool{
|
||||
"cover": true, "insert": true, "title page": true, "copyright": true,
|
||||
"appendix": true, "color insert": true, "color illustrations": true,
|
||||
}
|
||||
|
||||
// parsePDF extracts text from PDF bytes and returns it as a single chapter.
|
||||
//
|
||||
// The full readable text is returned as one chapter so the admin can manually
|
||||
// split it into chapters via the UI using --- markers.
|
||||
//
|
||||
// Strategy:
|
||||
// 1. Decrypt owner-protected PDFs (empty user password).
|
||||
// 2. Extract raw content streams for every page using pdfcpu ExtractContent.
|
||||
// 3. Concatenate text from all pages in order, skipping front matter
|
||||
// (cover, title page, copyright — typically the first 10 pages).
|
||||
func parsePDF(data []byte) ([]bookstore.Chapter, error) {
|
||||
// Decrypt owner-protected PDFs (empty user password).
|
||||
decrypted, err := decryptPDF(data)
|
||||
if err == nil {
|
||||
data = decrypted
|
||||
}
|
||||
|
||||
// Extract per-page text so we can detect chapter boundaries.
|
||||
numPages := r.NumPage()
|
||||
if numPages == 0 {
|
||||
return nil, fmt.Errorf("PDF has no pages")
|
||||
conf := model.NewDefaultConfiguration()
|
||||
conf.UserPW = ""
|
||||
conf.OwnerPW = ""
|
||||
|
||||
// Extract all page content streams to a temp directory.
|
||||
tmpDir, err := os.MkdirTemp("", "pdf-extract-*")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create temp dir: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
if err := api.ExtractContent(bytes.NewReader(data), tmpDir, "out", nil, conf); err != nil {
|
||||
return nil, fmt.Errorf("extract PDF content: %w", err)
|
||||
}
|
||||
|
||||
// Collect full text first with page markers so we can split by chapter.
|
||||
entries, err := os.ReadDir(tmpDir)
|
||||
if err != nil || len(entries) == 0 {
|
||||
return nil, fmt.Errorf("PDF has no content pages")
|
||||
}
|
||||
|
||||
// Parse page number from filename and build ordered text map.
|
||||
pageTexts := make(map[int]string, len(entries))
|
||||
maxPage := 0
|
||||
for _, e := range entries {
|
||||
pageNum := pageNumFromFilename(e.Name())
|
||||
if pageNum <= 0 {
|
||||
continue
|
||||
}
|
||||
raw, readErr := os.ReadFile(tmpDir + "/" + e.Name())
|
||||
if readErr != nil {
|
||||
continue
|
||||
}
|
||||
pageTexts[pageNum] = fixWin1252(extractTextFromContentStream(raw))
|
||||
if pageNum > maxPage {
|
||||
maxPage = pageNum
|
||||
}
|
||||
}
|
||||
|
||||
// Determine front-matter cutoff using bookmarks if available,
|
||||
// otherwise skip the first 10 pages (cover/title/copyright).
|
||||
bodyStart := 1
|
||||
bookmarks, bmErr := api.Bookmarks(bytes.NewReader(data), conf)
|
||||
if bmErr == nil {
|
||||
for _, bm := range bookmarks {
|
||||
title := strings.ToLower(strings.TrimSpace(bm.Title))
|
||||
if !pdfSkipBookmarks[title] && bm.PageFrom > 0 {
|
||||
// First non-front-matter bookmark — body starts here.
|
||||
bodyStart = bm.PageFrom
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if maxPage > 10 {
|
||||
bodyStart = 11
|
||||
}
|
||||
|
||||
// Concatenate all body pages.
|
||||
var sb strings.Builder
|
||||
fonts := make(map[string]*pdf.Font)
|
||||
for i := 1; i <= numPages; i++ {
|
||||
page := r.Page(i)
|
||||
if page.V.IsNull() {
|
||||
for p := bodyStart; p <= maxPage; p++ {
|
||||
t := strings.TrimSpace(pageTexts[p])
|
||||
if t == "" {
|
||||
continue
|
||||
}
|
||||
text, err := page.GetPlainText(fonts)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
sb.WriteString(text)
|
||||
sb.WriteByte('\n')
|
||||
sb.WriteString(t)
|
||||
sb.WriteString("\n\n")
|
||||
}
|
||||
|
||||
return extractChaptersFromText(sb.String()), nil
|
||||
text := strings.TrimSpace(sb.String())
|
||||
if text == "" {
|
||||
return nil, fmt.Errorf("could not extract any text from PDF")
|
||||
}
|
||||
|
||||
return []bookstore.Chapter{{
|
||||
Number: 1,
|
||||
Title: "Full Text",
|
||||
Content: text,
|
||||
}}, nil
|
||||
}
|
||||
|
||||
// pageNumFromFilename extracts the page number from a pdfcpu content-stream
|
||||
// filename like "out_Content_page_42.txt". Returns 0 if not parseable.
|
||||
func pageNumFromFilename(name string) int {
|
||||
// Strip directory prefix and extension.
|
||||
base := name
|
||||
if idx := strings.LastIndex(base, "/"); idx >= 0 {
|
||||
base = base[idx+1:]
|
||||
}
|
||||
if idx := strings.LastIndex(base, "."); idx >= 0 {
|
||||
base = base[:idx]
|
||||
}
|
||||
// Find last "_" and parse the number after it.
|
||||
if idx := strings.LastIndex(base, "_"); idx >= 0 {
|
||||
n, err := strconv.Atoi(base[idx+1:])
|
||||
if err == nil && n > 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// win1252ToUnicode maps the Windows-1252 control range 0x80–0x9F to the
|
||||
// Unicode characters they actually represent in that encoding.
|
||||
// Standard Latin-1 maps these bytes to control characters; Win-1252 maps
|
||||
// them to typographic symbols that appear in publisher PDFs.
|
||||
var win1252ToUnicode = map[byte]rune{
|
||||
0x80: '\u20AC', // €
|
||||
0x82: '\u201A', // ‚
|
||||
0x83: '\u0192', // ƒ
|
||||
0x84: '\u201E', // „
|
||||
0x85: '\u2026', // …
|
||||
0x86: '\u2020', // †
|
||||
0x87: '\u2021', // ‡
|
||||
0x88: '\u02C6', // ˆ
|
||||
0x89: '\u2030', // ‰
|
||||
0x8A: '\u0160', // Š
|
||||
0x8B: '\u2039', // ‹
|
||||
0x8C: '\u0152', // Œ
|
||||
0x8E: '\u017D', // Ž
|
||||
0x91: '\u2018', // ' (left single quotation mark)
|
||||
0x92: '\u2019', // ' (right single quotation mark / apostrophe)
|
||||
0x93: '\u201C', // " (left double quotation mark)
|
||||
0x94: '\u201D', // " (right double quotation mark)
|
||||
0x95: '\u2022', // • (bullet)
|
||||
0x96: '\u2013', // – (en dash)
|
||||
0x97: '\u2014', // — (em dash)
|
||||
0x98: '\u02DC', // ˜
|
||||
0x99: '\u2122', // ™
|
||||
0x9A: '\u0161', // š
|
||||
0x9B: '\u203A', // ›
|
||||
0x9C: '\u0153', // œ
|
||||
0x9E: '\u017E', // ž
|
||||
0x9F: '\u0178', // Ÿ
|
||||
}
|
||||
|
||||
// fixWin1252 replaces Windows-1252 specific bytes (0x80–0x9F) in a string
|
||||
// that was decoded as raw Latin-1 bytes with their proper Unicode equivalents.
|
||||
func fixWin1252(s string) string {
|
||||
// Fast path: if no bytes in 0x80–0x9F range, return unchanged.
|
||||
needsFix := false
|
||||
for i := 0; i < len(s); i++ {
|
||||
b := s[i]
|
||||
if b >= 0x80 && b <= 0x9F {
|
||||
needsFix = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !needsFix {
|
||||
return s
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.Grow(len(s))
|
||||
for i := 0; i < len(s); i++ {
|
||||
b := s[i]
|
||||
if b >= 0x80 && b <= 0x9F {
|
||||
if r, ok := win1252ToUnicode[b]; ok {
|
||||
sb.WriteRune(r)
|
||||
continue
|
||||
}
|
||||
}
|
||||
sb.WriteByte(b)
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// extractTextFromContentStream parses a raw PDF content stream and extracts
|
||||
// readable text from Tj and TJ operators.
|
||||
//
|
||||
// TJ arrays may contain a mix of literal strings (parenthesised) and hex glyph
|
||||
// arrays. Only the literal strings are decoded — hex arrays require per-font
|
||||
// ToUnicode CMaps and are skipped. Kerning adjustment numbers inside TJ arrays
|
||||
// are also ignored (they're just spacing hints).
|
||||
//
|
||||
// Line breaks are inserted on ET / Td / TD / T* operators.
|
||||
func extractTextFromContentStream(stream []byte) string {
|
||||
s := string(stream)
|
||||
var sb strings.Builder
|
||||
i := 0
|
||||
n := len(s)
|
||||
for i < n {
|
||||
// TJ array: [ ... ]TJ — collect all literal strings, skip hex & numbers.
|
||||
if s[i] == '[' {
|
||||
j := i + 1
|
||||
for j < n && s[j] != ']' {
|
||||
if s[j] == '(' {
|
||||
// Literal string inside TJ array.
|
||||
k := j + 1
|
||||
depth := 1
|
||||
for k < n && depth > 0 {
|
||||
if s[k] == '\\' {
|
||||
k += 2
|
||||
continue
|
||||
}
|
||||
if s[k] == '(' {
|
||||
depth++
|
||||
} else if s[k] == ')' {
|
||||
depth--
|
||||
}
|
||||
k++
|
||||
}
|
||||
lit := pdfUnescapeString(s[j+1 : k-1])
|
||||
if hasPrintableASCII(lit) {
|
||||
sb.WriteString(lit)
|
||||
}
|
||||
j = k
|
||||
continue
|
||||
}
|
||||
j++
|
||||
}
|
||||
// Check if this is a TJ operator (skip whitespace after ']').
|
||||
end := j + 1
|
||||
for end < n && (s[end] == ' ' || s[end] == '\t' || s[end] == '\r' || s[end] == '\n') {
|
||||
end++
|
||||
}
|
||||
if end+2 <= n && s[end:end+2] == "TJ" && (end+2 == n || !isAlphaNum(s[end+2])) {
|
||||
i = end + 2
|
||||
continue
|
||||
}
|
||||
i = j + 1
|
||||
continue
|
||||
}
|
||||
// Single string: (string) Tj
|
||||
if s[i] == '(' {
|
||||
j := i + 1
|
||||
depth := 1
|
||||
for j < n && depth > 0 {
|
||||
if s[j] == '\\' {
|
||||
j += 2
|
||||
continue
|
||||
}
|
||||
if s[j] == '(' {
|
||||
depth++
|
||||
} else if s[j] == ')' {
|
||||
depth--
|
||||
}
|
||||
j++
|
||||
}
|
||||
lit := pdfUnescapeString(s[i+1 : j-1])
|
||||
if hasPrintableASCII(lit) {
|
||||
// Check for Tj operator.
|
||||
end := j
|
||||
for end < n && (s[end] == ' ' || s[end] == '\t') {
|
||||
end++
|
||||
}
|
||||
if end+2 <= n && s[end:end+2] == "Tj" && (end+2 == n || !isAlphaNum(s[end+2])) {
|
||||
sb.WriteString(lit)
|
||||
i = end + 2
|
||||
continue
|
||||
}
|
||||
}
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
// Detect end of text object (ET) — add a newline.
|
||||
if i+2 <= n && s[i:i+2] == "ET" && (i+2 == n || !isAlphaNum(s[i+2])) {
|
||||
sb.WriteByte('\n')
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
// Detect Td / TD / T* — newline within text block.
|
||||
if i+2 <= n && (s[i:i+2] == "Td" || s[i:i+2] == "TD" || s[i:i+2] == "T*") &&
|
||||
(i+2 == n || !isAlphaNum(s[i+2])) {
|
||||
sb.WriteByte('\n')
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
i++
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func isAlphaNum(b byte) bool {
|
||||
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || b == '_'
|
||||
}
|
||||
|
||||
func hasPrintableASCII(s string) bool {
|
||||
for _, c := range s {
|
||||
if c >= 0x20 && c < 0x7F {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// pdfUnescapeString handles PDF string escape sequences.
|
||||
func pdfUnescapeString(s string) string {
|
||||
if !strings.ContainsRune(s, '\\') {
|
||||
return s
|
||||
}
|
||||
var sb strings.Builder
|
||||
i := 0
|
||||
for i < len(s) {
|
||||
if s[i] == '\\' && i+1 < len(s) {
|
||||
switch s[i+1] {
|
||||
case 'n':
|
||||
sb.WriteByte('\n')
|
||||
case 'r':
|
||||
sb.WriteByte('\r')
|
||||
case 't':
|
||||
sb.WriteByte('\t')
|
||||
case '(', ')', '\\':
|
||||
sb.WriteByte(s[i+1])
|
||||
default:
|
||||
// Octal escape \ddd
|
||||
if s[i+1] >= '0' && s[i+1] <= '7' {
|
||||
end := i + 2
|
||||
for end < i+5 && end < len(s) && s[end] >= '0' && s[end] <= '7' {
|
||||
end++
|
||||
}
|
||||
val, _ := strconv.ParseInt(s[i+1:end], 8, 16)
|
||||
sb.WriteByte(byte(val))
|
||||
i = end
|
||||
continue
|
||||
}
|
||||
sb.WriteByte(s[i+1])
|
||||
}
|
||||
i += 2
|
||||
} else {
|
||||
sb.WriteByte(s[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// ── EPUB parsing ──────────────────────────────────────────────────────────────
|
||||
@@ -152,6 +512,7 @@ func parseEPUB(data []byte) ([]bookstore.Chapter, error) {
|
||||
}
|
||||
|
||||
var chapters []bookstore.Chapter
|
||||
chNum := 0
|
||||
for i, href := range spineFiles {
|
||||
fullPath := opfDir + href
|
||||
content, err := epubFileContent(zr, fullPath)
|
||||
@@ -162,12 +523,14 @@ func parseEPUB(data []byte) ([]bookstore.Chapter, error) {
|
||||
if strings.TrimSpace(text) == "" {
|
||||
continue
|
||||
}
|
||||
chNum++
|
||||
title := titleMap[href]
|
||||
if title == "" {
|
||||
title = fmt.Sprintf("Chapter %d", i+1)
|
||||
title = fmt.Sprintf("Chapter %d", chNum)
|
||||
}
|
||||
_ = i // spine index unused for numbering
|
||||
chapters = append(chapters, bookstore.Chapter{
|
||||
Number: i + 1,
|
||||
Number: chNum,
|
||||
Title: title,
|
||||
Content: text,
|
||||
})
|
||||
@@ -464,80 +827,6 @@ func htmlToText(data []byte) string {
|
||||
return strings.TrimSpace(strings.Join(out, "\n"))
|
||||
}
|
||||
|
||||
// ── Chapter segmentation (shared by PDF and plain-text paths) ─────────────────
|
||||
|
||||
// extractChaptersFromText splits a block of plain text into chapters by
|
||||
// detecting heading lines that match chapterHeadingRE.
|
||||
// Falls back to paragraph-splitting when no headings are found.
|
||||
func extractChaptersFromText(text string) []bookstore.Chapter {
|
||||
lines := strings.Split(text, "\n")
|
||||
|
||||
type segment struct {
|
||||
title string
|
||||
number int
|
||||
lines []string
|
||||
}
|
||||
|
||||
var segments []segment
|
||||
var cur *segment
|
||||
chNum := 0
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if chapterHeadingRE.MatchString(line) {
|
||||
if cur != nil {
|
||||
segments = append(segments, *cur)
|
||||
}
|
||||
chNum++
|
||||
// Try to parse the explicit chapter number from the heading.
|
||||
if m := regexp.MustCompile(`\d+`).FindString(line); m != "" {
|
||||
if n, err := strconv.Atoi(m); err == nil && n > 0 && n < 100000 {
|
||||
chNum = n
|
||||
}
|
||||
}
|
||||
cur = &segment{title: line, number: chNum}
|
||||
} else if cur != nil && line != "" {
|
||||
cur.lines = append(cur.lines, line)
|
||||
}
|
||||
}
|
||||
if cur != nil {
|
||||
segments = append(segments, *cur)
|
||||
}
|
||||
|
||||
// Require segments to have meaningful content (>= 100 chars).
|
||||
var chapters []bookstore.Chapter
|
||||
for _, seg := range segments {
|
||||
content := strings.Join(seg.lines, "\n")
|
||||
if len(strings.TrimSpace(content)) < 50 {
|
||||
continue
|
||||
}
|
||||
chapters = append(chapters, bookstore.Chapter{
|
||||
Number: seg.number,
|
||||
Title: seg.title,
|
||||
Content: content,
|
||||
})
|
||||
}
|
||||
|
||||
// Fallback: no headings found — split by double newlines (paragraph blocks).
|
||||
if len(chapters) == 0 {
|
||||
paragraphs := strings.Split(text, "\n\n")
|
||||
n := 0
|
||||
for _, para := range paragraphs {
|
||||
para = strings.TrimSpace(para)
|
||||
if len(para) > 100 {
|
||||
n++
|
||||
chapters = append(chapters, bookstore.Chapter{
|
||||
Number: n,
|
||||
Title: fmt.Sprintf("Chapter %d", n),
|
||||
Content: para,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return chapters
|
||||
}
|
||||
|
||||
// ── Chapter ingestion ─────────────────────────────────────────────────────────
|
||||
|
||||
// IngestChapters stores extracted chapters for a book.
|
||||
|
||||
@@ -55,6 +55,7 @@ var _ bookstore.CoverStore = (*Store)(nil)
|
||||
var _ bookstore.TranslationStore = (*Store)(nil)
|
||||
var _ bookstore.AIJobStore = (*Store)(nil)
|
||||
var _ bookstore.ChapterImageStore = (*Store)(nil)
|
||||
var _ bookstore.BookAdminStore = (*Store)(nil)
|
||||
var _ taskqueue.Producer = (*Store)(nil)
|
||||
var _ taskqueue.Consumer = (*Store)(nil)
|
||||
var _ taskqueue.Reader = (*Store)(nil)
|
||||
@@ -226,6 +227,7 @@ type pbBook struct {
|
||||
Ranking int `json:"ranking"`
|
||||
Rating float64 `json:"rating"`
|
||||
Updated string `json:"updated"`
|
||||
Archived bool `json:"archived"`
|
||||
}
|
||||
|
||||
func (b pbBook) toDomain() domain.BookMeta {
|
||||
@@ -246,6 +248,7 @@ func (b pbBook) toDomain() domain.BookMeta {
|
||||
Ranking: b.Ranking,
|
||||
Rating: b.Rating,
|
||||
MetaUpdated: metaUpdated,
|
||||
Archived: b.Archived,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,7 +278,7 @@ func (s *Store) ReadMetadata(ctx context.Context, slug string) (domain.BookMeta,
|
||||
}
|
||||
|
||||
func (s *Store) ListBooks(ctx context.Context) ([]domain.BookMeta, error) {
|
||||
items, err := s.pb.listAll(ctx, "books", "", "title")
|
||||
items, err := s.pb.listAll(ctx, "books", "archived=false", "title")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -376,6 +379,84 @@ func (s *Store) ReindexChapters(ctx context.Context, slug string) (int, error) {
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// ── BookAdminStore ────────────────────────────────────────────────────────────
|
||||
|
||||
// ArchiveBook sets archived=true on the book record for slug.
|
||||
func (s *Store) ArchiveBook(ctx context.Context, slug string) error {
|
||||
book, err := s.getBookBySlug(ctx, slug)
|
||||
if err == ErrNotFound {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("ArchiveBook: %w", err)
|
||||
}
|
||||
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/books/records/%s", book.ID),
|
||||
map[string]any{"archived": true})
|
||||
}
|
||||
|
||||
// UnarchiveBook clears archived on the book record for slug.
|
||||
func (s *Store) UnarchiveBook(ctx context.Context, slug string) error {
|
||||
book, err := s.getBookBySlug(ctx, slug)
|
||||
if err == ErrNotFound {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("UnarchiveBook: %w", err)
|
||||
}
|
||||
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/books/records/%s", book.ID),
|
||||
map[string]any{"archived": false})
|
||||
}
|
||||
|
||||
// DeleteBook permanently removes all data for a book:
|
||||
// - PocketBase books record
|
||||
// - All PocketBase chapters_idx records for the slug
|
||||
// - All MinIO chapter markdown objects ({slug}/chapter-*.md)
|
||||
// - MinIO cover image (covers/{slug}.jpg)
|
||||
func (s *Store) DeleteBook(ctx context.Context, slug string) error {
|
||||
// 1. Fetch the book record to get its PocketBase ID.
|
||||
book, err := s.getBookBySlug(ctx, slug)
|
||||
if err == ErrNotFound {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("DeleteBook: fetch: %w", err)
|
||||
}
|
||||
|
||||
// 2. Delete all chapters_idx records.
|
||||
filter := fmt.Sprintf(`slug=%q`, slug)
|
||||
items, err := s.pb.listAll(ctx, "chapters_idx", filter, "")
|
||||
if err != nil && err != ErrNotFound {
|
||||
return fmt.Errorf("DeleteBook: list chapters_idx: %w", err)
|
||||
}
|
||||
for _, raw := range items {
|
||||
var rec struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if json.Unmarshal(raw, &rec) == nil && rec.ID != "" {
|
||||
if delErr := s.pb.delete(ctx, fmt.Sprintf("/api/collections/chapters_idx/records/%s", rec.ID)); delErr != nil {
|
||||
s.log.Warn("DeleteBook: delete chapters_idx record failed", "slug", slug, "id", rec.ID, "err", delErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Delete MinIO chapter objects.
|
||||
if err := s.mc.deleteObjects(ctx, s.mc.bucketChapters, slug+"/"); err != nil {
|
||||
s.log.Warn("DeleteBook: delete chapter objects failed", "slug", slug, "err", err)
|
||||
}
|
||||
|
||||
// 4. Delete MinIO cover image.
|
||||
if err := s.mc.deleteObjects(ctx, s.mc.bucketBrowse, CoverObjectKey(slug)); err != nil {
|
||||
s.log.Warn("DeleteBook: delete cover failed", "slug", slug, "err", err)
|
||||
}
|
||||
|
||||
// 5. Delete the PocketBase books record.
|
||||
if err := s.pb.delete(ctx, fmt.Sprintf("/api/collections/books/records/%s", book.ID)); err != nil {
|
||||
return fmt.Errorf("DeleteBook: delete books record: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── RankingStore ──────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) WriteRankingItem(ctx context.Context, item domain.RankingItem) error {
|
||||
@@ -654,13 +735,14 @@ func (s *Store) CreateImportTask(ctx context.Context, task domain.ImportTask) (s
|
||||
"file_name": task.Slug + "." + task.FileType,
|
||||
"file_type": task.FileType,
|
||||
"object_key": task.ObjectKey,
|
||||
"chapters_key": task.ChaptersKey,
|
||||
"author": task.Author,
|
||||
"cover_url": task.CoverURL,
|
||||
"summary": task.Summary,
|
||||
"book_status": task.BookStatus,
|
||||
"status": string(domain.TaskStatusPending),
|
||||
"chapters_done": 0,
|
||||
"chapters_total": 0,
|
||||
"chapters_total": task.ChaptersTotal,
|
||||
"started": time.Now().UTC().Format(time.RFC3339),
|
||||
"initiator_user_id": task.InitiatorUserID,
|
||||
}
|
||||
@@ -914,7 +996,7 @@ func (s *Store) FailTask(ctx context.Context, id, errMsg string) error {
|
||||
}
|
||||
|
||||
// HeartbeatTask updates the heartbeat_at field on a running task.
|
||||
// Tries scraping_tasks first, then audio_jobs, then translation_jobs.
|
||||
// Tries scraping_tasks, audio_jobs, translation_jobs, then import_tasks.
|
||||
func (s *Store) HeartbeatTask(ctx context.Context, id string) error {
|
||||
payload := map[string]any{
|
||||
"heartbeat_at": time.Now().UTC().Format(time.RFC3339),
|
||||
@@ -925,7 +1007,10 @@ func (s *Store) HeartbeatTask(ctx context.Context, id string) error {
|
||||
if err := s.pb.patch(ctx, fmt.Sprintf("/api/collections/audio_jobs/records/%s", id), payload); err == nil {
|
||||
return nil
|
||||
}
|
||||
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/translation_jobs/records/%s", id), payload)
|
||||
if err := s.pb.patch(ctx, fmt.Sprintf("/api/collections/translation_jobs/records/%s", id), payload); err == nil {
|
||||
return nil
|
||||
}
|
||||
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/import_tasks/records/%s", id), payload)
|
||||
}
|
||||
|
||||
// ReapStaleTasks finds all running tasks whose heartbeat_at is either missing
|
||||
@@ -943,7 +1028,7 @@ func (s *Store) ReapStaleTasks(ctx context.Context, staleAfter time.Duration) (i
|
||||
}
|
||||
|
||||
total := 0
|
||||
for _, collection := range []string{"scraping_tasks", "audio_jobs", "translation_jobs"} {
|
||||
for _, collection := range []string{"scraping_tasks", "audio_jobs", "translation_jobs", "import_tasks"} {
|
||||
items, err := s.pb.listAll(ctx, collection, filter, "")
|
||||
if err != nil {
|
||||
return total, fmt.Errorf("ReapStaleTasks list %s: %w", collection, err)
|
||||
@@ -1185,6 +1270,7 @@ func parseImportTask(raw json.RawMessage) (domain.ImportTask, error) {
|
||||
FileName string `json:"file_name"`
|
||||
FileType string `json:"file_type"`
|
||||
ObjectKey string `json:"object_key"`
|
||||
ChaptersKey string `json:"chapters_key"`
|
||||
Author string `json:"author"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
Genres string `json:"genres"` // stored as comma-separated
|
||||
@@ -1219,6 +1305,7 @@ func parseImportTask(raw json.RawMessage) (domain.ImportTask, error) {
|
||||
FileName: rec.FileName,
|
||||
FileType: rec.FileType,
|
||||
ObjectKey: rec.ObjectKey,
|
||||
ChaptersKey: rec.ChaptersKey,
|
||||
Author: rec.Author,
|
||||
CoverURL: rec.CoverURL,
|
||||
Genres: genres,
|
||||
@@ -1266,6 +1353,20 @@ func (s *Store) PutImportFile(ctx context.Context, key string, data []byte) erro
|
||||
return s.mc.putObject(ctx, "imports", key, "application/octet-stream", data)
|
||||
}
|
||||
|
||||
// PutImportChapters stores a pre-parsed chapters JSON blob in MinIO.
|
||||
func (s *Store) PutImportChapters(ctx context.Context, key string, data []byte) error {
|
||||
return s.mc.putObject(ctx, "imports", key, "application/json", data)
|
||||
}
|
||||
|
||||
// GetImportChapters retrieves the pre-parsed chapters JSON from MinIO.
|
||||
func (s *Store) GetImportChapters(ctx context.Context, key string) ([]byte, error) {
|
||||
data, err := s.mc.getObject(ctx, "imports", key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get chapters object: %w", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (s *Store) CoverExists(ctx context.Context, slug string) bool {
|
||||
return s.mc.coverExists(ctx, CoverObjectKey(slug))
|
||||
}
|
||||
|
||||
@@ -58,6 +58,8 @@ services:
|
||||
mc mb --ignore-existing local/audio;
|
||||
mc mb --ignore-existing local/avatars;
|
||||
mc mb --ignore-existing local/catalogue;
|
||||
mc mb --ignore-existing local/translations;
|
||||
mc mb --ignore-existing local/imports;
|
||||
echo 'buckets ready';
|
||||
"
|
||||
environment:
|
||||
|
||||
@@ -144,7 +144,8 @@ create "books" '{
|
||||
{"name":"total_chapters","type":"number"},
|
||||
{"name":"source_url", "type":"text"},
|
||||
{"name":"ranking", "type":"number"},
|
||||
{"name":"meta_updated", "type":"text"}
|
||||
{"name":"meta_updated", "type":"text"},
|
||||
{"name":"archived", "type":"bool"}
|
||||
]}'
|
||||
|
||||
create "chapters_idx" '{
|
||||
@@ -255,6 +256,7 @@ create "user_settings" '{
|
||||
{"name":"font_family", "type":"text"},
|
||||
{"name":"font_size", "type":"number"},
|
||||
{"name":"announce_chapter","type":"bool"},
|
||||
{"name":"audio_mode", "type":"text"},
|
||||
{"name":"updated", "type":"text"}
|
||||
]}'
|
||||
|
||||
@@ -299,6 +301,40 @@ create "translation_jobs" '{
|
||||
{"name":"heartbeat_at", "type":"date"}
|
||||
]}'
|
||||
|
||||
create "import_tasks" '{
|
||||
"name":"import_tasks","type":"base","fields":[
|
||||
{"name":"slug", "type":"text", "required":true},
|
||||
{"name":"title", "type":"text", "required":true},
|
||||
{"name":"file_name", "type":"text"},
|
||||
{"name":"file_type", "type":"text"},
|
||||
{"name":"object_key", "type":"text"},
|
||||
{"name":"chapters_key", "type":"text"},
|
||||
{"name":"author", "type":"text"},
|
||||
{"name":"cover_url", "type":"text"},
|
||||
{"name":"genres", "type":"text"},
|
||||
{"name":"summary", "type":"text"},
|
||||
{"name":"book_status", "type":"text"},
|
||||
{"name":"worker_id", "type":"text"},
|
||||
{"name":"initiator_user_id", "type":"text"},
|
||||
{"name":"status", "type":"text", "required":true},
|
||||
{"name":"chapters_done", "type":"number"},
|
||||
{"name":"chapters_total", "type":"number"},
|
||||
{"name":"error_message", "type":"text"},
|
||||
{"name":"started", "type":"date"},
|
||||
{"name":"finished", "type":"date"},
|
||||
{"name":"heartbeat_at", "type":"date"}
|
||||
]}'
|
||||
|
||||
create "notifications" '{
|
||||
"name":"notifications","type":"base","fields":[
|
||||
{"name":"user_id", "type":"text","required":true},
|
||||
{"name":"title", "type":"text","required":true},
|
||||
{"name":"message", "type":"text"},
|
||||
{"name":"link", "type":"text"},
|
||||
{"name":"read", "type":"bool"},
|
||||
{"name":"created", "type":"date"}
|
||||
]}'
|
||||
|
||||
create "ai_jobs" '{
|
||||
"name":"ai_jobs","type":"base","fields":[
|
||||
{"name":"kind", "type":"text", "required":true},
|
||||
@@ -355,6 +391,8 @@ add_field "user_settings" "locale" "text"
|
||||
add_field "user_settings" "font_family" "text"
|
||||
add_field "user_settings" "font_size" "number"
|
||||
add_field "user_settings" "announce_chapter" "bool"
|
||||
add_field "user_settings" "audio_mode" "text"
|
||||
add_field "books" "archived" "bool"
|
||||
|
||||
# ── 6. Indexes ────────────────────────────────────────────────────────────────
|
||||
add_index "chapters_idx" "idx_chapters_idx_slug_number" \
|
||||
|
||||
@@ -36,6 +36,14 @@ import type { Voice } from '$lib/types';
|
||||
|
||||
export type AudioStatus = 'idle' | 'loading' | 'generating' | 'ready' | 'error';
|
||||
export type NextStatus = 'none' | 'prefetching' | 'prefetched' | 'failed';
|
||||
/**
|
||||
* 'stream' – Use /api/audio-stream: audio starts playing within seconds,
|
||||
* stream is saved to MinIO concurrently. No runner task needed.
|
||||
* 'generate' – Legacy mode: queue a runner task, poll until done, then play
|
||||
* from the presigned MinIO URL. Needed for CF AI voices which
|
||||
* do not support native streaming.
|
||||
*/
|
||||
export type AudioMode = 'stream' | 'generate';
|
||||
|
||||
class AudioStore {
|
||||
// ── What is loaded ──────────────────────────────────────────────────────
|
||||
@@ -46,6 +54,13 @@ class AudioStore {
|
||||
voice = $state('af_bella');
|
||||
speed = $state(1.0);
|
||||
|
||||
/**
|
||||
* Playback mode:
|
||||
* 'stream' – pipe from /api/audio-stream (low latency, saves concurrently)
|
||||
* 'generate' – queue runner task, poll, then play presigned URL (CF AI / legacy)
|
||||
*/
|
||||
audioMode = $state<AudioMode>('stream');
|
||||
|
||||
/** Cover image URL for the currently loaded book. */
|
||||
cover = $state('');
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
import { cn } from '$lib/utils';
|
||||
import type { Voice } from '$lib/types';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import ChapterPickerOverlay from '$lib/components/ChapterPickerOverlay.svelte';
|
||||
|
||||
interface Props {
|
||||
slug: string;
|
||||
@@ -107,22 +108,10 @@
|
||||
|
||||
// ── Chapter picker state ─────────────────────────────────────────────────
|
||||
let showChapterPanel = $state(false);
|
||||
let chapterSearch = $state('');
|
||||
const filteredChapters = $derived(
|
||||
chapterSearch.trim() === ''
|
||||
? audioStore.chapters
|
||||
: audioStore.chapters.filter((ch) =>
|
||||
(ch.title || `Chapter ${ch.number}`)
|
||||
.toLowerCase()
|
||||
.includes(chapterSearch.toLowerCase()) ||
|
||||
String(ch.number).includes(chapterSearch)
|
||||
)
|
||||
);
|
||||
|
||||
function playChapter(chapterNumber: number) {
|
||||
audioStore.autoStartChapter = chapterNumber;
|
||||
showChapterPanel = false;
|
||||
chapterSearch = '';
|
||||
goto(`/books/${slug}/chapters/${chapterNumber}`);
|
||||
}
|
||||
|
||||
@@ -293,7 +282,7 @@
|
||||
// Close panels on Escape.
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
if (showChapterPanel) { showChapterPanel = false; chapterSearch = ''; }
|
||||
if (showChapterPanel) { showChapterPanel = false; }
|
||||
else { stopSample(); showVoicePanel = false; }
|
||||
}
|
||||
}
|
||||
@@ -613,41 +602,95 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Slow path: audio not yet in MinIO.
|
||||
//
|
||||
// For Kokoro / PocketTTS: always use the streaming endpoint so audio
|
||||
// starts playing within seconds. The stream handler checks MinIO first
|
||||
// (fast redirect if already cached) and otherwise generates + uploads
|
||||
// concurrently. Even if the async runner is already working on this
|
||||
// chapter, the stream will redirect to MinIO the moment the runner
|
||||
// finishes — no harmful double-generation occurs because the backend
|
||||
// deduplications via AudioExists on the next request.
|
||||
if (!voice.startsWith('cfai:')) {
|
||||
// PocketTTS outputs raw WAV — skip the ffmpeg transcode entirely.
|
||||
// WAV (PCM) is natively supported on all platforms including iOS Safari.
|
||||
// Kokoro and CF AI output MP3 natively, so keep mp3 for those.
|
||||
const isPocketTTS = voices.some((v) => v.id === voice && v.engine === 'pocket-tts');
|
||||
const format = isPocketTTS ? 'wav' : 'mp3';
|
||||
const qs = new URLSearchParams({ voice, format });
|
||||
const streamUrl = `/api/audio-stream/${slug}/${chapter}?${qs}`;
|
||||
// HEAD probe: check paywall without triggering generation.
|
||||
const headRes = await fetch(streamUrl, { method: 'HEAD' }).catch(() => null);
|
||||
if (headRes?.status === 402) {
|
||||
// Slow path: audio not yet in MinIO.
|
||||
//
|
||||
// For Kokoro / PocketTTS in 'stream' mode: use the streaming endpoint so
|
||||
// audio starts playing within seconds. The stream handler checks MinIO
|
||||
// first (fast redirect if already cached) and otherwise generates +
|
||||
// uploads concurrently.
|
||||
//
|
||||
// In 'generate' mode (user preference): queue a runner task and poll,
|
||||
// same as CF AI — audio plays only after the full file is ready in MinIO.
|
||||
if (!voice.startsWith('cfai:') && audioStore.audioMode === 'stream') {
|
||||
// PocketTTS outputs raw WAV — skip the ffmpeg transcode entirely.
|
||||
// WAV (PCM) is natively supported on all platforms including iOS Safari.
|
||||
// Kokoro and CF AI output MP3 natively, so keep mp3 for those.
|
||||
const isPocketTTS = voices.some((v) => v.id === voice && v.engine === 'pocket-tts');
|
||||
const format = isPocketTTS ? 'wav' : 'mp3';
|
||||
const qs = new URLSearchParams({ voice, format });
|
||||
const streamUrl = `/api/audio-stream/${slug}/${chapter}?${qs}`;
|
||||
// HEAD probe: check paywall without triggering generation.
|
||||
const headRes = await fetch(streamUrl, { method: 'HEAD' }).catch(() => null);
|
||||
if (headRes?.status === 402) {
|
||||
audioStore.status = 'idle';
|
||||
onProRequired?.();
|
||||
return;
|
||||
}
|
||||
audioStore.audioUrl = streamUrl;
|
||||
audioStore.status = 'ready';
|
||||
maybeStartPrefetch();
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-CF AI voices in 'generate' mode: queue runner task, show progress,
|
||||
// wait for full audio in MinIO before playing (same as CF AI but no preview).
|
||||
if (!voice.startsWith('cfai:')) {
|
||||
audioStore.status = 'generating';
|
||||
audioStore.isPreview = false;
|
||||
startProgress();
|
||||
|
||||
if (!presignResult.enqueued) {
|
||||
const res = await fetch(`/api/audio/${slug}/${chapter}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ voice })
|
||||
});
|
||||
|
||||
if (res.status === 402) {
|
||||
audioStore.status = 'idle';
|
||||
stopProgress();
|
||||
onProRequired?.();
|
||||
return;
|
||||
}
|
||||
audioStore.audioUrl = streamUrl;
|
||||
audioStore.status = 'ready';
|
||||
maybeStartPrefetch();
|
||||
return;
|
||||
|
||||
if (!res.ok) throw new Error(`Generation failed: HTTP ${res.status}`);
|
||||
|
||||
if (res.status === 200) {
|
||||
await res.body?.cancel();
|
||||
await finishProgress();
|
||||
const doneUrl = await tryPresign(slug, chapter, voice);
|
||||
if (!doneUrl.ready) throw new Error('Audio generated but presign returned 404');
|
||||
audioStore.audioUrl = doneUrl.url;
|
||||
audioStore.status = 'ready';
|
||||
restoreSavedAudioTime();
|
||||
maybeStartPrefetch();
|
||||
return;
|
||||
}
|
||||
// 202 — runner task enqueued, fall through to poll.
|
||||
}
|
||||
|
||||
// CF AI voices: use preview/swap strategy.
|
||||
// 1. Fetch a short ~1-2 min preview clip from the first text chunk
|
||||
// so playback starts immediately — no more waiting behind a spinner.
|
||||
// 2. Meanwhile keep polling the full audio job; when it finishes,
|
||||
// swap the <audio> src to the full URL preserving currentTime.
|
||||
const final = await pollAudioStatus(slug, chapter, voice);
|
||||
if (final.status === 'failed') {
|
||||
throw new Error(
|
||||
`Generation failed: ${(final as { error?: string }).error ?? 'unknown error'}`
|
||||
);
|
||||
}
|
||||
|
||||
await finishProgress();
|
||||
const doneUrl = await tryPresign(slug, chapter, voice);
|
||||
if (!doneUrl.ready) throw new Error('Audio generated but presign returned 404');
|
||||
audioStore.audioUrl = doneUrl.url;
|
||||
audioStore.status = 'ready';
|
||||
restoreSavedAudioTime();
|
||||
maybeStartPrefetch();
|
||||
return;
|
||||
}
|
||||
|
||||
// CF AI voices: use preview/swap strategy.
|
||||
// 1. Fetch a short ~1-2 min preview clip from the first text chunk
|
||||
// so playback starts immediately — no more waiting behind a spinner.
|
||||
// 2. Meanwhile keep polling the full audio job; when it finishes,
|
||||
// swap the <audio> src to the full URL preserving currentTime.
|
||||
audioStore.status = 'generating';
|
||||
audioStore.isPreview = false;
|
||||
startProgress();
|
||||
@@ -1001,7 +1044,7 @@
|
||||
{#if voices.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { stopSample(); showVoicePanel = !showVoicePanel; showChapterPanel = false; chapterSearch = ''; }}
|
||||
onclick={() => { stopSample(); showVoicePanel = !showVoicePanel; showChapterPanel = false; }}
|
||||
class={cn('flex items-center gap-1 text-xs transition-colors leading-none', showVoicePanel ? 'text-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)')}
|
||||
title={m.reader_change_voice()}
|
||||
>
|
||||
@@ -1019,6 +1062,33 @@
|
||||
{#if voices.length > 0}<span class="text-(--color-border) text-xs leading-none">·</span>{/if}
|
||||
<span class="text-xs text-(--color-muted) leading-none tabular-nums">~{estimatedMinutes} min</span>
|
||||
{/if}
|
||||
<!-- Stream / Generate mode toggle -->
|
||||
{#if !audioStore.voice.startsWith('cfai:')}
|
||||
<span class="text-(--color-border) text-xs leading-none">·</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { audioStore.audioMode = audioStore.audioMode === 'stream' ? 'generate' : 'stream'; }}
|
||||
class={cn(
|
||||
'flex items-center gap-0.5 text-xs leading-none transition-colors',
|
||||
audioStore.audioMode === 'stream'
|
||||
? 'text-(--color-brand)'
|
||||
: 'text-(--color-muted) hover:text-(--color-text)'
|
||||
)}
|
||||
title={audioStore.audioMode === 'stream' ? 'Stream mode — click to switch to generate' : 'Generate mode — click to switch to stream'}
|
||||
>
|
||||
{#if audioStore.audioMode === 'stream'}
|
||||
<svg class="w-3 h-3 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
Stream
|
||||
{:else}
|
||||
<svg class="w-3 h-3 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
|
||||
</svg>
|
||||
Generate
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1119,7 +1189,7 @@
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => { stopSample(); showVoicePanel = !showVoicePanel; showChapterPanel = false; chapterSearch = ''; }}
|
||||
onclick={() => { stopSample(); showVoicePanel = !showVoicePanel; showChapterPanel = false; }}
|
||||
class={cn('gap-1.5 text-xs', showVoicePanel ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : '')}
|
||||
title={m.reader_change_voice()}
|
||||
>
|
||||
@@ -1308,75 +1378,13 @@
|
||||
the fixed inset-0 positioning is never clipped by overflow-hidden or
|
||||
border-radius on any ancestor wrapping the AudioPlayer component. -->
|
||||
{#if showChapterPanel && audioStore.chapters.length > 0}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-[60] flex flex-col"
|
||||
style="background: var(--color-surface);"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-3 px-4 py-3 border-b border-(--color-border) shrink-0">
|
||||
<span class="text-sm font-semibold text-(--color-text) flex-1">Chapters</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { showChapterPanel = false; chapterSearch = ''; }}
|
||||
class="w-9 h-9 flex items-center justify-center rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
aria-label="Close chapter picker"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Search -->
|
||||
<div class="px-4 py-3 shrink-0 border-b border-(--color-border)">
|
||||
<div class="relative">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search chapters…"
|
||||
bind:value={chapterSearch}
|
||||
class="w-full pl-9 pr-4 py-2 text-sm bg-(--color-surface-2) border border-(--color-border) rounded-lg text-(--color-text) placeholder:text-(--color-muted) focus:outline-none focus:border-(--color-brand) transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Chapter list -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
{#each filteredChapters as ch (ch.number)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => playChapter(ch.number)}
|
||||
class={cn(
|
||||
'w-full flex items-center gap-3 px-4 py-3 border-b border-(--color-border)/40 transition-colors text-left',
|
||||
ch.number === chapter ? 'bg-(--color-brand)/8' : 'hover:bg-(--color-surface-2)'
|
||||
)}
|
||||
>
|
||||
<!-- Chapter number badge -->
|
||||
<span class={cn(
|
||||
'w-8 h-8 shrink-0 rounded-full border-2 flex items-center justify-center tabular-nums text-xs font-semibold transition-colors',
|
||||
ch.number === chapter
|
||||
? 'border-(--color-brand) bg-(--color-brand) text-(--color-surface)'
|
||||
: 'border-(--color-border) text-(--color-muted)'
|
||||
)}>{ch.number}</span>
|
||||
<!-- Title -->
|
||||
<span class={cn(
|
||||
'flex-1 text-sm truncate',
|
||||
ch.number === chapter ? 'font-semibold text-(--color-brand)' : 'text-(--color-text)'
|
||||
)}>{ch.title || `Chapter ${ch.number}`}</span>
|
||||
<!-- Now-playing indicator -->
|
||||
{#if ch.number === chapter}
|
||||
<svg class="w-4 h-4 shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{#if filteredChapters.length === 0}
|
||||
<p class="px-4 py-8 text-sm text-(--color-muted) text-center">No chapters match "{chapterSearch}"</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<ChapterPickerOverlay
|
||||
chapters={audioStore.chapters}
|
||||
activeChapter={chapter}
|
||||
zIndex="z-[60]"
|
||||
onselect={playChapter}
|
||||
onclose={() => { showChapterPanel = false; }}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- ── Float player overlay ──────────────────────────────────────────────────
|
||||
|
||||
139
ui/src/lib/components/ChapterPickerOverlay.svelte
Normal file
139
ui/src/lib/components/ChapterPickerOverlay.svelte
Normal file
@@ -0,0 +1,139 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
interface ChapterMeta {
|
||||
number: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** Full chapter list to render and filter. */
|
||||
chapters: ChapterMeta[];
|
||||
/** Number of the currently-active chapter (highlighted + auto-scrolled). */
|
||||
activeChapter: number;
|
||||
/** z-index class, e.g. "z-[60]" or "z-[80]". Defaults to "z-[60]". */
|
||||
zIndex?: string;
|
||||
/** Called when a chapter row is tapped. The overlay does NOT close itself. */
|
||||
onselect: (chapterNumber: number) => void;
|
||||
/** Called when the close / chevron-down button is tapped. */
|
||||
onclose: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
chapters,
|
||||
activeChapter,
|
||||
zIndex = 'z-[60]',
|
||||
onselect,
|
||||
onclose
|
||||
}: Props = $props();
|
||||
|
||||
let search = $state('');
|
||||
|
||||
const filtered = $derived(
|
||||
search.trim() === ''
|
||||
? chapters
|
||||
: chapters.filter((ch) =>
|
||||
(ch.title || `Chapter ${ch.number}`)
|
||||
.toLowerCase()
|
||||
.includes(search.toLowerCase()) ||
|
||||
String(ch.number).includes(search)
|
||||
)
|
||||
);
|
||||
|
||||
/** Scroll the active chapter into view instantly (no animation) when the
|
||||
* list is first rendered so the user never has to hunt for their position. */
|
||||
function scrollIfActive(node: HTMLElement, isActive: boolean) {
|
||||
if (isActive) node.scrollIntoView({ block: 'center', behavior: 'instant' });
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
search = '';
|
||||
onclose();
|
||||
}
|
||||
|
||||
function handleSelect(n: number) {
|
||||
search = '';
|
||||
onselect(n);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 flex flex-col {zIndex}"
|
||||
style="background: var(--color-surface);"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex items-center gap-3 px-4 py-3 border-b border-(--color-border) shrink-0"
|
||||
style="padding-top: max(0.75rem, env(safe-area-inset-top));"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleClose}
|
||||
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
aria-label="Close chapter picker"
|
||||
>
|
||||
<!-- chevron-down -->
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider flex-1">Chapters</span>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="px-4 py-3 shrink-0 border-b border-(--color-border)">
|
||||
<div class="relative">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search chapters…"
|
||||
bind:value={search}
|
||||
class="w-full pl-9 pr-4 py-2 text-sm bg-(--color-surface-2) border border-(--color-border) rounded-lg text-(--color-text) placeholder:text-(--color-muted) focus:outline-none focus:border-(--color-brand) transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chapter list -->
|
||||
<div
|
||||
class="flex-1 overflow-y-auto overscroll-contain"
|
||||
style="padding-bottom: env(safe-area-inset-bottom);"
|
||||
>
|
||||
{#each filtered as ch (ch.number)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleSelect(ch.number)}
|
||||
use:scrollIfActive={ch.number === activeChapter}
|
||||
class={cn(
|
||||
'w-full flex items-center gap-3 px-4 py-3 border-b border-(--color-border)/40 transition-colors text-left',
|
||||
ch.number === activeChapter ? 'bg-(--color-brand)/8' : 'hover:bg-(--color-surface-2)'
|
||||
)}
|
||||
>
|
||||
<span class={cn(
|
||||
'w-8 h-8 shrink-0 rounded-full border-2 flex items-center justify-center tabular-nums text-xs font-semibold transition-colors',
|
||||
ch.number === activeChapter
|
||||
? 'border-(--color-brand) bg-(--color-brand) text-(--color-surface)'
|
||||
: 'border-(--color-border) text-(--color-muted)'
|
||||
)}>{ch.number}</span>
|
||||
|
||||
<span class={cn(
|
||||
'flex-1 text-sm truncate',
|
||||
ch.number === activeChapter ? 'font-semibold text-(--color-brand)' : 'text-(--color-text)'
|
||||
)}>{ch.title || `Chapter ${ch.number}`}</span>
|
||||
|
||||
{#if ch.number === activeChapter}
|
||||
<!-- play icon -->
|
||||
<svg class="w-4 h-4 shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
{#if filtered.length === 0}
|
||||
<p class="px-4 py-8 text-sm text-(--color-muted) text-center">No chapters match "{search}"</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -3,6 +3,7 @@
|
||||
import { cn } from '$lib/utils';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { Voice } from '$lib/types';
|
||||
import ChapterPickerOverlay from '$lib/components/ChapterPickerOverlay.svelte';
|
||||
|
||||
interface Props {
|
||||
/** Called when the user closes the overlay. */
|
||||
@@ -92,25 +93,14 @@
|
||||
const filteredCfai = $derived(cfaiVoices.filter((v) => voiceLabel(v).toLowerCase().includes(voiceSearchLower)));
|
||||
|
||||
// ── Chapter search ────────────────────────────────────────────────────────
|
||||
let chapterSearch = $state('');
|
||||
// (search state is managed internally by ChapterPickerOverlay)
|
||||
|
||||
// Scroll the current chapter into view instantly (no animation) when the
|
||||
// chapter modal opens. Applied to every chapter button; only scrolls when
|
||||
// the chapter number matches the currently playing one. Runs once on mount
|
||||
// before the browser paints so no scroll animation is ever visible.
|
||||
function scrollIfActive(node: HTMLElement, isActive: boolean) {
|
||||
if (isActive) node.scrollIntoView({ block: 'center', behavior: 'instant' });
|
||||
// ── Chapter click-to-play ─────────────────────────────────────────────────
|
||||
function playChapter(chapterNumber: number) {
|
||||
audioStore.autoStartChapter = chapterNumber;
|
||||
onclose();
|
||||
goto(`/books/${audioStore.slug}/chapters/${chapterNumber}`);
|
||||
}
|
||||
const filteredChapters = $derived(
|
||||
chapterSearch.trim() === ''
|
||||
? audioStore.chapters
|
||||
: audioStore.chapters.filter((ch) =>
|
||||
(ch.title || `Chapter ${ch.number}`)
|
||||
.toLowerCase()
|
||||
.includes(chapterSearch.toLowerCase()) ||
|
||||
String(ch.number).includes(chapterSearch)
|
||||
)
|
||||
);
|
||||
|
||||
function voiceLabel(v: Voice | string): string {
|
||||
if (typeof v === 'string') {
|
||||
@@ -156,13 +146,6 @@
|
||||
voiceSearch = '';
|
||||
}
|
||||
|
||||
// ── Chapter click-to-play ─────────────────────────────────────────────────
|
||||
function playChapter(chapterNumber: number) {
|
||||
audioStore.autoStartChapter = chapterNumber;
|
||||
onclose();
|
||||
goto(`/books/${audioStore.slug}/chapters/${chapterNumber}`);
|
||||
}
|
||||
|
||||
// ── Speed ────────────────────────────────────────────────────────────────
|
||||
const SPEED_OPTIONS = [0.75, 1, 1.25, 1.5, 2] as const;
|
||||
|
||||
@@ -453,63 +436,13 @@
|
||||
|
||||
<!-- Chapter modal (full-screen overlay) -->
|
||||
{#if showChapterModal && audioStore.chapters.length > 0}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-[80] flex flex-col"
|
||||
style="background: var(--color-surface);"
|
||||
>
|
||||
<div class="flex items-center gap-3 px-4 py-3 border-b border-(--color-border) shrink-0" style="padding-top: max(0.75rem, env(safe-area-inset-top));">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { showChapterModal = false; }}
|
||||
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
aria-label="Close chapter picker"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider flex-1">Chapters</span>
|
||||
</div>
|
||||
<div class="px-4 py-3 shrink-0 border-b border-(--color-border)">
|
||||
<div class="relative">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search chapters…"
|
||||
bind:value={chapterSearch}
|
||||
class="w-full pl-9 pr-4 py-2 text-sm bg-(--color-surface-2) border border-(--color-border) rounded-lg text-(--color-text) placeholder:text-(--color-muted) focus:outline-none focus:border-(--color-brand) transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto overscroll-contain" style="padding-bottom: env(safe-area-inset-bottom);">
|
||||
{#each filteredChapters as ch (ch.number)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => playChapter(ch.number)}
|
||||
use:scrollIfActive={ch.number === audioStore.chapter}
|
||||
class={cn(
|
||||
'w-full flex items-center gap-3 px-4 py-3 border-b border-(--color-border)/40 transition-colors text-left',
|
||||
ch.number === audioStore.chapter ? 'bg-(--color-brand)/8' : 'hover:bg-(--color-surface-2)'
|
||||
)}
|
||||
>
|
||||
<span class={cn(
|
||||
'w-8 h-8 shrink-0 rounded-full border-2 flex items-center justify-center tabular-nums text-xs font-semibold transition-colors',
|
||||
ch.number === audioStore.chapter ? 'border-(--color-brand) bg-(--color-brand) text-(--color-surface)' : 'border-(--color-border) text-(--color-muted)'
|
||||
)}>{ch.number}</span>
|
||||
<span class={cn('flex-1 text-sm truncate', ch.number === audioStore.chapter ? 'font-semibold text-(--color-brand)' : 'text-(--color-text)')}>{ch.title || `Chapter ${ch.number}`}</span>
|
||||
{#if ch.number === audioStore.chapter}
|
||||
<svg class="w-4 h-4 shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{#if filteredChapters.length === 0}
|
||||
<p class="px-4 py-8 text-sm text-(--color-muted) text-center">No chapters match "{chapterSearch}"</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<ChapterPickerOverlay
|
||||
chapters={audioStore.chapters}
|
||||
activeChapter={audioStore.chapter}
|
||||
zIndex="z-[80]"
|
||||
onselect={playChapter}
|
||||
onclose={() => { showChapterModal = false; }}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- ── Controls area (bottom half) ───────────────────────────────────── -->
|
||||
@@ -664,24 +597,57 @@
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Announce chapter pill (only meaningful when auto-next is on) -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (audioStore.announceChapter = !audioStore.announceChapter)}
|
||||
class={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors',
|
||||
audioStore.announceChapter
|
||||
<!-- Announce chapter pill (only meaningful when auto-next is on) -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (audioStore.announceChapter = !audioStore.announceChapter)}
|
||||
class={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors',
|
||||
audioStore.announceChapter
|
||||
? 'border-(--color-brand) bg-(--color-brand)/15 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
|
||||
)}
|
||||
aria-pressed={audioStore.announceChapter}
|
||||
title={audioStore.announceChapter ? 'Chapter announcing on' : 'Chapter announcing off'}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
|
||||
</svg>
|
||||
Announce
|
||||
</button>
|
||||
|
||||
<!-- Stream / Generate mode toggle -->
|
||||
<!-- CF AI voices are batch-only and always use generate mode regardless of this setting -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
if (!audioStore.voice.startsWith('cfai:')) {
|
||||
audioStore.audioMode = audioStore.audioMode === 'stream' ? 'generate' : 'stream';
|
||||
}
|
||||
}}
|
||||
disabled={audioStore.voice.startsWith('cfai:')}
|
||||
class={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors',
|
||||
audioStore.voice.startsWith('cfai:')
|
||||
? 'border-(--color-border) bg-(--color-surface-2) text-(--color-border) cursor-not-allowed opacity-50'
|
||||
: audioStore.audioMode === 'stream'
|
||||
? 'border-(--color-brand) bg-(--color-brand)/15 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
|
||||
)}
|
||||
aria-pressed={audioStore.announceChapter}
|
||||
title={audioStore.announceChapter ? 'Chapter announcing on' : 'Chapter announcing off'}
|
||||
>
|
||||
)}
|
||||
aria-pressed={audioStore.audioMode === 'stream'}
|
||||
title={audioStore.voice.startsWith('cfai:') ? 'CF AI voices always use generate mode' : audioStore.audioMode === 'stream' ? 'Stream mode — audio starts instantly' : 'Generate mode — wait for full audio before playing'}
|
||||
>
|
||||
{#if audioStore.audioMode === 'stream' && !audioStore.voice.startsWith('cfai:')}
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
Announce
|
||||
</button>
|
||||
{:else}
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
|
||||
</svg>
|
||||
{/if}
|
||||
{audioStore.audioMode === 'stream' && !audioStore.voice.startsWith('cfai:') ? 'Stream' : 'Generate'}
|
||||
</button>
|
||||
|
||||
<!-- Sleep timer pill -->
|
||||
<button
|
||||
|
||||
44
ui/src/lib/paraglide/messages/admin_nav_notifications.js
Normal file
44
ui/src/lib/paraglide/messages/admin_nav_notifications.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Admin_Nav_NotificationsInputs */
|
||||
|
||||
const en_admin_nav_notifications = /** @type {(inputs: Admin_Nav_NotificationsInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Notifications`)
|
||||
};
|
||||
|
||||
const ru_admin_nav_notifications = /** @type {(inputs: Admin_Nav_NotificationsInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Уведомления`)
|
||||
};
|
||||
|
||||
const id_admin_nav_notifications = /** @type {(inputs: Admin_Nav_NotificationsInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Notifikasi`)
|
||||
};
|
||||
|
||||
const pt_admin_nav_notifications = /** @type {(inputs: Admin_Nav_NotificationsInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Notificações`)
|
||||
};
|
||||
|
||||
const fr_admin_nav_notifications = /** @type {(inputs: Admin_Nav_NotificationsInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Notifications`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Notifications" |
|
||||
*
|
||||
* @param {Admin_Nav_NotificationsInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const admin_nav_notifications = /** @type {((inputs?: Admin_Nav_NotificationsInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_NotificationsInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_admin_nav_notifications(inputs)
|
||||
if (locale === "ru") return ru_admin_nav_notifications(inputs)
|
||||
if (locale === "id") return id_admin_nav_notifications(inputs)
|
||||
if (locale === "pt") return pt_admin_nav_notifications(inputs)
|
||||
return fr_admin_nav_notifications(inputs)
|
||||
});
|
||||
@@ -76,6 +76,7 @@ export interface PBUserSettings {
|
||||
font_family?: string;
|
||||
font_size?: number;
|
||||
announce_chapter?: boolean;
|
||||
audio_mode?: string;
|
||||
updated?: string;
|
||||
}
|
||||
|
||||
@@ -1013,7 +1014,7 @@ export async function getSettings(
|
||||
|
||||
export async function saveSettings(
|
||||
sessionId: string,
|
||||
settings: { autoNext: boolean; voice: string; speed: number; theme?: string; locale?: string; fontFamily?: string; fontSize?: number; announceChapter?: boolean },
|
||||
settings: { autoNext: boolean; voice: string; speed: number; theme?: string; locale?: string; fontFamily?: string; fontSize?: number; announceChapter?: boolean; audioMode?: string },
|
||||
userId?: string
|
||||
): Promise<void> {
|
||||
const existing = await listOne<PBUserSettings & { id: string }>(
|
||||
@@ -1033,6 +1034,7 @@ export async function saveSettings(
|
||||
if (settings.fontFamily !== undefined) payload.font_family = settings.fontFamily;
|
||||
if (settings.fontSize !== undefined) payload.font_size = settings.fontSize;
|
||||
if (settings.announceChapter !== undefined) payload.announce_chapter = settings.announceChapter;
|
||||
if (settings.audioMode !== undefined) payload.audio_mode = settings.audioMode;
|
||||
if (userId) payload.user_id = userId;
|
||||
|
||||
if (existing) {
|
||||
@@ -1413,6 +1415,56 @@ export async function revokeAllUserSessions(userId: string): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all data associated with a user account:
|
||||
* - user_settings, user_library, progress, comment_votes, book_ratings,
|
||||
* user_subscriptions, user_sessions, notifications rows owned by the user
|
||||
* - the app_users record itself
|
||||
*
|
||||
* Does NOT delete audio files from MinIO (shared cache) or book comments
|
||||
* (anonymised to preserve discussion threads).
|
||||
*/
|
||||
export async function deleteUserAccount(userId: string, sessionId: string): Promise<void> {
|
||||
const collections = [
|
||||
{ name: 'user_settings', filter: `(user_id="${userId}" || session_id="${sessionId}")` },
|
||||
{ name: 'user_library', filter: `(user_id="${userId}" || session_id="${sessionId}")` },
|
||||
{ name: 'progress', filter: `(user_id="${userId}" || session_id="${sessionId}")` },
|
||||
{ name: 'comment_votes', filter: `user_id="${userId}"` },
|
||||
{ name: 'book_ratings', filter: `user_id="${userId}"` },
|
||||
{ name: 'user_subscriptions', filter: `(follower_id="${userId}" || followee_id="${userId}")` },
|
||||
{ name: 'notifications', filter: `user_id="${userId}"` },
|
||||
{ name: 'user_sessions', filter: `user_id="${userId}"` },
|
||||
];
|
||||
|
||||
const token = await getToken();
|
||||
|
||||
for (const { name, filter } of collections) {
|
||||
try {
|
||||
const rows = await listAll<{ id: string }>(name, filter);
|
||||
await Promise.all(
|
||||
rows.map((r) =>
|
||||
fetch(`${PB_URL}/api/collections/${name}/records/${r.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
}).catch(() => {})
|
||||
)
|
||||
);
|
||||
} catch {
|
||||
// Best-effort: log and continue so one failure doesn't abort the rest
|
||||
log.warn('pocketbase', `deleteUserAccount: failed to purge ${name}`, { userId });
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the user record last
|
||||
const res = await pbDelete(`/api/collections/app_users/records/${userId}`);
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
log.error('pocketbase', 'deleteUserAccount: failed to delete app_users record', { userId, status: res.status, body });
|
||||
throw new Error(`Failed to delete user record (${res.status})`);
|
||||
}
|
||||
log.info('pocketbase', 'deleteUserAccount: account deleted', { userId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the avatar_url field for a user record.
|
||||
*/
|
||||
|
||||
@@ -17,7 +17,7 @@ export const load: LayoutServerLoad = async ({ locals, url, cookies }) => {
|
||||
redirect(302, `/login`);
|
||||
}
|
||||
|
||||
let settings = { autoNext: false, voice: 'af_bella', speed: 1.0, theme: 'amber', locale: 'en', fontFamily: 'system', fontSize: 1.0, announceChapter: false };
|
||||
let settings = { autoNext: false, voice: 'af_bella', speed: 1.0, theme: 'amber', locale: 'en', fontFamily: 'system', fontSize: 1.0, announceChapter: false, audioMode: 'stream' };
|
||||
try {
|
||||
const row = await getSettings(locals.sessionId, locals.user?.id);
|
||||
if (row) {
|
||||
@@ -29,7 +29,8 @@ export const load: LayoutServerLoad = async ({ locals, url, cookies }) => {
|
||||
locale: row.locale ?? 'en',
|
||||
fontFamily: row.font_family ?? 'system',
|
||||
fontSize: row.font_size || 1.0,
|
||||
announceChapter: row.announce_chapter ?? false
|
||||
announceChapter: row.announce_chapter ?? false,
|
||||
audioMode: row.audio_mode ?? 'stream'
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -157,6 +157,7 @@
|
||||
audioStore.voice = data.settings.voice;
|
||||
audioStore.speed = data.settings.speed;
|
||||
audioStore.announceChapter = data.settings.announceChapter ?? false;
|
||||
audioStore.audioMode = (data.settings.audioMode === 'generate' ? 'generate' : 'stream');
|
||||
}
|
||||
// Always sync theme + font (profile page calls invalidateAll after saving)
|
||||
currentTheme = data.settings.theme ?? 'amber';
|
||||
@@ -179,6 +180,7 @@
|
||||
const fontFamily = currentFontFamily;
|
||||
const fontSize = currentFontSize;
|
||||
const announceChapter = audioStore.announceChapter;
|
||||
const audioMode = audioStore.audioMode;
|
||||
|
||||
// Skip saving until settings have been applied from the server AND
|
||||
// at least one user-driven change has occurred after that.
|
||||
@@ -189,7 +191,7 @@
|
||||
fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ autoNext, voice, speed, theme, fontFamily, fontSize, announceChapter })
|
||||
body: JSON.stringify({ autoNext, voice, speed, theme, fontFamily, fontSize, announceChapter, audioMode })
|
||||
}).catch(() => {});
|
||||
}, 800) as unknown as number;
|
||||
});
|
||||
|
||||
26
ui/src/routes/api/profile/+server.ts
Normal file
26
ui/src/routes/api/profile/+server.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { deleteUserAccount } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
/**
|
||||
* DELETE /api/profile
|
||||
*
|
||||
* Permanently deletes the authenticated user's account and all associated data:
|
||||
* settings, library, progress, votes, ratings, sessions, notifications.
|
||||
*
|
||||
* The app_users record is removed last. The caller should immediately log the
|
||||
* user out (submit the logout form) to clear the session cookie.
|
||||
*/
|
||||
export const DELETE: RequestHandler = async ({ locals }) => {
|
||||
if (!locals.user) error(401, 'Not authenticated');
|
||||
|
||||
try {
|
||||
await deleteUserAccount(locals.user.id, locals.sessionId);
|
||||
} catch (e) {
|
||||
log.error('profile', 'DELETE /api/profile failed', { userId: locals.user.id, err: String(e) });
|
||||
error(500, { message: 'Failed to delete account. Please try again or contact support.' });
|
||||
}
|
||||
|
||||
return json({ ok: true });
|
||||
};
|
||||
@@ -5,7 +5,7 @@ import { log } from '$lib/server/logger';
|
||||
|
||||
/**
|
||||
* GET /api/settings
|
||||
* Returns the current user's settings (auto_next, voice, speed, theme, locale, fontFamily, fontSize, announceChapter).
|
||||
* Returns the current user's settings (auto_next, voice, speed, theme, locale, fontFamily, fontSize, announceChapter, audioMode).
|
||||
* Returns defaults if no settings record exists yet.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
@@ -19,7 +19,8 @@ export const GET: RequestHandler = async ({ locals }) => {
|
||||
locale: settings?.locale ?? 'en',
|
||||
fontFamily: settings?.font_family ?? 'system',
|
||||
fontSize: settings?.font_size || 1.0,
|
||||
announceChapter: settings?.announce_chapter ?? false
|
||||
announceChapter: settings?.announce_chapter ?? false,
|
||||
audioMode: settings?.audio_mode ?? 'stream'
|
||||
});
|
||||
} catch (e) {
|
||||
log.error('settings', 'GET failed', { err: String(e) });
|
||||
@@ -29,7 +30,7 @@ export const GET: RequestHandler = async ({ locals }) => {
|
||||
|
||||
/**
|
||||
* PUT /api/settings
|
||||
* Body: { autoNext: boolean, voice: string, speed: number, theme?: string, locale?: string, fontFamily?: string, fontSize?: number, announceChapter?: boolean }
|
||||
* Body: { autoNext: boolean, voice: string, speed: number, theme?: string, locale?: string, fontFamily?: string, fontSize?: number, announceChapter?: boolean, audioMode?: string }
|
||||
* Saves user preferences.
|
||||
*/
|
||||
export const PUT: RequestHandler = async ({ request, locals }) => {
|
||||
@@ -73,6 +74,12 @@ export const PUT: RequestHandler = async ({ request, locals }) => {
|
||||
error(400, 'Invalid announceChapter — must be boolean');
|
||||
}
|
||||
|
||||
// audioMode is optional — if provided it must be a known value
|
||||
const validAudioModes = ['stream', 'generate'];
|
||||
if (body.audioMode !== undefined && !validAudioModes.includes(body.audioMode)) {
|
||||
error(400, `Invalid audioMode — must be one of: ${validAudioModes.join(', ')}`);
|
||||
}
|
||||
|
||||
try {
|
||||
await saveSettings(locals.sessionId, body, locals.user?.id);
|
||||
} catch (e) {
|
||||
|
||||
@@ -100,6 +100,58 @@
|
||||
const genres = $derived(parseGenres(data.book?.genres ?? []));
|
||||
const chapterList = $derived(data.chapters ?? []);
|
||||
|
||||
// ── Admin: split chapters (imported PDF/EPUB books) ──────────────────────
|
||||
const isFullTextBook = $derived(
|
||||
chapterList.length === 1 && chapterList[0].title === 'Full Text'
|
||||
);
|
||||
let splitText = $state('');
|
||||
let splitSaving = $state(false);
|
||||
let splitResult = $state<'saved' | 'error' | ''>('');
|
||||
let splitError = $state('');
|
||||
let splitOpen = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
// Pre-fill the textarea with chapter 1 content when the panel is opened.
|
||||
if (splitOpen && !splitText && data.book?.slug && isFullTextBook) {
|
||||
fetch(`/api/chapter-markdown/${encodeURIComponent(data.book.slug)}/1`)
|
||||
.then((r) => r.ok ? r.text() : '')
|
||||
.then((t) => {
|
||||
// Strip leading "# Full Text\n\n" header if present.
|
||||
splitText = t.replace(/^# Full Text\n\n/, '').trim();
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
async function splitChapters() {
|
||||
const slug = data.book?.slug;
|
||||
if (splitSaving || !slug) return;
|
||||
splitSaving = true;
|
||||
splitResult = '';
|
||||
splitError = '';
|
||||
try {
|
||||
const res = await fetch(`/api/admin/books/${encodeURIComponent(slug)}/split-chapters`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: splitText })
|
||||
});
|
||||
if (res.ok) {
|
||||
splitResult = 'saved';
|
||||
splitOpen = false;
|
||||
await invalidateAll();
|
||||
} else {
|
||||
const d = await res.json().catch(() => ({}));
|
||||
splitError = (d as any).error ?? 'Unknown error';
|
||||
splitResult = 'error';
|
||||
}
|
||||
} catch (e: any) {
|
||||
splitError = e?.message ?? '';
|
||||
splitResult = 'error';
|
||||
} finally {
|
||||
splitSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Admin: rescrape ───────────────────────────────────────────────────────
|
||||
let scraping = $state(false);
|
||||
let scrapeResult = $state<'queued' | 'busy' | 'error' | ''>('');
|
||||
@@ -979,7 +1031,7 @@
|
||||
</a>
|
||||
|
||||
<!-- Admin panel (collapsed by default, admin only) -->
|
||||
{#if data.isAdmin && book.source_url}
|
||||
{#if data.isAdmin}
|
||||
<div>
|
||||
<button
|
||||
onclick={() => (adminOpen = !adminOpen)}
|
||||
@@ -997,6 +1049,62 @@
|
||||
|
||||
{#if adminOpen}
|
||||
<div class="px-4 py-3 border-t border-(--color-border) flex flex-col gap-5">
|
||||
|
||||
<!-- Chapter split tool (only for imported books with single "Full Text" chapter) -->
|
||||
{#if isFullTextBook}
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">Split Chapters</p>
|
||||
<button
|
||||
onclick={() => { splitOpen = !splitOpen; splitResult = ''; splitError = ''; }}
|
||||
class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors"
|
||||
>
|
||||
{splitOpen ? 'Hide' : 'Edit'}
|
||||
</button>
|
||||
</div>
|
||||
{#if !splitOpen}
|
||||
<p class="text-xs text-(--color-muted)">
|
||||
This book has a single "Full Text" chapter. Use this tool to split it into chapters.
|
||||
</p>
|
||||
{/if}
|
||||
{#if splitOpen}
|
||||
<p class="text-xs text-(--color-muted)">
|
||||
Insert <code class="bg-(--color-surface-3) px-1 rounded">---</code> on its own line to divide chapters.
|
||||
Optionally start a segment with <code class="bg-(--color-surface-3) px-1 rounded">## Chapter Title</code>.
|
||||
</p>
|
||||
<textarea
|
||||
bind:value={splitText}
|
||||
rows="16"
|
||||
class="w-full px-2 py-1.5 rounded bg-(--color-surface-3) border border-(--color-border) text-(--color-text) text-xs font-mono focus:outline-none focus:border-(--color-brand) resize-y"
|
||||
placeholder="Paste or edit the full text here. Use --- to split chapters."
|
||||
></textarea>
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<button
|
||||
onclick={splitChapters}
|
||||
disabled={splitSaving || !splitText.trim()}
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium transition-colors
|
||||
{splitSaving || !splitText.trim() ? 'bg-(--color-surface-3) text-(--color-muted) cursor-not-allowed' : 'bg-(--color-brand)/20 text-(--color-brand-dim) hover:bg-(--color-brand)/40 border border-(--color-brand)/30'}"
|
||||
>
|
||||
{#if splitSaving}
|
||||
<svg class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
|
||||
Saving…
|
||||
{:else}
|
||||
Save chapters
|
||||
{/if}
|
||||
</button>
|
||||
{#if splitResult === 'saved'}
|
||||
<span class="text-xs text-green-400">Saved.</span>
|
||||
{:else if splitResult === 'error'}
|
||||
<span class="text-xs text-(--color-danger)">{splitError || 'Error.'}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<hr class="border-(--color-border)" />
|
||||
{/if}
|
||||
|
||||
<!-- Rescrape / range-scrape (only for scraped books with a source URL) -->
|
||||
{#if book.source_url}
|
||||
<!-- Rescrape -->
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<button
|
||||
@@ -1065,6 +1173,7 @@
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<hr class="border-(--color-border)" />
|
||||
|
||||
|
||||
@@ -3,15 +3,16 @@
|
||||
import { untrack, getContext } from 'svelte';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
import { audioStore } from '$lib/audio.svelte';
|
||||
import type { AudioMode } from '$lib/audio.svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { page } from '$app/state';
|
||||
import type { Voice } from '$lib/types';
|
||||
import { cn } from '$lib/utils';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
// ── Polar checkout ───────────────────────────────────────────────────────────
|
||||
// Customer portal: always link to the org portal
|
||||
const manageUrl = `https://polar.sh/libnovel/portal`;
|
||||
|
||||
let checkoutLoading = $state<'monthly' | 'annual' | null>(null);
|
||||
@@ -41,14 +42,12 @@
|
||||
}
|
||||
|
||||
// ── Avatar ───────────────────────────────────────────────────────────────────
|
||||
// Show a welcome banner when Polar redirects back with ?subscribed=1
|
||||
const justSubscribed = $derived(browser && page.url.searchParams.get('subscribed') === '1');
|
||||
|
||||
let avatarUrl = $state<string | null>(untrack(() => data.avatarUrl ?? null));
|
||||
let avatarUploading = $state(false);
|
||||
let avatarError = $state('');
|
||||
let fileInput: HTMLInputElement | null = null;
|
||||
|
||||
let cropFile = $state<File | null>(null);
|
||||
|
||||
function handleAvatarChange(e: Event) {
|
||||
@@ -83,9 +82,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handleCropCancel() {
|
||||
cropFile = null;
|
||||
}
|
||||
function handleCropCancel() { cropFile = null; }
|
||||
|
||||
// ── Voices ───────────────────────────────────────────────────────────────────
|
||||
let voices = $state<Voice[]>([]);
|
||||
@@ -93,7 +90,7 @@
|
||||
|
||||
const kokoroVoices = $derived(voices.filter((v) => v.engine === 'kokoro'));
|
||||
const pocketVoices = $derived(voices.filter((v) => v.engine === 'pocket-tts'));
|
||||
const cfaiVoices = $derived(voices.filter((v) => v.engine === 'cfai'));
|
||||
const cfaiVoices = $derived(voices.filter((v) => v.engine === 'cfai'));
|
||||
|
||||
function voiceLabel(v: Voice): string {
|
||||
if (v.engine === 'cfai') {
|
||||
@@ -102,14 +99,14 @@
|
||||
}
|
||||
if (v.engine === 'pocket-tts') {
|
||||
const name = v.id.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
return name + (v.gender ? ` (EN ${v.gender.toUpperCase()})` : '');
|
||||
return name + (v.gender ? ` (${v.lang?.toUpperCase().replace('-','')} ${v.gender.toUpperCase()})` : '');
|
||||
}
|
||||
// Kokoro: "af_bella" → "Bella (US F)"
|
||||
const langMap: Record<string, string> = {
|
||||
af: 'US', am: 'US', bf: 'UK', bm: 'UK',
|
||||
ef: 'ES', em: 'ES', ff: 'FR',
|
||||
hf: 'IN', hm: 'IN', 'if': 'IT', im: 'IT',
|
||||
jf: 'JP', jm: 'JP', pf: 'PT', pm: 'PT', zf: 'ZH', zm: 'ZH',
|
||||
af:'US', am:'US', bf:'UK', bm:'UK',
|
||||
ef:'ES', em:'ES', ff:'FR',
|
||||
hf:'IN', hm:'IN', 'if':'IT', im:'IT',
|
||||
jf:'JP', jm:'JP', pf:'PT', pm:'PT', zf:'ZH', zm:'ZH',
|
||||
};
|
||||
const prefix = v.id.slice(0, 2);
|
||||
const name = v.id.slice(3).replace(/^v0/, '').replace(/^([a-z])/, (c) => c.toUpperCase());
|
||||
@@ -125,21 +122,41 @@
|
||||
.catch(() => { voicesLoaded = true; });
|
||||
});
|
||||
|
||||
// ── Settings state ───────────────────────────────────────────────────────────
|
||||
let voice = $state(audioStore.voice);
|
||||
let speed = $state(audioStore.speed);
|
||||
let autoNext = $state(audioStore.autoNext);
|
||||
// Voice sample playback
|
||||
let samplePlayingVoice = $state<string | null>(null);
|
||||
let sampleAudio = $state<HTMLAudioElement | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
voice = audioStore.voice;
|
||||
speed = audioStore.speed;
|
||||
autoNext = audioStore.autoNext;
|
||||
});
|
||||
function stopSample() {
|
||||
if (sampleAudio) { sampleAudio.pause(); sampleAudio.src = ''; sampleAudio = null; }
|
||||
samplePlayingVoice = null;
|
||||
}
|
||||
|
||||
async function toggleSample(voiceId: string) {
|
||||
if (samplePlayingVoice === voiceId) { stopSample(); return; }
|
||||
stopSample();
|
||||
samplePlayingVoice = voiceId;
|
||||
try {
|
||||
const res = await fetch(`/api/presign/voice-sample?voice=${encodeURIComponent(voiceId)}`);
|
||||
if (res.status === 404) { samplePlayingVoice = null; return; }
|
||||
if (!res.ok) throw new Error();
|
||||
const { url } = await res.json() as { url: string };
|
||||
const audio = new Audio(url);
|
||||
sampleAudio = audio;
|
||||
audio.onended = () => { if (samplePlayingVoice === voiceId) stopSample(); };
|
||||
audio.onerror = () => { if (samplePlayingVoice === voiceId) stopSample(); };
|
||||
await audio.play();
|
||||
} catch { samplePlayingVoice = null; }
|
||||
}
|
||||
|
||||
// ── Settings state ────────────────────────────────────────────────────────────
|
||||
// All changes are written directly into audioStore / theme context.
|
||||
// The layout's debounced $effect owns the single PUT /api/settings call.
|
||||
// We only maintain a local saveStatus indicator here.
|
||||
|
||||
const settingsCtx = getContext<{ current: string; fontFamily: string; fontSize: number } | undefined>('theme');
|
||||
let selectedTheme = $state(untrack(() => data.settings?.theme ?? settingsCtx?.current ?? 'amber'));
|
||||
let selectedTheme = $state(untrack(() => data.settings?.theme ?? settingsCtx?.current ?? 'amber'));
|
||||
let selectedFontFamily = $state(untrack(() => data.settings?.fontFamily ?? settingsCtx?.fontFamily ?? 'system'));
|
||||
let selectedFontSize = $state(untrack(() => data.settings?.fontSize ?? settingsCtx?.fontSize ?? 1.0));
|
||||
let selectedFontSize = $state(untrack(() => data.settings?.fontSize ?? settingsCtx?.fontSize ?? 1.0));
|
||||
|
||||
const THEMES: { id: string; label: () => string; swatch: string; light?: boolean }[] = [
|
||||
{ id: 'amber', label: () => m.profile_theme_amber(), swatch: '#f59e0b' },
|
||||
@@ -166,51 +183,50 @@
|
||||
{ value: 1.3, label: () => m.profile_text_size_xl() },
|
||||
];
|
||||
|
||||
// ── Auto-save ────────────────────────────────────────────────────────────────
|
||||
// Local save-status indicator — layout's effect does the actual debounced save.
|
||||
type SaveStatus = 'idle' | 'saving' | 'saved';
|
||||
let saveStatus = $state<SaveStatus>('idle');
|
||||
let saveTimer = 0;
|
||||
let savedTimer = 0;
|
||||
let initialized = false;
|
||||
|
||||
function markSaved() {
|
||||
saveStatus = 'saving';
|
||||
clearTimeout(savedTimer);
|
||||
// Give a tick for layout's effect to fire, then show ✓ Saved
|
||||
savedTimer = setTimeout(() => {
|
||||
saveStatus = 'saved';
|
||||
savedTimer = setTimeout(() => (saveStatus = 'idle'), 2000) as unknown as number;
|
||||
}, 900) as unknown as number;
|
||||
}
|
||||
|
||||
// Propagate all settings changes into audioStore / context immediately.
|
||||
// Layout effect watches these and persists to the server (debounced 800ms).
|
||||
$effect(() => {
|
||||
// Read all settings deps to subscribe
|
||||
const t = selectedTheme;
|
||||
const t = selectedTheme;
|
||||
const ff = selectedFontFamily;
|
||||
const fs = selectedFontSize;
|
||||
const v = voice;
|
||||
const sp = speed;
|
||||
const an = autoNext;
|
||||
const v = audioStore.voice;
|
||||
const sp = audioStore.speed;
|
||||
const an = audioStore.autoNext;
|
||||
const ac = audioStore.announceChapter;
|
||||
const am = audioStore.audioMode;
|
||||
|
||||
// Apply context immediately (font/theme previews live without waiting for save)
|
||||
if (settingsCtx) {
|
||||
settingsCtx.current = t;
|
||||
settingsCtx.current = t;
|
||||
settingsCtx.fontFamily = ff;
|
||||
settingsCtx.fontSize = fs;
|
||||
settingsCtx.fontSize = fs;
|
||||
}
|
||||
audioStore.voice = v;
|
||||
audioStore.autoNext = an;
|
||||
|
||||
if (!initialized) { initialized = true; return; }
|
||||
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(async () => {
|
||||
saveStatus = 'saving';
|
||||
try {
|
||||
await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ autoNext: an, voice: v, speed: sp, theme: t, fontFamily: ff, fontSize: fs })
|
||||
});
|
||||
saveStatus = 'saved';
|
||||
clearTimeout(savedTimer);
|
||||
savedTimer = setTimeout(() => (saveStatus = 'idle'), 2000) as unknown as number;
|
||||
} catch {
|
||||
saveStatus = 'idle';
|
||||
}
|
||||
}, 800) as unknown as number;
|
||||
void v; void sp; void an; void ac; void am; // keep subscriptions live
|
||||
markSaved();
|
||||
});
|
||||
|
||||
// Keep theme/font writes flowing into layout context when changed from selectors
|
||||
$effect(() => { if (settingsCtx) settingsCtx.current = selectedTheme; });
|
||||
$effect(() => { if (settingsCtx) settingsCtx.fontFamily = selectedFontFamily; });
|
||||
$effect(() => { if (settingsCtx) settingsCtx.fontSize = selectedFontSize; });
|
||||
|
||||
// ── Tab ──────────────────────────────────────────────────────────────────────
|
||||
let activeTab = $state<'profile' | 'stats' | 'history'>('profile');
|
||||
|
||||
@@ -224,12 +240,12 @@
|
||||
is_current: boolean;
|
||||
};
|
||||
|
||||
let sessions = $state<Session[]>(untrack(() => data.sessions ?? []));
|
||||
let revokingId = $state<string | null>(null);
|
||||
let sessions = $state<Session[]>(untrack(() => data.sessions ?? []));
|
||||
let revokingId = $state<string | null>(null);
|
||||
let revokeError = $state('');
|
||||
|
||||
async function revokeSession(session: Session) {
|
||||
revokingId = session.id;
|
||||
revokingId = session.id;
|
||||
revokeError = '';
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${session.id}`, { method: 'DELETE' });
|
||||
@@ -247,6 +263,37 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── Danger zone ──────────────────────────────────────────────────────────────
|
||||
let deleteConfirmOpen = $state(false);
|
||||
let deleteConfirmText = $state('');
|
||||
let deleting = $state(false);
|
||||
let deleteError = $state('');
|
||||
|
||||
const DELETE_KEYWORD = untrack(() => data.user.username);
|
||||
const deleteReady = $derived(deleteConfirmText.trim() === DELETE_KEYWORD);
|
||||
|
||||
async function deleteAccount() {
|
||||
if (!deleteReady) return;
|
||||
deleting = true;
|
||||
deleteError = '';
|
||||
try {
|
||||
const res = await fetch('/api/profile', { method: 'DELETE' });
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({})) as { message?: string };
|
||||
deleteError = body.message ?? `Delete failed (${res.status}). Please try again.`;
|
||||
return;
|
||||
}
|
||||
// Server deleted account — submit logout form to clear session cookie
|
||||
const logoutForm = document.getElementById('logout-form') as HTMLFormElement | null;
|
||||
if (logoutForm) logoutForm.submit();
|
||||
} catch {
|
||||
deleteError = 'Network error. Please try again.';
|
||||
} finally {
|
||||
deleting = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Utilities ────────────────────────────────────────────────────────────────
|
||||
function formatDate(iso: string): string {
|
||||
if (!iso) return '—';
|
||||
try {
|
||||
@@ -293,7 +340,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Profile header ──────────────────────────────────────────────────────── -->
|
||||
<!-- ── Profile header ───────────────────────────────────────────────────── -->
|
||||
<div class="flex items-center gap-5 pt-2">
|
||||
<div class="relative shrink-0">
|
||||
<button
|
||||
@@ -353,10 +400,12 @@
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (activeTab = tab)}
|
||||
class="flex-1 py-2 rounded-lg text-sm font-medium transition-colors
|
||||
{activeTab === tab
|
||||
class={cn(
|
||||
'flex-1 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
activeTab === tab
|
||||
? 'bg-(--color-surface-3) text-(--color-text) shadow-sm'
|
||||
: 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
: 'text-(--color-muted) hover:text-(--color-text)'
|
||||
)}
|
||||
>
|
||||
{tab === 'profile' ? 'Profile' : tab === 'stats' ? 'Stats' : 'History'}
|
||||
</button>
|
||||
@@ -364,7 +413,8 @@
|
||||
</div>
|
||||
|
||||
{#if activeTab === 'profile'}
|
||||
<!-- ── Subscription ─────────────────────────────────────────────────────────── -->
|
||||
|
||||
<!-- ── Subscription ──────────────────────────────────────────────────────── -->
|
||||
{#if !data.isPro}
|
||||
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
@@ -424,20 +474,21 @@
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- ── Preferences ──────────────────────────────────────────────────────────── -->
|
||||
<!-- ── Preferences ───────────────────────────────────────────────────────── -->
|
||||
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) divide-y divide-(--color-border)">
|
||||
|
||||
<!-- Section header with auto-save indicator -->
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-6 py-4">
|
||||
<h2 class="text-base font-semibold text-(--color-text)">Preferences</h2>
|
||||
<span class="text-xs transition-all duration-300 {saveStatus === 'saving' ? 'text-(--color-muted)' : saveStatus === 'saved' ? 'text-(--color-success)' : 'opacity-0 pointer-events-none'}">
|
||||
{#if saveStatus === 'saving'}
|
||||
{m.profile_saving()}…
|
||||
{:else if saveStatus === 'saved'}
|
||||
✓ {m.profile_saved()}
|
||||
{:else}
|
||||
{m.profile_saved()}
|
||||
{/if}
|
||||
<span class={cn(
|
||||
'text-xs transition-all duration-300',
|
||||
saveStatus === 'saving' ? 'text-(--color-muted)' :
|
||||
saveStatus === 'saved' ? 'text-green-400' :
|
||||
'opacity-0 pointer-events-none'
|
||||
)}>
|
||||
{#if saveStatus === 'saving'}{m.profile_saving()}…
|
||||
{:else if saveStatus === 'saved'}✓ {m.profile_saved()}
|
||||
{:else}{m.profile_saved()}{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -446,16 +497,18 @@
|
||||
<p class="text-sm font-medium text-(--color-text)">{m.profile_theme_label()}</p>
|
||||
<div class="flex gap-2 flex-wrap items-center">
|
||||
{#each THEMES as t, i}
|
||||
{#if i === 3}
|
||||
{#if i === 6}
|
||||
<span class="w-px h-6 bg-(--color-border) mx-1 self-center"></span>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (selectedTheme = t.id)}
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium transition-colors
|
||||
{selectedTheme === t.id
|
||||
class={cn(
|
||||
'flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium transition-colors',
|
||||
selectedTheme === t.id
|
||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
|
||||
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'
|
||||
)}
|
||||
aria-pressed={selectedTheme === t.id}
|
||||
>
|
||||
<span class="w-3 h-3 rounded-full shrink-0 {t.light ? 'ring-1 ring-(--color-border)' : ''}" style="background: {t.swatch};"></span>
|
||||
@@ -473,10 +526,12 @@
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (selectedFontFamily = f.id)}
|
||||
class="px-3 py-2 rounded-lg border text-sm font-medium transition-colors
|
||||
{selectedFontFamily === f.id
|
||||
class={cn(
|
||||
'px-3 py-2 rounded-lg border text-sm font-medium transition-colors',
|
||||
selectedFontFamily === f.id
|
||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
|
||||
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'
|
||||
)}
|
||||
aria-pressed={selectedFontFamily === f.id}
|
||||
>
|
||||
{f.label()}
|
||||
@@ -493,10 +548,12 @@
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (selectedFontSize = s.value)}
|
||||
class="px-3 py-2 rounded-lg border text-sm font-medium transition-colors
|
||||
{selectedFontSize === s.value
|
||||
class={cn(
|
||||
'px-3 py-2 rounded-lg border text-sm font-medium transition-colors',
|
||||
selectedFontSize === s.value
|
||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
|
||||
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'
|
||||
)}
|
||||
aria-pressed={selectedFontSize === s.value}
|
||||
>
|
||||
{s.label()}
|
||||
@@ -505,34 +562,81 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TTS voice -->
|
||||
<!-- TTS voice — visual card picker grouped by engine -->
|
||||
<div class="px-6 py-5 space-y-3">
|
||||
<label class="block text-sm font-medium text-(--color-text)" for="voice-select">{m.profile_tts_voice()}</label>
|
||||
<p class="text-sm font-medium text-(--color-text)">{m.profile_tts_voice()}</p>
|
||||
{#if !voicesLoaded}
|
||||
<div class="h-9 bg-(--color-surface-3) rounded-lg animate-pulse"></div>
|
||||
<div class="space-y-2">
|
||||
{#each [1,2,3] as _}
|
||||
<div class="h-10 bg-(--color-surface-3) rounded-lg animate-pulse"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if voices.length === 0}
|
||||
<select id="voice-select" disabled class="w-full bg-(--color-surface-3) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-muted) text-sm cursor-not-allowed">
|
||||
<option>{m.common_loading()}</option>
|
||||
</select>
|
||||
<p class="text-sm text-(--color-muted) italic">No voices available.</p>
|
||||
{:else}
|
||||
<select id="voice-select" bind:value={voice}
|
||||
class="w-full bg-(--color-surface-3) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm focus:outline-none focus:ring-2 focus:ring-(--color-brand)">
|
||||
{#if kokoroVoices.length > 0}
|
||||
<optgroup label="Kokoro (GPU)">
|
||||
{#each kokoroVoices as v}<option value={v.id}>{voiceLabel(v)}</option>{/each}
|
||||
</optgroup>
|
||||
{/if}
|
||||
{#if pocketVoices.length > 0}
|
||||
<optgroup label="Pocket TTS (CPU)">
|
||||
{#each pocketVoices as v}<option value={v.id}>{voiceLabel(v)}</option>{/each}
|
||||
</optgroup>
|
||||
{/if}
|
||||
{#if cfaiVoices.length > 0}
|
||||
<optgroup label="Cloudflare AI">
|
||||
{#each cfaiVoices as v}<option value={v.id}>{voiceLabel(v)}</option>{/each}
|
||||
</optgroup>
|
||||
{/if}
|
||||
</select>
|
||||
<!-- Engine groups -->
|
||||
{#each [
|
||||
{ label: 'Kokoro (GPU)', voices: kokoroVoices },
|
||||
{ label: 'Pocket TTS (CPU)', voices: pocketVoices },
|
||||
{ label: 'Cloudflare AI', voices: cfaiVoices },
|
||||
].filter(g => g.voices.length > 0) as group}
|
||||
<div>
|
||||
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-widest mb-2">{group.label}</p>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-1.5">
|
||||
{#each group.voices as v (v.id)}
|
||||
{@const isSelected = audioStore.voice === v.id}
|
||||
{@const isPlaying = samplePlayingVoice === v.id}
|
||||
<!-- Use role=option div to avoid nested <button> inside <button> -->
|
||||
<div
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
tabindex="0"
|
||||
onclick={() => { audioStore.voice = v.id; }}
|
||||
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); audioStore.voice = v.id; } }}
|
||||
class={cn(
|
||||
'flex items-center justify-between gap-2 px-3 py-2 rounded-lg border text-sm transition-colors cursor-pointer select-none',
|
||||
isSelected
|
||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-text) hover:border-(--color-brand)/40'
|
||||
)}
|
||||
>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
{#if isSelected}
|
||||
<svg class="w-3.5 h-3.5 shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
||||
</svg>
|
||||
{:else}
|
||||
<span class="w-3.5 h-3.5 shrink-0"></span>
|
||||
{/if}
|
||||
<span class="truncate font-medium">{voiceLabel(v)}</span>
|
||||
</div>
|
||||
<!-- Sample play button -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => { e.stopPropagation(); toggleSample(v.id); }}
|
||||
class={cn(
|
||||
'shrink-0 w-6 h-6 rounded-full flex items-center justify-center transition-colors',
|
||||
isPlaying
|
||||
? 'bg-(--color-brand) text-(--color-surface)'
|
||||
: 'bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3)'
|
||||
)}
|
||||
title={isPlaying ? 'Stop sample' : 'Play sample'}
|
||||
>
|
||||
{#if isPlaying}
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="w-3 h-3 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -540,36 +644,101 @@
|
||||
<div class="px-6 py-5 space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-sm font-medium text-(--color-text)" for="speed-range">{m.profile_playback_speed({ speed: '' })}</label>
|
||||
<span class="text-sm font-mono text-(--color-brand)">{speed.toFixed(1)}x</span>
|
||||
<span class="text-sm font-mono text-(--color-brand)">{audioStore.speed.toFixed(1)}x</span>
|
||||
</div>
|
||||
<input id="speed-range" type="range" min="0.5" max="3.0" step="0.1" bind:value={speed}
|
||||
<input id="speed-range" type="range" min="0.5" max="3.0" step="0.1"
|
||||
bind:value={audioStore.speed}
|
||||
style="accent-color: var(--color-brand);" class="w-full" />
|
||||
<div class="flex justify-between text-xs text-(--color-muted)">
|
||||
<span>0.5x</span><span>3.0x</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Auto-advance -->
|
||||
<div class="px-6 py-5 flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-(--color-text)">{m.profile_auto_advance()}</p>
|
||||
<p class="text-xs text-(--color-muted) mt-0.5">Automatically load the next chapter when audio finishes</p>
|
||||
<!-- Playback toggles row -->
|
||||
<div class="px-6 py-5 space-y-4">
|
||||
<p class="text-sm font-medium text-(--color-text)">Playback</p>
|
||||
|
||||
<!-- Auto-advance -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-(--color-text)">{m.profile_auto_advance()}</p>
|
||||
<p class="text-xs text-(--color-muted) mt-0.5">Automatically load the next chapter when audio finishes</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={audioStore.autoNext}
|
||||
aria-label="Auto-advance to next chapter"
|
||||
onclick={() => (audioStore.autoNext = !audioStore.autoNext)}
|
||||
class={cn(
|
||||
'shrink-0 relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-(--color-brand) focus:ring-offset-2 focus:ring-offset-(--color-surface)',
|
||||
audioStore.autoNext ? 'bg-(--color-brand)' : 'bg-(--color-surface-3) border border-(--color-border)'
|
||||
)}
|
||||
>
|
||||
<span class={cn('inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform', audioStore.autoNext ? 'translate-x-6' : 'translate-x-1')}></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Announce chapter -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-(--color-text)">Announce chapter</p>
|
||||
<p class="text-xs text-(--color-muted) mt-0.5">Read the chapter title aloud before auto-advancing</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={audioStore.announceChapter}
|
||||
aria-label="Announce chapter title before auto-advance"
|
||||
onclick={() => (audioStore.announceChapter = !audioStore.announceChapter)}
|
||||
class={cn(
|
||||
'shrink-0 relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-(--color-brand) focus:ring-offset-2 focus:ring-offset-(--color-surface)',
|
||||
audioStore.announceChapter ? 'bg-(--color-brand)' : 'bg-(--color-surface-3) border border-(--color-border)'
|
||||
)}
|
||||
>
|
||||
<span class={cn('inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform', audioStore.announceChapter ? 'translate-x-6' : 'translate-x-1')}></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Audio mode -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-(--color-text)">Audio mode</p>
|
||||
<p class="text-xs text-(--color-muted) mt-0.5">
|
||||
{#if audioStore.audioMode === 'stream'}
|
||||
<strong class="text-(--color-text)">Stream</strong> — audio starts within seconds, saved in background
|
||||
{:else}
|
||||
<strong class="text-(--color-text)">Generate</strong> — wait for full audio before playing
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
audioStore.audioMode = audioStore.audioMode === 'stream' ? 'generate' : 'stream';
|
||||
}}
|
||||
disabled={audioStore.voice.startsWith('cfai:')}
|
||||
class={cn(
|
||||
'shrink-0 relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-(--color-brand) focus:ring-offset-2 focus:ring-offset-(--color-surface)',
|
||||
audioStore.voice.startsWith('cfai:')
|
||||
? 'opacity-40 cursor-not-allowed bg-(--color-surface-3) border border-(--color-border)'
|
||||
: audioStore.audioMode === 'stream'
|
||||
? 'bg-(--color-brand)'
|
||||
: 'bg-(--color-surface-3) border border-(--color-border)'
|
||||
)}
|
||||
title={audioStore.voice.startsWith('cfai:') ? 'CF AI voices always use generate mode' : undefined}
|
||||
>
|
||||
<span class={cn(
|
||||
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
|
||||
audioStore.audioMode === 'stream' && !audioStore.voice.startsWith('cfai:') ? 'translate-x-6' : 'translate-x-1'
|
||||
)}></span>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={autoNext}
|
||||
aria-label="Auto-advance to next chapter"
|
||||
onclick={() => (autoNext = !autoNext)}
|
||||
class="shrink-0 relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-(--color-brand) focus:ring-offset-2 focus:ring-offset-(--color-surface) {autoNext ? 'bg-(--color-brand)' : 'bg-(--color-surface-3) border border-(--color-border)'}"
|
||||
>
|
||||
<span class="inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform {autoNext ? 'translate-x-6' : 'translate-x-1'}"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<!-- ── Active sessions ──────────────────────────────────────────────────────── -->
|
||||
<!-- ── Active sessions ───────────────────────────────────────────────────── -->
|
||||
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6 space-y-4">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-(--color-text)">{m.profile_sessions_heading()}</h2>
|
||||
@@ -585,7 +754,12 @@
|
||||
{:else}
|
||||
<ul class="space-y-2">
|
||||
{#each sessions as session (session.id)}
|
||||
<li class="flex items-start justify-between gap-3 rounded-lg px-4 py-3 {session.is_current ? 'bg-(--color-brand)/10 border border-(--color-brand)/30' : 'bg-(--color-surface-3)/50 border border-(--color-border)/50'}">
|
||||
<li class={cn(
|
||||
'flex items-start justify-between gap-3 rounded-lg px-4 py-3',
|
||||
session.is_current
|
||||
? 'bg-(--color-brand)/10 border border-(--color-brand)/30'
|
||||
: 'bg-(--color-surface-3)/50 border border-(--color-border)/50'
|
||||
)}>
|
||||
<div class="min-w-0 space-y-0.5">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="text-sm font-medium text-(--color-text) truncate">{parseUA(session.user_agent)}</span>
|
||||
@@ -606,10 +780,12 @@
|
||||
<button
|
||||
onclick={() => revokeSession(session)}
|
||||
disabled={revokingId === session.id}
|
||||
class="shrink-0 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-50
|
||||
{session.is_current
|
||||
class={cn(
|
||||
'shrink-0 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-50',
|
||||
session.is_current
|
||||
? 'bg-(--color-danger)/10 text-(--color-danger) border border-(--color-danger)/60 hover:bg-(--color-danger)/20'
|
||||
: 'bg-(--color-surface-3) text-(--color-text) border border-(--color-border) hover:bg-(--color-surface-2)'}"
|
||||
: 'bg-(--color-surface-3) text-(--color-text) border border-(--color-border) hover:bg-(--color-surface-2)'
|
||||
)}
|
||||
>
|
||||
{revokingId === session.id ? '…' : session.is_current ? m.profile_session_sign_out() : m.profile_session_end()}
|
||||
</button>
|
||||
@@ -618,7 +794,74 @@
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- ── Danger zone ───────────────────────────────────────────────────────── -->
|
||||
<section class="rounded-xl border border-red-500/30 bg-red-500/5 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { deleteConfirmOpen = !deleteConfirmOpen; deleteConfirmText = ''; deleteError = ''; }}
|
||||
class="w-full flex items-center justify-between px-6 py-4 text-left hover:bg-red-500/5 transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-red-400">Danger zone</p>
|
||||
<p class="text-xs text-(--color-muted) mt-0.5">Irreversible actions — proceed with care</p>
|
||||
</div>
|
||||
<svg
|
||||
class={cn('w-4 h-4 text-(--color-muted) transition-transform', deleteConfirmOpen && 'rotate-180')}
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if deleteConfirmOpen}
|
||||
<div class="px-6 pb-6 space-y-4 border-t border-red-500/20">
|
||||
<div class="pt-4">
|
||||
<p class="text-sm font-medium text-(--color-text)">Delete account</p>
|
||||
<p class="text-xs text-(--color-muted) mt-1">
|
||||
This permanently deletes your account, reading history, settings, and all associated data. This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label for="delete-confirm" class="text-xs text-(--color-muted)">
|
||||
Type <strong class="text-(--color-text) font-mono">{DELETE_KEYWORD}</strong> to confirm
|
||||
</label>
|
||||
<input
|
||||
id="delete-confirm"
|
||||
type="text"
|
||||
bind:value={deleteConfirmText}
|
||||
placeholder={DELETE_KEYWORD}
|
||||
autocomplete="off"
|
||||
class="w-full bg-(--color-surface-3) border border-red-500/40 rounded-lg px-3 py-2 text-sm text-(--color-text) placeholder:text-(--color-border) focus:outline-none focus:ring-2 focus:ring-red-500/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if deleteError}
|
||||
<p class="text-sm text-red-400">{deleteError}</p>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={deleteAccount}
|
||||
disabled={!deleteReady || deleting}
|
||||
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-red-500/10 text-red-400 border border-red-500/40 text-sm font-semibold transition-colors hover:bg-red-500/20 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{#if deleting}
|
||||
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/></svg>
|
||||
Deleting…
|
||||
{:else}
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||
</svg>
|
||||
Delete my account
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{/if} <!-- end profile tab -->
|
||||
|
||||
{#if activeTab === 'stats'}
|
||||
<div class="space-y-4">
|
||||
@@ -629,9 +872,9 @@
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{#each [
|
||||
{ label: 'Chapters Read', value: data.stats.totalChaptersRead, icon: '📖' },
|
||||
{ label: 'Completed', value: data.stats.booksCompleted, icon: '✅' },
|
||||
{ label: 'Reading', value: data.stats.booksReading, icon: '📚' },
|
||||
{ label: 'Plan to Read', value: data.stats.booksPlanToRead, icon: '🔖' },
|
||||
{ label: 'Completed', value: data.stats.booksCompleted, icon: '✅' },
|
||||
{ label: 'Reading', value: data.stats.booksReading, icon: '📚' },
|
||||
{ label: 'Plan to Read', value: data.stats.booksPlanToRead, icon: '🔖' },
|
||||
] as stat}
|
||||
<div class="bg-(--color-surface-3) rounded-lg p-3 text-center">
|
||||
<p class="text-2xl font-bold text-(--color-text) tabular-nums">{stat.value}</p>
|
||||
@@ -670,8 +913,12 @@
|
||||
<h2 class="text-sm font-semibold text-(--color-muted) uppercase tracking-wider mb-3">Favourite Genres</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each data.stats.topGenres as genre, i}
|
||||
<span class="flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium
|
||||
{i === 0 ? 'bg-(--color-brand)/20 text-(--color-brand) border border-(--color-brand)/30' : 'bg-(--color-surface-3) text-(--color-text) border border-(--color-border)'}">
|
||||
<span class={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium',
|
||||
i === 0
|
||||
? 'bg-(--color-brand)/20 text-(--color-brand) border border-(--color-brand)/30'
|
||||
: 'bg-(--color-surface-3) text-(--color-text) border border-(--color-border)'
|
||||
)}>
|
||||
{#if i === 0}<span class="text-xs">🏆</span>{/if}
|
||||
{genre}
|
||||
</span>
|
||||
@@ -680,7 +927,6 @@
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Dropped books (only if any) -->
|
||||
{#if data.stats.booksDropped > 0}
|
||||
<p class="text-xs text-(--color-muted) text-center">
|
||||
{data.stats.booksDropped} dropped book{data.stats.booksDropped !== 1 ? 's' : ''} —
|
||||
@@ -706,7 +952,6 @@
|
||||
href="/books/{item.slug}/chapters/{item.chapter}"
|
||||
class="flex items-center gap-3 px-4 py-3 bg-(--color-surface-2) rounded-xl border border-(--color-border) hover:border-zinc-500 transition-colors group"
|
||||
>
|
||||
<!-- Cover thumbnail -->
|
||||
<div class="w-8 h-11 rounded overflow-hidden bg-(--color-surface-3) flex-shrink-0">
|
||||
{#if item.cover}
|
||||
<img src={item.cover} alt={item.title} class="w-full h-full object-cover" loading="lazy" />
|
||||
@@ -718,14 +963,10 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Title + chapter -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-(--color-text) truncate group-hover:text-(--color-brand) transition-colors">{item.title}</p>
|
||||
<p class="text-xs text-(--color-muted) mt-0.5">Chapter {item.chapter}</p>
|
||||
</div>
|
||||
|
||||
<!-- Relative time -->
|
||||
<p class="text-xs text-(--color-muted) shrink-0 tabular-nums">
|
||||
{#if item.updated}
|
||||
{(() => {
|
||||
|
||||
Reference in New Issue
Block a user