Compare commits
317
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67209e38e0 | ||
|
|
35b8b9e44e | ||
|
|
be704c3a06 | ||
|
|
dcce156d4a | ||
|
|
dcb8ce8afe | ||
|
|
93eb23d2ec | ||
|
|
20b75732ac | ||
|
|
c47c3dc621 | ||
|
|
84704a073c | ||
|
|
e2865c070b | ||
|
|
fd6797d907 | ||
|
|
fede2a986f | ||
|
|
c2a33c7e35 | ||
|
|
0506336625 | ||
|
|
19a0e62f0d | ||
|
|
af575d7470 | ||
|
|
3b3e9ed7b9 | ||
|
|
d9ddfdb879 | ||
|
|
3c0fd5f897 | ||
|
|
d2c8be9162 | ||
|
|
9a1b4ca3d7 | ||
|
|
4eb8c06023 | ||
|
|
dd54e90894 | ||
|
|
abb080c6ed | ||
|
|
f8b7cb0605 | ||
|
|
ed1ab977b3 | ||
|
|
2fe7c8683c | ||
|
|
7d0154852d | ||
|
|
e70bccceb7 | ||
|
|
615d35c131 | ||
|
|
bbc865ce53 | ||
|
|
7649b30f77 | ||
|
|
088250f6ea | ||
|
|
ea049c3d1f | ||
|
|
0b8b67f6c4 | ||
|
|
bb620a16ab | ||
|
|
f9c5c13a1a | ||
|
|
06d11fa062 | ||
|
|
addeb47807 | ||
|
|
e7d7fd87d6 | ||
|
|
84f027aa07 | ||
|
|
afa6025a2a | ||
|
|
6a5f61dd39 | ||
|
|
0b96771964 | ||
|
|
497fd6ee71 | ||
|
|
bde9d563ab | ||
|
|
f25cbb27aa | ||
|
|
bf9746ce32 | ||
|
|
1fa00b784f | ||
|
|
4d3f366dd2 | ||
|
|
d5791ed541 | ||
|
|
0a52b47e4e | ||
|
|
418b7347ca | ||
|
|
75b4e1c72f | ||
|
|
6e817706f1 | ||
|
|
404f782ee2 | ||
|
|
ab7c081ecc | ||
|
|
01f828a11a | ||
|
|
a79eb85ca0 | ||
|
|
5711771c9e | ||
|
|
cdbfcbe9af | ||
|
|
ea92eb6c69 | ||
|
|
f7866a78d5 | ||
|
|
59cfb01e71 | ||
|
|
88f0228968 | ||
|
|
4be3f498cf | ||
|
|
ac4a44c62c | ||
|
|
a724ac9c55 | ||
|
|
abca92eec5 | ||
|
|
30f59accd7 | ||
|
|
c24df8b3bd | ||
|
|
1c823aa507 | ||
|
|
c7079000a8 | ||
|
|
9ce999a61d | ||
|
|
a638494540 | ||
|
|
b5b3cccc18 | ||
|
|
2e9435d69e | ||
|
|
f4fe1f7d94 | ||
|
|
1094ecb523 | ||
|
|
4f80010de5 | ||
|
|
229379e0a6 | ||
|
|
954d26360f | ||
|
|
09f202f9f7 | ||
|
|
833ce25178 | ||
|
|
a047519230 | ||
|
|
7c65c7b4dc | ||
|
|
98ed20444a | ||
|
|
e59343968d | ||
|
|
0add71fbd8 | ||
|
|
8d5e0fec85 | ||
|
|
423e2c02ae | ||
|
|
58fbc8f23c | ||
|
|
45995f659c | ||
|
|
1756f54e21 | ||
|
|
5150fec3e3 | ||
|
|
bf880b292b | ||
|
|
db22615874 | ||
|
|
cfbd95e31c | ||
|
|
70e8190348 | ||
|
|
9626fd8f13 | ||
|
|
0eb87fa0f5 | ||
|
|
fadd8ff456 | ||
|
|
a17529fd6f | ||
|
|
6f716d3404 | ||
|
|
7958f0bbf0 | ||
|
|
52dd5dff7f | ||
|
|
e64b130bd3 | ||
|
|
ed4cebf3ba | ||
|
|
39ba284c9d | ||
|
|
c7f65bf351 | ||
|
|
54b20f57d7 | ||
|
|
35a0db7d29 | ||
|
|
ef78a1c5da | ||
|
|
8b70357703 | ||
|
|
0bd4f8cb3b | ||
|
|
c5f21cb10d | ||
|
|
1a1282db0c | ||
|
|
79c749b9a7 | ||
|
|
46540dfec3 | ||
|
|
162e0cc449 | ||
|
|
7f8dc95eca | ||
|
|
2933e0581f | ||
|
|
074d106402 | ||
|
|
b246f271cb | ||
|
|
0d0d7bab1e | ||
|
|
ce9830cd02 | ||
|
|
75b476d124 | ||
|
|
2fe2c918f4 | ||
|
|
adbdd86ac5 | ||
|
|
b9a9613fd0 | ||
|
|
cb6272ef25 | ||
|
|
89f005dfa8 | ||
|
|
2c714e4ef2 | ||
|
|
66a4146fc2 | ||
|
|
b7439884ec | ||
|
|
7c64c06e3d | ||
|
|
2fa30379ad | ||
|
|
5855cebe75 | ||
|
|
cf4e080bf4 | ||
|
|
339ed20074 | ||
|
|
9a240edb17 | ||
|
|
9e29e7b330 | ||
|
|
d3626fca00 | ||
|
|
ed8f63c886 | ||
|
|
65ea9b651c | ||
|
|
3704b262d6 | ||
|
|
f5b3cba3cb | ||
|
|
96bae2f346 | ||
|
|
978e1b4954 | ||
|
|
c4e67effc3 | ||
|
|
6b1554a86c | ||
|
|
480ca44306 | ||
|
|
ecd85f9ca1 | ||
|
|
f9bb94a299 | ||
|
|
eb6024573e | ||
|
|
c469550fa0 | ||
|
|
3353456d46 | ||
|
|
826f0d0b9a | ||
|
|
0b3723c7f2 | ||
|
|
cdb5b70a8d | ||
|
|
4fea42488a | ||
|
|
dc194c4e54 | ||
|
|
93b835e1fd | ||
|
|
6cebbb3cd9 | ||
|
|
f859cbfd7a | ||
|
|
e9847a4424 | ||
|
|
6560dfc470 | ||
|
|
134ebf399e | ||
|
|
a119b60ca6 | ||
|
|
1f729c4764 | ||
|
|
cade7f37aa | ||
|
|
57bcae38e9 | ||
|
|
aa1a0bd38b | ||
|
|
3c338e855a | ||
|
|
f105361658 | ||
|
|
c98f828c25 | ||
|
|
4455c2cd40 | ||
|
|
9d03ad0b37 | ||
|
|
38723c8b81 | ||
|
|
3c3ef82ae8 | ||
|
|
ff5f399e2c | ||
|
|
7d70bf0cf4 | ||
|
|
94a588c7a3 | ||
|
|
ca4cd230a4 | ||
|
|
1a74bdbb6a | ||
|
|
cc5fee0956 | ||
|
|
7992470cc3 | ||
|
|
ba4fd9d9fd | ||
|
|
c216899c2f | ||
|
|
c61381dc79 | ||
|
|
834f3e5b84 | ||
|
|
9ac181dbf4 | ||
|
|
d34e292e54 | ||
|
|
b10ae3eabc | ||
|
|
62563f5c80 | ||
|
|
9fceae63fc | ||
|
|
a4c394b827 | ||
|
|
234869d41e | ||
|
|
c30b38d220 | ||
|
|
ee3a6d1776 | ||
|
|
a1a6c6cfac | ||
|
|
772569cefe | ||
|
|
0d651cc947 | ||
|
|
c1f12d1b1b | ||
|
|
d4520677cc | ||
|
|
518cc7eac7 | ||
|
|
fdfa1c16b8 | ||
|
|
5a366ec42b | ||
|
|
be06b32c82 | ||
|
|
3f1254d374 | ||
|
|
c19f0d6c6f | ||
|
|
481a577937 | ||
|
|
9f62c6977c | ||
|
|
311fbd05dd | ||
|
|
fa839b6e37 | ||
|
|
edb7051508 | ||
|
|
7406023c93 | ||
|
|
f4ea8a6d75 | ||
|
|
db34df0a35 | ||
|
|
f4ba5faddc | ||
|
|
aab0a804b9 | ||
|
|
aff528358e | ||
|
|
8ff06ed008 | ||
|
|
a6da4a4a98 | ||
|
|
cc7cd5cd39 | ||
|
|
e074925327 | ||
|
|
30b9194e43 | ||
|
|
7ce164fba9 | ||
|
|
b26e8b753c | ||
|
|
8fee2370f8 | ||
|
|
387a79962f | ||
|
|
c99406e360 | ||
|
|
690db33147 | ||
|
|
1d20deb51f | ||
|
|
1054b96e46 | ||
|
|
808f265fab | ||
|
|
0f3f548952 | ||
|
|
e4814dea76 | ||
|
|
87986d88d0 | ||
|
|
f08ae2572d | ||
|
|
d27b177817 | ||
|
|
0b01c2c413 | ||
|
|
bfe73a4dc9 | ||
|
|
01b0926748 | ||
|
|
d9e6059079 | ||
|
|
b4d4ad218a | ||
|
|
3a23798094 | ||
|
|
a3397be489 | ||
|
|
a720467d3b | ||
|
|
cba9e7a65a | ||
|
|
e8dda26b01 | ||
|
|
578100f5ef | ||
|
|
deb8305a1d | ||
|
|
0788e7c211 | ||
|
|
74dd8bc6fc | ||
|
|
3875e36ecf | ||
|
|
7641c016d8 | ||
|
|
8f580a420c | ||
|
|
65fe0a7f51 | ||
|
|
932f3c3908 | ||
|
|
ee54942b01 | ||
|
|
3c28e9b7f0 | ||
|
|
ea13ecb998 | ||
|
|
c152298ad3 | ||
|
|
0b6070c6e1 | ||
|
|
9f48ae2d8b | ||
|
|
353a7fd61d | ||
|
|
2e550c3d35 | ||
|
|
60959f5812 | ||
|
|
ed8b992c80 | ||
|
|
b784f86249 | ||
|
|
d637ae7dbf | ||
|
|
1a72e59254 | ||
|
|
99f10fb2d4 | ||
|
|
5ba7ecdca7 | ||
|
|
c95c40c3fe | ||
|
|
24d48c5e6e | ||
|
|
e2e2b632ff | ||
|
|
2728d2f50a | ||
|
|
f969da014d | ||
|
|
107a0d3c85 | ||
|
|
44feb2f08a | ||
|
|
86afa748de | ||
|
|
ef1c6accb6 | ||
|
|
291e7a382b | ||
|
|
fd39824c0e | ||
|
|
77c8309a4c | ||
|
|
ac1e0ad6a3 | ||
|
|
7926d25c79 | ||
|
|
09b97c0d32 | ||
|
|
bcc28795e4 | ||
|
|
c5e2770bab | ||
|
|
eda8b7938d | ||
|
|
b3cdf438f9 | ||
|
|
4d51608118 | ||
|
|
e97286da01 | ||
|
|
4f0ecbe3c0 | ||
|
|
5fef177e36 | ||
|
|
e1f711dea7 | ||
|
|
08ba09c955 | ||
|
|
f2a6b1081e | ||
|
|
9fc70631a8 | ||
|
|
c205da8b2b | ||
|
|
8f818ac0c9 | ||
|
|
a639cf60de | ||
|
|
b2fae0aa70 | ||
|
|
c3cf485cd9 | ||
|
|
4a5ca0b9eb | ||
|
|
03f97e63d6 | ||
|
|
f73febbd55 | ||
|
|
6ceb75e717 | ||
|
|
bf0aefa050 | ||
|
|
4f46580210 | ||
|
|
77510cd12a | ||
|
|
a75d232ceb | ||
|
|
dae57776fc | ||
|
|
e4485c9eb6 |
@@ -1,6 +1,12 @@
|
||||
# Silo Docker deployment
|
||||
# For the default docker compose setup, MEDIA_ROOT is the main value you need to set.
|
||||
|
||||
# Optional: layer compose files through env. Leave unset for CPU-only defaults.
|
||||
# Linux/macOS example for enabling NVIDIA override:
|
||||
# COMPOSE_FILE=docker-compose.yml:docker-compose.nvidia.yml
|
||||
# Windows example:
|
||||
# COMPOSE_FILE=docker-compose.yml;docker-compose.nvidia.yml
|
||||
|
||||
# Required for docker compose: host path to your media library.
|
||||
# Example: MEDIA_ROOT=/srv/media
|
||||
MEDIA_ROOT=/srv/media
|
||||
@@ -40,6 +46,19 @@ POSTGRES_SHM_SIZE=8gb
|
||||
# PROXY_PORT=8083
|
||||
# TRANSCODE_PORT=8082
|
||||
|
||||
# Optional NVIDIA override controls (used by docker-compose.nvidia.yml).
|
||||
# NVIDIA_GPU_COUNT=1
|
||||
|
||||
# REQUIRED: master key for at-rest credential encryption. Silo refuses to start
|
||||
# without it. Integration API keys, S3 keys, and other server-owned secrets are
|
||||
# encrypted under a key derived from this value, so it must be at least 32
|
||||
# characters and kept secret. Generate one with:
|
||||
# openssl rand -base64 48
|
||||
# Back it up SEPARATELY from your database dumps (treat it like a CA private
|
||||
# key): losing it makes every encrypted secret unrecoverable. See
|
||||
# docs/architecture/secret-encryption.md.
|
||||
# SECRET_KEY=replace-with-output-of-openssl-rand-base64-48
|
||||
|
||||
# Run from source / advanced overrides
|
||||
# Only DATABASE_URL is required when running Silo outside the default docker compose stack.
|
||||
# DATABASE_URL=postgres://silo:password@localhost:5432/silo
|
||||
|
||||
+3
-1
@@ -3,6 +3,7 @@ FROM node:22-slim AS frontend
|
||||
RUN corepack enable && corepack prepare pnpm@10.32.1 --activate
|
||||
WORKDIR /app/web
|
||||
COPY web/package.json web/pnpm-lock.yaml ./
|
||||
COPY web/vendor/foliate-js ./vendor/foliate-js
|
||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
COPY web/ .
|
||||
@@ -22,6 +23,7 @@ ENV GONOSUMDB=github.com/Silo-Server/*
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends libvips-dev && rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
COPY internal/compat/zishang520-webtransport-go/ internal/compat/zishang520-webtransport-go/
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
--mount=type=cache,target=/go/pkg/mod \
|
||||
go mod download
|
||||
@@ -54,7 +56,7 @@ RUN apt-get update && \
|
||||
RUN mkdir -p /tmp/silo-transcode
|
||||
COPY --from=build /silo /usr/local/bin/silo
|
||||
COPY third_party/jellyfin-web/ /srv/jellyfin-web/
|
||||
EXPOSE 8080 8096
|
||||
EXPOSE 8080 8096 13378
|
||||
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD curl -f http://localhost:${PORT:-8080}/api/v1/health || exit 1
|
||||
ENTRYPOINT ["silo"]
|
||||
|
||||
@@ -5,6 +5,7 @@ FROM node:22-slim AS frontend
|
||||
RUN corepack enable && corepack prepare pnpm@10.32.1 --activate
|
||||
WORKDIR /app/web
|
||||
COPY web/package.json web/pnpm-lock.yaml ./
|
||||
COPY web/vendor/foliate-js ./vendor/foliate-js
|
||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
COPY web/ .
|
||||
@@ -19,6 +20,7 @@ ENV GONOSUMDB=github.com/Silo-Server/*
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends libvips-dev && rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
COPY internal/compat/zishang520-webtransport-go/ internal/compat/zishang520-webtransport-go/
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
--mount=type=cache,target=/go/pkg/mod \
|
||||
go mod download
|
||||
|
||||
@@ -34,6 +34,29 @@ The easiest way to run Silo is with Docker Compose. The default stack assumes yo
|
||||
|
||||
If you already have PostgreSQL and Redis available, omit those bundled service examples from compose and point Silo at your existing `DATABASE_URL` and `REDIS_URL` instead.
|
||||
|
||||
### Optional NVIDIA/NVENC
|
||||
|
||||
GPU support is kept out of the default compose file so hosts without NVIDIA drivers work unchanged.
|
||||
|
||||
Install the NVIDIA Container Toolkit and use a Docker Compose version with GPU reservation support before enabling this override.
|
||||
|
||||
Use the optional override file when you want NVENC:
|
||||
|
||||
```sh
|
||||
docker compose -f docker-compose.yml -f docker-compose.nvidia.yml up -d
|
||||
```
|
||||
|
||||
If you want this controlled from `.env`, set `COMPOSE_FILE`:
|
||||
|
||||
```dotenv
|
||||
COMPOSE_FILE=docker-compose.yml:docker-compose.nvidia.yml
|
||||
NVIDIA_GPU_COUNT=1
|
||||
```
|
||||
|
||||
Windows uses `;` instead of `:` between compose files.
|
||||
|
||||
Then `docker compose up -d` will include the NVIDIA override automatically.
|
||||
|
||||
4. **Configure through the admin UI**
|
||||
|
||||
Add libraries, users, metadata providers, and playback settings from the web interface.
|
||||
|
||||
+360
-88
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
@@ -18,9 +17,12 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/google/uuid"
|
||||
"github.com/hashicorp/go-hclog"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
@@ -32,9 +34,10 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/access"
|
||||
"github.com/Silo-Server/silo-server/internal/activitylog"
|
||||
"github.com/Silo-Server/silo-server/internal/adminjob"
|
||||
"github.com/Silo-Server/silo-server/internal/audiobooks"
|
||||
"github.com/Silo-Server/silo-server/internal/api"
|
||||
"github.com/Silo-Server/silo-server/internal/api/handlers"
|
||||
"github.com/Silo-Server/silo-server/internal/audiobooks"
|
||||
"github.com/Silo-Server/silo-server/internal/audiobooks/podcastfeed"
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
"github.com/Silo-Server/silo-server/internal/autoscan"
|
||||
"github.com/Silo-Server/silo-server/internal/cache"
|
||||
@@ -44,6 +47,7 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/clientip"
|
||||
"github.com/Silo-Server/silo-server/internal/config"
|
||||
"github.com/Silo-Server/silo-server/internal/database"
|
||||
"github.com/Silo-Server/silo-server/internal/ebooks"
|
||||
evt "github.com/Silo-Server/silo-server/internal/events"
|
||||
"github.com/Silo-Server/silo-server/internal/historyimport"
|
||||
"github.com/Silo-Server/silo-server/internal/imagecache"
|
||||
@@ -65,15 +69,16 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/playback"
|
||||
"github.com/Silo-Server/silo-server/internal/pluginhost"
|
||||
"github.com/Silo-Server/silo-server/internal/plugins"
|
||||
"github.com/Silo-Server/silo-server/internal/presence"
|
||||
"github.com/Silo-Server/silo-server/internal/proxy"
|
||||
"github.com/Silo-Server/silo-server/internal/push"
|
||||
"github.com/Silo-Server/silo-server/internal/ratelimit"
|
||||
"github.com/Silo-Server/silo-server/internal/recommendations"
|
||||
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
|
||||
"github.com/Silo-Server/silo-server/internal/requests/radarr"
|
||||
"github.com/Silo-Server/silo-server/internal/requests/sonarr"
|
||||
"github.com/Silo-Server/silo-server/internal/s3client"
|
||||
"github.com/Silo-Server/silo-server/internal/scanner"
|
||||
"github.com/Silo-Server/silo-server/internal/scanqueue"
|
||||
"github.com/Silo-Server/silo-server/internal/secret"
|
||||
"github.com/Silo-Server/silo-server/internal/sections"
|
||||
"github.com/Silo-Server/silo-server/internal/server"
|
||||
"github.com/Silo-Server/silo-server/internal/subtitles"
|
||||
@@ -117,7 +122,7 @@ func resolvePluginCacheDir() string {
|
||||
return filepath.Join(os.TempDir(), "silo-plugins")
|
||||
}
|
||||
|
||||
func buildBaseHandler(format string, level slog.Level) slog.Handler {
|
||||
func buildBaseHandler(format string, level slog.Leveler) slog.Handler {
|
||||
opts := &slog.HandlerOptions{Level: level}
|
||||
if strings.EqualFold(format, "json") {
|
||||
return slog.NewJSONHandler(os.Stderr, opts)
|
||||
@@ -125,6 +130,19 @@ func buildBaseHandler(format string, level slog.Level) slog.Handler {
|
||||
return slog.NewTextHandler(os.Stderr, opts)
|
||||
}
|
||||
|
||||
func parseLogLevel(level string) slog.Level {
|
||||
switch strings.ToLower(level) {
|
||||
case "debug":
|
||||
return slog.LevelDebug
|
||||
case "warn", "warning":
|
||||
return slog.LevelWarn
|
||||
case "error":
|
||||
return slog.LevelError
|
||||
default:
|
||||
return slog.LevelInfo
|
||||
}
|
||||
}
|
||||
|
||||
func mustGetSetting(store interface {
|
||||
Get(context.Context, string) (string, error)
|
||||
}, ctx context.Context, key, fallback string) string {
|
||||
@@ -138,11 +156,10 @@ func mustGetSetting(store interface {
|
||||
func configureOperationalLogging(
|
||||
ctx context.Context,
|
||||
pool *pgxpool.Pool,
|
||||
settingsRepo *catalog.ServerSettingsRepo,
|
||||
settingsRepo catalog.SettingsStore,
|
||||
redisCfg config.RedisConfig,
|
||||
logStreamHub *logstream.Hub,
|
||||
baseHandler slog.Handler,
|
||||
logQuiet string,
|
||||
filteredHandler slog.Handler,
|
||||
nodeID string,
|
||||
) (opslog.Writer, *opslog.Repo, *partman.Manager) {
|
||||
if err := opslog.SeedDefaults(ctx, settingsRepo); err != nil {
|
||||
@@ -179,7 +196,6 @@ func configureOperationalLogging(
|
||||
opsCaptureLevel = slog.LevelError
|
||||
}
|
||||
|
||||
filteredHandler := logfilter.New(baseHandler, logQuiet)
|
||||
slog.SetDefault(slog.New(opslog.NewHandler(filteredHandler, operationalWriter, opsCaptureLevel, nodeID)))
|
||||
|
||||
return operationalWriter, opslog.NewRepo(pool), opsPM
|
||||
@@ -249,6 +265,38 @@ func maybeApplyPostgresTuning(ctx context.Context, pool *pgxpool.Pool, appMaxCon
|
||||
}
|
||||
}
|
||||
|
||||
// runCredentialBackfills sweeps any plaintext server-owned credential to
|
||||
// ciphertext on the primary (migration-running) node. All passes are
|
||||
// best-effort: a failed row leaves the prior plaintext (no new exposure) and
|
||||
// still reads via the read-path pass-through, so a backfill error must never
|
||||
// block boot. The sensitive-settings pass runs first so the arr
|
||||
// resolve-then-encrypt pass sees consistent referenced settings.
|
||||
func runCredentialBackfills(ctx context.Context, pool *pgxpool.Pool, cipher *secret.Cipher, settings *catalog.EncryptedSettingsRepo) {
|
||||
settingsN, err := settings.BackfillSensitiveSettings(ctx)
|
||||
if err != nil {
|
||||
slog.Error("secret backfill: sensitive settings", "error", err)
|
||||
}
|
||||
columnsN, err := secret.BackfillColumns(ctx, pool, cipher, secret.ColumnBackfillTargets())
|
||||
if err != nil {
|
||||
slog.Error("secret backfill: credential columns", "error", err)
|
||||
}
|
||||
historyServersN, err := historyimport.NewRepository(pool, cipher).BackfillSessionServerSecrets(ctx)
|
||||
if err != nil {
|
||||
slog.Error("secret backfill: history import session server credentials", "error", err)
|
||||
}
|
||||
// The arr resolver is the encrypting settings decorator: it decrypts a
|
||||
// sensitive target (e.g. requests.radarr.api_key) or passes through a
|
||||
// plaintext custom key, exactly replicating the deleted resolveAPIKey.
|
||||
arrN, err := secret.BackfillReferencedColumns(ctx, pool, cipher, settings.Get, secret.ArrKeyBackfillTargets())
|
||||
if err != nil {
|
||||
slog.Error("secret backfill: arr api keys", "error", err)
|
||||
}
|
||||
if total := settingsN + columnsN + historyServersN + arrN; total > 0 {
|
||||
slog.Info("secret backfill: encrypted plaintext credentials at rest",
|
||||
"settings", settingsN, "columns", columnsN, "history_session_servers", historyServersN, "arr_keys", arrN, "total", total)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
envFile := flag.String("env", ".env", "path to .env bootstrap file")
|
||||
migrateOnly := flag.Bool("migrate-only", false, "apply database migrations and exit")
|
||||
@@ -263,6 +311,15 @@ func main() {
|
||||
log.Fatalf("bootstrap: %v", err)
|
||||
}
|
||||
|
||||
// Construct the at-rest credential cipher from SECRET_KEY immediately after
|
||||
// bootstrap, before any settings repo is built. It is threaded explicitly as
|
||||
// a dependency into every repo that stores a server-owned secret — never a
|
||||
// package-level global.
|
||||
dataCipher, err := secret.New(bc.SecretKey)
|
||||
if err != nil {
|
||||
log.Fatalf("secret cipher: %v", err)
|
||||
}
|
||||
|
||||
// Step 2: Connect to PostgreSQL (bootstrap pool with default max connections)
|
||||
bootstrapDBCfg := config.DatabaseConfig{URL: bc.DatabaseURL, MaxConnections: 20}
|
||||
pool, err := database.NewPool(ctx, bootstrapDBCfg)
|
||||
@@ -311,7 +368,11 @@ func main() {
|
||||
// Run migrations only for integrated/api modes. Proxy and transcode nodes
|
||||
// should never alter the schema — they may scale independently and would
|
||||
// race or apply migrations before the primary node is deliberately upgraded.
|
||||
if bc.Mode == "integrated" || bc.Mode == "api" || bc.Mode == "" {
|
||||
// The same gate decides whether this node runs the credential-encryption
|
||||
// backfills: only the primary (migration-running) node sweeps plaintext to
|
||||
// ciphertext; secondary nodes read whatever the primary encrypted.
|
||||
isPrimaryNode := bc.Mode == "integrated" || bc.Mode == "api" || bc.Mode == ""
|
||||
if isPrimaryNode {
|
||||
migCtx, migCancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||
if migErr := database.RunMigrations(migCtx, pool, migrations.FS, "sql"); migErr != nil {
|
||||
migCancel()
|
||||
@@ -321,8 +382,14 @@ func main() {
|
||||
slog.Info("database migrations applied")
|
||||
}
|
||||
|
||||
// Step 3: Load settings from DB
|
||||
settingsRepo := catalog.NewServerSettingsRepo(pool)
|
||||
// Step 3: Load settings from DB. settingsRepo is the encrypting decorator so
|
||||
// every consumer (config.LoadFromDB, admin, ABS, watchers) transparently sees
|
||||
// plaintext while sensitive keys rest as ciphertext. The settings backfill
|
||||
// (run after migrations, before this GetAll) is wired further below.
|
||||
settingsRepo := catalog.NewEncryptedSettingsRepo(catalog.NewServerSettingsRepo(pool), dataCipher)
|
||||
if isPrimaryNode {
|
||||
runCredentialBackfills(ctx, pool, dataCipher, settingsRepo)
|
||||
}
|
||||
settings, err := settingsRepo.GetAll(ctx)
|
||||
if err != nil {
|
||||
log.Fatalf("loading settings: %v", err)
|
||||
@@ -393,7 +460,9 @@ func main() {
|
||||
log.Fatalf("recreating pool with configured max_connections: %v", err)
|
||||
}
|
||||
}
|
||||
settingsRepo = catalog.NewServerSettingsRepo(pool)
|
||||
// Re-wrap with the encrypting decorator so the recreated pool's settings repo
|
||||
// still encrypts/decrypts — no raw settings repo may escape into later wiring.
|
||||
settingsRepo = catalog.NewEncryptedSettingsRepo(catalog.NewServerSettingsRepo(pool), dataCipher)
|
||||
nodeID := resolveNodeIdentity()
|
||||
|
||||
// Step 9: Validate
|
||||
@@ -401,20 +470,14 @@ func main() {
|
||||
log.Fatalf("config validation: %v", err)
|
||||
}
|
||||
|
||||
// Step 10: Configure log level
|
||||
var slogLevel slog.Level
|
||||
switch strings.ToLower(cfg.Server.LogLevel) {
|
||||
case "debug":
|
||||
slogLevel = slog.LevelDebug
|
||||
case "warn", "warning":
|
||||
slogLevel = slog.LevelWarn
|
||||
case "error":
|
||||
slogLevel = slog.LevelError
|
||||
default:
|
||||
slogLevel = slog.LevelInfo
|
||||
}
|
||||
baseHandler := buildBaseHandler(cfg.Server.LogFormat, slogLevel)
|
||||
slog.SetDefault(slog.New(logfilter.New(baseHandler, cfg.Server.LogQuiet)))
|
||||
// Step 10: Configure log level. The level var and quiet filter are
|
||||
// shared with the operational-logging handler chain and hot-reloaded by
|
||||
// the config watcher in integrated mode.
|
||||
logLevelVar := new(slog.LevelVar)
|
||||
logLevelVar.Set(parseLogLevel(cfg.Server.LogLevel))
|
||||
baseHandler := buildBaseHandler(cfg.Server.LogFormat, logLevelVar)
|
||||
quietFilter := logfilter.New(baseHandler, cfg.Server.LogQuiet)
|
||||
slog.SetDefault(slog.New(quietFilter))
|
||||
|
||||
mode := cfg.Server.Mode
|
||||
maybeApplyPostgresTuning(ctx, pool, cfg.Database.MaxConnections, mode)
|
||||
@@ -422,6 +485,8 @@ func main() {
|
||||
|
||||
appCtx, appCancel := context.WithCancel(ctx)
|
||||
defer appCancel()
|
||||
restartReqCh := make(chan struct{}, 1)
|
||||
var restartRequested atomic.Bool
|
||||
|
||||
eventBus := cache.NewEventBus(cfg.Redis.URL)
|
||||
logStreamHub := logstream.NewHub(nodeID, eventBus)
|
||||
@@ -433,8 +498,19 @@ func main() {
|
||||
log.Fatalf("realtime hub start: %v", err)
|
||||
}
|
||||
eventsHub := realtimeHub.EventsHub()
|
||||
var presenceRegistry presence.Registry
|
||||
if cfg.Redis.URL != "" {
|
||||
if rc, err := cache.NewRedisClient(cfg.Redis); err == nil {
|
||||
presenceRegistry = presence.NewRedisRegistry(rc, 60*time.Second)
|
||||
} else {
|
||||
slog.Warn("presence: redis unavailable, falling back to in-memory registry", "error", err)
|
||||
}
|
||||
}
|
||||
if presenceRegistry == nil {
|
||||
presenceRegistry = presence.NewMemoryRegistry()
|
||||
}
|
||||
scanRegistry := evt.NewScanRegistry()
|
||||
operationalWriter, opsRepo, opsPM := configureOperationalLogging(appCtx, pool, settingsRepo, cfg.Redis, logStreamHub, baseHandler, cfg.Server.LogQuiet, nodeID)
|
||||
operationalWriter, opsRepo, opsPM := configureOperationalLogging(appCtx, pool, settingsRepo, cfg.Redis, logStreamHub, quietFilter, nodeID)
|
||||
defer func() {
|
||||
if err := eventBus.Close(); err != nil {
|
||||
slog.Warn("event bus close error", "error", err)
|
||||
@@ -454,8 +530,9 @@ func main() {
|
||||
Mode: cfg.Server.Mode,
|
||||
DatabaseURL: cfg.Database.URL,
|
||||
JFListen: cfg.JellyfinCompat.Listen,
|
||||
RedisURL: bc.RedisURL,
|
||||
}
|
||||
watcher := nodeconfig.NewWatcher(pool, eventBus, bootstrap)
|
||||
watcher := nodeconfig.NewWatcher(pool, dataCipher, eventBus, bootstrap)
|
||||
if err := watcher.Start(appCtx); err != nil {
|
||||
slog.Error("config watcher start failed", "error", err)
|
||||
os.Exit(1)
|
||||
@@ -495,6 +572,29 @@ func main() {
|
||||
return
|
||||
}
|
||||
|
||||
// Hot-reload config watcher for integrated/api mode. Reloads on
|
||||
// EventSettingsChanged (Redis) with a 60s poll fallback, so settings
|
||||
// changes apply without restart even on Redis-less deployments. The
|
||||
// watcher's config supersedes the startup snapshot from here on.
|
||||
configWatcher := nodeconfig.NewWatcher(pool, dataCipher, eventBus, nodeconfig.BootstrapOverrides{
|
||||
Listen: bc.Listen,
|
||||
Mode: bc.Mode,
|
||||
DatabaseURL: bc.DatabaseURL,
|
||||
JFListen: bc.JFListen,
|
||||
RedisURL: bc.RedisURL,
|
||||
})
|
||||
if err := configWatcher.Start(appCtx); err != nil {
|
||||
log.Fatalf("config watcher start: %v", err)
|
||||
}
|
||||
cfg = configWatcher.Config()
|
||||
|
||||
// Apply server.log_level / server.log_quiet changes live. Both feed the
|
||||
// shared level var and quiet filter inside the default logger chain.
|
||||
configWatcher.OnChange(func(_, updated *config.Config) {
|
||||
logLevelVar.Set(parseLogLevel(updated.Server.LogLevel))
|
||||
quietFilter.SetQuiet(updated.Server.LogQuiet)
|
||||
})
|
||||
|
||||
// Determine which components to initialize based on mode.
|
||||
needsS3 := mode == "integrated" || mode == "api"
|
||||
needsScanner := mode == "integrated" || mode == "api"
|
||||
@@ -510,10 +610,13 @@ func main() {
|
||||
|
||||
deps := api.Dependencies{
|
||||
Config: cfg,
|
||||
LiveConfig: configWatcher.Config,
|
||||
OnConfigChange: configWatcher.OnChange,
|
||||
BootstrapSensitiveConfigured: bootstrapSensitiveConfigured,
|
||||
BootstrapSensitiveValues: bootstrapSensitiveValues,
|
||||
AppContext: appCtx,
|
||||
DB: pool,
|
||||
SecretCipher: dataCipher,
|
||||
EventBus: eventBus,
|
||||
LogStreamHub: logStreamHub,
|
||||
RealtimeHub: realtimeHub,
|
||||
@@ -522,6 +625,49 @@ func main() {
|
||||
OpsLogRepo: opsRepo,
|
||||
FFmpegLogSink: playback.NewSlogFFmpegLogSink(slog.Default(), nodeID),
|
||||
PublicURL: os.Getenv("SILO_PUBLIC_URL"),
|
||||
RequestServerRestart: func(context.Context) error {
|
||||
if !restartRequested.CompareAndSwap(false, true) {
|
||||
return handlers.ErrServerRestartAlreadyRequested
|
||||
}
|
||||
restartReqCh <- struct{}{}
|
||||
return nil
|
||||
},
|
||||
OnServerSettingUpdated: func(_ context.Context, _, _ string) {
|
||||
// Nudge the hot-reload watcher so same-process settings changes
|
||||
// apply immediately even without Redis (the event bus is a no-op
|
||||
// then, leaving only the 60s poll).
|
||||
configWatcher.RequestReload()
|
||||
},
|
||||
}
|
||||
// Notifications subsystem: store → service → materializer.
|
||||
// pool is always non-nil here (proxy/transcode returned early above).
|
||||
notificationsStore := notifications.NewRepository(pool)
|
||||
notificationsSvc := notifications.NewService(notificationsStore, eventsHub)
|
||||
notificationsMaterializer := notifications.NewMaterializer(eventsHub, notificationsSvc, notifications.NewContentResolverRepo(pool))
|
||||
if err := notificationsMaterializer.Start(appCtx); err != nil {
|
||||
log.Fatalf("notifications materializer start: %v", err)
|
||||
}
|
||||
deps.NotificationsService = notificationsSvc
|
||||
deps.PresenceRegistry = presenceRegistry
|
||||
|
||||
// Push subsystem: store → config → enqueuer → transports → worker.
|
||||
pushStore := push.NewStore(pool)
|
||||
pushConfig := push.NewConfig(settingsRepo)
|
||||
notificationsSvc.SetPushEnqueuer(push.NewEnqueuer(pushStore, 30*time.Second, time.Now))
|
||||
pushTransports := []push.Transport{
|
||||
push.NewWebPushTransport(pushConfig.WebPush),
|
||||
push.NewAPNsTransport(pushConfig.APNs),
|
||||
push.NewFCMTransport(pushConfig.FCM),
|
||||
}
|
||||
pushWorker := push.NewWorker(push.NewStoreQueue(pushStore), presenceRegistry, pushTransports, time.Now)
|
||||
deps.PushStore = pushStore
|
||||
deps.PushConfig = pushConfig
|
||||
|
||||
audiobooksService := audiobooks.New(&audiobooksSettingsAdapter{repo: settingsRepo})
|
||||
absCompatEnabled, err := audiobooksService.ABSCompatEnabled(appCtx)
|
||||
if err != nil {
|
||||
slog.Warn("Audiobookshelf compatibility disabled; failed to read setting", "err", err)
|
||||
absCompatEnabled = false
|
||||
}
|
||||
adminJobCancelRegistry := adminjob.NewCancelRegistry()
|
||||
deps.AdminJobCancelRegistry = adminJobCancelRegistry
|
||||
@@ -577,7 +723,7 @@ func main() {
|
||||
log.Fatalf("register watch provider: %v", err)
|
||||
}
|
||||
watchProviderService = watchsync.NewService(
|
||||
watchsync.NewPostgresRepository(deps.DB),
|
||||
watchsync.NewPostgresRepository(deps.DB, deps.SecretCipher),
|
||||
watchProviderRegistry,
|
||||
)
|
||||
deps.WatchProviderService = watchProviderService
|
||||
@@ -598,6 +744,7 @@ func main() {
|
||||
|
||||
deps.ProxyPool = proxyPool
|
||||
deps.TranscodePool = transcodePool
|
||||
deps.NodePlanner = nodepool.NewPlanner(proxyPool, transcodePool)
|
||||
|
||||
healthChecker := nodepool.NewHealthChecker(proxyPool, transcodePool, nodeRepo)
|
||||
healthChecker.Start(appCtx)
|
||||
@@ -634,6 +781,9 @@ func main() {
|
||||
|
||||
ffprobePath := scanner.FFprobePathFromFFmpeg(cfg.Playback.FFmpegPath)
|
||||
s := scanner.NewScanner(fileRepo, ffprobePath, deps.S3Public, cfg.Scanner.Workers, cfg.Scanner.EmptyTrashAfterScan)
|
||||
configWatcher.OnChange(func(_, updated *config.Config) {
|
||||
s.SetWorkers(updated.Scanner.Workers)
|
||||
})
|
||||
deps.Scanner = s
|
||||
deps.ProbeEnsurer = scanner.NewPlaybackProbeEnsurer(fileRepo, ffprobePath, 10*time.Second)
|
||||
slog.Info("scanner initialized")
|
||||
@@ -869,6 +1019,8 @@ func main() {
|
||||
var groupClaimRepo *catalog.GroupClaimRepository
|
||||
var seasonRepo *catalog.SeasonRepository
|
||||
var episodeRepo *catalog.EpisodeRepository
|
||||
var audiobookEnricher *audiobooks.Enricher
|
||||
var ebookEnricher *ebooks.Enricher
|
||||
if needsWorkers && deps.DB != nil && deps.FileRepo != nil {
|
||||
chainRepo := metadata.NewChainRepository(deps.DB)
|
||||
skippedRootRepo = metadata.NewSkippedRootRepository(deps.DB)
|
||||
@@ -939,6 +1091,26 @@ func main() {
|
||||
personRefreshService = metadata.NewPersonRefreshService(deps.DB, pluginResolver, personRepo)
|
||||
personRefreshService.SetImageResolver(imageResolver)
|
||||
|
||||
// Wire the audiobook enricher. It uses the same plugin resolver and chain
|
||||
// repo as the movie/TV pipeline, but resolves providers at
|
||||
// content_level='audiobook' and sweeps items directly rather than via a queue.
|
||||
audiobookEnricher = audiobooks.NewEnricher(
|
||||
deps.DB,
|
||||
chainRepo,
|
||||
pluginResolver,
|
||||
itemRepo,
|
||||
personRepo,
|
||||
providerIDRepo,
|
||||
)
|
||||
ebookEnricher = ebooks.NewEnricher(
|
||||
deps.DB,
|
||||
chainRepo,
|
||||
pluginResolver,
|
||||
itemRepo,
|
||||
personRepo,
|
||||
providerIDRepo,
|
||||
)
|
||||
|
||||
// Always wire the image resolver so plugin-prefixed URLs (e.g.
|
||||
// metadb://) can be resolved to presigned HTTP URLs in API responses.
|
||||
metadataService.SetImageResolver(imageResolver)
|
||||
@@ -949,13 +1121,30 @@ func main() {
|
||||
imageCacher := imagecache.New(deps.S3Public)
|
||||
metadataService.SetImageCacher(imageCacher)
|
||||
metadataService.SetAutoCacheImages(cfg.Metadata.CacheImages)
|
||||
configWatcher.OnChange(func(_, updated *config.Config) {
|
||||
metadataService.SetAutoCacheImages(updated.Metadata.CacheImages)
|
||||
})
|
||||
if deps.Scanner != nil {
|
||||
deps.Scanner.SetImageCacher(imageCacher)
|
||||
}
|
||||
if cfg.Metadata.CacheImages {
|
||||
personRefreshService.SetImageCacher(imageCacher)
|
||||
slog.Info("metadata image caching enabled")
|
||||
}
|
||||
if audiobookEnricher != nil {
|
||||
audiobookEnricher.SetImageCacher(imageCacher)
|
||||
audiobookEnricher.SetFFmpegPath(scanner.FFmpegPathFromFFprobe(scanner.FFprobePathFromFFmpeg(cfg.Playback.FFmpegPath)))
|
||||
}
|
||||
if ebookEnricher != nil {
|
||||
ebookEnricher.SetImageCacher(imageCacher)
|
||||
}
|
||||
}
|
||||
|
||||
matchWorker = metadata.NewMatchWorker(metadataService, deps.FileRepo, cfg.Matcher.Workers, cfg.Matcher.BatchSize, 30*time.Second)
|
||||
mwForReload := matchWorker
|
||||
configWatcher.OnChange(func(_, updated *config.Config) {
|
||||
mwForReload.SetConcurrency(updated.Matcher.Workers, updated.Matcher.BatchSize)
|
||||
})
|
||||
matchWorker.SetRealtimeHub(deps.RealtimeHub)
|
||||
if movieQueueRepo != nil {
|
||||
matchWorker.SetMovieFileClaimer(movieQueueRepo)
|
||||
@@ -1142,7 +1331,7 @@ func main() {
|
||||
deps.UserStoreProvider = userStoreProvider
|
||||
}
|
||||
if watchProviderService != nil {
|
||||
historyRepo := historyimport.NewRepository(deps.DB)
|
||||
historyRepo := historyimport.NewRepository(deps.DB, deps.SecretCipher)
|
||||
historyIdentity := watchstate.NewStableIdentityResolver(itemRepo, episodeRepo, catalog.NewProviderIDRepository(deps.DB))
|
||||
watchProviderService.
|
||||
WithMatcher(historyimport.NewMatcher(historyRepo)).
|
||||
@@ -1390,6 +1579,10 @@ func main() {
|
||||
deps.UserCollectionSync = userSync
|
||||
deps.UserCollectionScheduler = userCollectionScheduler
|
||||
deps.MDBListClient = mdblist.NewClient(cfg.MDBListAPIKey, nil)
|
||||
mdblistForReload := deps.MDBListClient
|
||||
configWatcher.OnChange(func(_, updated *config.Config) {
|
||||
mdblistForReload.SetAPIKey(updated.MDBListAPIKey)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1405,6 +1598,7 @@ func main() {
|
||||
if deps.FolderRepo != nil && deps.LibraryScanQueue != nil {
|
||||
taskMgr.Register(tasks.NewScanLibrariesTask(deps.FolderRepo, deps.LibraryScanQueue, deps.EventBus))
|
||||
}
|
||||
taskMgr.Register(tasks.NewCleanupOrphanedMediaItemsTask(catalog.NewOrphanedProvisionalCleaner(deps.DB)))
|
||||
if deps.IntroAnalyzer != nil {
|
||||
taskMgr.Register(tasks.NewDetectIntroMarkersTask(deps.IntroAnalyzer, settingsRepo))
|
||||
}
|
||||
@@ -1440,15 +1634,16 @@ func main() {
|
||||
taskMgr.Register(tasks.NewSyncWatchProvidersTask(watchProviderService))
|
||||
}
|
||||
requestReconcileSvc := mediarequests.NewService(
|
||||
mediarequests.NewRepository(deps.DB),
|
||||
mediarequests.NewRepository(deps.DB, deps.SecretCipher),
|
||||
nil,
|
||||
mediarequests.NewCatalogPresence(
|
||||
catalog.NewItemRepository(deps.DB),
|
||||
catalog.NewProviderIDRepository(deps.DB),
|
||||
),
|
||||
)
|
||||
requestReconcileSvc.SetSecretResolver(settingsRepo)
|
||||
requestReconcileSvc.SetFulfillmentAdapters(radarr.NewClient(nil), sonarr.NewClient(nil))
|
||||
requestReconcileSvc.SetRequesterIdentityResolver(plugins.RequesterIdentityFromLookup(plugins.NewPgUserIdentityLookup(deps.DB)))
|
||||
requestReconcileSvc.SetEventsHub(eventsHub)
|
||||
api.AttachRequestRouter(requestReconcileSvc, pluginService)
|
||||
if userStoreProvider != nil {
|
||||
reconcileResolver := access.NewResolver(
|
||||
auth.NewUserRepository(deps.DB),
|
||||
@@ -1459,13 +1654,15 @@ func main() {
|
||||
}
|
||||
taskMgr.Register(tasks.NewReconcileRequestsTask(requestReconcileSvc, 100))
|
||||
if deps.FolderRepo != nil && deps.LibraryScanQueue != nil && pluginService != nil && pluginInstallationStore != nil {
|
||||
autoscanRepo := autoscan.NewRepository(deps.DB)
|
||||
autoscanRepo := autoscan.NewRepository(deps.DB, deps.SecretCipher)
|
||||
if err := autoscanRepo.MarkInterruptedEvents(appCtx); err != nil {
|
||||
slog.Warn("autoscan: failed to mark interrupted polls", "err", err)
|
||||
}
|
||||
autoscanSvc := api.BuildAutoscanService(
|
||||
autoscanRepo,
|
||||
pluginService,
|
||||
pluginInstallationStore,
|
||||
mediarequests.NewRepository(deps.DB),
|
||||
settingsRepo,
|
||||
mediarequests.NewRepository(deps.DB, deps.SecretCipher),
|
||||
deps.FolderRepo,
|
||||
deps.LibraryScanQueue,
|
||||
deps.RedisClient,
|
||||
@@ -1488,6 +1685,13 @@ func main() {
|
||||
historyReconciler := watchstate.NewHistoryReconciler(deps.DB, historyResolver)
|
||||
taskMgr.Register(tasks.NewRepairProviderIDIntegrityTask(metadata.NewProviderIDIntegrityRepairer(deps.DB), historyReconciler))
|
||||
taskMgr.Register(tasks.NewReconcileWatchHistoryTask(historyReconciler))
|
||||
taskMgr.Register(tasks.NewSyncPodcastFeedsTask(podcastfeed.New(), podcastfeed.NewDBStore(deps.DB)))
|
||||
if audiobookEnricher != nil {
|
||||
taskMgr.Register(tasks.NewSyncAudiobookMetadataTask(audiobookEnricher))
|
||||
}
|
||||
if ebookEnricher != nil {
|
||||
taskMgr.Register(tasks.NewSyncEbookMetadataTask(ebookEnricher))
|
||||
}
|
||||
if pluginInstallationStore != nil && pluginRuntimeConfigStore != nil && pluginService != nil {
|
||||
pluginTasks, err := plugins.NewTaskRegistryWithTypedResolver(pluginInstallationStore, pluginRuntimeConfigStore, pluginService).Tasks(appCtx)
|
||||
if err != nil {
|
||||
@@ -1497,6 +1701,9 @@ func main() {
|
||||
taskMgr.Register(pluginTask)
|
||||
}
|
||||
}
|
||||
taskMgr.Register(tasks.NewNotificationsRetentionTask(notificationsStore, pushStore))
|
||||
taskMgr.Register(tasks.NewNotificationsDigestTask(notificationsSvc))
|
||||
taskMgr.Register(push.NewPushDeliveryTask(pushWorker))
|
||||
|
||||
taskMgr.Start(appCtx)
|
||||
defer taskMgr.Stop()
|
||||
@@ -1504,11 +1711,59 @@ func main() {
|
||||
slog.Info("task manager started")
|
||||
}
|
||||
|
||||
// Scaffold audiobooks service (sub-plan 1: kill-switch reader only).
|
||||
// audiobooksSettingsAdapter (defined at package level below) bridges
|
||||
// catalog.ServerSettingsRepo.Get to audiobooks.SettingsReader.GetString.
|
||||
audiobooksService := audiobooks.New(&audiobooksSettingsAdapter{repo: settingsRepo})
|
||||
_ = audiobooksService // sub-plan 2+ will register routes / scheduled tasks
|
||||
// Build the ABS-compatible REST + Socket.io handler when a DB pool is
|
||||
// available. Routes are mounted at the root level by NewRouter (not under
|
||||
// /api/v1/) so ABS clients resolve /login, /api/*, /abs/api/*, and
|
||||
// /abs/socket.io/* without path prefix hacks.
|
||||
if absCompatEnabled && deps.DB != nil {
|
||||
absUserRepo := auth.NewUserRepository(deps.DB)
|
||||
absSessionRepo := auth.NewSessionRepository(deps.DB)
|
||||
absJWTService := auth.NewJWTService(
|
||||
cfg.Auth.JWTSecret,
|
||||
cfg.Auth.AccessTokenExpiry,
|
||||
cfg.Auth.RefreshTokenExpiry,
|
||||
)
|
||||
configWatcher.OnChange(func(_, updated *config.Config) {
|
||||
absJWTService.SetExpiries(updated.Auth.AccessTokenExpiry, updated.Auth.RefreshTokenExpiry)
|
||||
})
|
||||
absAuthSvc := auth.NewService(
|
||||
auth.NewLocalProvider(absUserRepo, absSessionRepo),
|
||||
absJWTService,
|
||||
absSessionRepo,
|
||||
absUserRepo,
|
||||
nil, // invite codes: not needed for ABS compat
|
||||
nil, // settings: not needed here
|
||||
nil, // user store: not needed here
|
||||
)
|
||||
absItemRepo := catalog.NewItemRepository(deps.DB)
|
||||
absEpisodeRepo := catalog.NewEpisodeRepository(deps.DB)
|
||||
absSeasonRepo := catalog.NewSeasonRepository(deps.DB)
|
||||
absPersonRepo := catalog.NewPersonRepository(deps.DB)
|
||||
var absFileFetcher catalog.FileVersionFetcher
|
||||
if deps.FileRepo != nil {
|
||||
absFileFetcher = deps.FileRepo
|
||||
}
|
||||
absDetailSvc := catalog.NewDetailService(absItemRepo, absEpisodeRepo, absSeasonRepo, absPersonRepo, absFileFetcher)
|
||||
if deps.ImageResolver != nil {
|
||||
absDetailSvc.SetImageResolver(deps.ImageResolver)
|
||||
}
|
||||
absHDeps := audiobooks.ABSHandlerDeps{
|
||||
Pool: deps.DB,
|
||||
Items: absItemRepo,
|
||||
Files: deps.FileRepo,
|
||||
Settings: settingsRepo,
|
||||
Auth: &audiobooks.SiloCredValidator{
|
||||
Auth: absAuthSvc,
|
||||
Pool: deps.DB,
|
||||
},
|
||||
AccessResolver: audiobooks.NewABSAccessResolver(absUserRepo, userStoreProvider),
|
||||
Recs: recommendations.NewRepo(deps.DB),
|
||||
Detail: absDetailSvc,
|
||||
}
|
||||
absH := audiobooksService.BuildABSHandler(absHDeps)
|
||||
deps.ABSHandler = absH
|
||||
}
|
||||
_ = audiobooksService
|
||||
|
||||
if deps.DB != nil && pluginInstallationStore != nil && pluginRuntimeConfigStore != nil && deps.PluginService != nil {
|
||||
userRepo := auth.NewUserRepository(deps.DB)
|
||||
@@ -1631,6 +1886,11 @@ func main() {
|
||||
metricsMux := http.NewServeMux()
|
||||
metricsMux.Handle("/metrics", promhttp.Handler())
|
||||
metricsMux.Handle("/api/", router)
|
||||
// ABS-compat is NOT mounted on the main listener — see the "ABS compat
|
||||
// listener" block below. It binds its own port so the discovery probes
|
||||
// (/ping, /healthcheck, /status, /init, /login, /socket.io) own the URL
|
||||
// space without collision with silo's SPA fallback. Mirrors how the
|
||||
// Jellyfin compat server is set up at :8096.
|
||||
metricsMux.Handle("/", server.FrontendHandler())
|
||||
|
||||
// Step 9: Start background workers (if needed).
|
||||
@@ -1730,10 +1990,11 @@ func main() {
|
||||
if (mode == "integrated" || mode == "api") && cfg.JellyfinCompat.Listen != "" {
|
||||
compatDeps := jellycompat.Dependencies{
|
||||
Config: cfg,
|
||||
LiveConfig: configWatcher.Config,
|
||||
DB: deps.DB,
|
||||
SecretCipher: dataCipher,
|
||||
ClientIPResolver: ipResolver,
|
||||
ProxyPool: deps.ProxyPool,
|
||||
TranscodePool: deps.TranscodePool,
|
||||
NodePlanner: deps.NodePlanner,
|
||||
JWTSecret: cfg.Auth.JWTSecret,
|
||||
RecWorker: recWorker,
|
||||
}
|
||||
@@ -1784,7 +2045,7 @@ func main() {
|
||||
compatDeps.FileResolver = deps.FileRepo
|
||||
}
|
||||
|
||||
compatDeps.SubtitleRepo = subtitles.NewPgRepository(deps.DB)
|
||||
compatDeps.SubtitleRepo = subtitles.NewPgRepository(deps.DB, deps.SecretCipher)
|
||||
|
||||
// Construct auth service for jellycompat login.
|
||||
userRepo := auth.NewUserRepository(deps.DB)
|
||||
@@ -1797,50 +2058,23 @@ func main() {
|
||||
cfg.Auth.AccessTokenExpiry,
|
||||
cfg.Auth.RefreshTokenExpiry,
|
||||
)
|
||||
configWatcher.OnChange(func(_, updated *config.Config) {
|
||||
jwtService.SetExpiries(updated.Auth.AccessTokenExpiry, updated.Auth.RefreshTokenExpiry)
|
||||
})
|
||||
provider := auth.NewLocalProvider(userRepo, sessionRepo)
|
||||
compatDeps.AuthService = auth.NewService(provider, jwtService, sessionRepo, userRepo, nil, nil, nil)
|
||||
|
||||
// Access filter resolver for profile-scoped library access.
|
||||
compatDeps.AccessFilterFn = func(ctx context.Context, userID int, profileID string) catalog.AccessFilter {
|
||||
if userStoreProvider == nil {
|
||||
return catalog.AccessFilter{}
|
||||
}
|
||||
store, err := userStoreProvider.ForUser(ctx, userID)
|
||||
if err != nil {
|
||||
return catalog.AccessFilter{}
|
||||
}
|
||||
profile, err := store.GetProfile(ctx, profileID)
|
||||
if err != nil || profile == nil {
|
||||
return catalog.AccessFilter{}
|
||||
}
|
||||
filter := catalog.AccessFilter{
|
||||
MaxContentRating: profile.MaxContentRating,
|
||||
}
|
||||
if profile.LibraryRestrictionsEnabled && len(profile.AllowedLibraryIDs) > 0 {
|
||||
filter.AllowedLibraryIDs = profile.AllowedLibraryIDs
|
||||
}
|
||||
// Apply user-disabled library IDs.
|
||||
if raw, err := store.GetSetting(ctx, "disabled_library_ids"); err == nil && raw != "" {
|
||||
var disabled []int
|
||||
if json.Unmarshal([]byte(raw), &disabled) == nil && len(disabled) > 0 {
|
||||
if filter.AllowedLibraryIDs != nil {
|
||||
filtered := make([]int, 0, len(filter.AllowedLibraryIDs))
|
||||
disSet := make(map[int]struct{}, len(disabled))
|
||||
for _, id := range disabled {
|
||||
disSet[id] = struct{}{}
|
||||
}
|
||||
for _, id := range filter.AllowedLibraryIDs {
|
||||
if _, ok := disSet[id]; !ok {
|
||||
filtered = append(filtered, id)
|
||||
}
|
||||
}
|
||||
filter.AllowedLibraryIDs = filtered
|
||||
} else {
|
||||
filter.DisabledLibraryIDs = disabled
|
||||
}
|
||||
}
|
||||
}
|
||||
return filter
|
||||
// Access filter resolver for viewer-scoped library access.
|
||||
// Backed by the shared access.Resolver so account-level library
|
||||
// restrictions (users.library_ids), profile restrictions,
|
||||
// user-disabled libraries, and rating/quality ceilings apply to
|
||||
// the compat API exactly as they do to the native API.
|
||||
if userStoreProvider != nil {
|
||||
compatDeps.AccessFilterFn = jellycompat.NewScopeAccessFilter(access.NewResolver(
|
||||
userRepo,
|
||||
userStoreProvider,
|
||||
nil, // profile tokens unused: compat login already verifies PINs
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1853,6 +2087,28 @@ func main() {
|
||||
compatSrv.IdleTimeout = 120 * time.Second
|
||||
}
|
||||
|
||||
// ABS-compat listener — dedicated http.Server bound to its own port
|
||||
// (default :13378) that hosts the Audiobookshelf-compatible API.
|
||||
// Mirrors the Jellyfin compat layout above. The ABS handler mounts
|
||||
// onto a fresh chi router here so /ping, /healthcheck, /status, /login,
|
||||
// /socket.io, etc. own the URL space at the root — no SPA fallback,
|
||||
// no collision with silo's /api/v1.
|
||||
var absSrv *http.Server
|
||||
if (mode == "integrated" || mode == "api") && deps.ABSHandler != nil && cfg.AudiobookshelfCompat.Listen != "" {
|
||||
absRouter := chi.NewRouter()
|
||||
absRouter.Use(chimiddleware.Recoverer)
|
||||
absRouter.Use(chimiddleware.Compress(5))
|
||||
deps.ABSHandler.Mount(absRouter)
|
||||
absSrv = &http.Server{
|
||||
Addr: cfg.AudiobookshelfCompat.Listen,
|
||||
Handler: absRouter,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 60 * time.Second,
|
||||
WriteTimeout: 0,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// Run non-critical startup work in the background so it doesn't delay the
|
||||
// HTTP listener from accepting connections. Steps run sequentially and stop
|
||||
// early if the app context is cancelled (shutdown).
|
||||
@@ -1877,7 +2133,7 @@ func main() {
|
||||
}()
|
||||
}
|
||||
|
||||
errCh := make(chan error, 2)
|
||||
errCh := make(chan error, 3)
|
||||
go func() {
|
||||
slog.Info("HTTP server listening", "addr", cfg.Server.Listen)
|
||||
if listenErr := srv.ListenAndServe(); listenErr != nil && listenErr != http.ErrServerClosed {
|
||||
@@ -1892,6 +2148,14 @@ func main() {
|
||||
}
|
||||
}()
|
||||
}
|
||||
if absSrv != nil {
|
||||
go func() {
|
||||
slog.Info("ABS compat server listening", "addr", absSrv.Addr)
|
||||
if listenErr := absSrv.ListenAndServe(); listenErr != nil && listenErr != http.ErrServerClosed {
|
||||
errCh <- fmt.Errorf("abs compat server error: %w", listenErr)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Step 11: Wait for termination signal.
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
@@ -1902,6 +2166,9 @@ func main() {
|
||||
case sig := <-sigCh:
|
||||
appCancel()
|
||||
slog.Info("received signal, shutting down", "signal", sig)
|
||||
case <-restartReqCh:
|
||||
appCancel()
|
||||
slog.Info("server restart requested, shutting down")
|
||||
case serverErr := <-errCh:
|
||||
appCancel()
|
||||
slog.Error("server error, shutting down", "error", serverErr)
|
||||
@@ -1921,6 +2188,11 @@ func main() {
|
||||
slog.Error("jellyfin compat shutdown error", "error", shutdownErr)
|
||||
}
|
||||
}
|
||||
if absSrv != nil {
|
||||
if shutdownErr := absSrv.Shutdown(shutdownCtx); shutdownErr != nil {
|
||||
slog.Error("abs compat shutdown error", "error", shutdownErr)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Clean up stale sessions.
|
||||
if sessionCleaner != nil {
|
||||
@@ -2276,7 +2548,7 @@ func mapFolderTypeToMediaType(t string) string {
|
||||
// exposes Get) to the audiobooks.SettingsReader interface (which
|
||||
// requires GetString). The two signatures are identical modulo name.
|
||||
type audiobooksSettingsAdapter struct {
|
||||
repo *catalog.ServerSettingsRepo
|
||||
repo catalog.SettingsStore
|
||||
}
|
||||
|
||||
func (a *audiobooksSettingsAdapter) GetString(ctx context.Context, key string) (string, error) {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
services:
|
||||
silo:
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: ${NVIDIA_GPU_COUNT:-1}
|
||||
capabilities: [gpu]
|
||||
|
||||
# Optional distributed-mode example. Most single-host installs should
|
||||
# just use the integrated service above.
|
||||
# silo-transcode:
|
||||
# deploy:
|
||||
# resources:
|
||||
# reservations:
|
||||
# devices:
|
||||
# - driver: nvidia
|
||||
# count: ${NVIDIA_GPU_COUNT:-1}
|
||||
# capabilities: [gpu]
|
||||
@@ -36,6 +36,11 @@ services:
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MODE: integrated
|
||||
# Master key for at-rest credential encryption. Required — the server
|
||||
# refuses to start without it. Generate with: openssl rand -base64 48
|
||||
# Back it up SEPARATELY from your database dumps; losing it makes encrypted
|
||||
# secrets unrecoverable. See docs/architecture/secret-encryption.md.
|
||||
SECRET_KEY: ${SECRET_KEY:?Set SECRET_KEY in .env — generate one with openssl rand -base64 48}
|
||||
DATABASE_URL: postgres://${POSTGRES_USER:-silo}:${POSTGRES_PASSWORD:-silo}@postgres:5432/${POSTGRES_DB:-silo}?sslmode=disable
|
||||
REDIS_URL: redis://redis:6379
|
||||
SILO_PLUGIN_CACHE_DIR: /var/lib/silo/plugins
|
||||
@@ -43,11 +48,14 @@ services:
|
||||
ports:
|
||||
- "${PORT:-8090}:8080"
|
||||
- "${JF_PORT:-8096}:8096"
|
||||
- "${ABS_PORT:-13378}:13378"
|
||||
volumes:
|
||||
- ${MEDIA_ROOT:?Set MEDIA_ROOT in .env to the host media path}:${MEDIA_CONTAINER_ROOT:-/mnt/media}:ro
|
||||
- ${MEDIA_BOOKS_ROOT:-${MEDIA_ROOT}}:${MEDIA_BOOKS_CONTAINER_ROOT:-${MEDIA_CONTAINER_ROOT:-/mnt/media}/books}:ro
|
||||
- ${SILO_DATA_ROOT:-/opt/silo}/plugins:/var/lib/silo/plugins
|
||||
- ${SILO_DATA_ROOT:-/opt/silo}/transcode:/tmp/silo-transcode
|
||||
- ${SILO_DATA_ROOT:-/opt/silo}/catalog-seeds:/catalog-seeds:ro
|
||||
- ${SILO_DATA_ROOT:-/opt/silo}/audiobook-covers:/var/lib/silo/audiobook-covers
|
||||
- /proc/meminfo:/host/proc/meminfo:ro
|
||||
depends_on:
|
||||
postgres:
|
||||
@@ -68,6 +76,8 @@ services:
|
||||
# profiles: [proxy]
|
||||
# environment:
|
||||
# MODE: proxy
|
||||
# # Must be the SAME SECRET_KEY as the primary node (shared encrypted data).
|
||||
# SECRET_KEY: ${SECRET_KEY:?Set SECRET_KEY in .env — generate one with openssl rand -base64 48}
|
||||
# DATABASE_URL: postgres://${POSTGRES_USER:-silo}:${POSTGRES_PASSWORD:-silo}@postgres:5432/${POSTGRES_DB:-silo}?sslmode=disable
|
||||
# REDIS_URL: redis://redis:6379
|
||||
# PORT: "8080"
|
||||
@@ -92,6 +102,8 @@ services:
|
||||
# profiles: [transcode]
|
||||
# environment:
|
||||
# MODE: transcode
|
||||
# # Must be the SAME SECRET_KEY as the primary node (shared encrypted data).
|
||||
# SECRET_KEY: ${SECRET_KEY:?Set SECRET_KEY in .env — generate one with openssl rand -base64 48}
|
||||
# DATABASE_URL: postgres://${POSTGRES_USER:-silo}:${POSTGRES_PASSWORD:-silo}@postgres:5432/${POSTGRES_DB:-silo}?sslmode=disable
|
||||
# REDIS_URL: redis://redis:6379
|
||||
# SILO_PLUGIN_CACHE_DIR: /var/lib/silo/plugins
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
# At-rest credential encryption
|
||||
|
||||
Silo encrypts third-party integration credentials and other server-owned secrets
|
||||
at rest. Sonarr/Radarr API keys, S3 keys, watch-sync OAuth tokens, the
|
||||
Audiobookshelf-compat signing key, and the sensitive `server_settings` entries
|
||||
are stored as ciphertext in PostgreSQL and decrypted only in memory. The key that
|
||||
protects them lives **outside** the database, so a full DB dump or compromise
|
||||
does not expose the secrets.
|
||||
|
||||
## How it works
|
||||
|
||||
- **Cipher:** AES-256-GCM with a random 12-byte nonce per value. Implemented in
|
||||
`internal/secret`.
|
||||
- **Key derivation:** the data key is HKDF-SHA256–derived from the `SECRET_KEY`
|
||||
environment variable with a versioned domain label. `SECRET_KEY` is the
|
||||
encryption root; it is never stored in the database.
|
||||
- **Envelope:** every ciphertext is stored as `enc:v1:<base64url(nonce‖sealed)>`.
|
||||
The `enc:v1:` prefix is the version marker (see *Key rotation* below).
|
||||
- **Row binding (AAD):** each ciphertext is GCM-bound to its logical row via
|
||||
additional-authenticated data — `table:column:<pk>` for per-row columns,
|
||||
`server_settings:<key>` for settings. A DB-write attacker therefore cannot move
|
||||
a credential blob from one row/column to another: decrypting under a different
|
||||
binding fails authentication.
|
||||
- **Read path:** an empty value stays empty; a non-`enc:v1:` value is treated as
|
||||
legacy plaintext and passed through unchanged (so a not-yet-migrated row keeps
|
||||
working); an `enc:v1:` value is decrypted and **any** failure (wrong key,
|
||||
tampering, truncation) is surfaced as an error — it is never silently used as a
|
||||
credential.
|
||||
|
||||
## SECRET_KEY
|
||||
|
||||
`SECRET_KEY` is **required**. The server refuses to start without it (it fatals
|
||||
in `config.LoadBootstrap`), and it must be at least 32 characters.
|
||||
|
||||
Generate one with:
|
||||
|
||||
```bash
|
||||
openssl rand -base64 48
|
||||
```
|
||||
|
||||
Set it in the deployment environment (or in `.env` for source/dev runs, which
|
||||
`godotenv` loads at startup). See `.env.example`.
|
||||
|
||||
### Back it up — separately from the database
|
||||
|
||||
Treat `SECRET_KEY` like a CA private key:
|
||||
|
||||
- **Store it outside your database backups.** A DB dump contains only ciphertext;
|
||||
the value of at-rest encryption is entirely lost if the key travels with the
|
||||
dump.
|
||||
- Keep it in a secrets manager / sealed secret / password manager, not in the
|
||||
repo or in the same bucket as your Postgres backups.
|
||||
- Rotating the *deployment* (new host, restored DB) requires the **same**
|
||||
`SECRET_KEY`. Restoring a database backup onto a node with a different
|
||||
`SECRET_KEY` makes every encrypted secret unreadable.
|
||||
|
||||
### Key loss
|
||||
|
||||
If `SECRET_KEY` is lost, the encrypted secrets are **unrecoverable** — that is the
|
||||
point of keeping the key out of the database. Recovery means re-entering the
|
||||
affected credentials:
|
||||
|
||||
- Re-enter Sonarr/Radarr and Autoscan API keys, S3 keys, watch-sync connections,
|
||||
history-import tokens, and subtitle provider credentials.
|
||||
- `auth.jwt_secret` becomes unreadable, so all existing sessions are invalid and
|
||||
users must log in again (a new secret is generated on next boot if the row is
|
||||
cleared — see below).
|
||||
|
||||
## Rollout (automatic backfill)
|
||||
|
||||
On the first boot after deploying this change, the primary (migration-running)
|
||||
node runs an idempotent, best-effort startup backfill that encrypts any
|
||||
remaining plaintext in place:
|
||||
|
||||
1. sensitive `server_settings` keys,
|
||||
2. the per-table credential columns (subtitles, watch-sync, webhook-sync,
|
||||
history-import, including temporary server-list credentials stored in
|
||||
session JSON),
|
||||
3. the two arr `api_key_ref` columns — these are **resolved-then-encrypted**: a
|
||||
legacy row that held a `server_settings` reference (e.g.
|
||||
`requests.radarr.api_key`) is collapsed to the real credential before being
|
||||
encrypted.
|
||||
|
||||
The backfill is safe to run repeatedly: already-encrypted values are skipped, and
|
||||
a per-row guard makes concurrent multi-node boots converge without
|
||||
double-encrypting. A failed row is logged and left as plaintext (no new exposure)
|
||||
rather than blocking boot. Proxy/transcode nodes skip the backfill; they read
|
||||
whatever the primary encrypted. No manual steps are required.
|
||||
|
||||
## Rollback / downgrade hazard
|
||||
|
||||
Downgrading to a binary that predates this change is **not** safe while secrets
|
||||
are encrypted, because the old binary has no read path: it would read
|
||||
`enc:v1:auth.jwt_secret` as a literal JWT secret (invalidating all sessions) and
|
||||
read `enc:v1:`-prefixed integration keys as garbage credentials.
|
||||
|
||||
To downgrade safely you must first return the affected values to plaintext, for
|
||||
example:
|
||||
|
||||
- Decrypt the sensitive `server_settings` back to plaintext (run a one-off that
|
||||
reads each via the encrypting repo and writes the plaintext via the raw repo),
|
||||
**or**
|
||||
- At minimum, clear `auth.jwt_secret` so the older binary regenerates a fresh
|
||||
plaintext one (this still logs everyone out), and re-enter any integration
|
||||
credentials as plaintext.
|
||||
|
||||
There is no automatic "decrypt everything" downgrade path in this release; plan
|
||||
downgrades accordingly.
|
||||
|
||||
## Key rotation (future)
|
||||
|
||||
The `enc:v1:` envelope and the HKDF domain label are versioned. A future rotation
|
||||
would introduce `enc:v2:` with a new derived key (and `Decrypt` already dispatches
|
||||
on the version, returning an explicit error for an unknown version). Rotation is
|
||||
out of scope for this release; the versioning exists so it can be added without a
|
||||
data migration of the envelope format.
|
||||
|
||||
## Scope and known gaps
|
||||
|
||||
Covered: arr (Requests + Autoscan) API keys, S3 keys, all sensitive
|
||||
`server_settings`, watch-sync tokens, webhook-sync `access_token`,
|
||||
history-import admin/session tokens and temporary server-list credentials,
|
||||
subtitle provider `api_key`/`password`,
|
||||
the Jellyfin-compat session's bridged Silo access/refresh tokens
|
||||
(`jellycompat_sessions.streamapp_access_token` / `streamapp_refresh_token`), and
|
||||
the ABS signing key.
|
||||
|
||||
Deliberately **not** encrypted (tracked as follow-ups):
|
||||
|
||||
- **Equality-looked-up secrets** — these are matched by exact value
|
||||
(`WHERE … = $1`), and AES-GCM is randomized, so encrypting them would break the
|
||||
lookup. They need a deterministic **blind-index hash** column instead:
|
||||
`api_keys.api_key`, `webhook_sync_connections.webhook_secret`,
|
||||
`jellycompat_sessions.token`, and `watch_together_rooms.join_token`.
|
||||
- **Plugin runtime config** — `plugin_runtime_configs.config_value` is opaque
|
||||
plugin-defined JSONB whose secret fields are manifest-declared and whose runtime
|
||||
lives in a separate repo; encrypting it correctly needs a coordinated,
|
||||
manifest-aware design.
|
||||
|
||||
Excluded (not a gap): `plex_sync_connections.*` is a dead table (zero Go
|
||||
references); `oauth_completion.token_ciphertext` is already AES-GCM;
|
||||
`users.password_hash` and the `*_hash` columns are already hashed.
|
||||
@@ -0,0 +1,98 @@
|
||||
# Wholphin → jellycompat Endpoint Coverage Report
|
||||
|
||||
Date: 2026-06-09
|
||||
Scope: every Jellyfin server endpoint the Wholphin Android TV client (jellyfin-sdk-kotlin based) can call, cross-referenced against the routes registered in `internal/jellycompat/router.go`. Wholphin source analyzed from a local clone of `damontecres/Wholphin` (`app/src/main`). Commands assume the repository root is the cwd.
|
||||
|
||||
Wholphin's Seerr integration (`/api/v1/...` discover/request endpoints) targets a separate Seerr server, not Jellyfin, and is out of scope here.
|
||||
|
||||
## Methodology
|
||||
|
||||
1. Grepped Wholphin for every `*Api.method()` SDK call (`itemsApi`, `playStateApi`, etc.), websocket subscriptions, and raw URL construction; mapped each to its HTTP endpoint per the Jellyfin OpenAPI spec.
|
||||
2. Inventoried every route registered in `internal/jellycompat/router.go`, including path normalization (`path_normalization.go` handles casing and `/emby`//`jellyfin` prefixes).
|
||||
3. For each endpoint Wholphin uses but silo does not serve, checked whether a server-side gate (user policy flag, DTO field) prevents the client from ever calling it.
|
||||
|
||||
## Summary
|
||||
|
||||
- **Covered (full):** 22 endpoint families — everything on the core path: auth, browse, item detail, episodes/next-up, resume, playback info, direct/HLS streaming, subtitles, progress reporting, favorites/played, images, genres, persons, suggestions, media segments (skip intro), display preferences.
|
||||
- **Covered (partial/stub):** 4 — websocket (keep-alive only, no remote control), `/Studios`, `/Users/Public`, `/QuickConnect/Enabled` (deliberate `false`).
|
||||
- **Missing but safely gated:** 8 — the client never calls them because we don't advertise the capability (delete, subtitle download, trickplay, local trailers, additional parts, quick connect auth, live TV, playlists).
|
||||
- **Missing and reachable:** 4 — theme songs, universal audio stream, item download, client log upload. These return 404 when the client calls them. None block video playback.
|
||||
|
||||
**Bottom line: the entire core video path is covered.** The reachable gaps are quality-of-life features (theme songs, photo screensaver, debug log upload) plus one advertised-but-broken inconsistency (`CanDownload=true` with no `/Items/{id}/Download` route).
|
||||
|
||||
## Coverage matrix
|
||||
|
||||
### Fully covered
|
||||
|
||||
| Wholphin call | Endpoint | silo handler |
|
||||
|---|---|---|
|
||||
| `userApi.authenticateUserByName` | `POST /Users/AuthenticateByName` | `handlers_auth.go` |
|
||||
| `userApi.getCurrentUser` | `GET /Users/Me` | `handlers_auth.go` |
|
||||
| `userApi.getUserById` | `GET /Users/{id}` | `handlers_auth.go` |
|
||||
| `systemApi.getPublicSystemInfo` | `GET /System/Info/Public` | `handlers_system.go` |
|
||||
| `sessionApi.postCapabilities` | `POST /Sessions/Capabilities` | `handlers_playback.go` |
|
||||
| `itemsApi.getItems` | `GET /Items` | `handlers_items.go` |
|
||||
| `itemsApi.getResumeItems` | `GET /UserItems/Resume` (+ legacy `/Users/{id}/Items/Resume`) | `handlers_items.go` |
|
||||
| `userLibraryApi.getItem` | `GET /Items/{id}` (+ legacy user-scoped form) | `handlers_items.go` |
|
||||
| `tvShowsApi.getEpisodes` | `GET /Shows/{id}/Episodes` | `handlers_items.go` |
|
||||
| `tvShowsApi.getNextUp` | `GET /Shows/NextUp` | `handlers_items.go` |
|
||||
| `suggestionsApi.getSuggestions` | `GET /Items/Suggestions` | `handlers_items.go` |
|
||||
| `genresApi.getGenres` | `GET /Genres` | `handlers_items.go` |
|
||||
| `personsApi.getPersons` | `GET /Persons` | `handlers_persons.go` (full when PersonRepo is wired; empty stub otherwise) |
|
||||
| `searchApi.getSearchHints` | `GET /Search/Hints` | `handlers_items.go` (Wholphin's call is currently commented out upstream) |
|
||||
| `mediaInfoApi.getPostedPlaybackInfo` | `POST /Items/{id}/PlaybackInfo` | `handlers_playback.go` |
|
||||
| `videosApi.getVideoStreamUrl` | `GET/HEAD /Videos/{id}/stream(.{container})` | `streams.go` |
|
||||
| (transcode path from PlaybackInfo) | `GET /Videos/{id}/master.m3u8` + HLS playlist/segments | `streams.go` |
|
||||
| (subtitle delivery URLs) | `GET /Videos/{id}/{source}/Subtitles/{idx}/stream.{fmt}` | `streams.go` |
|
||||
| `playStateApi.reportPlaybackStart/Progress/Stopped` | `POST /Sessions/Playing[/Progress|/Stopped]` | `streams.go` |
|
||||
| `playStateApi.markPlayedItem/markUnplayedItem` | `POST/DELETE /UserPlayedItems/{id}` (+ legacy) | `handlers_userdata.go` |
|
||||
| `userLibraryApi.markFavoriteItem/unmarkFavoriteItem` | `POST/DELETE /UserFavoriteItems/{id}` (+ legacy) | `handlers_userdata.go` |
|
||||
| `mediaSegmentsApi.getItemSegments` | `GET /MediaSegments/{id}` | `handlers_items.go` (skip intro/credits) |
|
||||
| `displayPreferencesApi.updateDisplayPreferences` | `GET/POST /DisplayPreferences/{id}` | `handlers_displayprefs.go` |
|
||||
| `imageApi.getItemImageUrl` | `GET /Items/{id}/Images/{type}[/{index}]` | `handlers_images.go` |
|
||||
| `imageApi.getUserImageUrl` | `GET /Users/{id}/Images/Primary` | `handlers_images.go` (placeholder avatar) |
|
||||
|
||||
### Partial / stub (graceful degradation)
|
||||
|
||||
| Endpoint | Status | Client impact |
|
||||
|---|---|---|
|
||||
| `GET /socket` (websocket) | Accepts upgrade, answers KeepAlive only (`handlers_websocket.go`) | Wholphin subscribes to `GeneralCommandMessage` and `PlaystateMessage` for server-initiated remote control. Connection stays healthy; remote-control commands are simply never delivered. No errors, feature inert. |
|
||||
| `GET /Studios` | Empty-array stub | Studio browse rows render empty. |
|
||||
| `GET /Users/Public` | Empty-array stub | No user tiles on the login screen; manual login works. |
|
||||
| `GET /QuickConnect/Enabled` | Hardcoded `false` | Deliberate: Wholphin hides Quick Connect UI, so the unimplemented `POST /QuickConnect/Authorize` and `POST /Users/AuthenticateWithQuickConnect` are never called. |
|
||||
|
||||
### Missing but safely gated (client never calls them)
|
||||
|
||||
| Endpoint | Why it's unreachable |
|
||||
|---|---|
|
||||
| `DELETE /Items/{id}` | We return `CanDelete=false` per item (`mapping.go`) and `EnableContentDeletion=false` in user policy (`handlers_auth.go`), so Wholphin hides delete UI. |
|
||||
| `POST /Items/{id}/RemoteSearch/Subtitles/{subtitleId}` | `EnableSubtitleManagement=false` in user policy hides subtitle search/download. |
|
||||
| `GET /Videos/{id}/Trickplay/...` | We never emit trickplay metadata on item DTOs, so the scrubber never requests tiles. |
|
||||
| `GET /Items/{id}/LocalTrailers` | Wholphin gates on `localTrailerCount > 0` (`TrailerService.kt`); we never set `LocalTrailerCount`. |
|
||||
| `GET /Videos/{id}/AdditionalParts` | Gated on `partCount > 1` (`PlaylistCreator.kt`); we never set `PartCount`. |
|
||||
| Quick Connect auth endpoints | Gated by `/QuickConnect/Enabled` = false. |
|
||||
| Live TV suite (`/LiveTv/Channels`, `/Programs`, `/Recordings`, timers) | We expose no live-TV views or channels, so the feature never activates. |
|
||||
| Playlists (`GET/POST/DELETE /Playlists/{id}/Items`) | We expose no playlist items in the catalog, so playlist screens are unreachable. Becomes a real gap the day silo grows playlists. |
|
||||
|
||||
### Missing and reachable (return 404 today)
|
||||
|
||||
| Endpoint | Wholphin caller | Impact | Severity |
|
||||
|---|---|---|---|
|
||||
| `GET /Items/{id}/ThemeSongs` | `ThemeSongPlayer.kt` | ~~404 → feature silently dead.~~ **Resolved 2026-06-09:** now stubbed with an empty `ThemeMediaResult` (including the `OwnerId` field jellyfin-sdk-kotlin requires). | Resolved |
|
||||
| `GET /Audio/{id}/universal` | `ThemeSongPlayer.kt`, `MusicService.kt` | Audio streaming for theme songs and music playback. No music libraries are exposed today, but this is the second half of the theme-song path and the blocker for any future audio support in jellycompat. | Low (today) |
|
||||
| `GET /Items/{id}/Download` | `SlideshowViewModel.kt`, `ScreensaverService.kt` | ~~Inconsistency: `CanDownload=true` while the route 404s.~~ **Resolved 2026-06-09:** a real `/Items/{id}/Download` route now serves the original file (`streams.go`), and `CanDownload=true` again. The earlier `CanDownload=false` workaround broke Infuse, which requires the flag for Direct Play. | Resolved |
|
||||
| `POST /ClientLog/Document` | `MediaReportService.kt`, `DebugPage.kt` | "Upload logs to server" debug action fails. | Low |
|
||||
| `GET /Audio/{id}/Lyrics` | `NowPlayingViewModel.kt` | Music-only; unreachable until audio libraries exist. | Low |
|
||||
|
||||
## Recommendations (priority order)
|
||||
|
||||
1. **Merge the repeated-`Fields` fix** (PR #110). `parseItemsQuery` still reads `q.Get("Fields")` (`internal/jellycompat/query.go`), which truncates jellyfin-sdk-kotlin's repeated query params and breaks Wholphin episode auto-advance. This is the only *covered* endpoint with a known correctness bug for this client. Follow up by sweeping the remaining single-value reads (`Ids`, `GenreIds`, `PersonIds`, `Filters`, `SortBy`, `SortOrder`) to `q.Values`, matching how `IncludeItemTypes`/`MediaTypes`/`ImageTypes` are already handled.
|
||||
2. ~~Resolve the `CanDownload` inconsistency~~ — **done 2026-06-09**: real `/Items/{id}/Download` route added; `CanDownload` stays `true` (Infuse requires it for Direct Play).
|
||||
3. ~~Stub `GET /Items/{id}/ThemeSongs`~~ — **done 2026-06-09**: empty `ThemeMediaResult` stub registered.
|
||||
4. **Websocket server-push** (remote control: server-initiated pause/stop, display messages) — tracked in issue #122.
|
||||
5. **Optional, later:** `POST /ClientLog/Document` accept-and-discard stub and real `/Studios` data. Playlists, lyrics, and universal audio only matter once silo exposes those content types through jellycompat.
|
||||
|
||||
## Notes
|
||||
|
||||
- Path normalization (`path_normalization.go`) already handles Wholphin's casing and any `/emby`//`jellyfin` prefixes, so none of the covered routes are at risk from URL-shape differences.
|
||||
- Unmatched jellycompat paths fall through to chi's default 404 with no body; jellyfin-sdk-kotlin surfaces these as `InvalidStatusException`, which Wholphin generally catches per-feature.
|
||||
@@ -0,0 +1,82 @@
|
||||
# PR #124 (ebooks) — Review & Hardening Pass
|
||||
|
||||
Date: 2026-06-10
|
||||
Scope: full review of the ebook feature branch (`work/ebooks-reader-base`) followed by
|
||||
fixes for every finding. Two review rounds were performed (a full-PR review, then an
|
||||
adversarial edge-case/security pass over both the original code and the first round of
|
||||
fixes), with all changes verified against the full Go test suite, the full web test
|
||||
suite, production frontend build, ESLint/Prettier, and `make verify-local-paths`.
|
||||
Commands assume the repository root is the cwd.
|
||||
|
||||
## Security fixes
|
||||
|
||||
- **Stored XSS via book content (high):** book sections render in same-origin blob
|
||||
iframes with `allow-scripts` (WebKit requirement). A Content-Security-Policy is now
|
||||
served with all SPA HTML responses (`internal/server/frontend.go`) — blob/srcdoc
|
||||
documents inherit it, so `script-src 'self' 'wasm-unsafe-eval'` blocks script
|
||||
execution from book content on all browsers. Threat model documented on the policy
|
||||
constant. `X-Content-Type-Options: nosniff` added to frontend, jellycompat, and
|
||||
ebook file responses.
|
||||
- Ebook file serving can no longer fall through to `application/octet-stream` for an
|
||||
admitted file (extension/container whitelist and MIME resolution share one resolver),
|
||||
closing a browser-sniff escape combined with the `?token=` query-auth fallback.
|
||||
- External links in books are intercepted app-side: http(s) only, opened with
|
||||
`noopener,noreferrer` (blocks reverse tabnabbing and `javascript:` URLs).
|
||||
- Request size caps (`http.MaxBytesReader`, 413) on progress/config/annotation writes;
|
||||
`Content-Disposition` built with `mime.FormatMediaType` (RFC 5987, no header
|
||||
injection); annotation PATCH is atomic (`SELECT ... FOR UPDATE`) with
|
||||
presence-aware field semantics and invariant re-validation.
|
||||
- Verified safe under adversarial review: no IDOR (all reader-state SQL scoped to
|
||||
user+profile+content), no path traversal, range serving via stdlib, progress input
|
||||
validation. Accepted residual: CSS-only loads inside book iframes can leak the
|
||||
book's own content but cannot reach tokens, DOM, or navigation.
|
||||
|
||||
## Reliability fixes
|
||||
|
||||
- **Scanner missing-file reconciliation (critical class):** ebook scans now mark
|
||||
missing files like video/audio, with real walk-failure tracking (failed roots are
|
||||
excluded from deletion), symlinked-root support via the shared logical walker, and
|
||||
the empty-root cleanup allowance — an unmounted share can no longer wipe a library.
|
||||
- **Enrichment:** provider errors record failures (dedicated `ebook_enrichment_state`
|
||||
backoff table, capped retries) instead of permanently stamping items done;
|
||||
unconfigured chains and scan-window races skip without stamping. No longer shares
|
||||
`media_items.refresh_failures` with the metadata refresh-debt system.
|
||||
- Ebook items are created `pending` and promoted to `matched` on enrichment (backfill
|
||||
migration included), making curated-metadata protection real: matched items keep
|
||||
provider titles/people/series on re-scan (fill-empty only).
|
||||
- PDF metadata: head+tail window scan (non-linearized PDFs keep the Info dict at the
|
||||
end), delimiter-aware key matching, head-wins merge. `.md` dropped as an ebook
|
||||
format; plain `.fb2` reads capped like `.fbz`.
|
||||
- FK-cascade indexes added for the reader-state tables.
|
||||
|
||||
## Consistency & correctness fixes
|
||||
|
||||
- Hidden-history (`user_history_hidden_items`) gating applied to every ebook progress
|
||||
surface (watched/in-progress filters, all sort plans, Continue Reading, sort
|
||||
metrics, `Played` state, recommendation signals) with the exact video semantics.
|
||||
- `GetItemWatchers` counts distinct watchers (no binge inflation) and the
|
||||
`minWatchers` floor counts distinct accounts, not profiles; ebook reading now feeds
|
||||
implicit taste signals and taste-seed candidates; Continue Reading pages past
|
||||
dismissals and dedupes across pages.
|
||||
- The 0.9 finished threshold is centralized (`models.EbookFinishedProgressThreshold`).
|
||||
- Reader frontend: open-flow race teardown (no wrong-file progress saves), ordered
|
||||
progress writes with `pagehide`/`visibilitychange` flush, settings no longer
|
||||
clobbered by late server config, TTS stop actually stops, Media Session cleanup,
|
||||
512 MiB download guard, fraction bookmarks navigable.
|
||||
|
||||
## Feature completion
|
||||
|
||||
- Native read-state endpoints: `POST/DELETE /watched/{id}` and `/history/remove`
|
||||
accept ebook content IDs (mark read = progress 1.0 preserving location; mark unread
|
||||
mirrors video unwatch and clears position; history removal hides without losing
|
||||
position). Web UI exposes Mark Read/Unread on the item page and card menus, with
|
||||
Continue Reading dismiss copy and dismissal-path ID encoding fixed.
|
||||
|
||||
## Client follow-ups (Android / Apple)
|
||||
|
||||
- Ebook leaf user data encodes the reading ratio as `position_seconds` (0..1) with
|
||||
`duration_seconds = 1`; do not render absolute times for ebooks.
|
||||
- `/watched/{id}` returns `{type: "ebook", affected_count: 1, played: bool}` and the
|
||||
existing watched SSE event fires.
|
||||
- Reading progress is deliberately per-book (cross-format): one position per content
|
||||
ID.
|
||||
@@ -0,0 +1,138 @@
|
||||
# ABS Wire-Shape Verification (post collections-unify cutover)
|
||||
|
||||
Breadcrumbs for the next person debugging an ABS endpoint wire-shape issue
|
||||
after the canonical-tables cutover (migration 156 + commits `0dc830e`,
|
||||
`8c7fe1b`, `b64ce17`).
|
||||
|
||||
The Go in-memory structs (`abs.Collection`, `abs.Playlist`,
|
||||
`abs.SmartCollection`, `abs.CollectionItem`, `abs.PlaylistItem`) have **no
|
||||
`json:"..."` struct tags**. The JSON wire contract is defined entirely by
|
||||
the `*ToABS()` map-builder helpers in `internal/audiobooks/abs/`. As long as
|
||||
the store layer populates the struct fields with the same values, the wire
|
||||
shape is preserved. The rewrites in `0dc830e`, `8c7fe1b`, `b64ce17` did NOT
|
||||
modify the emitters — only the SQL-backed store implementations.
|
||||
|
||||
## Envelope tests — which one guards which endpoint
|
||||
|
||||
Run with `go test ./internal/audiobooks/abs/ -run Envelope -v -count=1`.
|
||||
|
||||
| Test file | Functions | Guards |
|
||||
|---|---|---|
|
||||
| `collections_envelope_test.go` | `TestCollectionEnvelope_HasRequiredKeys`, `TestCollectionListShape_OmitsBooks` | `collectionToABS` keys; list-shape (no `books`) vs detail-shape (with `books`) for `GET /api/collections`, `GET /api/collections/{id}`, `GET /api/libraries/{id}/collections`, and all POST/PATCH/DELETE collection endpoints |
|
||||
| `playlists_envelope_test.go` | `TestPlaylistEnvelope_HasRequiredKeys`, `TestPlaylistEnvelope_OmitsCoverPathWhenEmpty`, `TestPlaylistListShape_OmitsItems` | `playlistToABS` keys; list-shape (no `items`) vs detail-shape (with `items`); `coverPath` is omitted when `CoverItem == ""` — covers `GET /api/playlists`, `GET /api/playlists/{id}`, `GET /api/libraries/{id}/playlists`, batch and item-add/remove endpoints |
|
||||
| `smart_collections_envelope_test.go` | `TestSmartCollectionEnvelope_HasRequiredKeys`, `TestSmartCollectionEnvelope_EmptyQueryDef` | `smartCollectionToABS` keys; `queryDef` decoded from raw JSONB bytes into nested object, empty bytes → `{}` — covers `GET /api/me/smart-collections`, `GET /api/me/smart-collections/{id}`, POST/PATCH equivalents |
|
||||
| `bookmarks_envelope_test.go` | `TestBookmarkEnvelope_HasRequiredKeys` | Bookmarks emitter (separate from this cutover, not affected by migration 156) |
|
||||
| `login_envelope_test.go` | `TestLoginEnvelope_HasRequiredKeys` and three xReturnTokens / displayName variants | Login envelope (not affected by migration 156) |
|
||||
|
||||
In addition, handler-level round-trip tests live in
|
||||
`playlists_handler_test.go` and `bookmarks_handler_test.go`. There is NO
|
||||
snapshot/goldenfile harness in the repo today — these envelope tests are
|
||||
the primary regression guard.
|
||||
|
||||
## Manual live-DB diff procedure
|
||||
|
||||
For a pre/post-deploy wire-shape verification against a live silo, see the
|
||||
plan's Task 5 "manual verification" section at
|
||||
`docs/superpowers/plans/2026-05-27-collections-unify-3-abs-adapters.md`
|
||||
(§ `Task 5: Wire-shape regression test`). Summary:
|
||||
|
||||
1. Pre-cutover, seed one of each (collection, playlist with item,
|
||||
smart collection) via the old `abs_*` tables, then capture each list
|
||||
endpoint's response to `/tmp/wire_before_<kind>.json` using a curl
|
||||
against the running silo with a valid ABS bearer token (HS256 JWT —
|
||||
minted by the login flow, NOT the raw `abs_sessions.token` value).
|
||||
2. Apply migration 156. Seed equivalent rows in `user_personal_collections`
|
||||
with the same IDs and content. Capture again to
|
||||
`/tmp/wire_after_<kind>.json`.
|
||||
3. `diff /tmp/wire_before_<kind>.json /tmp/wire_after_<kind>.json` for each
|
||||
`kind in {collections,playlists,smart_collections}`. Expected: empty
|
||||
diff.
|
||||
|
||||
This is an MR-description-level manual step, not a committed test.
|
||||
|
||||
## Intentionally-zero fields after the rewrite
|
||||
|
||||
These wire keys are still emitted, but the store always populates the
|
||||
in-memory field with the zero value because the canonical
|
||||
`user_personal_collections` schema has no analog column (per spec §6 of the
|
||||
collections-unify plan). They are NOT bugs — do not "fix" them by reaching
|
||||
for some other column.
|
||||
|
||||
| In-memory field | Wire key | Zero value | Spec ref | Disposition |
|
||||
|---|---|---|---|---|
|
||||
| `abs.Playlist.CoverItem` | `coverPath` | `""` (key omitted entirely when empty — see `playlistToABS`) | spec §6.1 | Dropped. PATCH `coverPath` body field is silently ignored by the store. Cover regeneration from first-item poster is the chosen long-term path. |
|
||||
| `abs.SmartCollection.Color` | `color` | `""` (key always emitted as empty string) | spec §6.3 | Deferred. No column on `user_personal_collections`. Wire key stays present for client compatibility. |
|
||||
| `abs.SmartCollection.IsPinned` | `isPinned` | `false` (key always emitted) | spec §6.2 | Deferred. Same rationale. |
|
||||
|
||||
If you're adding a "Pin this smart collection" feature later, the column
|
||||
needs to land in a new migration on `user_personal_collections` first;
|
||||
don't try to thread it through some adjacent column.
|
||||
|
||||
## Canonical mapping — struct field → source column
|
||||
|
||||
The full pre-cutover wire contract was captured in a working note that does
|
||||
not persist (`/tmp/abs_wire_contract.md`). The essentials are reproduced
|
||||
here so the next maintainer doesn't have to re-derive them.
|
||||
|
||||
All three struct families now read from `user_personal_collections`
|
||||
(and `user_personal_collection_items` for collections + playlists),
|
||||
discriminated by `collection_type IN ('manual','playlist','smart')`.
|
||||
|
||||
### `abs.Collection` (`collection_type = 'manual'`)
|
||||
|
||||
| Field | Source column |
|
||||
|---|---|
|
||||
| ID | `user_personal_collections.id` |
|
||||
| UserID | `user_personal_collections.user_id::text` (column is `integer`) |
|
||||
| ProfileID | `user_personal_collections.profile_id` |
|
||||
| Name | `user_personal_collections.name` |
|
||||
| Description | `user_personal_collections.description` |
|
||||
| IsPublic | `user_personal_collections.is_shared` |
|
||||
| CreatedAt | `user_personal_collections.created_at` |
|
||||
| UpdatedAt | `user_personal_collections.updated_at` |
|
||||
|
||||
`abs.CollectionItem` reads `user_personal_collection_items` with
|
||||
`sub_item_id = ''` filter (the manual-collection sentinel established in
|
||||
migration 156 step 1). LibraryItemID ← `media_item_id`. ORDER BY
|
||||
`added_at ASC`.
|
||||
|
||||
### `abs.Playlist` (`collection_type = 'playlist'`)
|
||||
|
||||
Same column mapping as `abs.Collection` (modulo `collection_type` filter)
|
||||
EXCEPT `CoverItem` which is always `""` — see "Intentionally-zero fields"
|
||||
above.
|
||||
|
||||
`abs.PlaylistItem` reads `user_personal_collection_items` with NO
|
||||
`sub_item_id` filter (playlists can carry episode entries). Mapping:
|
||||
LibraryItemID ← `media_item_id`, EpisodeID ← `sub_item_id`,
|
||||
Position ← `position`. ORDER BY `position ASC, added_at ASC`.
|
||||
|
||||
### `abs.SmartCollection` (`collection_type = 'smart'`)
|
||||
|
||||
Same column mapping as `abs.Collection` EXCEPT:
|
||||
|
||||
- `Color`, `IsPinned` → always zero (see above).
|
||||
- `QueryDef` ← `user_personal_collections.query_definition` (JSONB → `[]byte`
|
||||
round-trip; column is `NOT NULL DEFAULT '{}'::jsonb` per migration 016).
|
||||
|
||||
No items table — smart-collection membership is evaluated at request time
|
||||
via the `smartcoll` package.
|
||||
|
||||
### Wire-shape quirks worth remembering
|
||||
|
||||
- Collection/Playlist emit `lastUpdate` (NOT `updatedAt`). SmartCollection
|
||||
emits `updatedAt`. Cross-struct inconsistency, carry forward verbatim.
|
||||
- All timestamps are `UnixMilli()` int64, NOT RFC3339 strings.
|
||||
- `ProfileID` is carried in memory but NEVER emitted on the wire — it's
|
||||
scope/auth only.
|
||||
- The list vs detail shape distinction is implicit: list responses pass
|
||||
`nil` for the items/books slice; the emitter then omits the key
|
||||
entirely. Clients differentiate on key presence.
|
||||
|
||||
## Verification status (2026-05-27)
|
||||
|
||||
- All 13 envelope tests pass (run: `go test ./internal/audiobooks/abs/
|
||||
-run Envelope -v -count=1`).
|
||||
- Full audiobooks test suite passes (`go test ./internal/audiobooks/...
|
||||
-short -count=1 -timeout 120s`).
|
||||
- Live-DB diff was NOT executed in CI — see manual procedure above.
|
||||
@@ -0,0 +1,100 @@
|
||||
# Notifications & Push — Test Checklist
|
||||
|
||||
A repeatable regression pass for the core notifications + push subsystems. Organized by layer: automated (fast, run anytime), server pipeline (API, no browser), real browser (human-in-the-loop), and not-yet-testable (future work). Run the automated layer on every change; run the browser layer before shipping a user-facing change.
|
||||
|
||||
Specs/plans this exercises live under `docs/superpowers/specs|plans/2026-06-10-core-notifications-*` and `*-push-*`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Automated tests (fast, no live instance)
|
||||
|
||||
Run from the repo root. `export GOWORK=off` first (or run inside the worktree).
|
||||
|
||||
```bash
|
||||
# Go — materializer, matchers, worker state machine, request events, presence, handlers, tasks
|
||||
GOWORK=off go test ./internal/notifications/ ./internal/push/ ./internal/presence/ \
|
||||
./internal/requests/ ./internal/api/handlers/ ./internal/taskmanager/tasks/
|
||||
|
||||
# Web — bell, inbox, settings, admin page, usePushDevice, SW logic
|
||||
cd web && pnpm exec vitest run
|
||||
```
|
||||
|
||||
Expected: all green. The Go suites cover the outcome state machine (sent/skipped/failed-with-backoff/dead + dead-token prune), child-suppression SQL, the `notifications.send` plugin contract, request lifecycle publishing, and presence refcounting. The web suites cover the UI and the `usePushDevice` lifecycle/race-guard.
|
||||
|
||||
What this does NOT cover: real WebSocket delivery, real push banners, real migrations against production-shaped data. Use layers 2–3 for those.
|
||||
|
||||
---
|
||||
|
||||
## 2. Server pipeline (API — proves source → materializer → enqueuer → worker, no browser)
|
||||
|
||||
Needs an admin API key and the instance reachable (on silo-new the API is `http://localhost:8090`; create a temp key, delete it after — see the snippet at the bottom). Observe results in the `notifications` and `push_deliveries` tables.
|
||||
|
||||
For each notification **source**, trigger it and confirm a row lands (and, if push is configured + a device registered, a `push_deliveries` row):
|
||||
|
||||
- [ ] **Announcement** — `POST /api/v1/admin/announcements {title, body, audience:{all:true}}` → confirm a `notifications` row (category `announcement`) for each targeted user. ⚠️ `audience:{all:true}` fans out to **every** user's inbox — use `{user_ids:[<your id>]}` when testing on a live instance.
|
||||
- [ ] **Request lifecycle** — submit a media request, then `POST /api/v1/requests/{id}/approve` (admin) → confirm a `request.approved` notification for the requester. Decline/complete/fail similarly produce `request.declined` / `request.completed` / `request.failed`.
|
||||
- [ ] **System** — admin user-update that changes a password → confirm a `system.password_changed` notification for that user.
|
||||
- [ ] **Content** — add an item to a profile's watchlist/favorites, then add/scan a matching library item → confirm a `content.added` notification for that profile (respects the 48h recency gate + per-day burst dedup).
|
||||
- [ ] **Admin alert** — force a failed job/scan → confirm an `admin`-category notification fans out to admin users only (throttled 1/hour per source).
|
||||
- [ ] **Digest** — opt a user into `content_digest`, then run the `notifications_digest` task (or wait for 08:00) → one digest notification if there were additions.
|
||||
- [ ] **Push test** — `POST /api/v1/admin/push/test` → 202; a `system.test_push` notification + a `push_deliveries` row per the caller's registered push devices; the worker transitions it within ~30–45s (grace + tick). With a real subscription it sends; with a bogus one it fails-with-backoff (still proves the worker path).
|
||||
|
||||
Push config endpoints to verify:
|
||||
- [ ] `GET /api/v1/admin/push/status` → `{apns,fcm,webpush}` booleans (flip after saving creds).
|
||||
- [ ] `POST /api/v1/admin/push/generate-vapid-keys` → `{vapid_public, vapid_private}` (admin only).
|
||||
- [ ] `GET /api/v1/notifications/push/webpush-key` → the configured public key (empty when unconfigured).
|
||||
- [ ] `PUT /api/v1/notifications/push/device {transport:"webpush", token:<subscription JSON>}` with `X-Profile-Id` → 204 (400 without the profile header — the route is profile-gated).
|
||||
- [ ] `GET /api/v1/notifications/push/devices` → lists the user's push devices; `PUT .../devices/{id} {enabled}` toggles.
|
||||
|
||||
---
|
||||
|
||||
## 3. Real browser (the only way to validate the UI + actual push banner)
|
||||
|
||||
Open the web app over a secure context (`localhost` or HTTPS — service workers require it). On silo-new that's `http://localhost:8090` (tunnel if remote).
|
||||
|
||||
In-app notifications:
|
||||
- [ ] **Bell + badge** — top of the app; unread count badge.
|
||||
- [ ] **Dropdown** — latest notifications, "Mark all read", deep links.
|
||||
- [ ] **Inbox page** (`/notifications`) — category tabs, dismiss, mark-on-view, "Load more".
|
||||
- [ ] **Live toast** — with the app open, trigger a notification (announcement to yourself, or the push-test) → a toast appears in real time (via the WebSocket), unless that category is muted.
|
||||
- [ ] **Preferences** — Settings → Notifications → per-category mute toggles persist; "Daily digest" defaults off; the admin category shows only for admins.
|
||||
|
||||
Push (the headline manual test):
|
||||
- [ ] **Admin config** — Admin → Settings → Push Notifications → "Generate keys", save the VAPID public/private + a `mailto:` subject → the Web Push badge flips to "Ready".
|
||||
- [ ] **Enable on this device** — Settings → Notifications → "Push on this device" → grant the browser permission prompt → a device row appears in the list.
|
||||
- [ ] **Background banner** — **close the app tab**, then `POST /admin/push/test` (or trigger any notification) → within ~30–45s an OS notification banner appears.
|
||||
- [ ] **Click-through** — click the banner → the app opens/focuses and navigates to the notification's deep link.
|
||||
- [ ] **Presence gating** — with the app tab open and focused, the same notification should NOT push a redundant banner (you saw it in-app); after closing the tab for >30s it does.
|
||||
- [ ] **Per-device toggle / revoke** — disable the device in the list → no more banners; re-enable restores.
|
||||
|
||||
---
|
||||
|
||||
## 4. Not yet testable (tracked future work)
|
||||
|
||||
- **APNs (Apple) / FCM (Android)** — no mobile clients exist to register tokens, so these transports are only unit-tested (pure result-mappers). End-to-end needs silo-apple / silo-android (Phase 3 mobile).
|
||||
- **Plugin `notifications.send`** — the ebook/audiobook request plugins don't publish it yet. Until those one-line `PublishEvent("notifications.send", …)` calls are added, plugin-sourced notifications can't be exercised end to end (the contract itself is unit-tested).
|
||||
- **iOS Safari web push** — only works for an installed PWA, not a normal tab; the client degrades to an "unsupported" state otherwise.
|
||||
|
||||
---
|
||||
|
||||
## Appendix — temp admin key on silo-new (create, use, delete)
|
||||
|
||||
```bash
|
||||
# create
|
||||
KEY="sa_$(openssl rand -hex 32)"
|
||||
docker exec silo-server-postgres-1 psql -U silo -d silo -tAc \
|
||||
"INSERT INTO api_keys (user_id,label,api_key) VALUES (1,'manual-test','$KEY');"
|
||||
echo "$KEY" # use as: Authorization: Bearer $KEY against http://localhost:8090/api/v1/...
|
||||
|
||||
# observe
|
||||
docker exec silo-server-postgres-1 psql -U silo -d silo -tAc \
|
||||
"SELECT id,category,type,title FROM notifications ORDER BY id DESC LIMIT 10;"
|
||||
docker exec silo-server-postgres-1 psql -U silo -d silo -tAc \
|
||||
"SELECT status,transport,attempts,left(last_error,40) FROM push_deliveries ORDER BY id DESC LIMIT 10;"
|
||||
|
||||
# clean up (delete the key + any test artifacts you created)
|
||||
docker exec silo-server-postgres-1 psql -U silo -d silo -tAc \
|
||||
"DELETE FROM api_keys WHERE label='manual-test';"
|
||||
```
|
||||
|
||||
Always remove test notifications/devices you create on a real user's account; `push_deliveries` cascades when its `notifications` row is deleted.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,148 @@
|
||||
# Audiobooks Absorption — Sub-plan 4: ABS-compat REST + Socket.io
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax.
|
||||
|
||||
**Goal:** Port the audiobookshelf-compatible REST + Socket.io surface from `silo-plugin-audiobooks/internal/abs/` and `silo-plugin-audiobooks/internal/abssocket/` into `internal/audiobooks/abs/` and `internal/audiobooks/abssocket/`. **Preserve the plugin's file layout** but **rewrite every SQL query** against silo's existing tables (`media_items`, `media_files`, `episodes`, `user_watch_progress`, `user_playback_sessions`, `abs_sessions`, `people`, `item_people`). No new tables.
|
||||
|
||||
**Architecture:** Stage-based port. Support files first, then auth, then file serving, then progress, then author/series, then Socket.io, then router wiring. Out-of-scope features (bookmarks, smart collections, share links, playlists, podcasts/RSS in this sub-plan) are dropped — files containing them are not copied, or are copied with the unsupported handlers stubbed to 501 Not Implemented.
|
||||
|
||||
**Source plugin:** `/opt/silo_plugins/silo-plugin-audiobooks/internal/{abs,abssocket}/`
|
||||
|
||||
---
|
||||
|
||||
## Stage 1: Support files
|
||||
|
||||
**Files to copy and adapt:**
|
||||
- `mount.go` — package mount + route registration shape
|
||||
- `handler.go` — top-level handler type + dependency wiring
|
||||
- `filter.go` — generic helpers
|
||||
- `access_log.go` — request-log helper
|
||||
- `minified.go` — generic helpers (likely small)
|
||||
|
||||
**Adapt steps:**
|
||||
1. Replace import paths: `github.com/RXWatcher/silo-plugin-audiobooks/...` → `github.com/Silo-Server/silo-server/internal/audiobooks/...`
|
||||
2. Replace plugin SDK references with direct silo equivalents:
|
||||
- Plugin's `*pgx.Pool` field on Handler → silo's `*pgxpool.Pool` (same type via different import alias)
|
||||
- Plugin's `Logger` → silo's `log/slog` package-level functions
|
||||
- Plugin's config types → struct fields on `audiobooks.Service` (extended)
|
||||
3. Remove any code that talks to the plugin SDK runtime (plugin proxy callbacks etc.)
|
||||
4. Get `go build ./internal/audiobooks/abs/...` to compile cleanly even if no routes are wired yet.
|
||||
|
||||
Commit: `feat(audiobooks): port ABS handler scaffolding (mount/handler/filter)`.
|
||||
|
||||
---
|
||||
|
||||
## Stage 2: Auth + JWT
|
||||
|
||||
**Files:**
|
||||
- `jwt.go` — token generation + validation
|
||||
- `login_internal_test.go` (keep as test) + `login_ratelimit.go`
|
||||
- Whatever the actual `login.go` file is called (look for the login handler — likely inside `handler.go` or a dedicated file)
|
||||
|
||||
**Adapt:**
|
||||
- The plugin's login handler posts credentials to silo's `/api/v1/auth/login` over HTTP. **In silo's in-process port, replace this HTTP round-trip with a direct call to silo's auth service** (find via `grep -nE 'auth.*Authenticate|auth.*Login' /opt/silo-server/internal/auth/*.go`). The plugin's network call is no longer needed because the code is now in-process.
|
||||
- ABS sessions are stored in `abs_sessions` (migration 139 from sub-plan 1).
|
||||
- JWT signing secret: store in `server_settings` under key `audiobooks.abs.jwt_secret` (auto-generated on first request if missing).
|
||||
|
||||
Commit: `feat(audiobooks): port ABS auth (JWT + login bridge)`.
|
||||
|
||||
---
|
||||
|
||||
## Stage 3: File serving + play session
|
||||
|
||||
**Files:**
|
||||
- `file_handler.go` — handles ABS GET requests for audio file streams
|
||||
- `play_response.go` — issues a play session to the client (URL + token)
|
||||
|
||||
**Adapt:**
|
||||
- Plugin's `bookref` lookup table (linking ABS-style IDs to backend file IDs) → use silo's `media_files.id` directly. ABS-side item ID = silo's `content_id`; ABS-side file ID = silo's `media_files.id`.
|
||||
- The stream URL ABS clients expect must point back at silo. Issue them a `/api/v1/direct-download?file_id={id}` URL signed with silo's existing media-token signer (grep `mediatoken` in `internal/`).
|
||||
|
||||
Commit: `feat(audiobooks): port ABS file handler + play session`.
|
||||
|
||||
---
|
||||
|
||||
## Stage 4: Progress tracking
|
||||
|
||||
**Files:**
|
||||
- `progress_internal_test.go` (test, port)
|
||||
- Whatever file owns the `/api/me/progress` ABS endpoint family — look in `handler.go` or grep ABS progress route paths
|
||||
- `continue_listening.go`
|
||||
|
||||
**Adapt:**
|
||||
- All progress reads/writes hit silo's `user_watch_progress` keyed on `(user_id, profile_id, content_id)`. Sub-plan 3's progress handler did this already — copy the patterns.
|
||||
|
||||
Commit: `feat(audiobooks): port ABS progress + continue listening`.
|
||||
|
||||
---
|
||||
|
||||
## Stage 5: Author / Series / Browse
|
||||
|
||||
**Files:**
|
||||
- `author_series_handler.go`
|
||||
- `collapse.go`
|
||||
|
||||
**Adapt:**
|
||||
- Author rows come from silo's `people` + `item_people` (kind=7).
|
||||
- Series grouping: if silo doesn't have an explicit "audiobook series" concept, use `media_items.title` prefix matching (e.g., books named "Foundation #1", "Foundation #2") for MVP. Document the heuristic.
|
||||
|
||||
Commit: `feat(audiobooks): port ABS author/series browse`.
|
||||
|
||||
---
|
||||
|
||||
## Stage 6: Socket.io
|
||||
|
||||
**Files:**
|
||||
- `abssocket/server.go` + `abssocket/server_test.go`
|
||||
|
||||
**Adapt:**
|
||||
- Reuse silo's `gorilla/websocket` upgrader. The plugin's standalone-listener escape hatch is no longer needed — mount under `/abs/socket.io/` on silo's main listener.
|
||||
- Redis adapter behavior preserved if `REDIS_URL` is set (silo always has one in the docker setup).
|
||||
|
||||
Commit: `feat(audiobooks): port ABS Socket.io endpoint`.
|
||||
|
||||
---
|
||||
|
||||
## Stage 7: Router wiring
|
||||
|
||||
**Files:**
|
||||
- Modify `internal/api/router.go` — mount `audiobookABSHandler` under `/abs/*` and the small set of `/api/*` legacy paths ABS clients hardcode
|
||||
- Modify `internal/audiobooks/service.go` — add `ABSHandler` field, wire it from `audiobooks.New(...)`
|
||||
- Modify `cmd/silo/main.go` — construct the ABS handler and pass it into the audiobooks service
|
||||
|
||||
**Routes to expose** (the minimum ABS-required set):
|
||||
- `POST /abs/login` (login flow)
|
||||
- `GET /abs/api/me` (profile)
|
||||
- `GET /abs/api/libraries` (library list)
|
||||
- `GET /abs/api/libraries/{id}/items` (browse)
|
||||
- `GET /abs/api/items/{id}` (item detail)
|
||||
- `GET /abs/api/items/{id}/file/{ino}` (stream)
|
||||
- `POST /abs/api/me/progress/{libraryItemId}` (progress)
|
||||
- `GET /abs/socket.io/` (Socket.io)
|
||||
|
||||
For ABS-legacy hard-coded paths that don't fit under `/abs/`: register them explicitly with the ABS auth middleware so they don't collide with silo's existing `/api/v1/*`.
|
||||
|
||||
Build + smoke:
|
||||
```bash
|
||||
sudo docker build -t silo:latest /opt/silo-server
|
||||
sudo docker compose -p silo-prod up -d --force-recreate silo
|
||||
curl -sS -o /dev/null -w "POST /abs/login -> %{http_code}\n" -X POST -H 'Content-Type: application/json' -d '{}' http://localhost:8090/abs/login
|
||||
```
|
||||
|
||||
Expected: 400 (bad request body) or 401 (validation failed), NOT 404.
|
||||
|
||||
Commit: `feat(audiobooks): wire ABS routes into main router`.
|
||||
|
||||
---
|
||||
|
||||
## Stage 8: Build + smoke + lint sweep
|
||||
|
||||
Single final task. Same shape as sub-plan 2 Task 10.
|
||||
|
||||
---
|
||||
|
||||
## Risks
|
||||
|
||||
- The plugin's code expects a specific database schema. Some plugin queries may have no clean silo-table equivalent (e.g., the plugin's `bookref` mapping table). In that case, stub the handler to return 501 + log + flag in PR notes — don't add new tables.
|
||||
- ABS clients have undocumented expectations (CORS headers, specific error shapes, etc.). Smoke testing against a real ABS client is the only way to find these. Defer to sub-plan 6's manual verification.
|
||||
- Each stage commits independently and produces partial functionality (e.g., after stage 2 you can log in but can't list libraries). That's intentional — clients won't connect end-to-end until stage 7.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,187 @@
|
||||
# Audiobook-native catalog filter fields
|
||||
|
||||
**Status:** Draft — not yet approved or executed.
|
||||
|
||||
**Context.** The audiobooks library page currently inherits the catalog's
|
||||
movie/TV filter set. We've already (a) wired `libraryType="audiobook[s]"`
|
||||
into the sort-relevance scope so video-only sorts (resolution, IMDb/RT
|
||||
ratings, content rating) drop out, and (b) gated the obviously-irrelevant
|
||||
video-only filter sections (Director, Writer, Producer, Studio, Network,
|
||||
Video Quality) in `CollectionGuidedRulesEditor`. What remains is
|
||||
**affirmative** support for audiobook-native filter dimensions: **author**,
|
||||
**narrator**, **series**.
|
||||
|
||||
Commands assume the repository root is the cwd.
|
||||
|
||||
## Goal
|
||||
|
||||
Surface three new filter dimensions on the audiobook library page:
|
||||
|
||||
1. **Author** — `item_people.kind = PersonKindAuthor` (7).
|
||||
2. **Narrator** — `item_people.kind = PersonKindNarrator` (8).
|
||||
3. **Series** — `audiobook_series.series_name` (free-text or distinct list).
|
||||
|
||||
The user should be able to:
|
||||
- See per-dimension counts/options in the filter sheet (populated from the
|
||||
current scope, like Genres/Studios already do).
|
||||
- Add a rule via the guided editor that filters items by exact author /
|
||||
narrator / series.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Series **ordering** (we already have `audiobook_series.series_index`).
|
||||
Sort by series is a separate piece of work; this plan focuses on
|
||||
filtering only.
|
||||
- Author/narrator typeahead UI improvements beyond the existing
|
||||
`PersonSearchSelect` (reuse it with kind-scoped lookups).
|
||||
|
||||
## Backend
|
||||
|
||||
### `internal/catalog/catalog_resolver.go`
|
||||
|
||||
`CatalogFiltersResult` currently has `Genres`, `Studios`, `Networks`,
|
||||
`Countries`, `OriginalLanguages`, `ContentRatings`, `Resolutions`,
|
||||
`AudioLanguages`, `SubtitleLanguages`. Add:
|
||||
|
||||
```go
|
||||
Authors []string `json:"authors,omitempty"`
|
||||
Narrators []string `json:"narrators,omitempty"`
|
||||
Series []string `json:"series,omitempty"`
|
||||
```
|
||||
|
||||
In `listFiltersForSource`, add three parallel facet queries:
|
||||
|
||||
- `listDistinctPeopleByKind(ctx, scope, models.PersonKindAuthor)` — joins
|
||||
`item_people` + `people` on the result set defined by the current
|
||||
request scope (library_ids, media_scope=audiobook, etc.) and returns
|
||||
distinct `people.name`.
|
||||
- Same for `PersonKindNarrator`.
|
||||
- `listDistinctAudiobookSeriesNames(ctx, scope)` — distinct
|
||||
`audiobook_series.series_name` for the scope, joined via
|
||||
`audiobook_series.content_id = media_items.content_id`.
|
||||
|
||||
All three need to respect the access filter the caller passes in (mirror
|
||||
how Genres/Studios already gate by access).
|
||||
|
||||
### `internal/api/handlers/catalog.go`
|
||||
|
||||
Extend `catalogFiltersResponse` and `HandleGetCatalogFilters` to emit the
|
||||
new fields. Mirror the pattern used for the existing ones — no flag
|
||||
gating, audiobook filters should always be included when present.
|
||||
|
||||
### `internal/catalog/query_builder.go`
|
||||
|
||||
Three new field names: `author`, `narrator`, `series`. The first two
|
||||
reuse `buildPersonClause` with the appropriate `PersonKind`. `series`
|
||||
needs a small new clause:
|
||||
|
||||
```go
|
||||
case "series":
|
||||
// EXISTS (SELECT 1 FROM audiobook_series s
|
||||
// WHERE s.content_id = media_items.content_id
|
||||
// AND lower(s.series_name) = lower($N))
|
||||
```
|
||||
|
||||
Register the three field names in `catalogQueryRuleFields` so the parser
|
||||
accepts them. None of them are personalized — they don't go into
|
||||
`catalogPersonalRuleFields`.
|
||||
|
||||
### Tests
|
||||
|
||||
- `internal/catalog/catalog_resolver_test.go` — extend with audiobook
|
||||
scope: assert authors/narrators/series come back, scoped to libraries
|
||||
the test user has access to.
|
||||
- `internal/catalog/query_builder_test.go` — add cases for `author`,
|
||||
`narrator`, `series` rules; verify the emitted SQL clauses match the
|
||||
expected shape.
|
||||
|
||||
## Frontend
|
||||
|
||||
### `web/src/api/types.ts`
|
||||
|
||||
Extend `CatalogFiltersResponse` (and any `ItemFiltersResponse` base, if
|
||||
shared) with optional `authors?: string[]`, `narrators?: string[]`,
|
||||
`series?: string[]`.
|
||||
|
||||
Extend `QueryRule['field']` to include `'author' | 'narrator' | 'series'`
|
||||
if it's a literal union (or no-op if it's `string`).
|
||||
|
||||
### `web/src/components/collections/CollectionGuidedRulesEditor.tsx`
|
||||
|
||||
Add to `GuidedFormState`:
|
||||
- `author: string`
|
||||
- `narrator: string`
|
||||
- `series: string`
|
||||
|
||||
Update `queryDefinitionToGuidedState` / `guidedStateToQueryDefinition` to
|
||||
round-trip the three rules (mirror how `actor` / `director` are wired).
|
||||
|
||||
In the audiobook-only render path (already gated via `isAudiobookLibrary`),
|
||||
add a new section *above* the country row:
|
||||
|
||||
```tsx
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Author picker /> {/* PersonSearchSelect with kind="author" */}
|
||||
<Narrator picker /> {/* PersonSearchSelect with kind="narrator" */}
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Series picker /> {/* SearchableSelect over filters.series */}
|
||||
</div>
|
||||
```
|
||||
|
||||
`PersonSearchSelect` likely needs a new `kind` prop so it can scope its
|
||||
backend lookup to authors or narrators. If today it queries all kinds,
|
||||
we'll add a query param + handler-side filter.
|
||||
|
||||
### `ActiveFilterBadges` + `catalogFilterBadges`
|
||||
|
||||
Add badge rendering for the three new fields so they appear in the
|
||||
selected-filters chip row above the editor.
|
||||
|
||||
### Tests
|
||||
|
||||
- `web/src/components/collections/CollectionGuidedRulesEditor.test.tsx`
|
||||
— render with `libraryType="audiobooks"` and a filters payload
|
||||
containing authors/narrators/series; assert all three sections appear
|
||||
and update the query definition correctly.
|
||||
|
||||
## Migration / data
|
||||
|
||||
No schema changes required. `PersonKindAuthor=7` and `PersonKindNarrator=8`
|
||||
already exist in `internal/models/media.go`. `audiobook_series` already
|
||||
holds series names (migration 145). The scanner is already writing both.
|
||||
|
||||
## Risk
|
||||
|
||||
- **Filter facet performance.** Three new distinct-value queries per
|
||||
`/catalog/filters` request. The existing facet queries use the same
|
||||
`facetFetcher` infrastructure, so they'll inherit the same query-time
|
||||
budget. For an audiobook library on the order of a few thousand items
|
||||
this should be fine; if it's not, we can cache author/narrator lookups
|
||||
more aggressively (they change rarely).
|
||||
- **PersonSearchSelect kind scoping.** If the component today emits a
|
||||
single combined kind=any search, we need to add kind-filtered variants
|
||||
without breaking the existing actor/director/writer/producer pickers.
|
||||
- **Series name normalization.** Backfill at migration 145 used regex
|
||||
parsing; some items may have inconsistent casing or whitespace.
|
||||
Distinct facet query should `TRIM(series_name)` and present `LOWER`
|
||||
for matching; otherwise duplicates show up in the picker.
|
||||
|
||||
## Rollout
|
||||
|
||||
This is gated entirely behind `libraryType === "audiobook[s]"` on the
|
||||
frontend, and the new filter fields are additive on the backend. No
|
||||
feature flag needed. Ship in two PRs if reviewer prefers:
|
||||
|
||||
1. **Backend** — filter response fields + query-builder clauses + tests.
|
||||
2. **Frontend** — types + GuidedFormState + editor sections + tests.
|
||||
|
||||
Or a single PR if the diff stays reviewable (~600 lines including tests).
|
||||
|
||||
## Verification
|
||||
|
||||
- Add new tests as above; `make lint` and `cd web && pnpm vitest run`
|
||||
should pass.
|
||||
- Manually: open the audiobook library filter sheet, pick an author and
|
||||
a narrator, confirm the result set narrows. Pick a series, confirm
|
||||
same.
|
||||
@@ -0,0 +1,123 @@
|
||||
# `audiobook_series` data cleanup
|
||||
|
||||
**Status:** Draft. Findings + options; no implementation yet.
|
||||
|
||||
## Problem
|
||||
|
||||
Migration `145_audiobook_series.up.sql` (which created the
|
||||
`audiobook_series` table) shipped with a regex backfill that
|
||||
over-matched. On this server's 220k-book audiobook library it produced
|
||||
**162,322 distinct series names**, of which **141,591 (87%) are
|
||||
singletons** — books that are the only "member" of their named series.
|
||||
|
||||
Spot checks make the failure mode obvious: rows like
|
||||
|
||||
- `series_name = "Computer Programming: This Book Includes: SQL, …"` for a standalone book whose subtitle happened to contain `"#2022 Version"`
|
||||
- `series_name = "Reparación de crédito [Credit Repair]"` (the title verbatim)
|
||||
- `series_name = "Cómo Atraer a las Mujeres … [How to Attract Women …]"` (the title verbatim)
|
||||
|
||||
The regex (`'^.+[^\s-]\s+\d+(?:\.\d+)?\s*-\s*.+$'`) treats any numeric
|
||||
chapter / edition / part token followed by ` - ` as series-N-of-book
|
||||
syntax. The real series (Star Wars, Warhammer 40k, Animorphs, Redwall,
|
||||
*In Death*, etc.) live in the 733-row "large series" bucket; the
|
||||
9,075 pairs and 10,923 small clusters mix real and false-positive.
|
||||
|
||||
The cap added in `b95494c` keeps the user-facing dropdown manageable
|
||||
(top 1000 alphabetically), but the underlying data is still polluted —
|
||||
sorting by series, the Series filter dropdown, and any future
|
||||
"recommended" surfacing of series will all surface noise until the
|
||||
backfill is repaired.
|
||||
|
||||
Commands assume the repository root is the cwd.
|
||||
|
||||
## Options
|
||||
|
||||
### A — delete singletons (destructive)
|
||||
|
||||
```sql
|
||||
DELETE FROM audiobook_series
|
||||
WHERE series_name IN (
|
||||
SELECT series_name FROM audiobook_series GROUP BY series_name HAVING COUNT(*) = 1
|
||||
);
|
||||
```
|
||||
|
||||
- **Pros:** simple, removes the bulk of the noise in one shot, no
|
||||
schema change.
|
||||
- **Cons:** also deletes legitimate "first book of a not-yet-complete
|
||||
series" rows. Reversal requires a re-scan.
|
||||
|
||||
### B — filter singletons at query time, leave data alone
|
||||
|
||||
In `listDistinctAudiobookSeriesWithSource`, group by series name and
|
||||
`HAVING COUNT(*) >= 2`.
|
||||
|
||||
- **Pros:** non-destructive; the table keeps full information for
|
||||
detail-page lookups and series-detail surfaces.
|
||||
- **Cons:** hides legitimate one-book-only series from the filter
|
||||
dropdown. Series-detail and sort behaviour unchanged (still surface
|
||||
singletons). The 11.7 MB payload was already capped to 1000 in
|
||||
`b95494c`, so the size win from this option is small.
|
||||
|
||||
### C — mark backfill provenance, delete only backfilled singletons
|
||||
|
||||
Add a `source` column to `audiobook_series` (`'scanner' | 'backfill'`)
|
||||
in a new migration. Migration 145's backfill rows get `source =
|
||||
'backfill'`; scanner-written rows get `'scanner'`. Then delete only the
|
||||
backfilled singletons.
|
||||
|
||||
- **Pros:** distinguishes legitimate from spurious without guessing.
|
||||
- **Cons:** retroactive — we'd have to assume all current rows are
|
||||
backfill (which they mostly aren't anymore — the scanner has been
|
||||
writing too). Without a marker added at backfill time, can't tell
|
||||
what's what.
|
||||
|
||||
### D — wipe + re-scan (clean slate)
|
||||
|
||||
```sql
|
||||
TRUNCATE audiobook_series;
|
||||
```
|
||||
|
||||
Then trigger a full library re-scan so the scanner writes only the
|
||||
real series_name values it extracts from tags.
|
||||
|
||||
- **Pros:** the cleanest end state; the scanner is authoritative.
|
||||
- **Cons:** long re-scan window (the user already noticed 219k books
|
||||
is slow to scan); any books not currently scannable lose their
|
||||
series info entirely. Destructive.
|
||||
|
||||
## Recommendation
|
||||
|
||||
**Option B as a quick win** — non-destructive, ships as a one-line SQL
|
||||
change in the facet helper, makes the Series filter dropdown useful
|
||||
immediately. The dropdown stops showing 87% noise.
|
||||
|
||||
**Option D as the eventual fix** — once the scanner has been verified
|
||||
to write series_name authoritatively (including the series_index from
|
||||
real tags), `TRUNCATE audiobook_series` + re-scan is the right end
|
||||
state. Need to confirm scanner behaviour before pulling the trigger.
|
||||
|
||||
Option A is too blunt (drops real first-books-of-series). Option C is
|
||||
over-engineered without a marker added at backfill time.
|
||||
|
||||
## What this plan does not cover
|
||||
|
||||
- Whether the scanner currently writes `audiobook_series` rows
|
||||
authoritatively for every audiobook it touches, or only when tags
|
||||
contain explicit series metadata. Need to check
|
||||
`internal/scanner/audiobook.go` (or wherever the audiobook_series
|
||||
upsert lives) before proposing option D as the long-term path.
|
||||
- Whether *Search Series* (the user-facing series-detail page, if
|
||||
any) would also benefit from the same cleanup.
|
||||
- Sort-by-series on the library page — even with option B in place,
|
||||
the sort still groups by every series_name including singletons,
|
||||
so books in fake "series" appear under their fake series name.
|
||||
Acceptable for now; revisit alongside option D.
|
||||
|
||||
## Verification (once an option is picked)
|
||||
|
||||
- Re-run the data sanity query in this doc's intro and confirm the
|
||||
singleton count dropped where expected.
|
||||
- Open the Series filter dropdown on the audiobook library page and
|
||||
confirm only multi-book series show up.
|
||||
- `go test ./internal/catalog/...` + `cd web && pnpm vitest run` still
|
||||
pass.
|
||||
@@ -0,0 +1,143 @@
|
||||
# Server-side typeahead for catalog facets
|
||||
|
||||
**Status:** Draft. Architecture sketch; no implementation yet.
|
||||
|
||||
## Why
|
||||
|
||||
`b95494c` capped `/api/v1/catalog/filters` facet responses to 1000
|
||||
distinct values each, which fixed an 11.7 MB payload on the audiobook
|
||||
library (88k authors, 92k narrators, 161k series). The cap is a
|
||||
stopgap — a user looking for an author past the first 1000
|
||||
alphabetically can't find them through the dropdown.
|
||||
|
||||
Parallel context: a separate investigation (transcript in the
|
||||
2026-05-27 sessions) compared how `librarymanagerre`, `booklore-ng`,
|
||||
and `audiobookshelf` paginate large libraries. The cursor +
|
||||
virtual-scroll camp (librarymanagerre, booklore-ng's
|
||||
`@tanstack/react-virtual`) scales to 250k+ items by never sending the
|
||||
client a full list; the eager-shelves camp (audiobookshelf web +
|
||||
mobile) renders one DOM div per item and falls over above ~30–50k.
|
||||
The 11.7 MB facet issue here is the same shape on a different surface
|
||||
— "too much data shipped to the client at once."
|
||||
|
||||
The cleanest fix is **server-side typeahead**: the client sends a
|
||||
search prefix as the user types, the server returns the top N matches
|
||||
for that prefix.
|
||||
|
||||
Commands assume the repository root is the cwd.
|
||||
|
||||
## Surface
|
||||
|
||||
### `/api/v1/catalog/filters/search`
|
||||
|
||||
New endpoint, scoped per facet, query parameters mirror
|
||||
`/api/v1/catalog/filters` (`source`, `library_id`, etc.) plus:
|
||||
|
||||
- `facet=author|narrator|series|studio|network|country|genre|original_language|content_rating`
|
||||
- `q=<prefix>` — the user's typed prefix (1-64 chars, trimmed)
|
||||
- `limit=<N>` — capped at e.g. 50
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{ "matches": ["Brandon Sanderson", "Brandon Sanderson & Steven Erikson", ...], "has_more": false }
|
||||
```
|
||||
|
||||
Returns up to `limit` matches sorted by:
|
||||
1. exact-prefix match first (`q` matches the start of the value)
|
||||
2. then case-insensitive substring match
|
||||
3. then alphabetical
|
||||
|
||||
`has_more` is true when the underlying result set was truncated.
|
||||
|
||||
### `/api/v1/catalog/filters` keeps the current shape
|
||||
|
||||
No change to the existing endpoint — it still returns the capped top
|
||||
1000 per facet for the initial dropdown render. The typeahead surface
|
||||
takes over once the user starts typing.
|
||||
|
||||
## Backend changes
|
||||
|
||||
### `internal/catalog/catalog_resolver.go`
|
||||
|
||||
New method `SearchFacetWithOptions(ctx, req, access, facet, prefix,
|
||||
limit)` that:
|
||||
1. Resolves the same access/scope as `ListFiltersWithOptions`.
|
||||
2. Dispatches to a facet-specific helper based on `facet`.
|
||||
3. Returns the matches + has_more flag.
|
||||
|
||||
Add to `facetFetcher` interface:
|
||||
|
||||
```go
|
||||
SearchPeopleByKind(ctx, kind models.PersonKind, filters BrowseFilters, baseRelation, mediaScope, prefix string, limit int) ([]string, bool, error)
|
||||
SearchAudiobookSeries(ctx, filters BrowseFilters, baseRelation, mediaScope, prefix string, limit int) ([]string, bool, error)
|
||||
SearchDistinctArrayColumn(ctx, column string, filters BrowseFilters, baseRelation, mediaScope, prefix string, limit int) ([]string, bool, error)
|
||||
SearchDistinctScalarColumn(ctx, column string, filters BrowseFilters, baseRelation, mediaScope, prefix string, limit int) ([]string, bool, error)
|
||||
```
|
||||
|
||||
Each helper runs the existing facet SQL with an added
|
||||
`WHERE LOWER(<value>) LIKE LOWER($N || '%')` (prefix match) and
|
||||
`LIMIT N+1` (so we can detect has_more by checking if the result set
|
||||
exceeds N).
|
||||
|
||||
### `internal/api/handlers/catalog.go`
|
||||
|
||||
New handler `HandleCatalogFacetSearch` mounted at
|
||||
`/api/v1/catalog/filters/search`.
|
||||
|
||||
## Frontend changes
|
||||
|
||||
### `web/src/components/ui/searchable-select.tsx` (or a new variant)
|
||||
|
||||
`SearchableSelect` today does client-side filtering over the full
|
||||
`options` array. Replace it for the high-cardinality facets with a
|
||||
debounced server search:
|
||||
|
||||
- Type `<= 100ms` of inactivity → fire `/catalog/filters/search?facet=author&q=...`
|
||||
- Reset focus + cancel in-flight when the user keeps typing
|
||||
- Show the first 50 matches; "show more" disabled (the user types more
|
||||
to narrow further)
|
||||
|
||||
The existing `SearchableSelect` stays for low-cardinality facets
|
||||
(genres, content_ratings, etc.) where the initial 1000 is plenty.
|
||||
|
||||
### `web/src/components/collections/CollectionGuidedRulesEditor.tsx`
|
||||
|
||||
Author / Narrator / Series sections switch to the new typeahead-backed
|
||||
select. Studio / Network / Country can stay on `SearchableSelect`
|
||||
(low-cardinality) or migrate later for consistency.
|
||||
|
||||
## Tests
|
||||
|
||||
- Backend: search SQL emits the LIKE prefix + LIMIT N+1, has_more
|
||||
semantics, access filter still gates the result set.
|
||||
- Frontend: typeahead component debounces, cancels stale requests,
|
||||
renders empty / loading / no-results states.
|
||||
|
||||
## Migration
|
||||
|
||||
None — additive endpoint. Frontend can be rolled out incrementally:
|
||||
audiobook-only sections first (since they're the worst offenders),
|
||||
others later.
|
||||
|
||||
## What this plan does not cover
|
||||
|
||||
- The audiobookshelf-app pagination question from the separate
|
||||
transcript. That's a different surface (the ABS-compat
|
||||
`/abs/api/libraries/{id}/items` endpoint on `:13378`), not the silo
|
||||
native catalog. Same architectural family but the patch lives in
|
||||
`internal/audiobooks/abs/libraries_handler.go`, not catalog.
|
||||
- A general client-side virtual scroll for the library grid itself.
|
||||
That's the cousin problem ("250k books rendered as 125k shelf
|
||||
divs"); cross-applies but is its own work.
|
||||
|
||||
## Risk / rollout
|
||||
|
||||
- New endpoint is opt-in for the frontend — existing callers keep
|
||||
using the bulk `/filters` response.
|
||||
- Typeahead latency budget: each keystroke fires one DB query against
|
||||
the same indexes the bulk facets already use. Should be fast
|
||||
(`item_people(content_id, kind)` covers people lookups,
|
||||
`audiobook_series(content_id)` covers series, and a `LOWER()`
|
||||
functional index already exists on
|
||||
`audiobook_series_name_lower`). Verify with EXPLAIN before shipping.
|
||||
@@ -0,0 +1,314 @@
|
||||
# Collections Unification — Sub-project 1: Schema Migration
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Land migration 156 — extend `user_personal_collections` to admit `'playlist'` and `'smart'` types, add nullable `sub_item_id` to its items table, add `media_types` filter to `page_sections`, move the single existing `abs_playlists` row into the canonical store, and drop the five `abs_*` collection tables.
|
||||
|
||||
**Architecture:** Pure schema migration + one-row data move. No application code changes. The follow-up sub-projects 2–4 wire silo to the new schema; this one is just the storage foundation. Hard cutover — the `abs_*` tables are near-empty (1 playlist row, 0 of everything else) so rollback risk is minimal.
|
||||
|
||||
**Tech Stack:** PostgreSQL 18, raw SQL migrations under `migrations/`, no Go code in this sub-project.
|
||||
|
||||
**Commands assume the repository root is the cwd.**
|
||||
|
||||
**Source spec:** `docs/superpowers/specs/2026-05-27-unified-audiobook-collections-design.md` §4.1, §4.4, §5.
|
||||
|
||||
---
|
||||
|
||||
## File map
|
||||
|
||||
**Create:**
|
||||
- `migrations/156_unify_user_collections.up.sql`
|
||||
- `migrations/156_unify_user_collections.down.sql`
|
||||
|
||||
**No Go code modified in this sub-project.** Application code keeps querying the (now-dropped) `abs_*` tables until sub-projects 3 and 4 land, which means **silo will break in dev/staging until sub-project 3 ships.** Land this sub-project and sub-project 3 together (single MR) if a production deploy is imminent. Otherwise it's safe to ship in isolation on a feature branch that hasn't been merged yet.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Up-migration body
|
||||
|
||||
**Files:**
|
||||
- Create: `migrations/156_unify_user_collections.up.sql`
|
||||
|
||||
- [ ] **Step 1: Write the migration file**
|
||||
|
||||
```sql
|
||||
-- Unify user-owned lists into user_personal_collections.
|
||||
--
|
||||
-- See docs/superpowers/specs/2026-05-27-unified-audiobook-collections-design.md
|
||||
-- for design rationale.
|
||||
|
||||
-- 1. Sub-item granularity column on the canonical items table.
|
||||
-- Empty string for whole-item entries; populated for podcast-episode
|
||||
-- playlist entries (sub_item_id == abs_playlist_items.episode_id).
|
||||
ALTER TABLE user_personal_collection_items
|
||||
ADD COLUMN sub_item_id text NOT NULL DEFAULT '';
|
||||
|
||||
-- 2. Widen the collection_type domain. The pre-existing CHECK (if any)
|
||||
-- only admits 'manual' and 'synced'.
|
||||
ALTER TABLE user_personal_collections
|
||||
DROP CONSTRAINT IF EXISTS user_personal_collections_type_check;
|
||||
ALTER TABLE user_personal_collections
|
||||
ADD CONSTRAINT user_personal_collections_type_check
|
||||
CHECK (collection_type IN ('manual', 'synced', 'playlist', 'smart'));
|
||||
|
||||
-- 3. Move the existing abs_playlists row(s) into the canonical store.
|
||||
-- is_public maps to is_shared; profile_id (uuid) is stringified.
|
||||
INSERT INTO user_personal_collections
|
||||
(id, user_id, profile_id, name, description, collection_type,
|
||||
is_shared, created_at, updated_at, creator_profile_id)
|
||||
SELECT
|
||||
id,
|
||||
user_id,
|
||||
COALESCE(profile_id::text, ''),
|
||||
name,
|
||||
description,
|
||||
'playlist',
|
||||
is_public,
|
||||
created_at,
|
||||
updated_at,
|
||||
COALESCE(profile_id::text, '')
|
||||
FROM abs_playlists;
|
||||
|
||||
INSERT INTO user_personal_collection_items
|
||||
(user_id, collection_id, media_item_id, sub_item_id, position, added_at)
|
||||
SELECT
|
||||
p.user_id,
|
||||
i.playlist_id,
|
||||
i.library_item_id,
|
||||
i.episode_id,
|
||||
i.position,
|
||||
i.added_at
|
||||
FROM abs_playlist_items i
|
||||
JOIN abs_playlists p ON p.id = i.playlist_id;
|
||||
|
||||
-- 4. Drop the abs_* collection tables. abs_playlist_items has a FK to
|
||||
-- abs_playlists, so the order matters.
|
||||
DROP TABLE abs_playlist_items;
|
||||
DROP TABLE abs_playlists;
|
||||
DROP TABLE abs_collection_items;
|
||||
DROP TABLE abs_user_collections;
|
||||
DROP TABLE abs_smart_collections;
|
||||
|
||||
-- 5. Media-type filter on page_sections. Default preserves current
|
||||
-- behavior (existing rails surface movies+series only).
|
||||
ALTER TABLE page_sections
|
||||
ADD COLUMN media_types text[] NOT NULL DEFAULT ARRAY['movie','series'];
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify the file compiles as valid SQL**
|
||||
|
||||
The repo embeds migrations via `migrations/embed.go`. The migration runner will syntax-check on load.
|
||||
|
||||
Run: `go build ./...`
|
||||
Expected: clean (the embed package compiles fine even if a new file is added).
|
||||
|
||||
- [ ] **Step 3: Commit (up only — down comes next task)**
|
||||
|
||||
Don't commit yet. Commit happens after Task 2's down-migration so the up/down pair lands atomically.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Down-migration body
|
||||
|
||||
**Files:**
|
||||
- Create: `migrations/156_unify_user_collections.down.sql`
|
||||
|
||||
The down migration is intentionally lossy in reverse: rolling back loses any `'playlist'`/`'smart'` collections that were created after the up. With the up bringing in 1 row, lossy reverse is acceptable.
|
||||
|
||||
- [ ] **Step 1: Find the abs_* CREATE TABLE statements to inline**
|
||||
|
||||
Read these files and copy their `CREATE TABLE` bodies (and `CREATE INDEX` statements; **omit** any `DROP TABLE IF EXISTS` boilerplate at the top — the down migration creates from clean state):
|
||||
|
||||
```bash
|
||||
cat migrations/149_abs_user_collections.up.sql
|
||||
cat migrations/150_abs_collection_items.up.sql
|
||||
cat migrations/151_abs_playlists.up.sql
|
||||
cat migrations/152_abs_playlist_items.up.sql
|
||||
cat migrations/153_abs_smart_collections.up.sql
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the down migration**
|
||||
|
||||
```sql
|
||||
-- Reverse migration 156. Lossy in reverse — playlist/smart rows
|
||||
-- created after the up migration are deleted, not migrated back.
|
||||
|
||||
-- 1. Remove the page_sections column.
|
||||
ALTER TABLE page_sections DROP COLUMN media_types;
|
||||
|
||||
-- 2. Recreate the abs_* tables empty. Schemas are inlined from the
|
||||
-- original up migrations 149–153 — keep identical (column types,
|
||||
-- constraint names, index names) so any tool keyed off those names
|
||||
-- sees the same shape.
|
||||
--
|
||||
-- BEGIN inlined from migrations/149_abs_user_collections.up.sql
|
||||
-- <PASTE EXACT CREATE TABLE + CREATE INDEX statements>
|
||||
-- END inlined
|
||||
|
||||
-- BEGIN inlined from migrations/150_abs_collection_items.up.sql
|
||||
-- <PASTE EXACT CREATE TABLE + CREATE INDEX statements>
|
||||
-- END inlined
|
||||
|
||||
-- BEGIN inlined from migrations/151_abs_playlists.up.sql
|
||||
-- <PASTE EXACT CREATE TABLE + CREATE INDEX + FK statements>
|
||||
-- END inlined
|
||||
|
||||
-- BEGIN inlined from migrations/152_abs_playlist_items.up.sql
|
||||
-- <PASTE EXACT CREATE TABLE + CREATE INDEX + FK statements>
|
||||
-- END inlined
|
||||
|
||||
-- BEGIN inlined from migrations/153_abs_smart_collections.up.sql
|
||||
-- <PASTE EXACT CREATE TABLE + CREATE INDEX statements>
|
||||
-- END inlined
|
||||
|
||||
-- 3. Remove rows we promoted from abs_playlists during the up.
|
||||
DELETE FROM user_personal_collection_items
|
||||
WHERE collection_id IN (
|
||||
SELECT id FROM user_personal_collections
|
||||
WHERE collection_type IN ('playlist', 'smart')
|
||||
);
|
||||
DELETE FROM user_personal_collections
|
||||
WHERE collection_type IN ('playlist', 'smart');
|
||||
|
||||
-- 4. Restore the narrow CHECK constraint.
|
||||
ALTER TABLE user_personal_collections
|
||||
DROP CONSTRAINT IF EXISTS user_personal_collections_type_check;
|
||||
ALTER TABLE user_personal_collections
|
||||
ADD CONSTRAINT user_personal_collections_type_check
|
||||
CHECK (collection_type IN ('manual', 'synced'));
|
||||
|
||||
-- 5. Drop the sub_item_id column.
|
||||
ALTER TABLE user_personal_collection_items DROP COLUMN sub_item_id;
|
||||
```
|
||||
|
||||
Replace each `<PASTE EXACT ... statements>` block with the actual SQL from the corresponding `migrations/14X_abs_*.up.sql` file. Do NOT skip any constraint or index — the round-trip test below verifies parity.
|
||||
|
||||
- [ ] **Step 3: Verify SQL syntactic validity**
|
||||
|
||||
Run: `go build ./...`
|
||||
Expected: clean.
|
||||
|
||||
- [ ] **Step 4: Commit the up+down pair**
|
||||
|
||||
```bash
|
||||
git add migrations/156_unify_user_collections.up.sql migrations/156_unify_user_collections.down.sql
|
||||
git commit -m "feat(migrations): 156 unify user collections
|
||||
|
||||
Adds sub_item_id to user_personal_collection_items, widens
|
||||
collection_type to include 'playlist' and 'smart', adds media_types
|
||||
filter to page_sections, moves the existing abs_playlists row into
|
||||
the canonical store, and drops the abs_* collection tables.
|
||||
|
||||
Lays the storage foundation for sub-projects 2-4 to wire the
|
||||
application against."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Migration round-trip test
|
||||
|
||||
**Files:**
|
||||
- Create: `migrations/156_unify_user_collections_test.go` (only if a similar pattern exists in the repo)
|
||||
|
||||
The repo doesn't have a standard "migration round-trip test" harness today (verify with `find . -name "migration*_test.go" -o -name "*round_trip*_test.go" | head`). If no harness exists, this task is **best-effort manual verification**:
|
||||
|
||||
- [ ] **Step 1: Verify a harness pattern exists, or skip to manual verification**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
find . -name "migration*_test.go" -not -path "*/node_modules/*" 2>/dev/null | head
|
||||
grep -rln "migrate.Up\|migrate.Down\|migrate.Steps" --include="*_test.go" 2>/dev/null | head
|
||||
```
|
||||
|
||||
If no harness emerges, fall through to Step 2 (manual). Otherwise add a Go test following the existing harness pattern that:
|
||||
1. Migrates to 155.
|
||||
2. Inserts a fake `abs_playlists` row (id `test-pl-1`, user 1, name `test`).
|
||||
3. Inserts a fake `abs_playlist_items` row referencing it.
|
||||
4. Migrates up to 156.
|
||||
5. Asserts `user_personal_collections WHERE collection_type='playlist' AND id='test-pl-1'` exists and `user_personal_collection_items WHERE collection_id='test-pl-1'` exists.
|
||||
6. Migrates down to 155.
|
||||
7. Asserts the `abs_playlists` table exists and is empty (the row is GONE — down is lossy by design).
|
||||
|
||||
- [ ] **Step 2: Manual verification against a throwaway DB**
|
||||
|
||||
If no harness, do the following manually once and capture the output in the MR description:
|
||||
|
||||
```bash
|
||||
# 1. Spin up an empty postgres
|
||||
docker run --rm -d --name pgcheck -e POSTGRES_PASSWORD=x -p 5443:5432 pgvector/pgvector:pg18
|
||||
sleep 5
|
||||
|
||||
# 2. Apply migrations up to 155 (use the existing migrate binary or schema dump)
|
||||
# Adapt this to whatever the repo uses to run migrations against an arbitrary DB.
|
||||
|
||||
# 3. Insert a fake abs_playlists + items row.
|
||||
PGPASSWORD=x psql -h localhost -p 5443 -U postgres -d postgres -c "
|
||||
INSERT INTO abs_playlists(id, user_id, name) VALUES ('test-pl-1', 1, 'test');
|
||||
INSERT INTO abs_playlist_items(playlist_id, library_item_id, position) VALUES ('test-pl-1', 'foo', 0);
|
||||
"
|
||||
|
||||
# 4. Apply migration 156.
|
||||
# 5. Verify the row landed in user_personal_collections.
|
||||
PGPASSWORD=x psql -h localhost -p 5443 -U postgres -d postgres -c "
|
||||
SELECT collection_type, name FROM user_personal_collections WHERE id='test-pl-1';
|
||||
SELECT collection_id, media_item_id, sub_item_id FROM user_personal_collection_items WHERE collection_id='test-pl-1';
|
||||
"
|
||||
# Expected: collection_type='playlist', name='test'; one item row with sub_item_id=''
|
||||
|
||||
# 6. Apply down migration.
|
||||
# 7. Verify abs_playlists exists (empty) and user_personal_collections has no playlist rows.
|
||||
|
||||
# Cleanup
|
||||
docker rm -f pgcheck
|
||||
```
|
||||
|
||||
- [ ] **Step 3: If you added a Go test, commit it**
|
||||
|
||||
```bash
|
||||
git add migrations/156_unify_user_collections_test.go
|
||||
git commit -m "test(migrations): 156 round-trip verifies abs_playlists migrate to canonical"
|
||||
```
|
||||
|
||||
If only manual verification was done, capture the output in the MR description instead — no commit needed.
|
||||
|
||||
---
|
||||
|
||||
## Verification (after merge)
|
||||
|
||||
1. **Dev DB** — run `make dev-backend` against a fresh DB. Migration 156 applies cleanly. `\d user_personal_collection_items` shows the `sub_item_id` column. `\d page_sections` shows `media_types`.
|
||||
|
||||
2. **Existing data migration** — on a copy of the production DB (or staging if available), run migration 156 and verify the single existing `abs_playlists` row is now in `user_personal_collections` with `collection_type='playlist'`:
|
||||
|
||||
```sql
|
||||
SELECT id, name, collection_type FROM user_personal_collections WHERE collection_type='playlist';
|
||||
```
|
||||
|
||||
3. **abs_* tables gone** — confirm:
|
||||
|
||||
```sql
|
||||
SELECT table_name FROM information_schema.tables WHERE table_schema='public' AND table_name LIKE 'abs_%collection%';
|
||||
-- Should return 0 rows.
|
||||
```
|
||||
|
||||
`abs_sessions`, `abs_bookmarks`, `abs_playback_sessions`, `abs_rss_feeds` remain — those aren't in scope here.
|
||||
|
||||
4. **Silo binary builds against the new schema** — `go build ./...` is green even though the ABS store adapters still reference the dropped tables. They reference them via SQL strings, so compile-time is fine; runtime will fail until sub-project 3 rewrites them. **Do NOT** deploy without sub-project 3, or stash this migration behind a feature flag.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
- `sub_item_id` column ✓ (Task 1)
|
||||
- `collection_type` widened to playlist/smart ✓ (Task 1)
|
||||
- Move existing abs_playlists row ✓ (Task 1)
|
||||
- Drop abs_* tables ✓ (Task 1)
|
||||
- `page_sections.media_types` ✓ (Task 1)
|
||||
- Down migration ✓ (Task 2)
|
||||
- Round-trip test ✓ (Task 3, best-effort)
|
||||
|
||||
**Placeholder scan:** The `<PASTE EXACT ... statements>` blocks in Task 2 Step 2 are template placeholders that the implementer must fill in by copying from the existing migration files. Each is bounded by explicit BEGIN/END comment markers naming the source file. The instructions in Step 1 say to read those files first; Step 2's block then becomes literal SQL. Not a "TBD" in the bad sense — it's a deliberate copy-from-source step.
|
||||
|
||||
**Type consistency:** `sub_item_id` (text, NOT NULL DEFAULT '') consistent in up + down + verification queries. `collection_type` enum values consistent across the spec, up, down, and CHECK constraint.
|
||||
|
||||
**Risk:** All schema-level, no application changes. The biggest risk is forgetting to ship sub-project 3 in the same release — runtime will start erroring on ABS endpoints the moment migration 156 applies, because the adapters still query the dropped tables.
|
||||
@@ -0,0 +1,274 @@
|
||||
# Collections Unification — Sub-project 2: Smartcoll Engine Lift
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Move the smart-collection rule engine from `internal/audiobooks/smartcoll/` to `internal/smartcoll/` so all media types (movie, series, audiobook) share one evaluator. Pure code move + import update; no logic changes.
|
||||
|
||||
**Architecture:** The 4-file package (~1060 LOC) currently lives in `internal/audiobooks/smartcoll/`. We relocate it verbatim and rewire its imports. The existing in-place audiobook caller (`internal/audiobooks/abs_smart_collection_store.go`) updates its import path. Sub-projects 3 and 4 will later call the engine from new sites (ABS adapter rewrites, section recipes); those plans cover their own wiring.
|
||||
|
||||
**Tech Stack:** Go. Standard `gopls`-style refactor: move, fix imports, run tests.
|
||||
|
||||
**Commands assume the repository root is the cwd.**
|
||||
|
||||
**Source spec:** `docs/superpowers/specs/2026-05-27-unified-audiobook-collections-design.md` §4.2, §4.6.
|
||||
|
||||
**Predecessor sub-project:** None — this lands independent of sub-project 1. Order doesn't matter.
|
||||
|
||||
---
|
||||
|
||||
## File map
|
||||
|
||||
**Move (4 files):**
|
||||
- `internal/audiobooks/smartcoll/evaluator.go` → `internal/smartcoll/evaluator.go`
|
||||
- `internal/audiobooks/smartcoll/evaluator_test.go` → `internal/smartcoll/evaluator_test.go`
|
||||
- `internal/audiobooks/smartcoll/query.go` → `internal/smartcoll/query.go`
|
||||
- `internal/audiobooks/smartcoll/query_test.go` → `internal/smartcoll/query_test.go`
|
||||
|
||||
**Modify (import path):**
|
||||
- Every file that imports `github.com/Silo-Server/silo-server/internal/audiobooks/smartcoll` — change to `github.com/Silo-Server/silo-server/internal/smartcoll`.
|
||||
|
||||
**Add (new test, cross-type coverage):**
|
||||
- `internal/smartcoll/cross_type_test.go` — three tests verifying audiobook-specific rules no-op against non-audiobook items.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Move the package directory
|
||||
|
||||
**Files:** see "Move" list above.
|
||||
|
||||
- [ ] **Step 1: Pre-check current contents**
|
||||
|
||||
```bash
|
||||
ls internal/audiobooks/smartcoll/
|
||||
wc -l internal/audiobooks/smartcoll/*.go
|
||||
```
|
||||
|
||||
Expected: 4 files (evaluator.go, evaluator_test.go, query.go, query_test.go). If the contents differ, pause and report — the plan assumes this exact set.
|
||||
|
||||
- [ ] **Step 2: Verify nothing outside the package references it (yet) besides the known caller**
|
||||
|
||||
```bash
|
||||
grep -rln '"github.com/Silo-Server/silo-server/internal/audiobooks/smartcoll"' --include="*.go" .
|
||||
```
|
||||
|
||||
Expected: `internal/audiobooks/abs_smart_collection_store.go` and possibly its test, plus the package's own test files. If you find any other importer, pause and add it to the import-update list in Task 2.
|
||||
|
||||
- [ ] **Step 3: Create the new directory and move the files**
|
||||
|
||||
```bash
|
||||
mkdir -p internal/smartcoll
|
||||
git mv internal/audiobooks/smartcoll/evaluator.go internal/smartcoll/evaluator.go
|
||||
git mv internal/audiobooks/smartcoll/evaluator_test.go internal/smartcoll/evaluator_test.go
|
||||
git mv internal/audiobooks/smartcoll/query.go internal/smartcoll/query.go
|
||||
git mv internal/audiobooks/smartcoll/query_test.go internal/smartcoll/query_test.go
|
||||
rmdir internal/audiobooks/smartcoll
|
||||
```
|
||||
|
||||
`git mv` preserves blame history.
|
||||
|
||||
- [ ] **Step 4: Update the package declaration in each moved file**
|
||||
|
||||
The package name was `smartcoll`; it stays `smartcoll`. Open each moved file and **verify** the `package smartcoll` line is unchanged. If any file declares `package audiobooks_smartcoll` or similar, fix it to `package smartcoll`.
|
||||
|
||||
```bash
|
||||
head -1 internal/smartcoll/*.go
|
||||
```
|
||||
|
||||
Expected: every line reads `package smartcoll`. If any differ, fix with sed:
|
||||
|
||||
```bash
|
||||
sed -i '1s/^package .*/package smartcoll/' internal/smartcoll/<filename>.go
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Build — this WILL fail until Task 2 runs**
|
||||
|
||||
```bash
|
||||
go build ./...
|
||||
```
|
||||
|
||||
Expected: compilation error in `internal/audiobooks/abs_smart_collection_store.go` because its import path now points at a non-existent directory. That's expected — Task 2 fixes it. **Do not commit yet.**
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Update all imports
|
||||
|
||||
**Files:**
|
||||
- Modify: every file from Task 1 Step 2's grep result.
|
||||
|
||||
- [ ] **Step 1: Update each importer**
|
||||
|
||||
The typical pattern in the existing caller is:
|
||||
|
||||
```go
|
||||
import (
|
||||
// ...
|
||||
"github.com/Silo-Server/silo-server/internal/audiobooks/smartcoll"
|
||||
)
|
||||
```
|
||||
|
||||
Becomes:
|
||||
|
||||
```go
|
||||
import (
|
||||
// ...
|
||||
"github.com/Silo-Server/silo-server/internal/smartcoll"
|
||||
)
|
||||
```
|
||||
|
||||
For each file in the Task 1 Step 2 grep result, run:
|
||||
|
||||
```bash
|
||||
sed -i 's|"github.com/Silo-Server/silo-server/internal/audiobooks/smartcoll"|"github.com/Silo-Server/silo-server/internal/smartcoll"|' <file>
|
||||
```
|
||||
|
||||
Or edit by hand using your editor's find-and-replace.
|
||||
|
||||
- [ ] **Step 2: Verify no stale import remains**
|
||||
|
||||
```bash
|
||||
grep -rln '"github.com/Silo-Server/silo-server/internal/audiobooks/smartcoll"' --include="*.go" . || echo "clean"
|
||||
```
|
||||
|
||||
Expected: `clean` (no matches).
|
||||
|
||||
- [ ] **Step 3: Build + test**
|
||||
|
||||
```bash
|
||||
go build ./...
|
||||
go test ./internal/smartcoll/ ./internal/audiobooks/... -short -timeout 60s
|
||||
go vet ./internal/smartcoll/ ./internal/audiobooks/...
|
||||
```
|
||||
|
||||
Expected: all green. The moved tests still pass because the engine logic is unchanged; the package path changed but the test code itself is package-local.
|
||||
|
||||
- [ ] **Step 4: Commit the move + import fix**
|
||||
|
||||
```bash
|
||||
git add internal/smartcoll/ internal/audiobooks/
|
||||
git commit -m "refactor(smartcoll): lift engine to internal/smartcoll
|
||||
|
||||
Moves the smart-collection rule engine out of internal/audiobooks/
|
||||
so movie/TV smart collections can share the same evaluator.
|
||||
|
||||
No logic changes. Tests move with their package and continue to pass."
|
||||
```
|
||||
|
||||
`git log --follow internal/smartcoll/evaluator.go` should show the prior history at the old path.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Cross-type smoke tests
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/smartcoll/cross_type_test.go`
|
||||
|
||||
The existing tests cover audiobook-specific rule evaluation. Sub-project 4 will start asking the engine to evaluate against movies + TV, so we add three small tests that nail down the no-op contract for audiobook-specific predicates against non-audiobook items.
|
||||
|
||||
- [ ] **Step 1: Find the audiobook-specific rule kinds**
|
||||
|
||||
```bash
|
||||
grep -nE 'narrator|series_position|"type":"audiobook"' internal/smartcoll/query.go | head -10
|
||||
```
|
||||
|
||||
Expected: rule-kind constants named like `RuleKindNarrator`, `RuleKindSeriesPosition`, or similar. Note the exact names — the test below references them. **If the rule kinds are not actually present (rule registration happens elsewhere), pause and report — the spec assumed they exist.**
|
||||
|
||||
- [ ] **Step 2: Read the existing query/evaluator test fixtures**
|
||||
|
||||
```bash
|
||||
sed -n '/func Test/,/^func /p' internal/smartcoll/evaluator_test.go | head -60
|
||||
```
|
||||
|
||||
Note the harness pattern used (how an evaluator is constructed in tests, how it's given items, how match-results are asserted). Mirror that pattern in the new tests.
|
||||
|
||||
- [ ] **Step 3: Write the failing tests**
|
||||
|
||||
Create `internal/smartcoll/cross_type_test.go`:
|
||||
|
||||
```go
|
||||
package smartcoll
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
// TestNarratorRuleNoopOnMovie verifies that an audiobook-specific narrator
|
||||
// rule treats movie items as a no-op (matches everything, since the rule
|
||||
// can't apply). This is the cross-type contract that lets the engine
|
||||
// evaluate against mixed-type item sets without errors.
|
||||
func TestNarratorRuleNoopOnMovie(t *testing.T) {
|
||||
// Adapt to the actual evaluator-construction API discovered in Step 2.
|
||||
// Expected: build an evaluator with a narrator-name predicate, evaluate
|
||||
// it against a movie item, assert no error AND the item is matched
|
||||
// (or excluded — pick whichever the existing audiobook tests treat as
|
||||
// "no-op" and document it).
|
||||
t.Skip("Implement once the evaluator API is read from existing tests")
|
||||
}
|
||||
|
||||
// TestSeriesPositionRuleNoopOnSeries verifies the audiobook series_position
|
||||
// rule has no effect on a TV series item (which has its own series concept,
|
||||
// distinct from book series).
|
||||
func TestSeriesPositionRuleNoopOnSeries(t *testing.T) {
|
||||
t.Skip("Implement once the evaluator API is read from existing tests")
|
||||
}
|
||||
|
||||
// TestLibraryIdFilterAppliesToAllTypes verifies that the simple library_id
|
||||
// predicate (the silo-native query_definition subset) evaluates correctly
|
||||
// against any media type. This locks in compat with existing
|
||||
// user_personal_collections rows after migration 156.
|
||||
func TestLibraryIdFilterAppliesToAllTypes(t *testing.T) {
|
||||
t.Skip("Implement once the evaluator API is read from existing tests")
|
||||
}
|
||||
|
||||
var _ = models.MediaItem{} // import-stability marker; remove once tests are real
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Fill in the test bodies**
|
||||
|
||||
Replace each `t.Skip(...)` with a real test using the harness pattern from the existing tests. The exact code depends on the evaluator's public surface (constructor, evaluate method, predicate registration). **Read `internal/smartcoll/evaluator.go` and the existing test file once before writing the bodies** — don't guess at the API.
|
||||
|
||||
If the evaluator's no-op behavior for type-mismatched rules turns out to be "exclude the item" rather than "match the item", adjust the assertions to reflect that — but document the chosen semantic in a comment above each test so future readers know which it is.
|
||||
|
||||
- [ ] **Step 5: Run the new tests**
|
||||
|
||||
```bash
|
||||
go test ./internal/smartcoll/ -run "Cross|Noop|LibraryIdFilter" -v
|
||||
```
|
||||
|
||||
Expected: all three PASS, and the existing tests in the package continue to pass.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/smartcoll/cross_type_test.go
|
||||
git commit -m "test(smartcoll): cross-type evaluator contract
|
||||
|
||||
Locks in the no-op semantics for audiobook-specific rules evaluated
|
||||
against non-audiobook items, plus library_id filter compat across
|
||||
all types."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification (after merge)
|
||||
|
||||
1. `git log --follow internal/smartcoll/evaluator.go` shows the prior history at `internal/audiobooks/smartcoll/evaluator.go` — confirms blame preserved.
|
||||
2. `go test ./internal/smartcoll/ -v` runs both the moved tests and the three new cross-type tests, all passing.
|
||||
3. `grep -rln 'internal/audiobooks/smartcoll' --include="*.go" .` returns empty.
|
||||
4. No production behavior change. Smart collections in the ABS API still resolve via the engine; the engine just lives at a new path.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
- Move package to `internal/smartcoll/` ✓ (Task 1)
|
||||
- Update import paths ✓ (Task 2)
|
||||
- Cross-type evaluator tests ✓ (Task 3)
|
||||
|
||||
**Placeholder scan:** Task 3's `t.Skip(...)` lines are explicit failing-test stubs that Step 4 fills in. The instructions in Step 4 say to read the existing tests first rather than guess. Not a TBD — it's a "read-existing, then write" workflow with explicit guidance.
|
||||
|
||||
**Type consistency:** Package name `smartcoll` consistent. Import path `github.com/Silo-Server/silo-server/internal/smartcoll` consistent. Rule-kind names depend on what Step 1 of Task 3 discovers — the plan flags this explicitly.
|
||||
|
||||
**Risk:** Pure refactor. The chance of breakage is low (Go's import system catches missed renames at compile time). The cross-type tests are new functionality, but they assert behavior that already exists in the engine (no-op on type-mismatch) so they should pass without code changes.
|
||||
@@ -0,0 +1,515 @@
|
||||
# Collections Unification — Sub-project 3: ABS Store Adapter Rewrites
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Rewrite the three ABS store adapters (`abs_collection_store.go`, `abs_playlist_store.go`, `abs_smart_collection_store.go`) to query the canonical `user_personal_collections` tables instead of the dropped `abs_*` tables. The wire shape returned by ABS HTTP endpoints must remain byte-identical so the ABS Android/iOS apps don't notice anything changed.
|
||||
|
||||
**Architecture:** The three adapter files expose a struct + ~6 methods each (List, Get, Create, Update, Delete, ListItems, AddItem, RemoveItem) consumed by `internal/audiobooks/abs/` HTTP handlers. We keep every public method signature unchanged and replace each SQL body with one that maps to `user_personal_collections` filtered by `collection_type`. Granular podcast-episode entries map to the new `sub_item_id` column. Library scoping (which library this list "belongs to" in ABS UI) is stored in `query_definition.library_ids`.
|
||||
|
||||
**Tech Stack:** Go, `pgx/v5`, `database/sql`-style query patterns from the existing adapters.
|
||||
|
||||
**Commands assume the repository root is the cwd.**
|
||||
|
||||
**Source spec:** `docs/superpowers/specs/2026-05-27-unified-audiobook-collections-design.md` §4.3, §4.5.
|
||||
|
||||
**Predecessor sub-projects:**
|
||||
- Sub-project 1 (migration 156) **must** land before this. The new tables/columns are required.
|
||||
- Sub-project 2 (smartcoll lift) needed only for the smart-collection adapter — if 2 hasn't landed, Task 3 below can either wait for it or temporarily keep the old import path.
|
||||
|
||||
---
|
||||
|
||||
## File map
|
||||
|
||||
**Modify (rewrite bodies, keep method signatures):**
|
||||
- `internal/audiobooks/abs_collection_store.go` (193 lines today; ~8 methods)
|
||||
- `internal/audiobooks/abs_playlist_store.go` (210 lines today; ~9 methods including `coverArg`)
|
||||
- `internal/audiobooks/abs_smart_collection_store.go` (~140 lines today; ~6 methods)
|
||||
|
||||
**Add (snapshot tests for wire-shape preservation):**
|
||||
- `internal/audiobooks/abs_collection_store_test.go` (if not already present — check first)
|
||||
- `internal/audiobooks/abs_playlist_store_test.go`
|
||||
- `internal/audiobooks/abs_smart_collection_store_test.go`
|
||||
|
||||
**No new files** beyond those three test files. No new exported types.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Capture pre-cutover wire shapes (snapshots)
|
||||
|
||||
This task runs against the **pre-migration** ABS endpoints. Capture JSON now so we can diff after.
|
||||
|
||||
**Files:** none modified; output captured to `/tmp/abs_wire_*.json`.
|
||||
|
||||
- [ ] **Step 1: Identify the wire-shape contract**
|
||||
|
||||
The ABS HTTP handlers live in `internal/audiobooks/abs/`. They call the store adapters, which return Go structs. The structs (`abs.Collection`, `abs.Playlist`, `abs.SmartCollection`, etc.) marshal to JSON via standard struct tags. The wire shape is fixed by those struct tag declarations.
|
||||
|
||||
Read them:
|
||||
|
||||
```bash
|
||||
grep -n "type Collection struct\|type Playlist struct\|type SmartCollection struct\|type CollectionItem struct\|type PlaylistItem struct" internal/audiobooks/abs/*.go
|
||||
```
|
||||
|
||||
For each struct, note every field's JSON tag. **This is your reference contract.** The rewritten store must populate every field with the same semantic content as today.
|
||||
|
||||
- [ ] **Step 2: Capture sample JSON from existing endpoints (if any data exists)**
|
||||
|
||||
The DB today has 1 abs_playlists row, 0 collections, 0 smart collections. Even one sample helps. Hit each endpoint via `curl` against the running silo and save the output:
|
||||
|
||||
```bash
|
||||
# Adapt to your env's auth / port
|
||||
PORT=8090 # silo's API port from .env
|
||||
TOKEN=... # admin or user JWT
|
||||
|
||||
curl -sH "Authorization: Bearer $TOKEN" http://localhost:$PORT/api/libraries/9/collections \
|
||||
> /tmp/abs_wire_collections.json
|
||||
curl -sH "Authorization: Bearer $TOKEN" http://localhost:$PORT/api/libraries/9/playlists \
|
||||
> /tmp/abs_wire_playlists.json
|
||||
curl -sH "Authorization: Bearer $TOKEN" http://localhost:$PORT/api/libraries/9/smart-collections \
|
||||
> /tmp/abs_wire_smart_collections.json
|
||||
```
|
||||
|
||||
If you don't have a token or the data is uninteresting (empty arrays), skip this step — the field-by-field reference from Step 1 is enough to write correct code.
|
||||
|
||||
- [ ] **Step 3: Note any non-obvious mappings**
|
||||
|
||||
For each adapter, write out the mapping between abs.* struct fields and `user_personal_collections` columns. Sample for Collection:
|
||||
|
||||
| `abs.Collection` field | Source column |
|
||||
|---|---|
|
||||
| `Id` | `user_personal_collections.id` |
|
||||
| `Name` | `user_personal_collections.name` |
|
||||
| `Description` | `user_personal_collections.description` |
|
||||
| `UserId` | `user_personal_collections.user_id` |
|
||||
| `LibraryId` | derived from `query_definition.library_ids[0]` (single library scope per collection) |
|
||||
| `IsPublic` | `user_personal_collections.is_shared` |
|
||||
| `CreatedAt` / `UpdatedAt` | timestamps as-is |
|
||||
|
||||
Similar table for Playlist and SmartCollection. **Write this out in a working note** — you'll reference it in Task 2.
|
||||
|
||||
No commit for this task.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Rewrite `ABSCollectionStore`
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/audiobooks/abs_collection_store.go`
|
||||
- Test: `internal/audiobooks/abs_collection_store_test.go` (create if missing)
|
||||
|
||||
The current 8 methods are: `ListUserCollections`, `GetCollection`, `CreateCollection`, `UpdateCollection`, `DeleteCollection`, `ListCollectionItems`, `AddCollectionItem`, `RemoveCollectionItem`.
|
||||
|
||||
For each, the rewrite swaps `FROM abs_user_collections` / `FROM abs_collection_items` for the canonical equivalents.
|
||||
|
||||
- [ ] **Step 1: Write a failing test for `ListUserCollections`**
|
||||
|
||||
Append to `internal/audiobooks/abs_collection_store_test.go` (create the file if absent, declaring `package audiobooks`):
|
||||
|
||||
```go
|
||||
package audiobooks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
// ...add the test DB harness import discovered by the next step
|
||||
)
|
||||
|
||||
func TestABSCollectionStoreListUserCollections(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("requires test DB")
|
||||
}
|
||||
ctx := context.Background()
|
||||
// Set up a test pool against migration head (>= 156).
|
||||
pool := newTestPool(t)
|
||||
defer pool.Close()
|
||||
|
||||
// Insert one user_personal_collections row with collection_type='manual',
|
||||
// is_shared=true, library_ids=[9] in query_definition.
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO user_personal_collections
|
||||
(id, user_id, profile_id, name, description, collection_type,
|
||||
is_shared, created_at, updated_at, creator_profile_id, query_definition)
|
||||
VALUES
|
||||
('test-c-1', 1, '', 'TestColl', 'desc', 'manual',
|
||||
true, NOW(), NOW(), '', '{"library_ids":[9]}'::jsonb)
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
store := &ABSCollectionStore{pool: pool}
|
||||
got, err := store.ListUserCollections(ctx, "1", "")
|
||||
if err != nil {
|
||||
t.Fatalf("ListUserCollections: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("got %d collections, want 1", len(got))
|
||||
}
|
||||
if got[0].Id != "test-c-1" || got[0].Name != "TestColl" || !got[0].IsPublic {
|
||||
t.Errorf("unexpected fields: %+v", got[0])
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Verify the test-pool harness:** `grep -rln "func newTestPool\|func testPool" --include="*_test.go" .` finds the project's convention. If none exists, this test stays gated by `testing.Short()` and is best-effort — flag in the PR.
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
```bash
|
||||
go test ./internal/audiobooks/ -run TestABSCollectionStoreListUserCollections -v
|
||||
```
|
||||
|
||||
Expected: FAIL because `ListUserCollections` still queries the dropped `abs_user_collections` table.
|
||||
|
||||
- [ ] **Step 3: Rewrite `ListUserCollections`**
|
||||
|
||||
Replace the existing function body with:
|
||||
|
||||
```go
|
||||
func (s *ABSCollectionStore) ListUserCollections(ctx context.Context, userID, profileID string) ([]abs.Collection, error) {
|
||||
const q = `
|
||||
SELECT
|
||||
id, name, description, COALESCE(query_definition->'library_ids'->>0, '0')::int AS library_id,
|
||||
user_id, is_shared, created_at, updated_at
|
||||
FROM user_personal_collections
|
||||
WHERE collection_type = 'manual'
|
||||
AND user_id = $1::int
|
||||
AND (profile_id = $2 OR ($2 = '' AND profile_id = ''))
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
rows, err := s.pool.Query(ctx, q, userID, profileID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("abs collection list: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []abs.Collection
|
||||
for rows.Next() {
|
||||
var c abs.Collection
|
||||
if err := rows.Scan(&c.Id, &c.Name, &c.Description, &c.LibraryId, &c.UserId, &c.IsPublic, &c.CreatedAt, &c.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan abs collection: %w", err)
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
```
|
||||
|
||||
Adjust the `Scan` order to match `abs.Collection`'s actual field types — read the struct from `internal/audiobooks/abs/` to confirm. If `Id` is named `ID` per Go convention, fix accordingly.
|
||||
|
||||
- [ ] **Step 4: Run the test, verify it passes**
|
||||
|
||||
```bash
|
||||
go test ./internal/audiobooks/ -run TestABSCollectionStoreListUserCollections -v
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Rewrite the remaining 7 methods using the same pattern**
|
||||
|
||||
For each method, apply this template:
|
||||
|
||||
| Method | Canonical operation |
|
||||
|---|---|
|
||||
| `GetCollection(id)` | `SELECT … WHERE id=$1 AND collection_type='manual'` |
|
||||
| `CreateCollection(c)` | `INSERT INTO user_personal_collections (...) VALUES (..., 'manual', ..., jsonb_build_object('library_ids', jsonb_build_array(c.LibraryId)))` |
|
||||
| `UpdateCollection(c)` | `UPDATE user_personal_collections SET name=$2, description=$3, is_shared=$4, query_definition=$5, updated_at=NOW() WHERE id=$1 AND collection_type='manual'` |
|
||||
| `DeleteCollection(id)` | `DELETE FROM user_personal_collections WHERE id=$1 AND collection_type='manual'` (cascades to items via the existing FK) |
|
||||
| `ListCollectionItems(collectionID)` | `SELECT media_item_id, sub_item_id, position FROM user_personal_collection_items WHERE collection_id=$1 ORDER BY position, added_at` — map `media_item_id`→`abs.CollectionItem.LibraryItemId`, `sub_item_id`→`abs.CollectionItem.EpisodeId` |
|
||||
| `AddCollectionItem(collectionID, libraryItemID)` | `INSERT INTO user_personal_collection_items (user_id, collection_id, media_item_id, sub_item_id, position, added_at) SELECT user_id, $1, $2, '', COALESCE(MAX(position)+1, 0), NOW() FROM user_personal_collections c LEFT JOIN user_personal_collection_items i ON i.collection_id = c.id WHERE c.id=$1 GROUP BY user_id` |
|
||||
| `RemoveCollectionItem(collectionID, libraryItemID)` | `DELETE FROM user_personal_collection_items WHERE collection_id=$1 AND media_item_id=$2 AND sub_item_id=''` |
|
||||
|
||||
Each gets its own targeted test mirroring Step 1's structure: insert seed data, call the method, assert the canonical-table side-effect (`SELECT … FROM user_personal_collection_items …`).
|
||||
|
||||
For brevity, the plan shows the SQL above as a table; the actual implementation has each method as a complete function. Reuse the connection-error-wrapping style and the `fmt.Errorf("...: %w", err)` pattern already in the file.
|
||||
|
||||
- [ ] **Step 6: Run all tests for the file**
|
||||
|
||||
```bash
|
||||
go test ./internal/audiobooks/ -run TestABSCollectionStore -v
|
||||
```
|
||||
|
||||
Expected: all PASS.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/audiobooks/abs_collection_store.go internal/audiobooks/abs_collection_store_test.go
|
||||
git commit -m "refactor(audiobooks): ABS collection store queries canonical tables
|
||||
|
||||
Rewrites ABSCollectionStore's 8 methods to read/write
|
||||
user_personal_collections (collection_type='manual') instead of the
|
||||
dropped abs_user_collections / abs_collection_items tables. Library
|
||||
scope is now encoded in query_definition.library_ids.
|
||||
|
||||
ABS HTTP handlers consume the same method signatures; wire shape
|
||||
is unchanged."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Rewrite `ABSPlaylistStore`
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/audiobooks/abs_playlist_store.go`
|
||||
- Test: `internal/audiobooks/abs_playlist_store_test.go` (create if missing)
|
||||
|
||||
Same pattern as Task 2. Methods: `ListUserPlaylists`, `GetPlaylist`, `coverArg`, `CreatePlaylist`, `UpdatePlaylist`, `DeletePlaylist`, `ListPlaylistItems`, `AddPlaylistItem`, `RemovePlaylistItem`.
|
||||
|
||||
Filter on `collection_type='playlist'` instead of `'manual'`.
|
||||
|
||||
**Special handling for `coverArg`:** `abs_playlists` has a `cover_item` foreign key to `media_items`; the canonical `user_personal_collections` has only `poster_url` (text). Per spec §6 open question, the design recommends dropping `cover_item` and regenerating poster URLs from the first item. For this rewrite:
|
||||
|
||||
- `coverArg` becomes a noop / removed: the new CreatePlaylist doesn't accept a cover_item field on insert.
|
||||
- The wire-shape struct can keep its `CoverItem` field; the rewrite always serializes it as `""` (empty). If the ABS app surfaces a cover, it falls back to the first item's poster — same as the audiobook UI does today.
|
||||
|
||||
- [ ] **Step 1: Write a failing test for `ListUserPlaylists`**
|
||||
|
||||
```go
|
||||
func TestABSPlaylistStoreListUserPlaylists(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("requires test DB")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool := newTestPool(t)
|
||||
defer pool.Close()
|
||||
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO user_personal_collections
|
||||
(id, user_id, profile_id, name, description, collection_type,
|
||||
is_shared, created_at, updated_at, creator_profile_id)
|
||||
VALUES
|
||||
('test-pl-1', 1, '', 'TestPlaylist', 'desc', 'playlist',
|
||||
false, NOW(), NOW(), '')
|
||||
`)
|
||||
if err != nil { t.Fatalf("seed: %v", err) }
|
||||
|
||||
store := &ABSPlaylistStore{pool: pool}
|
||||
got, err := store.ListUserPlaylists(ctx, "1", "")
|
||||
if err != nil { t.Fatalf("ListUserPlaylists: %v", err) }
|
||||
if len(got) != 1 || got[0].Id != "test-pl-1" || got[0].Name != "TestPlaylist" {
|
||||
t.Errorf("unexpected: %+v", got)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test, fail, then rewrite**
|
||||
|
||||
```bash
|
||||
go test ./internal/audiobooks/ -run TestABSPlaylistStoreListUserPlaylists -v
|
||||
```
|
||||
|
||||
Expected: FAIL until rewrite lands.
|
||||
|
||||
- [ ] **Step 3: Rewrite `ListUserPlaylists`**
|
||||
|
||||
```go
|
||||
func (s *ABSPlaylistStore) ListUserPlaylists(ctx context.Context, userID, profileID string) ([]abs.Playlist, error) {
|
||||
const q = `
|
||||
SELECT id, name, description, user_id, is_shared, created_at, updated_at
|
||||
FROM user_personal_collections
|
||||
WHERE collection_type = 'playlist'
|
||||
AND user_id = $1::int
|
||||
AND (profile_id = $2 OR ($2 = '' AND profile_id = ''))
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
rows, err := s.pool.Query(ctx, q, userID, profileID)
|
||||
if err != nil { return nil, fmt.Errorf("abs playlist list: %w", err) }
|
||||
defer rows.Close()
|
||||
|
||||
var out []abs.Playlist
|
||||
for rows.Next() {
|
||||
var p abs.Playlist
|
||||
if err := rows.Scan(&p.Id, &p.Name, &p.Description, &p.UserId, &p.IsPublic, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan abs playlist: %w", err)
|
||||
}
|
||||
// CoverItem deliberately left as zero value; see plan + spec §6.
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Rewrite the remaining methods**
|
||||
|
||||
| Method | Canonical operation |
|
||||
|---|---|
|
||||
| `GetPlaylist(id)` | `SELECT … WHERE id=$1 AND collection_type='playlist'` |
|
||||
| `CreatePlaylist(p)` | `INSERT … VALUES (..., 'playlist', ...)` — drop cover_item handling |
|
||||
| `UpdatePlaylist(p)` | `UPDATE … SET name=$2, description=$3, is_shared=$4, updated_at=NOW() WHERE id=$1 AND collection_type='playlist'` |
|
||||
| `DeletePlaylist(id)` | `DELETE FROM user_personal_collections WHERE id=$1 AND collection_type='playlist'` |
|
||||
| `ListPlaylistItems(playlistID)` | `SELECT media_item_id, sub_item_id, position FROM user_personal_collection_items WHERE collection_id=$1 ORDER BY position, added_at` — map `sub_item_id`→`abs.PlaylistItem.EpisodeId` (empty string when no episode) |
|
||||
| `AddPlaylistItem(playlistID, libraryItemID, episodeID)` | `INSERT INTO user_personal_collection_items (user_id, collection_id, media_item_id, sub_item_id, position, added_at) SELECT user_id, $1, $2, $3, COALESCE(MAX(i.position)+1, 0), NOW() FROM user_personal_collections c LEFT JOIN user_personal_collection_items i ON i.collection_id = c.id WHERE c.id=$1 GROUP BY user_id` |
|
||||
| `RemovePlaylistItem(playlistID, libraryItemID, episodeID)` | `DELETE FROM user_personal_collection_items WHERE collection_id=$1 AND media_item_id=$2 AND sub_item_id=$3` |
|
||||
| `coverArg(cover string)` | Delete entirely — no longer used. |
|
||||
|
||||
- [ ] **Step 5: Run all playlist tests**
|
||||
|
||||
```bash
|
||||
go test ./internal/audiobooks/ -run TestABSPlaylistStore -v
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/audiobooks/abs_playlist_store.go internal/audiobooks/abs_playlist_store_test.go
|
||||
git commit -m "refactor(audiobooks): ABS playlist store queries canonical tables
|
||||
|
||||
Rewrites ABSPlaylistStore methods to read/write user_personal_collections
|
||||
(collection_type='playlist') and user_personal_collection_items
|
||||
(sub_item_id for episode-level entries). Drops the cover_item field
|
||||
(no longer modeled in the canonical store; clients fall back to
|
||||
first-item poster, same as audiobook UI today).
|
||||
|
||||
Wire shape preserved otherwise."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Rewrite `ABSSmartCollectionStore`
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/audiobooks/abs_smart_collection_store.go`
|
||||
- Test: `internal/audiobooks/abs_smart_collection_store_test.go` (create if missing)
|
||||
|
||||
Filter on `collection_type='smart'`. The rule DSL stored in `query_definition` (jsonb) — same column the silo-native query_definition uses. Note: the source column on `abs_smart_collections` was named `query_def` (not `query_definition`); the canonical column is `query_definition`. **Read+write target the canonical name.**
|
||||
|
||||
`color` and `is_pinned` from `abs_smart_collections` have no canonical equivalent (spec §6 defers them). The rewrite emits empty/false for those fields in the JSON output. If the ABS app silently relies on either, ship a follow-up to add the columns.
|
||||
|
||||
- [ ] **Step 1: Write a failing test**
|
||||
|
||||
```go
|
||||
func TestABSSmartCollectionStoreListUserSmart(t *testing.T) {
|
||||
if testing.Short() { t.Skip("requires test DB") }
|
||||
ctx := context.Background()
|
||||
pool := newTestPool(t)
|
||||
defer pool.Close()
|
||||
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO user_personal_collections
|
||||
(id, user_id, profile_id, name, description, collection_type,
|
||||
is_shared, created_at, updated_at, creator_profile_id, query_definition)
|
||||
VALUES
|
||||
('test-sc-1', 1, '', 'TestSmart', '', 'smart',
|
||||
false, NOW(), NOW(), '', '{"rules":[]}'::jsonb)
|
||||
`)
|
||||
if err != nil { t.Fatalf("seed: %v", err) }
|
||||
|
||||
store := &ABSSmartCollectionStore{pool: pool}
|
||||
got, err := store.ListUserSmartCollections(ctx, "1", "")
|
||||
if err != nil { t.Fatalf("ListUserSmartCollections: %v", err) }
|
||||
if len(got) != 1 || got[0].Id != "test-sc-1" {
|
||||
t.Errorf("unexpected: %+v", got)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Rewrite the 6 methods**
|
||||
|
||||
| Method | Canonical operation |
|
||||
|---|---|
|
||||
| `ListUserSmartCollections` | `SELECT id, name, description, query_definition, user_id, is_shared FROM user_personal_collections WHERE collection_type='smart' AND user_id=$1::int AND (profile_id=$2 OR ...) ORDER BY created_at DESC` |
|
||||
| `GetSmartCollection(id)` | same with `WHERE id=$1 AND collection_type='smart'` |
|
||||
| `CreateSmartCollection(c)` | `INSERT … 'smart' … query_definition=$N::jsonb` |
|
||||
| `UpdateSmartCollection(c)` | `UPDATE … SET name=$2, description=$3, query_definition=$4, is_shared=$5, updated_at=NOW() WHERE id=$1 AND collection_type='smart'` |
|
||||
| `DeleteSmartCollection(id)` | `DELETE FROM user_personal_collections WHERE id=$1 AND collection_type='smart'` |
|
||||
| Materializer (if present) | Calls `internal/smartcoll.Evaluate(...)` against `media_items` filtered by `query_definition.library_ids` |
|
||||
|
||||
For `color` and `is_pinned`: read from `abs.SmartCollection` zero-value on read paths; ignore on write paths. If the wire shape unconditionally includes them, leave the zero values in place — clients receive `"color":"", "is_pinned":false`.
|
||||
|
||||
- [ ] **Step 3: Tests + commit**
|
||||
|
||||
Same shape as Tasks 2 and 3. Commit message:
|
||||
|
||||
```
|
||||
refactor(audiobooks): ABS smart collection store queries canonical tables
|
||||
|
||||
Rewrites ABSSmartCollectionStore methods to read/write user_personal_collections
|
||||
(collection_type='smart'). Rule DSL goes into query_definition (formerly
|
||||
the abs_smart_collections.query_def column).
|
||||
|
||||
color and is_pinned have no canonical analog; emitted as zero values.
|
||||
See spec §6 for the deferred decision on those columns.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Wire-shape regression test (snapshot diff)
|
||||
|
||||
**Files:**
|
||||
- Add or extend: an integration test that drives the ABS handlers and asserts the JSON output for each endpoint hasn't changed.
|
||||
|
||||
If a snapshot/golden-file test framework already exists in the repo, use it (`grep -rln 'goldenfile\|snapshot' --include="*_test.go" internal/audiobooks/`). Otherwise this task is **manual verification** captured in the MR description:
|
||||
|
||||
- [ ] **Step 1: Seed identical fixtures pre- and post-rewrite**
|
||||
|
||||
Before the rewrite commits, seed one of each:
|
||||
- An `abs_user_collections` row (pre-migration) with name "FixtureColl", 1 item.
|
||||
- An `abs_playlists` row (pre-migration) with name "FixturePL", 1 item with sub_item.
|
||||
- An `abs_smart_collections` row (pre-migration) with name "FixtureSmart", rules `{"any":[…]}`.
|
||||
|
||||
Capture the JSON response from each list endpoint. Save to `/tmp/wire_before_*.json`.
|
||||
|
||||
- [ ] **Step 2: Run migration 156, then seed equivalent rows in canonical tables**
|
||||
|
||||
Insert one of each `user_personal_collections` row with the same Id, Name, etc.
|
||||
|
||||
Capture the JSON response. Save to `/tmp/wire_after_*.json`.
|
||||
|
||||
- [ ] **Step 3: Diff**
|
||||
|
||||
```bash
|
||||
for kind in collections playlists smart_collections; do
|
||||
diff /tmp/wire_before_${kind}.json /tmp/wire_after_${kind}.json
|
||||
done
|
||||
```
|
||||
|
||||
Expected: empty diff for each. If anything differs, note the field and either patch the rewrite to match or flag it as a wire-shape regression in the PR description so the ABS team is aware.
|
||||
|
||||
- [ ] **Step 4: Commit the test (if framework supports it) or paste the diff into the PR description**
|
||||
|
||||
```bash
|
||||
git add internal/audiobooks/abs_wireshape_test.go # if applicable
|
||||
git commit -m "test(audiobooks): wire-shape snapshot for ABS adapters
|
||||
|
||||
Locks in JSON parity for /api/libraries/{id}/collections|playlists|
|
||||
smart-collections after the canonical-table cutover."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification (after merge)
|
||||
|
||||
1. Silo binary starts cleanly against a migration-156 DB.
|
||||
2. ABS endpoints respond:
|
||||
- `GET /api/libraries/9/collections` — returns the 0 manual collections, no errors.
|
||||
- `GET /api/libraries/9/playlists` — returns the 1 promoted playlist with `Id` preserved.
|
||||
- `GET /api/libraries/9/smart-collections` — returns 0 smart collections, no errors.
|
||||
3. ABS Android app (or whatever client is in use) opens the library, sees the same content it saw pre-migration.
|
||||
4. Creating a new collection from the ABS app inserts into `user_personal_collections`, not anywhere else:
|
||||
|
||||
```sql
|
||||
SELECT collection_type, COUNT(*) FROM user_personal_collections GROUP BY 1;
|
||||
```
|
||||
|
||||
Expected: counts increase for the relevant `collection_type` as the user creates content.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
- Collection store rewrite ✓ (Task 2)
|
||||
- Playlist store rewrite ✓ (Task 3)
|
||||
- Smart collection store rewrite ✓ (Task 4)
|
||||
- Wire-shape preservation ✓ (Tasks 1, 5)
|
||||
- Drop cover_item ✓ (Task 3, per spec §6)
|
||||
- color / is_pinned deferred ✓ (Task 4, per spec §6)
|
||||
|
||||
**Placeholder scan:** Task 2 Step 5 lists 7 remaining methods as a table rather than spelling out the full Go body of each. The table gives the exact SQL semantics, the column mappings, and a pointer at the existing file's error-wrap style — sufficient for a competent implementer to write the bodies. The full code for each method would balloon this plan from ~700 to ~2000 lines; the table form is the right granularity. Same applies to Task 3 Step 4 and Task 4 Step 2. Each table entry could equally be a TDD task on its own; the implementer is free to expand any row into a write-test-fail-implement-pass-commit cycle.
|
||||
|
||||
**Type consistency:** `abs.Collection`, `abs.Playlist`, `abs.SmartCollection`, `abs.CollectionItem`, `abs.PlaylistItem` referenced consistently. `collection_type` enum values `'manual'`, `'playlist'`, `'smart'` consistent. `sub_item_id` column name consistent.
|
||||
|
||||
**Risk:** Largest sub-project of the four. Three files rewritten end-to-end. The biggest risk is wire-shape regressions where a struct field's serialized name differs subtly between adapters — Task 5 mitigates with the diff approach, but it's manual unless a snapshot harness exists. Sub-project 1 must land first; sub-project 2 must land first if Task 4 imports the new `internal/smartcoll` path.
|
||||
@@ -0,0 +1,711 @@
|
||||
# Collections Unification — Sub-project 4: Section Recipes for Audiobooks
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make `page_sections` media-type-parameterized so existing recipes can include audiobooks, and add two new audiobook-flavored recipes (`continue_listening`, `by_audiobook_series`).
|
||||
|
||||
**Architecture:** Each recipe declaration in `internal/sections/recipes/` exposes a `SupportedMediaTypes` field. `PageSection` rows carry a `media_types text[]` column (added by migration 156 — sub-project 1). The fetchers add `WHERE mi.type = ANY($media_types)` to their queries. Existing recipes default to `['movie','series']` so current behavior is preserved.
|
||||
|
||||
**Tech Stack:** Go, PostgreSQL, existing `internal/sections/` + `internal/sections/recipes/` packages.
|
||||
|
||||
**Commands assume the repository root is the cwd.**
|
||||
|
||||
**Source spec:** `docs/superpowers/specs/2026-05-27-unified-audiobook-collections-design.md` §4.4.
|
||||
|
||||
**Predecessor sub-project:** Sub-project 1 (migration 156) must land first — `page_sections.media_types` is required.
|
||||
|
||||
---
|
||||
|
||||
## File map
|
||||
|
||||
**Modify:**
|
||||
- `internal/sections/types.go` — add `media_types []string` to the `PageSection` Go struct
|
||||
- `internal/sections/registry.go` (or wherever recipes are registered — verify via grep) — surface `SupportedMediaTypes()` on the Recipe interface
|
||||
- `internal/sections/recipes/*.go` — every recipe file declares its supported types
|
||||
- `internal/sections/recipes/registry.go` — recipe registry exposes the supported-types info to the admin API
|
||||
- Section fetchers (likely `internal/sections/fetcher.go` or similar) — wire `media_types` into the SQL `WHERE` clause
|
||||
|
||||
**Create:**
|
||||
- `internal/sections/recipes/continue_listening.go` + test
|
||||
- `internal/sections/recipes/by_audiobook_series.go` + test
|
||||
|
||||
**Add to admin API:**
|
||||
- `internal/api/handlers/recipes.go` — the `GET /api/sections/recipes` endpoint surfaces the per-recipe supported-media-types in its response
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Audit current recipe shape
|
||||
|
||||
This task is read-only — produces a working note for the rest of the plan.
|
||||
|
||||
- [ ] **Step 1: Map the current recipe interface**
|
||||
|
||||
```bash
|
||||
grep -nE "type Recipe interface|type RecipeDefinition|func.*Type\(\) string|func.*Definition\(\)|SupportedMediaTypes" internal/sections/recipes/*.go internal/sections/types.go internal/sections/registry.go 2>/dev/null
|
||||
```
|
||||
|
||||
Read the `Recipe` interface and any current registration code. Note:
|
||||
- The exact method signatures recipes already implement.
|
||||
- How a recipe is registered (`init()` calls? explicit `Register`? slice literal?).
|
||||
- Whether `RecipeDefinition` carries metadata returned to the admin UI; that's where `SupportedMediaTypes` will surface.
|
||||
|
||||
- [ ] **Step 2: Map the fetcher's current SQL**
|
||||
|
||||
```bash
|
||||
grep -nE "FetchOne|FetchSection|SELECT.*FROM media_items|type Fetcher" internal/sections/*.go 2>/dev/null | head -20
|
||||
```
|
||||
|
||||
Find the function that turns a `ResolvedSection` into a list of `media_items`. Note its query shape — where the `WHERE` clause lives, what parameters it already takes.
|
||||
|
||||
- [ ] **Step 3: Note the section persistence shape**
|
||||
|
||||
```bash
|
||||
grep -nE "INSERT INTO page_sections|UPDATE page_sections|SELECT.*FROM page_sections" internal/sections/*.go internal/api/handlers/sections*.go 2>/dev/null | head -10
|
||||
```
|
||||
|
||||
Find where `page_sections` rows are read and written. The `media_types` column needs to be read/written there too.
|
||||
|
||||
No commit for this task — it produces a mental map you'll reference in Tasks 2–5.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Add `media_types` to the `PageSection` struct + serde
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/sections/types.go` (`PageSection` struct)
|
||||
- Modify: wherever `page_sections` rows are scanned and written (from Task 1 Step 3)
|
||||
|
||||
- [ ] **Step 1: Write failing test for `media_types` round-trip**
|
||||
|
||||
The test goes in whichever package owns the `PageSection` repository — likely `internal/sections/` itself. If a repository test file exists, append; otherwise create one:
|
||||
|
||||
```go
|
||||
func TestPageSectionMediaTypesRoundTrip(t *testing.T) {
|
||||
if testing.Short() { t.Skip("requires test DB") }
|
||||
ctx := context.Background()
|
||||
pool := newTestPool(t) // adapt to project's test pool harness
|
||||
|
||||
repo := NewSectionRepository(pool)
|
||||
|
||||
sec := &PageSection{
|
||||
Name: "Audiobook test",
|
||||
RecipeType: "library_staples",
|
||||
MediaTypes: []string{"audiobook"},
|
||||
// ...fill in other required fields from the struct definition
|
||||
}
|
||||
if err := repo.Create(ctx, sec); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
got, err := repo.GetByID(ctx, sec.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetByID: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(got.MediaTypes, []string{"audiobook"}) {
|
||||
t.Errorf("MediaTypes = %v, want [audiobook]", got.MediaTypes)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Adapt the constructor name, struct fields, and DB harness to actual project shape (found in Task 1).
|
||||
|
||||
- [ ] **Step 2: Run the test to confirm failure**
|
||||
|
||||
Run: `go test ./internal/sections/ -run TestPageSectionMediaTypesRoundTrip -v`
|
||||
Expected: FAIL — the `MediaTypes` field doesn't exist yet.
|
||||
|
||||
- [ ] **Step 3: Add the field to `PageSection`**
|
||||
|
||||
In `internal/sections/types.go`, find the `PageSection` struct (or whatever it's called per Task 1's audit) and add:
|
||||
|
||||
```go
|
||||
type PageSection struct {
|
||||
// ...existing fields
|
||||
MediaTypes []string `json:"media_types" db:"media_types"`
|
||||
}
|
||||
```
|
||||
|
||||
If the project's struct tagging convention differs, follow that convention. The DB column is `text[]`; the Go type is `[]string`. pgx's default decoder handles this directly.
|
||||
|
||||
- [ ] **Step 4: Update the repository's read/write SQL**
|
||||
|
||||
For each `INSERT`/`UPDATE` against `page_sections`, add `media_types` to the column list and bind parameter list. For each `SELECT`, add it to the projection and scan into the new field.
|
||||
|
||||
Example pattern (adapt to actual code):
|
||||
|
||||
```go
|
||||
const insertSQL = `
|
||||
INSERT INTO page_sections (id, name, recipe_type, ..., media_types)
|
||||
VALUES ($1, $2, $3, ..., $N::text[])
|
||||
`
|
||||
// ...
|
||||
_, err := pool.Exec(ctx, insertSQL, sec.ID, sec.Name, sec.RecipeType, ..., sec.MediaTypes)
|
||||
```
|
||||
|
||||
For SELECTs, add `media_types` to the projection and `&sec.MediaTypes` to the scan list.
|
||||
|
||||
- [ ] **Step 5: Run the test, verify pass**
|
||||
|
||||
Run: `go test ./internal/sections/ -run TestPageSectionMediaTypesRoundTrip -v`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Build + broader test**
|
||||
|
||||
```bash
|
||||
go build ./...
|
||||
go test ./internal/sections/ ./internal/api/handlers/ -short -timeout 90s
|
||||
```
|
||||
|
||||
Expected: clean.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/sections/types.go internal/sections/*.go internal/api/handlers/sections*.go
|
||||
git commit -m "feat(sections): expose page_sections.media_types in Go
|
||||
|
||||
Adds MediaTypes []string to PageSection + repo read/write paths.
|
||||
Migration 156 (sub-project 1) added the underlying column with
|
||||
DEFAULT ARRAY['movie','series']; existing rows retain that default,
|
||||
preserving today's behavior."
|
||||
```
|
||||
|
||||
Only stage the files you actually modified. Verify with `git diff --cached --stat` before commit.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Recipe interface — `SupportedMediaTypes`
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/sections/recipes/registry.go` (interface declaration)
|
||||
- Modify: every `*.go` recipe file in `internal/sections/recipes/` (each gains a one-line method)
|
||||
|
||||
- [ ] **Step 1: Find the Recipe interface**
|
||||
|
||||
```bash
|
||||
grep -n "type Recipe " internal/sections/recipes/*.go internal/sections/*.go
|
||||
```
|
||||
|
||||
Note the file + line of the interface declaration.
|
||||
|
||||
- [ ] **Step 2: Add a `SupportedMediaTypes()` method to the interface**
|
||||
|
||||
In whichever file declares the interface:
|
||||
|
||||
```go
|
||||
type Recipe interface {
|
||||
// ...existing methods
|
||||
SupportedMediaTypes() []string
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Implement on every existing recipe**
|
||||
|
||||
For each `*Recipe` struct in `internal/sections/recipes/`, add the method. Existing recipes default to movies+series to preserve behavior:
|
||||
|
||||
```go
|
||||
func (libStaple) SupportedMediaTypes() []string { return []string{"movie", "series"} }
|
||||
func (moodRecipe) SupportedMediaTypes() []string { return []string{"movie", "series"} }
|
||||
func (handPickedRecipe) SupportedMediaTypes() []string { return []string{"movie", "series"} }
|
||||
// ...and so on for every recipe in the directory
|
||||
```
|
||||
|
||||
Use this enumeration as a checklist (from `ls internal/sections/recipes/*.go`, excluding tests):
|
||||
|
||||
- admin_curated_list
|
||||
- custom
|
||||
- discovery
|
||||
- editorial
|
||||
- hand_picked
|
||||
- library_staples
|
||||
- mood
|
||||
- personalized
|
||||
|
||||
Each gets one method. Audiobook-eligible recipes — `library_staples`, `discovery`, `hand_picked`, `mood`, `personalized` — extend to include `"audiobook"` if their underlying query already handles all `media_items.type` values transparently. Conservative call: only `library_staples` extends to all three on this pass. The rest stay movies+series until each one's recipe-specific query is audited. **Bias toward the conservative default.**
|
||||
|
||||
Specifically:
|
||||
|
||||
```go
|
||||
func (libStaple) SupportedMediaTypes() []string { return []string{"movie", "series", "audiobook"} }
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build**
|
||||
|
||||
```bash
|
||||
go build ./...
|
||||
```
|
||||
|
||||
Expected: clean. If any recipe is missed, the Go compiler will complain that the type doesn't satisfy the interface.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/sections/recipes/
|
||||
git commit -m "feat(recipes): SupportedMediaTypes per recipe
|
||||
|
||||
Adds the SupportedMediaTypes() method to the Recipe interface and
|
||||
implements it on each existing recipe. Conservative defaults preserve
|
||||
today's behavior: only library_staples opens to audiobooks; others
|
||||
stay movies+series until their underlying queries are audited."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Fetcher applies the `media_types` filter
|
||||
|
||||
**Files:**
|
||||
- Modify: the section fetcher implementation file (found in Task 1 Step 2)
|
||||
|
||||
- [ ] **Step 1: Write a failing test**
|
||||
|
||||
Append to (or create) the fetcher's test file. Pattern:
|
||||
|
||||
```go
|
||||
func TestFetcherFiltersByMediaTypes(t *testing.T) {
|
||||
if testing.Short() { t.Skip("requires test DB") }
|
||||
ctx := context.Background()
|
||||
pool := newTestPool(t)
|
||||
|
||||
// Seed: one movie, one audiobook, both eligible for the same recipe.
|
||||
seedTestMediaItem(t, pool, "mov-1", "movie", "Test Movie")
|
||||
seedTestMediaItem(t, pool, "ab-1", "audiobook", "Test Book")
|
||||
|
||||
fetcher := NewFetcher(pool)
|
||||
|
||||
t.Run("media_types movies only", func(t *testing.T) {
|
||||
got, _ := fetcher.FetchOne(ctx, ResolvedSection{
|
||||
SectionType: "library_staples",
|
||||
MediaTypes: []string{"movie"},
|
||||
}, nil, nil, 1, "", catalog.AccessFilter{})
|
||||
assertContains(t, got.Items, "mov-1")
|
||||
assertExcludes(t, got.Items, "ab-1")
|
||||
})
|
||||
|
||||
t.Run("media_types audiobook only", func(t *testing.T) {
|
||||
got, _ := fetcher.FetchOne(ctx, ResolvedSection{
|
||||
SectionType: "library_staples",
|
||||
MediaTypes: []string{"audiobook"},
|
||||
}, nil, nil, 1, "", catalog.AccessFilter{})
|
||||
assertContains(t, got.Items, "ab-1")
|
||||
assertExcludes(t, got.Items, "mov-1")
|
||||
})
|
||||
|
||||
t.Run("media_types both", func(t *testing.T) {
|
||||
got, _ := fetcher.FetchOne(ctx, ResolvedSection{
|
||||
SectionType: "library_staples",
|
||||
MediaTypes: []string{"movie", "audiobook"},
|
||||
}, nil, nil, 1, "", catalog.AccessFilter{})
|
||||
assertContains(t, got.Items, "mov-1", "ab-1")
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Adapt `ResolvedSection`, `FetchOne` signature, `seedTestMediaItem`, `assertContains` / `assertExcludes` to the project's actual shapes. The exact assertions depend on what `SectionWithItems` exposes — likely a `.Items []SectionItem` slice.
|
||||
|
||||
- [ ] **Step 2: Run the test, verify fail**
|
||||
|
||||
```bash
|
||||
go test ./internal/sections/ -run TestFetcherFiltersByMediaTypes -v
|
||||
```
|
||||
|
||||
Expected: FAIL — `ResolvedSection.MediaTypes` field doesn't exist yet OR the fetcher SQL doesn't filter.
|
||||
|
||||
- [ ] **Step 3: Add `MediaTypes` to `ResolvedSection`**
|
||||
|
||||
In `internal/sections/types.go` (or wherever `ResolvedSection` lives):
|
||||
|
||||
```go
|
||||
type ResolvedSection struct {
|
||||
// ...existing fields
|
||||
MediaTypes []string
|
||||
}
|
||||
```
|
||||
|
||||
The resolver code that builds `ResolvedSection` from a `PageSection` row needs one more line to pass `MediaTypes` through. Find it:
|
||||
|
||||
```bash
|
||||
grep -n "ResolvedSection{" internal/sections/*.go internal/api/handlers/*.go
|
||||
```
|
||||
|
||||
For each instance that builds one from a `PageSection`, add `MediaTypes: section.MediaTypes,`.
|
||||
|
||||
- [ ] **Step 4: Wire the filter into the fetcher SQL**
|
||||
|
||||
In the fetcher implementation, find the query that pulls `media_items`. Add to its `WHERE` clause:
|
||||
|
||||
```go
|
||||
const query = `
|
||||
SELECT ...
|
||||
FROM media_items mi
|
||||
WHERE ...
|
||||
AND mi.type = ANY($N::text[])
|
||||
...
|
||||
`
|
||||
// ...
|
||||
rows, err := pool.Query(ctx, query, ..., mediaTypes)
|
||||
```
|
||||
|
||||
If the fetcher dispatches to per-recipe resolvers, the filter goes inside each resolver's query. Most likely there's a shared query-building helper — find it via `grep -n "type = ANY\|recipe.*Resolve" internal/sections/`.
|
||||
|
||||
If the fetcher receives a `ResolvedSection` and dispatches to recipe resolvers based on `SectionType`, the resolvers each accept the `MediaTypes` slice as part of their `ResolverContext` (or whatever the recipe-side context type is). Add a field to that context type and thread it through.
|
||||
|
||||
**Validation rule**: the fetcher must reject a request where `ResolvedSection.MediaTypes` contains a type not in the recipe's `SupportedMediaTypes()`. Add this guard in the dispatch path:
|
||||
|
||||
```go
|
||||
recipe, ok := registry.Get(resolved.SectionType)
|
||||
if !ok { return result, fmt.Errorf("unknown recipe %q", resolved.SectionType) }
|
||||
supported := setOf(recipe.SupportedMediaTypes())
|
||||
for _, mt := range resolved.MediaTypes {
|
||||
if _, ok := supported[mt]; !ok {
|
||||
return result, fmt.Errorf("recipe %q does not support media type %q", resolved.SectionType, mt)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`setOf` is a 3-line helper that returns `map[string]struct{}` from a slice.
|
||||
|
||||
- [ ] **Step 5: Run the test, verify pass**
|
||||
|
||||
```bash
|
||||
go test ./internal/sections/ -run TestFetcherFiltersByMediaTypes -v
|
||||
```
|
||||
|
||||
Expected: PASS for all three subtests.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/sections/
|
||||
git commit -m "feat(sections): fetcher honors media_types filter
|
||||
|
||||
ResolvedSection carries a MediaTypes slice. Fetcher dispatch validates
|
||||
the slice against the recipe's SupportedMediaTypes() and forwards it
|
||||
into the underlying SQL (WHERE mi.type = ANY(\$N::text[]))."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: New recipe — `continue_listening`
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/sections/recipes/continue_listening.go`
|
||||
- Create: `internal/sections/recipes/continue_listening_test.go`
|
||||
|
||||
The audiobook analog of `continue_watching`. Surfaces audiobook items the user has progress on but hasn't finished.
|
||||
|
||||
- [ ] **Step 1: Read the existing `continue_watching` recipe (if present)**
|
||||
|
||||
```bash
|
||||
ls internal/sections/recipes/ | grep -i continue
|
||||
grep -rln "continue_watching\|user_watch_progress" internal/sections/recipes/ internal/sections/
|
||||
```
|
||||
|
||||
Identify the closest analog. Copy its overall structure (registration, params struct, Resolve method shape). The audiobook version differs only in `type = 'audiobook'` filter and possibly which progress columns it inspects (audiobook progress may live in the same `user_watch_progress` table — verify with `\d user_watch_progress` against the DB).
|
||||
|
||||
- [ ] **Step 2: Write a failing test**
|
||||
|
||||
```go
|
||||
package recipes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestContinueListeningRecipeRegistered(t *testing.T) {
|
||||
r, ok := registry.Get("continue_listening")
|
||||
if !ok {
|
||||
t.Fatal("continue_listening recipe not registered")
|
||||
}
|
||||
got := r.SupportedMediaTypes()
|
||||
want := []string{"audiobook"}
|
||||
if !equalStringSlices(got, want) {
|
||||
t.Errorf("SupportedMediaTypes = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContinueListeningResolvesProgressedAudiobooks(t *testing.T) {
|
||||
if testing.Short() { t.Skip("requires test DB") }
|
||||
// Seed two audiobook items: one with progress > 0 and not finished;
|
||||
// one with progress = 0. Run the recipe's Resolve method. Assert only
|
||||
// the first is returned.
|
||||
t.Skip("Implement once existing continue_watching test pattern is read")
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Implement the recipe**
|
||||
|
||||
```go
|
||||
// Package recipes
|
||||
package recipes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
type continueListeningRecipe struct{}
|
||||
|
||||
type ContinueListeningParams struct {
|
||||
// e.g. max items, library scope; mirror continue_watching's shape
|
||||
MaxItems int `json:"max_items"`
|
||||
}
|
||||
|
||||
func (continueListeningRecipe) Type() string { return "continue_listening" }
|
||||
func (continueListeningRecipe) NewParams() any { return &ContinueListeningParams{} }
|
||||
func (continueListeningRecipe) DefaultCacheTTL() time.Duration { return 5 * time.Minute }
|
||||
func (continueListeningRecipe) SupportedMediaTypes() []string { return []string{"audiobook"} }
|
||||
|
||||
func (continueListeningRecipe) Resolve(rc ResolverContext) (ResolvedItems, error) {
|
||||
const q = `
|
||||
SELECT mi.content_id, mi.title, mi.year, mi.poster_path
|
||||
FROM media_items mi
|
||||
JOIN user_watch_progress uwp ON uwp.content_id = mi.content_id
|
||||
WHERE mi.type = 'audiobook'
|
||||
AND uwp.user_id = $1
|
||||
AND uwp.position_seconds > 0
|
||||
AND COALESCE(uwp.completed, false) = false
|
||||
ORDER BY uwp.updated_at DESC
|
||||
LIMIT $2
|
||||
`
|
||||
params := rc.Params.(*ContinueListeningParams)
|
||||
limit := params.MaxItems
|
||||
if limit <= 0 || limit > 50 { limit = 20 }
|
||||
|
||||
rows, err := rc.Pool.Query(rc.Ctx, q, rc.UserID, limit)
|
||||
if err != nil { return ResolvedItems{}, fmt.Errorf("continue_listening: %w", err) }
|
||||
defer rows.Close()
|
||||
|
||||
var items []ResolvedItem
|
||||
for rows.Next() {
|
||||
var it ResolvedItem
|
||||
if err := rows.Scan(&it.ContentID, &it.Title, &it.Year, &it.PosterPath); err != nil {
|
||||
return ResolvedItems{}, fmt.Errorf("continue_listening scan: %w", err)
|
||||
}
|
||||
items = append(items, it)
|
||||
}
|
||||
return ResolvedItems{Items: items}, rows.Err()
|
||||
}
|
||||
|
||||
func (continueListeningRecipe) Validate(raw json.RawMessage) error {
|
||||
var p ContinueListeningParams
|
||||
return json.Unmarshal(raw, &p)
|
||||
}
|
||||
|
||||
func (continueListeningRecipe) Definition() RecipeDefinition {
|
||||
return RecipeDefinition{
|
||||
Type: "continue_listening",
|
||||
Name: "Continue Listening",
|
||||
Description: "Audiobooks you've started but haven't finished.",
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
registry.Register(continueListeningRecipe{})
|
||||
}
|
||||
```
|
||||
|
||||
**Adapt the field names** (`PosterPath`, `Pool`, `UserID`, `Ctx`, `Params`, etc.) to whatever the existing recipes use. **Adapt the SQL** to the actual `user_watch_progress` column names — verify with `\d user_watch_progress` against the DB.
|
||||
|
||||
- [ ] **Step 4: Run tests, verify pass**
|
||||
|
||||
```bash
|
||||
go test ./internal/sections/recipes/ -run TestContinueListening -v
|
||||
```
|
||||
|
||||
Expected: registration test PASS; the resolve test still skipped (or implemented and passing).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/sections/recipes/continue_listening.go internal/sections/recipes/continue_listening_test.go
|
||||
git commit -m "feat(recipes): continue_listening audiobook rail
|
||||
|
||||
Audiobook analog of continue_watching. Returns books the user has
|
||||
progress on but hasn't finished, sorted by most-recently-listened."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: New recipe — `by_audiobook_series`
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/sections/recipes/by_audiobook_series.go`
|
||||
- Create: `internal/sections/recipes/by_audiobook_series_test.go`
|
||||
|
||||
Surfaces books grouped by series, drawing from the `audiobook_series` table populated by the scanner.
|
||||
|
||||
- [ ] **Step 1: Write the registration test**
|
||||
|
||||
```go
|
||||
func TestByAudiobookSeriesRecipeRegistered(t *testing.T) {
|
||||
r, ok := registry.Get("by_audiobook_series")
|
||||
if !ok { t.Fatal("by_audiobook_series not registered") }
|
||||
if got := r.SupportedMediaTypes(); !equalStringSlices(got, []string{"audiobook"}) {
|
||||
t.Errorf("SupportedMediaTypes = %v, want [audiobook]", got)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Implement the recipe**
|
||||
|
||||
```go
|
||||
type byAudiobookSeriesRecipe struct{}
|
||||
|
||||
type ByAudiobookSeriesParams struct {
|
||||
SeriesName string `json:"series_name"`
|
||||
}
|
||||
|
||||
func (byAudiobookSeriesRecipe) Type() string { return "by_audiobook_series" }
|
||||
func (byAudiobookSeriesRecipe) NewParams() any { return &ByAudiobookSeriesParams{} }
|
||||
func (byAudiobookSeriesRecipe) DefaultCacheTTL() time.Duration { return 30 * time.Minute }
|
||||
func (byAudiobookSeriesRecipe) SupportedMediaTypes() []string { return []string{"audiobook"} }
|
||||
|
||||
func (byAudiobookSeriesRecipe) Resolve(rc ResolverContext) (ResolvedItems, error) {
|
||||
const q = `
|
||||
SELECT mi.content_id, mi.title, mi.year, mi.poster_path
|
||||
FROM media_items mi
|
||||
JOIN audiobook_series s ON s.content_id = mi.content_id
|
||||
WHERE mi.type = 'audiobook'
|
||||
AND s.series_name = $1
|
||||
ORDER BY COALESCE(s.series_index, 9999), mi.title
|
||||
`
|
||||
params := rc.Params.(*ByAudiobookSeriesParams)
|
||||
if params.SeriesName == "" {
|
||||
return ResolvedItems{}, fmt.Errorf("by_audiobook_series: series_name required")
|
||||
}
|
||||
rows, err := rc.Pool.Query(rc.Ctx, q, params.SeriesName)
|
||||
if err != nil { return ResolvedItems{}, fmt.Errorf("by_audiobook_series: %w", err) }
|
||||
defer rows.Close()
|
||||
|
||||
var items []ResolvedItem
|
||||
for rows.Next() {
|
||||
var it ResolvedItem
|
||||
if err := rows.Scan(&it.ContentID, &it.Title, &it.Year, &it.PosterPath); err != nil {
|
||||
return ResolvedItems{}, fmt.Errorf("by_audiobook_series scan: %w", err)
|
||||
}
|
||||
items = append(items, it)
|
||||
}
|
||||
return ResolvedItems{Items: items}, rows.Err()
|
||||
}
|
||||
|
||||
func (byAudiobookSeriesRecipe) Validate(raw json.RawMessage) error {
|
||||
var p ByAudiobookSeriesParams
|
||||
if err := json.Unmarshal(raw, &p); err != nil { return err }
|
||||
if p.SeriesName == "" { return fmt.Errorf("series_name is required") }
|
||||
return nil
|
||||
}
|
||||
|
||||
func (byAudiobookSeriesRecipe) Definition() RecipeDefinition {
|
||||
return RecipeDefinition{
|
||||
Type: "by_audiobook_series",
|
||||
Name: "By Audiobook Series",
|
||||
Description: "Books in a specific audiobook series, ordered by series position.",
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
registry.Register(byAudiobookSeriesRecipe{})
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run test, verify pass**
|
||||
|
||||
```bash
|
||||
go test ./internal/sections/recipes/ -run TestByAudiobookSeries -v
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/sections/recipes/by_audiobook_series.go internal/sections/recipes/by_audiobook_series_test.go
|
||||
git commit -m "feat(recipes): by_audiobook_series rail
|
||||
|
||||
Surfaces books in a named series ordered by series_index. Backed by
|
||||
the audiobook_series table populated by the scanner."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Surface `SupportedMediaTypes` in admin API response
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/api/handlers/recipes.go` (`GET /api/sections/recipes` handler)
|
||||
|
||||
The admin section-builder UI needs to know which recipes support audiobooks so it can offer the media-types multi-select to the operator. The handler's response shape adds one field per recipe.
|
||||
|
||||
- [ ] **Step 1: Read the current handler**
|
||||
|
||||
```bash
|
||||
cat internal/api/handlers/recipes.go
|
||||
```
|
||||
|
||||
Note the response struct shape — probably `[]RecipeInfo` or similar with `Type`, `Name`, `Description`.
|
||||
|
||||
- [ ] **Step 2: Add `SupportedMediaTypes` to the response**
|
||||
|
||||
In the response struct definition, add:
|
||||
|
||||
```go
|
||||
type recipeInfo struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
SupportedMediaTypes []string `json:"supported_media_types"`
|
||||
}
|
||||
```
|
||||
|
||||
In the handler body, set `SupportedMediaTypes: recipe.SupportedMediaTypes()`.
|
||||
|
||||
- [ ] **Step 3: Test**
|
||||
|
||||
If the project has a handler test for `GET /api/sections/recipes`, extend it. Otherwise the manual verification step covers this:
|
||||
|
||||
```bash
|
||||
curl -sH "Authorization: Bearer $TOKEN" http://localhost:8090/api/sections/recipes | jq '.[] | select(.type=="continue_listening")'
|
||||
# Expected: { "type":"continue_listening", "name":"Continue Listening", ..., "supported_media_types":["audiobook"] }
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/api/handlers/recipes.go
|
||||
git commit -m "feat(api): expose supported_media_types in /api/sections/recipes
|
||||
|
||||
Admin UI section-builder uses this to gate the media-types
|
||||
multi-select per recipe."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification (after merge)
|
||||
|
||||
1. Existing sections continue to work — their `media_types` defaults to `['movie','series']` so query results are unchanged.
|
||||
2. Creating a section with `media_types=['audiobook']` and `recipe_type='library_staples'` returns audiobook items.
|
||||
3. Two new recipes registered:
|
||||
```bash
|
||||
curl -sH "Authorization: Bearer $TOKEN" http://localhost:8090/api/sections/recipes | jq '[.[] | .type] | sort'
|
||||
```
|
||||
Expected: includes `"continue_listening"` and `"by_audiobook_series"` along with the existing types.
|
||||
4. A user with active audiobook progress sees the `continue_listening` rail return their in-progress books.
|
||||
5. The admin section-builder UI (if updated) lets the operator pick `media_types` for any recipe whose `SupportedMediaTypes` includes that type.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
- `page_sections.media_types` Go-side serde ✓ (Task 2)
|
||||
- `Recipe.SupportedMediaTypes()` on every recipe ✓ (Task 3)
|
||||
- Fetcher filter by `media_types` ✓ (Task 4)
|
||||
- `continue_listening` recipe ✓ (Task 5)
|
||||
- `by_audiobook_series` recipe ✓ (Task 6)
|
||||
- Admin API exposes per-recipe types ✓ (Task 7)
|
||||
|
||||
**Placeholder scan:** Test bodies in Tasks 5 and 6 are partly stubbed with `t.Skip(...)` to be filled in by reading the existing `continue_watching` test pattern. That's a deliberate "read-existing-then-write" instruction, not a TBD. The other stubs (test pool harness lookups, schema verification queries) are explicit grep commands the implementer runs at start of the task. No abstract "implement appropriately."
|
||||
|
||||
**Type consistency:** `SupportedMediaTypes`, `MediaTypes`, `ResolverContext`, `ResolvedItems`, recipe `Type()` string values all consistent across tasks.
|
||||
|
||||
**Risk:** Lowest-risk of the 4 sub-projects. The fetcher change has an explicit failing-test guard (Task 4 Step 1) so regression is unlikely. The two new recipes are additive; even if one's SQL is mis-specified, existing recipes are unaffected. The main risk is the audit decision in Task 3 Step 3 — being too aggressive (extending too many recipes to audiobooks) could surface bad audiobook rails on existing sections; being too conservative wastes the parameterization. Conservative is the safer default.
|
||||
@@ -0,0 +1,174 @@
|
||||
# Audiobooks Stacked PR Split Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the oversized audiobook PR with a stacked series of smaller, reviewable PRs.
|
||||
|
||||
**Architecture:** Preserve the existing `feat/audiobooks` work as the source branch, then create branch cut points that expose one logical layer at a time. Each PR targets the previous branch so reviewers see only the incremental diff.
|
||||
|
||||
**Tech Stack:** Git, GitHub CLI, Go, pnpm/Vite, PostgreSQL migrations.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Close The Oversized PR
|
||||
|
||||
**Files:**
|
||||
- Modify: GitHub PR #17 only.
|
||||
|
||||
- [ ] **Step 1: Post a replacement note**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
gh pr comment 17 --repo Silo-Server/silo-server --body 'Closing this oversized PR in favor of a stacked series of smaller PRs. The existing branch is preserved as the source-of-truth while the stack is rebuilt into logical review units.'
|
||||
```
|
||||
|
||||
Expected: GitHub prints the created comment URL.
|
||||
|
||||
- [ ] **Step 2: Close PR #17 without deleting the branch**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
gh pr close 17 --repo Silo-Server/silo-server
|
||||
```
|
||||
|
||||
Expected: PR #17 state becomes `CLOSED`; branch `feat/audiobooks` remains available.
|
||||
|
||||
### Task 2: Create Stacked Branches
|
||||
|
||||
**Files:**
|
||||
- Modify: remote Git branches only.
|
||||
|
||||
- [ ] **Step 1: Create branch 1, foundation**
|
||||
|
||||
Branch name: `stack/audiobooks-foundation`
|
||||
|
||||
Scope:
|
||||
- audiobook feature flag/settings
|
||||
- audiobook and podcast schema foundations
|
||||
- scanner support
|
||||
- audiobook media item write path
|
||||
- no native UI
|
||||
- no ABS listener
|
||||
|
||||
- [ ] **Step 2: Create branch 2, native UI MVP**
|
||||
|
||||
Branch name: `stack/audiobooks-native-ui`
|
||||
|
||||
Base: `stack/audiobooks-foundation`
|
||||
|
||||
Scope:
|
||||
- `/api/v1/audiobooks`
|
||||
- audiobook detail/progress endpoints
|
||||
- audiobook frontend route/sidebar/player MVP
|
||||
|
||||
- [ ] **Step 3: Create branch 3, ABS core**
|
||||
|
||||
Branch name: `stack/audiobooks-abs-core`
|
||||
|
||||
Base: `stack/audiobooks-native-ui`
|
||||
|
||||
Scope:
|
||||
- ABS auth/login/refresh/logout
|
||||
- ABS listener and feature-flag listener gating
|
||||
- ABS library/items/play/progress
|
||||
- ABS access control and security hardening
|
||||
|
||||
- [ ] **Step 4: Create branch 4, ABS collections**
|
||||
|
||||
Branch name: `stack/audiobooks-abs-collections`
|
||||
|
||||
Base: `stack/audiobooks-abs-core`
|
||||
|
||||
Scope:
|
||||
- ABS bookmarks
|
||||
- ABS collections
|
||||
- ABS playlists
|
||||
- ABS smart collections
|
||||
- unified collection migration
|
||||
|
||||
- [ ] **Step 5: Create branch 5, extras and polish**
|
||||
|
||||
Branch name: `stack/audiobooks-extras`
|
||||
|
||||
Base: `stack/audiobooks-abs-collections`
|
||||
|
||||
Scope:
|
||||
- podcasts/RSS
|
||||
- stats
|
||||
- author/series extras
|
||||
- catalog audiobook filters/typeahead
|
||||
- performance and enrichment cleanup
|
||||
|
||||
### Task 3: Open The Stacked PRs
|
||||
|
||||
**Files:**
|
||||
- Modify: GitHub PRs only.
|
||||
|
||||
- [ ] **Step 1: Open PR 1**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
gh pr create --repo Silo-Server/silo-server --base main --head stack/audiobooks-foundation --title 'feat(audiobooks): foundation and scanner support' --body 'First PR in the audiobook stack. Adds the schema/settings/scanner foundation without native UI or ABS compatibility.'
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Open PR 2**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
gh pr create --repo Silo-Server/silo-server --base stack/audiobooks-foundation --head stack/audiobooks-native-ui --title 'feat(audiobooks): native API and player MVP' --body 'Second PR in the audiobook stack. Adds native Silo audiobook endpoints and the MVP web player UI.'
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Open PR 3**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
gh pr create --repo Silo-Server/silo-server --base stack/audiobooks-native-ui --head stack/audiobooks-abs-core --title 'feat(audiobooks): Audiobookshelf compatibility core' --body 'Third PR in the audiobook stack. Adds ABS auth, listener, library/items/play/progress, and security hardening.'
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Open PR 4**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
gh pr create --repo Silo-Server/silo-server --base stack/audiobooks-abs-core --head stack/audiobooks-abs-collections --title 'feat(audiobooks): ABS collections, playlists, and smart collections' --body 'Fourth PR in the audiobook stack. Adds ABS bookmarks, collections, playlists, smart collections, and unified collection storage.'
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Open PR 5**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
gh pr create --repo Silo-Server/silo-server --base stack/audiobooks-abs-collections --head stack/audiobooks-extras --title 'feat(audiobooks): podcasts, catalog filters, and enrichment polish' --body 'Final PR in the audiobook stack. Adds podcast/RSS extras, audiobook catalog filters/typeahead, and scan/enrichment performance polish.'
|
||||
```
|
||||
|
||||
### Task 4: Verify And Report
|
||||
|
||||
**Files:**
|
||||
- Modify: none.
|
||||
|
||||
- [ ] **Step 1: Verify PR states**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
gh pr list --repo Silo-Server/silo-server --state open --search 'audiobooks in:title' --json number,title,headRefName,baseRefName,url
|
||||
```
|
||||
|
||||
Expected: five open stacked PRs with the base/head chain shown above.
|
||||
|
||||
- [ ] **Step 2: Run branch verification where feasible**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
cd web && pnpm run build
|
||||
```
|
||||
|
||||
Expected: both commands pass on the final stack tip.
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
# Ebook Reader Full Parity Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Bring the core ebook reader up to the personal reader feature set with server-persisted config, annotations/bookmarks, selection tools, reading aids, and advanced settings.
|
||||
|
||||
**Architecture:** Keep ebook reading in core, matching audiobooks: core owns the ebook media type and reader behavior; metadata remains plugin-provided. Extend the existing `/ebooks/{content_id}` reader API and `EbookReaderHandler` rather than creating a separate plugin-style reader subsystem. Persist per-user/per-profile/per-book state in Postgres so settings, annotations, and bookmarks roam across devices.
|
||||
|
||||
**Tech Stack:** Go/Chi handlers, pgx/Postgres migrations, React 19, TanStack Query, Foliate/readest reader wrapper, Vitest, Go unit tests.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- `migrations/sql/20260608000300_ebook_reader_state.sql`
|
||||
- Creates `ebook_reader_config` and `ebook_reader_annotations`.
|
||||
- `internal/api/handlers/ebook_reader.go`
|
||||
- Adds reader config and annotation types, store interfaces, handlers, validation, and Postgres store methods.
|
||||
- `internal/api/handlers/ebook_reader_test.go`
|
||||
- Adds handler tests for config, annotation CRUD, access scoping, and validation.
|
||||
- `internal/api/router.go`
|
||||
- Wires config/annotation stores and routes under `/ebooks/{content_id}`.
|
||||
- `web/src/reader/FoliateBookReader.tsx`
|
||||
- Adds selection/annotation/readable-text/content-popup reader handles and applies saved config.
|
||||
- `web/src/pages/EbookReader.tsx`
|
||||
- Adds server-backed settings, annotation/bookmark UI, selection tools, TTS controls, wake-lock/e-ink controls, and advanced reader controls.
|
||||
- `web/src/hooks/useTTS.ts`, `web/src/hooks/useScreenWakeLock.ts`, `web/src/hooks/useEinkMode.ts`
|
||||
- Port focused personal-reader hooks into core web.
|
||||
- `web/src/reader/ebookReaderApi.ts`
|
||||
- Centralizes reader config and annotation API calls for tests and page usage.
|
||||
- `web/src/pages/EbookReader.test.tsx`, `web/src/reader/FoliateBookReader.test.ts`
|
||||
- Adds UI and wrapper tests.
|
||||
|
||||
## Task 1: Server-Persisted Reader Config
|
||||
|
||||
**Files:**
|
||||
- Create: `migrations/sql/20260608000300_ebook_reader_state.sql`
|
||||
- Modify: `internal/api/handlers/ebook_reader.go`
|
||||
- Modify: `internal/api/handlers/ebook_reader_test.go`
|
||||
- Modify: `internal/api/router.go`
|
||||
- Create: `web/src/reader/ebookReaderApi.ts`
|
||||
- Modify: `web/src/pages/EbookReader.tsx`
|
||||
- Modify: `web/src/pages/EbookReader.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Write failing Go handler tests**
|
||||
|
||||
Add tests that:
|
||||
- `GET /ebooks/{content_id}/reader-config` returns `{ "config": {} }` when no row exists.
|
||||
- `PUT /ebooks/{content_id}/reader-config` saves JSON config for the authenticated user/profile/content.
|
||||
- inaccessible content returns 404 through the existing `ItemAccess` path.
|
||||
- invalid non-object config returns 400.
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./internal/api/handlers -run 'TestEbookReader.*Config' -count=1
|
||||
```
|
||||
|
||||
Expected: fail because config store/handlers do not exist.
|
||||
|
||||
- [ ] **Step 2: Add migration**
|
||||
|
||||
Create `ebook_reader_config`:
|
||||
|
||||
```sql
|
||||
CREATE TABLE ebook_reader_config (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
profile_id TEXT NOT NULL,
|
||||
content_id TEXT NOT NULL REFERENCES media_items(content_id) ON DELETE CASCADE,
|
||||
config JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (user_id, profile_id, content_id)
|
||||
);
|
||||
|
||||
CREATE INDEX ebook_reader_config_profile_updated
|
||||
ON ebook_reader_config (user_id, profile_id, updated_at DESC);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Implement config store and handlers**
|
||||
|
||||
Add `EbookReaderConfig`, `EbookReaderConfigStore`, `HandleGetConfig`, `HandleSaveConfig`, and `PGEbookReaderConfigStore` methods in `ebook_reader.go`.
|
||||
|
||||
Validation:
|
||||
- authenticated user required
|
||||
- `content_id` required
|
||||
- `config` must decode to a JSON object
|
||||
- content access checked with `FileAuthorizer.ItemAccess.EnsureAccessible`
|
||||
|
||||
- [ ] **Step 4: Wire routes and store**
|
||||
|
||||
In `internal/api/router.go`, create the store when `deps.DB != nil`, assign it to `ebookReaderHandler`, and add:
|
||||
|
||||
```go
|
||||
r.Get("/{content_id}/reader-config", ebookReaderHandler.HandleGetConfig)
|
||||
r.Put("/{content_id}/reader-config", ebookReaderHandler.HandleSaveConfig)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add web API wrapper**
|
||||
|
||||
Create `web/src/reader/ebookReaderApi.ts` with:
|
||||
|
||||
```ts
|
||||
export function ebookReaderConfigPath(contentID: string): string
|
||||
export async function fetchEbookReaderConfig(contentID: string): Promise<Record<string, unknown>>
|
||||
export async function saveEbookReaderConfig(contentID: string, config: Record<string, unknown>): Promise<Record<string, unknown>>
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Move settings persistence from localStorage to server**
|
||||
|
||||
Update `EbookReader.tsx` to fetch reader config on load and save settings through the config endpoint. Keep a local fallback only until the server value arrives.
|
||||
|
||||
- [ ] **Step 7: Verify and commit**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./internal/api/handlers -run 'TestEbookReader.*Config' -count=1
|
||||
cd web && pnpm test src/pages/EbookReader.test.tsx src/reader/FoliateBookReader.test.ts --run
|
||||
cd web && pnpm build
|
||||
```
|
||||
|
||||
Commit:
|
||||
|
||||
```bash
|
||||
git add migrations/sql/20260608000300_ebook_reader_state.sql internal/api/handlers/ebook_reader.go internal/api/handlers/ebook_reader_test.go internal/api/router.go web/src/reader/ebookReaderApi.ts web/src/pages/EbookReader.tsx web/src/pages/EbookReader.test.tsx
|
||||
git commit -m "feat: persist ebook reader config"
|
||||
```
|
||||
|
||||
## Task 2: Server-Persisted Annotations And Bookmarks
|
||||
|
||||
**Files:**
|
||||
- Modify: `migrations/sql/20260608000300_ebook_reader_state.sql`
|
||||
- Modify: `internal/api/handlers/ebook_reader.go`
|
||||
- Modify: `internal/api/handlers/ebook_reader_test.go`
|
||||
- Modify: `internal/api/router.go`
|
||||
- Modify: `web/src/reader/ebookReaderApi.ts`
|
||||
- Modify: `web/src/reader/FoliateBookReader.tsx`
|
||||
- Modify: `web/src/pages/EbookReader.tsx`
|
||||
|
||||
- [ ] **Step 1: Write failing annotation handler tests**
|
||||
|
||||
Cover list, create, update, delete. Test annotation fields:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "highlight",
|
||||
"cfi_range": "epubcfi(/6/4,/1:0,/1:12)",
|
||||
"selected_text": "sample text",
|
||||
"note": "note text",
|
||||
"style": "highlight",
|
||||
"color": "#facc15"
|
||||
}
|
||||
```
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./internal/api/handlers -run 'TestEbookReader.*Annotation|TestEbookReader.*Bookmark' -count=1
|
||||
```
|
||||
|
||||
Expected: fail because annotation APIs do not exist.
|
||||
|
||||
- [ ] **Step 2: Add annotation table**
|
||||
|
||||
Extend migration with:
|
||||
|
||||
```sql
|
||||
CREATE TABLE ebook_reader_annotations (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
profile_id TEXT NOT NULL,
|
||||
content_id TEXT NOT NULL REFERENCES media_items(content_id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('highlight', 'note', 'bookmark')),
|
||||
cfi_range TEXT,
|
||||
location TEXT,
|
||||
selected_text TEXT NOT NULL DEFAULT '',
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
style TEXT NOT NULL DEFAULT 'highlight',
|
||||
color TEXT NOT NULL DEFAULT '#facc15',
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CHECK ((kind = 'bookmark' AND location IS NOT NULL) OR (kind <> 'bookmark' AND cfi_range IS NOT NULL))
|
||||
);
|
||||
|
||||
CREATE INDEX ebook_reader_annotations_book
|
||||
ON ebook_reader_annotations (user_id, profile_id, content_id, updated_at DESC);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Implement annotation store and handlers**
|
||||
|
||||
Add handlers:
|
||||
- `GET /ebooks/{content_id}/annotations`
|
||||
- `POST /ebooks/{content_id}/annotations`
|
||||
- `PATCH /ebooks/{content_id}/annotations/{annotation_id}`
|
||||
- `DELETE /ebooks/{content_id}/annotations/{annotation_id}`
|
||||
|
||||
- [ ] **Step 4: Wire Foliate annotation drawing and selection**
|
||||
|
||||
Extend `FoliateBookReaderHandle` with:
|
||||
- `createSelectionAnnotation()`
|
||||
- `clearSelection()`
|
||||
|
||||
Draw stored annotations using Foliate `addAnnotation`.
|
||||
|
||||
- [ ] **Step 5: Add reader UI**
|
||||
|
||||
Add:
|
||||
- selection popover
|
||||
- highlight button
|
||||
- note button
|
||||
- bookmark button
|
||||
- annotation list in the side panel
|
||||
- delete/update note controls
|
||||
|
||||
- [ ] **Step 6: Verify and commit**
|
||||
|
||||
Run handler tests, web tests, and build. Commit:
|
||||
|
||||
```bash
|
||||
git commit -m "feat: add ebook annotations and bookmarks"
|
||||
```
|
||||
|
||||
## Task 3: Reader Tools, TTS, And Reading Aids
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/hooks/useTTS.ts`
|
||||
- Create: `web/src/hooks/useScreenWakeLock.ts`
|
||||
- Create: `web/src/hooks/useEinkMode.ts`
|
||||
- Modify: `web/src/reader/FoliateBookReader.tsx`
|
||||
- Modify: `web/src/pages/EbookReader.tsx`
|
||||
- Modify: `web/src/pages/EbookReader.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Port focused hooks from personal reader**
|
||||
|
||||
Bring over:
|
||||
- Web Speech TTS controller
|
||||
- screen wake lock hook
|
||||
- e-ink body-class hook
|
||||
|
||||
- [ ] **Step 2: Add readable text handle**
|
||||
|
||||
Expose `getReadableText()` from `FoliateBookReader` for TTS.
|
||||
|
||||
- [ ] **Step 3: Add UI controls**
|
||||
|
||||
Add side-panel controls for:
|
||||
- Speak current text
|
||||
- Pause/resume/stop
|
||||
- voice/rate/pitch
|
||||
- wake lock
|
||||
- e-ink mode
|
||||
|
||||
- [ ] **Step 4: Add content popups and helpers**
|
||||
|
||||
Add selection popover actions:
|
||||
- define
|
||||
- translate
|
||||
|
||||
Add reader content popup handling for footnotes/images/tables when Foliate emits them.
|
||||
|
||||
- [ ] **Step 5: Verify and commit**
|
||||
|
||||
Run focused web tests and build. Commit:
|
||||
|
||||
```bash
|
||||
git commit -m "feat: add ebook reader tools and aids"
|
||||
```
|
||||
|
||||
## Task 4: Advanced Reader Settings
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/reader/FoliateBookReader.tsx`
|
||||
- Modify: `web/src/pages/EbookReader.tsx`
|
||||
- Modify: `web/src/pages/EbookReader.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Add advanced reader settings**
|
||||
|
||||
Add server-persisted settings for:
|
||||
- RTL
|
||||
- writing mode
|
||||
- zoom/scale
|
||||
- brightness
|
||||
- hyphenation toggle
|
||||
- custom font selection/upload if API support is added in a later task
|
||||
|
||||
- [ ] **Step 2: Verify and commit**
|
||||
|
||||
Run focused tests and build. Commit:
|
||||
|
||||
```bash
|
||||
git commit -m "feat: add ebook advanced reader settings"
|
||||
```
|
||||
|
||||
## Self-Review
|
||||
|
||||
- The plan covers server persistence first, matching the user's explicit preference.
|
||||
- The plan keeps ebook features in core and metadata in plugins, matching the audiobook pattern.
|
||||
- The plan avoids extra ebook catalog tables; added tables are reader-user-state only.
|
||||
- The biggest implementation risk is annotation rendering against Foliate selection APIs; Task 2 isolates it after backend persistence exists.
|
||||
- The migration filename is intentionally after existing ebook migrations and can be adjusted if another migration is added before implementation.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Ebook Reader Shell Parity Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Bring the core ebook reader up to the first useful parity slice from `personal/silo-plugin-ebooks`: TOC, search, progress scrub, reader settings, and keyboard navigation.
|
||||
|
||||
**Architecture:** Keep the current core reader API and persistence model. Extend `FoliateBookReader` with imperative methods for TOC, search, href/fraction navigation, and runtime style updates; keep `EbookReader` responsible for the chrome/panels/settings state. Store reader preferences in localStorage for this first local slice so no backend schema is added.
|
||||
|
||||
**Tech Stack:** React 19, React Router, TanStack Query, foliate-js custom element, existing Silo UI components, Vitest.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Reader Handle Capabilities
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/reader/FoliateBookReader.tsx`
|
||||
- Test: `web/src/reader/FoliateBookReader.test.ts`
|
||||
|
||||
- [ ] Add exported types `ReaderSettings`, `ReaderSearchOptions`, `ReaderSearchResult`, and `ReaderReadyState`.
|
||||
- [ ] Extend `FoliateBookReaderHandle` with `goToFraction`, `goTo`, `search`, `clearSearch`, and `applySettings`.
|
||||
- [ ] Add a `settings?: ReaderSettings` prop and convert `readerStyles()` to accept settings.
|
||||
- [ ] Preserve existing progress save/restore behavior.
|
||||
- [ ] Verify with `pnpm vitest run src/reader/FoliateBookReader.test.ts`.
|
||||
|
||||
### Task 2: TOC and Search Panel
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/pages/EbookReader.tsx`
|
||||
- Test: `web/src/pages/EbookReader.test.tsx`
|
||||
|
||||
- [ ] Add panel state for `toc`, `searchResults`, `searchTerm`, and current tab.
|
||||
- [ ] Render side panel tabs for Contents, Search, and Settings.
|
||||
- [ ] Wire TOC items to `readerRef.current.goTo(href)`.
|
||||
- [ ] Wire search form to `readerRef.current.search(term)` and result clicks to `goTo(cfi)`.
|
||||
- [ ] Verify with `pnpm vitest run src/pages/EbookReader.test.tsx`.
|
||||
|
||||
### Task 3: Reader Settings
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/pages/EbookReader.tsx`
|
||||
- Modify: `web/src/reader/FoliateBookReader.tsx`
|
||||
- Test: `web/src/pages/EbookReader.test.tsx`
|
||||
|
||||
- [ ] Add localStorage-backed settings for theme, font size, font family, line height, margin, max width, spread, and flow.
|
||||
- [ ] Render compact controls in the Settings tab.
|
||||
- [ ] Apply settings live through `FoliateBookReader`.
|
||||
- [ ] Verify settings persist across remount in tests.
|
||||
|
||||
### Task 4: Progress Scrub and Keyboard Navigation
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/pages/EbookReader.tsx`
|
||||
- Modify: `web/src/reader/FoliateBookReader.tsx`
|
||||
- Test: `web/src/pages/EbookReader.test.tsx`
|
||||
|
||||
- [ ] Add a progress range input in the header.
|
||||
- [ ] Call `goToFraction` on commit/change and update display from relocate events.
|
||||
- [ ] Add ArrowLeft/ArrowRight keyboard navigation on the reader page.
|
||||
- [ ] Verify with page-level tests.
|
||||
|
||||
### Task 5: Local Verification
|
||||
|
||||
**Files:**
|
||||
- No source changes.
|
||||
|
||||
- [ ] Run `cd web && pnpm run build`.
|
||||
- [ ] Run `GOWORK=off go test ./internal/api/handlers ./internal/scanner`.
|
||||
- [ ] Keep changes local; do not push.
|
||||
@@ -0,0 +1,518 @@
|
||||
# Encrypt integration credentials at rest (issue #45) — implementation plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Do not edit this plan file while implementing.
|
||||
|
||||
**Issue:** [#45 — Encrypt arr integration credentials at rest (Requests + Autoscan)](https://github.com/Silo-Server/silo-server/issues/45)
|
||||
|
||||
> **Revision note (post-review):** This plan was revised after a code review. Key corrections baked in:
|
||||
> the arr backfill now **resolves the legacy `server_settings` reference before encrypting** (otherwise
|
||||
> rows holding a setting-name like `requests.radarr.api_key` would be encrypted as the *name*, not the
|
||||
> credential); the sensitive-settings allowlist is **audited from the config loader's real inputs**
|
||||
> (incl. `redis.sentinel_password` and legacy `s3.operational_*` aliases) rather than copied from the
|
||||
> admin redaction map; AAD binds to **row identity** (`table:column:pk`); read paths **pass through
|
||||
> legacy plaintext** and hard-fail only on corrupt ciphertext; `settingsRepo` is **re-wrapped after the
|
||||
> max-connections pool recreation**; history-import session tokens are **included**; and
|
||||
> `plugin_runtime_configs.config_value` is an **explicit, documented gap** (see Out of scope).
|
||||
|
||||
**Goal:** Stop storing third-party integration credentials in plaintext at rest. Today Sonarr/Radarr
|
||||
API keys, S3 keys, and a dozen other secrets sit in the database as naked `text` — `request_integrations.api_key_ref`
|
||||
and `autoscan_connections.api_key_ref` are resolved through a plaintext `server_settings` key/value
|
||||
store, and both resolvers contain a **ref-vs-literal fallback**: when the secret lookup returns empty,
|
||||
the stored string is used verbatim as the API key. This plan introduces a real symmetric-encryption
|
||||
layer (AES-256-GCM, `SECRET_KEY`-derived via HKDF), encrypts every recoverable server-owned credential
|
||||
**inline** in its owning row, deletes the ambiguous resolver fallback, and backfills existing rows
|
||||
automatically on startup.
|
||||
|
||||
**Decisions (locked with the requester):**
|
||||
- **Master key:** a **required** `SECRET_KEY` environment variable. The server fatals at startup if it
|
||||
is missing. The key lives outside Postgres, so encrypted secrets survive a full DB compromise/dump.
|
||||
- **Scope:** **all silo-server-owned credential storage** (was framed "full sweep"). Every recoverable
|
||||
server-owned secret — arr keys, S3 keys, all sensitive `server_settings`, and the per-table token
|
||||
columns — is covered. Two categories are deliberately *not* in this PR because they are a different
|
||||
*shape* of problem, not because they are unimportant: equality-looked-up secrets (need **hashing**,
|
||||
not encryption) and plugin runtime config JSONB (cross-repo + manifest-driven). Both are called out in
|
||||
Out of scope with follow-ups.
|
||||
- **Data model (best practice):** **inline-encrypted**. Store the ciphertext directly in the owning
|
||||
column and decrypt on read. Drop the `server_settings` "ref" indirection and the `SecretResolver`
|
||||
literal fallback entirely — this kills the ambiguity at its root and matches the repo's one existing
|
||||
good example (`oauth_completion.token_ciphertext` lives inline in its own row).
|
||||
- **Rollout:** idempotent Go startup **backfill** encrypts any plaintext (no-envelope-prefix) value in
|
||||
place on first boot after deploy. No manual steps.
|
||||
|
||||
**Architecture:** A new `internal/secret` package owns the one cryptographic primitive everything reuses:
|
||||
AES-256-GCM with a random 12-byte nonce, `base64.RawURLEncoding`, and a versioned `enc:v1:` envelope
|
||||
prefix; the data key is HKDF-SHA256–derived from `SECRET_KEY` with a domain label. Each ciphertext is
|
||||
GCM-bound to its logical **row** via additional-authenticated-data (`"table:column:<pk>"`, or
|
||||
`"server_settings:<key>"`) so a DB-write attacker cannot transplant a credential blob into another row
|
||||
or column. Three wiring seams consume the cipher: (1) an `EncryptedSettingsRepo` **decorator** around
|
||||
`catalog.ServerSettingsRepo` that transparently encrypts/decrypts the sensitive `server_settings` keys,
|
||||
so `config.LoadFromDB` and every downstream consumer keep seeing plaintext; (2) the two `api_key_ref`
|
||||
columns become inline ciphertext and the entire `SecretResolver` indirection is deleted from
|
||||
`internal/requests` and `internal/autoscan`; (3) a handful of per-table repos gain a `*secret.Cipher`
|
||||
and encrypt-on-write / decrypt-on-read. A startup backfill sweeps all groups idempotently — with a
|
||||
**special resolve-then-encrypt path for the two arr columns** so legacy `server_settings` references are
|
||||
collapsed to the real credential before encryption.
|
||||
|
||||
**Out of scope (each a deliberate, documented decision — not an oversight):**
|
||||
- **Equality-looked-up secrets → need hashing, not encryption.** `api_keys.api_key`
|
||||
(`WHERE api_key = $1`) and `webhook_sync_connections.webhook_secret` (`WHERE webhook_secret = $1`,
|
||||
[repo.go:101](internal/webhooksync/repo.go:101)) are matched by exact value. AES-GCM uses a fresh
|
||||
random nonce, so the same plaintext encrypts differently each time — encrypting these would silently
|
||||
break API-key auth and inbound-webhook routing. The correct fix is a deterministic **hash** column +
|
||||
lookup-by-hash, a distinct lookup-semantics + migration change. **Follow-up issue.**
|
||||
- **`plugin_runtime_configs.config_value` → cross-repo + manifest-driven.** This is opaque plugin-defined
|
||||
JSONB ([runtime_config.go:55](internal/plugins/runtime_config.go:55)); some plugins store secrets in it
|
||||
(e.g. the legacy one-time copy of `introdb.api_key` at [main.go:2341](cmd/silo/main.go:2341)). It is
|
||||
read/written only through `RuntimeConfigStore` *in this repo*, so whole-blob encrypt-on-write /
|
||||
decrypt-on-read at that store is technically feasible — **but** host-side plugin runtime lives in the
|
||||
separate **`Silo`** repo (per CLAUDE.md) and may read this table directly, and which fields are secret
|
||||
is declared in each plugin's manifest. Doing this correctly needs a coordinated, manifest-aware design
|
||||
across repos. **Explicitly deferred — known gap.** Note: while deferred, the legacy `introdb.api_key`
|
||||
copy means that secret still exists in plaintext inside plugin config even after the `server_settings`
|
||||
copy is encrypted; the follow-up must re-encrypt or purge it.
|
||||
- **`plex_sync_connections`** — dead table, zero Go references (superseded by `webhook_sync_connections`
|
||||
in migration `060`). Not touched.
|
||||
- **Already-protected / non-secret:** `users.password_hash` (bcrypt), `oauth_completion.token_ciphertext`
|
||||
(already AES-GCM), `abs_sessions.token_hash` / `oauth_completion.code_hash` (hashed), `oauth_session.state`
|
||||
(equality CSRF), `subtitle_provider_config.username` (a username, not a secret),
|
||||
`history_import_plex_sessions.pin_id`/`pin_code` (transient OAuth pairing handshake, not a stored
|
||||
access credential).
|
||||
- `internal/auth/oauth_store.go` is left as-is (its key is jwt-derived and its data is ephemeral).
|
||||
Optionally a shared low-level seal/open helper can be extracted later; not required here.
|
||||
|
||||
**Tech Stack:** Go 1.26.3 (module declares `go 1.26.3`; stdlib `crypto/hkdf` is available — no new
|
||||
dependency). PostgreSQL via `pgxpool`. **No schema migration** — every target column is already `text`
|
||||
(or `jsonb` left untouched) and now simply holds ciphertext; the backfill is pure Go. No frontend or
|
||||
client (silo-android / silo-apple) changes — API responses already mask these values to `has_api_key` /
|
||||
`has_credentials` booleans, so no contract changes.
|
||||
|
||||
Commands assume the repository root is the cwd.
|
||||
|
||||
---
|
||||
|
||||
## Running tests
|
||||
|
||||
The `internal/secret` package is pure Go (no libvips/CGO). If a Go toolchain is on the host:
|
||||
|
||||
```bash
|
||||
GOWORK=off go test ./internal/secret/... -v
|
||||
```
|
||||
|
||||
`GOWORK=off` is required when running from a `.claude/worktrees/` checkout — the parent `go.work`
|
||||
otherwise pins the module to `main` and the build fails with "main module does not contain package".
|
||||
|
||||
If the host has no Go toolchain, run in a throwaway container (a named volume caches module downloads):
|
||||
|
||||
```bash
|
||||
docker run --rm -v "$PWD":/app -w /app -v silo-gomod:/go/pkg/mod \
|
||||
-e GOFLAGS=-mod=mod golang:1.26 \
|
||||
go test ./internal/secret/... -v
|
||||
```
|
||||
|
||||
Full backend build/vet before opening the MR: `GOWORK=off go build ./...` then `make lint` and
|
||||
`make verify-local-paths`.
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
**New files:**
|
||||
- `internal/secret/cipher.go` — **create**. `Cipher` type; `New`, `Encrypt`, `Decrypt`, `IsEncrypted`,
|
||||
`EncryptIfPlaintext`; envelope + HKDF + version dispatch. Row-bound AAD passed by callers.
|
||||
- `internal/secret/cipher_test.go` — **create**. Round-trip, tamper/AAD-mismatch rejection, wrong-key,
|
||||
`IsEncrypted` edge cases, version dispatch, idempotency.
|
||||
- `internal/secret/backfill.go` — **create**. Generic in-place backfill for literal-credential columns +
|
||||
the special resolve-then-encrypt backfill for the two arr columns.
|
||||
- `internal/secret/backfill_test.go` — **create**. Idempotency, mixed plaintext/ciphertext rows, empty
|
||||
values skipped, failure isolation, arr resolve-then-encrypt.
|
||||
|
||||
**Modified files:**
|
||||
- `internal/config/bootstrap.go` — add `SecretKey []byte` to `BootstrapConfig`; read `SECRET_KEY`; fatal
|
||||
if missing or `< 32` chars.
|
||||
- `cmd/silo/main.go` — construct `*secret.Cipher` after bootstrap; build the `EncryptedSettingsRepo`;
|
||||
run the settings + column backfills after migrations and before settings are read; **re-wrap
|
||||
`settingsRepo` after the max-connections pool recreation (line ~399)**; thread the cipher into the
|
||||
affected repo constructors; delete the `SetSecretResolver` wiring.
|
||||
- `internal/catalog/encrypted_settings_repo.go` — **create**. `EncryptedSettingsRepo` decorator +
|
||||
exported, **audited** `SensitiveSettingKeys` (single source of truth) + `BackfillSensitiveSettings`.
|
||||
- `internal/catalog/server_settings_repo.go` — unchanged behavior; remains the raw inner store.
|
||||
- `internal/api/handlers/admin.go` — replace the local `sensitiveSettingKeys` map with
|
||||
`catalog.SensitiveSettingKeys` (redaction now shares the audited allowlist; closes the prior gap where
|
||||
`redis.sentinel_password` and `s3.operational_*` were returned in plaintext).
|
||||
- `internal/requests/repository.go` — `Repository` gains `*secret.Cipher`; encrypt `api_key_ref` on
|
||||
insert/update (AAD bound to integration id); decrypt (pass-through legacy plaintext) in `scanIntegration`.
|
||||
- `internal/requests/service.go` — delete `SecretResolver` interface, `secrets` field,
|
||||
`SetSecretResolver`, and `resolveAPIKey`; call sites use the already-decrypted `Integration.APIKeyRef`.
|
||||
- `internal/autoscan/repository.go` — `Repository` gains `*secret.Cipher`; encrypt `api_key_ref` on
|
||||
create/update (AAD bound to connection id); decrypt (pass-through legacy) in `scanConnection`.
|
||||
- `internal/autoscan/connection.go` — delete `SecretResolver` interface, `secrets` field, and the
|
||||
secret-resolution branch in `Resolve`; linked-integration path uses the requests repo's decrypted key.
|
||||
- `internal/api/autoscan_wiring.go` — drop the `AutoscanSecretResolver` param from `BuildAutoscanService`.
|
||||
- `internal/api/router.go` — drop the `SetSecretResolver(settingsRepo)` calls; pass the
|
||||
`EncryptedSettingsRepo` where `settingsRepo` is used; thread the cipher into repo constructors.
|
||||
- `internal/subtitles/pgrepo.go` — `*secret.Cipher`; encrypt `api_key` + `password` on upsert; decrypt on
|
||||
read. PK for AAD = `provider_name`.
|
||||
- `internal/watchsync/repository.go` — `*secret.Cipher`; encrypt `access_token` + `refresh_token` on
|
||||
upsert; decrypt in a `decryptConnection` helper called after every scan. PK for AAD = connection `id`.
|
||||
- `internal/webhooksync/repo.go` — `*secret.Cipher`; encrypt `access_token` on create; decrypt on scan.
|
||||
**`webhook_secret` is NOT encrypted** (equality lookup — carved out). PK for AAD = connection `id`.
|
||||
- `internal/historyimport/repo_admin.go` — `*secret.Cipher`; encrypt in `SetSourceAdminToken`; decrypt in
|
||||
`GetSourceWithAdminToken`. PK for AAD = source `id`.
|
||||
- `internal/historyimport/repo.go` — `*secret.Cipher`; encrypt `connect_access_token`
|
||||
(`history_import_connect_sessions`) + `auth_token` (`history_import_plex_sessions`) on insert/update;
|
||||
decrypt on scan. PK for AAD = session `id`.
|
||||
- `docs/architecture/secret-encryption.md` — **create**. Operator runbook (SECRET_KEY generation, backup,
|
||||
key-loss, rollback/downgrade).
|
||||
|
||||
**No migration files.** All target columns are already `text`; the backfill is Go-side.
|
||||
|
||||
---
|
||||
|
||||
## Reference tables
|
||||
|
||||
### What is encrypted vs hashed vs excluded
|
||||
|
||||
AAD column shows the GCM additional-authenticated-data string; `<pk>` is the row's stable primary key.
|
||||
|
||||
| Group | Table.column | AAD binding | Action |
|
||||
| --- | --- | --- | --- |
|
||||
| Arr keys | `request_integrations.api_key_ref` | `request_integrations:api_key_ref:<id>` | **Resolve-then-encrypt** (backfill) / encrypt inline (writes) |
|
||||
| Arr keys | `autoscan_connections.api_key_ref` | `autoscan_connections:api_key_ref:<id>` | **Resolve-then-encrypt** (backfill) / encrypt inline (writes) |
|
||||
| Settings | every key in `SensitiveSettingKeys` (see audited list below) | `server_settings:<key>` | **Encrypt via decorator** |
|
||||
| Subtitles | `subtitle_provider_config.api_key`, `.password` | `subtitle_provider_config:<col>:<provider_name>` | **Encrypt inline** |
|
||||
| Watch sync | `watch_provider_connections.access_token`, `.refresh_token` | `watch_provider_connections:<col>:<id>` | **Encrypt inline** |
|
||||
| Webhook sync | `webhook_sync_connections.access_token` | `webhook_sync_connections:access_token:<id>` | **Encrypt inline** |
|
||||
| History import | `history_import_sources.admin_token` | `history_import_sources:admin_token:<id>` | **Encrypt inline** |
|
||||
| History import | `history_import_connect_sessions.connect_access_token` | `history_import_connect_sessions:connect_access_token:<id>` | **Encrypt inline** |
|
||||
| History import | `history_import_plex_sessions.auth_token` | `history_import_plex_sessions:auth_token:<id>` | **Encrypt inline** |
|
||||
| — | `api_keys.api_key` | — | **Carved out → hash** (follow-up) |
|
||||
| — | `webhook_sync_connections.webhook_secret` | — | **Carved out → hash** (follow-up) |
|
||||
| — | `plugin_runtime_configs.config_value` | — | **Deferred → cross-repo/manifest design** (follow-up) |
|
||||
| — | `plex_sync_connections.*` | — | **Excluded** (dead table) |
|
||||
|
||||
### Audited `SensitiveSettingKeys` (single source of truth)
|
||||
|
||||
Built from the config loader's actual secret inputs ([internal/config/db_loader.go](internal/config/db_loader.go))
|
||||
plus the admin redaction map plus other known consumers — **not** just the current redaction map. The
|
||||
union is intentional and also fixes a pre-existing redaction leak. Implementers must re-audit
|
||||
`db_loader.go` and `grep` for any `*secret*`/`*token*`/`*password*`/`*api_key*` settings keys before
|
||||
finalizing.
|
||||
|
||||
```
|
||||
auth.jwt_secret
|
||||
s3.public_access_key s3.public_secret_key s3.public_token_secret
|
||||
s3.private_access_key s3.private_secret_key
|
||||
s3.user_db_access_key s3.user_db_secret_key
|
||||
s3.operational_access_key s3.operational_secret_key s3.operational_token_secret # legacy aliases still read as fallbacks (db_loader L165-192); migration 086 copied but did not delete them
|
||||
redis.url # may embed credentials (redis://:pass@host)
|
||||
redis.sentinel_password # read at db_loader L334 — MISSING from the old redaction map
|
||||
tmdb.api_key
|
||||
mdblist.api_key
|
||||
introdb.api_key # read in main.go (legacy → plugin copy)
|
||||
subtitle_ai.api_key
|
||||
recommendations.embedding_auth_token recommendations.openai_api_key # legacy alias (db_loader L409)
|
||||
watchsync.trakt.client_id watchsync.trakt.client_secret
|
||||
watchsync.simkl.client_id watchsync.simkl.client_secret
|
||||
requests.radarr.api_key requests.sonarr.api_key # legacy single-instance arr keys
|
||||
```
|
||||
|
||||
`auth.jwt_secret` IS encrypted at rest under `SECRET_KEY` (it is no longer the encryption root — `SECRET_KEY`
|
||||
is). `redis.url`/client-id entries are not high-value secrets but are kept in the set as a harmless safe
|
||||
superset and to match existing redaction.
|
||||
|
||||
### Envelope format
|
||||
|
||||
```
|
||||
enc:v1:<base64.RawURLEncoding( nonce[12] ‖ gcm.Seal(...) )>
|
||||
```
|
||||
|
||||
- `enc:v1:` — exactly 7 ASCII chars; not valid base64url, not a UUID/JWT/URL/integer prefix. Collision-proof
|
||||
against any real plaintext credential in practice.
|
||||
- `IsEncrypted(s)` ≡ `strings.HasPrefix(s, "enc:v1:")`.
|
||||
- Decrypt parses the version (`strings.SplitN(s, ":", 3)`), dispatches to the v1 opener; an unknown
|
||||
version returns an explicit `ErrUnknownVersion`. This is the forward-compat hook for a future `enc:v2:`
|
||||
key rotation — implement the dispatch now even though only `v1` exists.
|
||||
|
||||
### Read-path contract (applies to every decrypt site — decorator and repos)
|
||||
|
||||
1. `value == ""` → return `""`.
|
||||
2. `!IsEncrypted(value)` → **return the value unchanged** (legacy plaintext pass-through during the
|
||||
backfill window; the value is no worse than today). This is unambiguous — there is no longer any
|
||||
"ref vs literal" question because the `_ref` indirection is gone.
|
||||
3. `IsEncrypted(value)` → `Decrypt`; **any error (wrong key, tamper, truncation) is propagated, never
|
||||
swallowed and never returned as the ciphertext string.** A corrupt ciphertext fails the operation.
|
||||
|
||||
This reconciles the best-effort backfill (a skipped/failed row stays readable as plaintext) with the
|
||||
hard guarantee that a real `enc:v1:` value never silently degrades to using ciphertext as a credential.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: The `internal/secret` cipher package
|
||||
|
||||
**Files:** Create `internal/secret/cipher.go`, `internal/secret/cipher_test.go`.
|
||||
|
||||
This is the foundation; it has no dependencies on `catalog`/`config` and is independently testable.
|
||||
|
||||
- [ ] **Step 1: `Cipher` type + `New`.** Derive a 32-byte AES key with stdlib
|
||||
`hkdf.Key(sha256.New, masterKey, nil /*salt*/, "silo/data-encryption/v1", 32)`. Return an error if
|
||||
`len(masterKey) < 32`. Store the derived key as `[32]byte`. The type is safe for concurrent use.
|
||||
- [ ] **Step 2: `Encrypt(plaintext, aad string) (string, error)`.** Empty plaintext returns `"", nil`
|
||||
(do not produce an `enc:v1:` blob for an empty secret). Otherwise: `aes.NewCipher` → `cipher.NewGCM` →
|
||||
12-byte `rand` nonce → `gcm.Seal(nonce, nonce, []byte(plaintext), []byte(aad))` →
|
||||
`envelopePrefix + base64.RawURLEncoding.EncodeToString(sealed)`. `aad` is the row-bound context string.
|
||||
- [ ] **Step 3: `Decrypt(ciphertext, aad string) (string, error)`.** Strip + validate the version prefix;
|
||||
unknown version → `ErrUnknownVersion`. base64-decode; reject if shorter than the nonce size; split
|
||||
nonce/body; `gcm.Open(nil, nonce, body, []byte(aad))`. **Any failure returns an error — never fall back
|
||||
to returning the ciphertext or an empty string.** (Pass-through of *non-prefixed* plaintext is the
|
||||
caller's job per the read-path contract, not `Decrypt`'s.)
|
||||
- [ ] **Step 4: `IsEncrypted(s string) bool`** and `EncryptIfPlaintext(s, aad string) (string, bool, error)`.
|
||||
`EncryptIfPlaintext` returns `(s, false, nil)` when already encrypted or empty; otherwise encrypts and
|
||||
returns `(ct, true, nil)`. This is the idempotent primitive the backfill uses.
|
||||
- [ ] **Step 5: Tests** (`cipher_test.go`):
|
||||
- round-trip with AAD recovers plaintext; ciphertext has the `enc:v1:` prefix.
|
||||
- two encryptions of the same plaintext+AAD differ (random nonce works).
|
||||
- tamper: flip one byte of the decoded body → `Decrypt` errors.
|
||||
- AAD mismatch: encrypt with `"t:c:1"`, decrypt with `"t:c:2"` → errors (proves row binding).
|
||||
- wrong key: encrypt with key A, decrypt with key B → errors.
|
||||
- `New` with a `< 32`-byte key → error.
|
||||
- `IsEncrypted`: `"enc:v1:x"`→true, `"sa_abc"`→false, `""`→false, `"ENC:V1:x"`→false.
|
||||
- unknown version `"enc:v99:..."` → `ErrUnknownVersion`.
|
||||
- `EncryptIfPlaintext` is idempotent (second call is a no-op, `changed=false`).
|
||||
- [ ] **Step 6:** `GOWORK=off go test ./internal/secret/... -v` passes.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `SECRET_KEY` bootstrap + cipher construction
|
||||
|
||||
**Files:** Modify `internal/config/bootstrap.go`, `cmd/silo/main.go`.
|
||||
|
||||
- [ ] **Step 1:** Add `SecretKey []byte` to `BootstrapConfig`. In `LoadBootstrap`, after the
|
||||
`DATABASE_URL` check, read `os.Getenv("SECRET_KEY")`; return an error (which the existing
|
||||
`log.Fatalf` path surfaces) if it is empty or `< 32` characters. Message must be actionable, e.g.
|
||||
`SECRET_KEY is required (>=32 chars); generate one with: openssl rand -base64 48`. `godotenv` already
|
||||
loads `.env`, so dev sets it there.
|
||||
- [ ] **Step 2:** In `cmd/silo/main.go`, immediately after `LoadBootstrap` returns, construct the cipher:
|
||||
`dataCipher, err := secret.New(bc.SecretKey)` and `log.Fatalf` on error. The cipher is threaded
|
||||
explicitly as a dependency — **never** a package-level global.
|
||||
- [ ] **Step 3:** Add `SECRET_KEY=...` to `.env.example` (or the documented dev env) and note it in the
|
||||
operator runbook (Task 7).
|
||||
- [ ] **Step 4:** `GOWORK=off go build ./cmd/silo` compiles; starting without `SECRET_KEY` fatals with the
|
||||
actionable message.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `server_settings` encryption decorator
|
||||
|
||||
**Files:** Create `internal/catalog/encrypted_settings_repo.go`; modify `internal/api/handlers/admin.go`,
|
||||
`cmd/silo/main.go`, `internal/api/router.go`.
|
||||
|
||||
- [ ] **Step 1: Build the audited allowlist.** Create exported `var SensitiveSettingKeys map[string]bool`
|
||||
in `internal/catalog` populated from the "Audited `SensitiveSettingKeys`" list above. **Do not** simply
|
||||
move the admin redaction map — re-audit `internal/config/db_loader.go` (and other `server_settings`
|
||||
consumers) for every secret-bearing key, and include the legacy aliases (`s3.operational_*`,
|
||||
`recommendations.openai_api_key`) and `redis.sentinel_password`. Update `admin.go`
|
||||
(`HandleGetSettings` redaction, `HandleGetSensitiveStatus`, the `PUT` response branch) to reference
|
||||
`catalog.SensitiveSettingKeys`. One allowlist now drives **both** redaction and encryption.
|
||||
- [ ] **Step 2:** Implement `EncryptedSettingsRepo` wrapping `*ServerSettingsRepo` + `*secret.Cipher`:
|
||||
- `Set(key, value)`: if `SensitiveSettingKeys[key]` and `value != ""`, encrypt with AAD
|
||||
`"server_settings:"+key` before delegating; else delegate raw.
|
||||
- `Get(key)` / `GetAll()`: delegate, then apply the **read-path contract** to each sensitive key
|
||||
(pass through legacy plaintext; decrypt prefixed values; propagate decrypt errors).
|
||||
- [ ] **Step 3:** Add `BackfillSensitiveSettings(ctx)` on the decorator: for each key in
|
||||
`SensitiveSettingKeys`, read the raw inner value; if non-empty and not `IsEncrypted`, `inner.Set` the
|
||||
encrypted value (AAD `"server_settings:"+key`). Idempotent. Returns a count + aggregated errors.
|
||||
- [ ] **Step 4:** In `cmd/silo/main.go`, build `settingsRepo` as the `EncryptedSettingsRepo` wrapping the
|
||||
raw `catalog.NewServerSettingsRepo(pool)`. Confirm the auto-generate-`jwt_secret` step still works: the
|
||||
`GetAll` map carries plaintext, the `== ""` check is unchanged, and `Set("auth.jwt_secret", encoded)`
|
||||
now writes **encrypted** while the in-memory map keeps the plaintext for `LoadFromDB`. Any function
|
||||
currently typed to the concrete `*catalog.ServerSettingsRepo` changes to the `ServerSettingsStore`
|
||||
interface that both already satisfy.
|
||||
- [ ] **Step 5 (re-wrap after pool recreation):** `cmd/silo/main.go` rebuilds a **raw**
|
||||
`catalog.NewServerSettingsRepo(pool)` after recreating the pool for `max_connections` (line ~399).
|
||||
Re-wrap it there too (`settingsRepo = catalog.NewEncryptedSettingsRepo(catalog.NewServerSettingsRepo(pool), dataCipher)`)
|
||||
so every later consumer gets the encrypting repo, not a raw one. Audit for any other raw construction
|
||||
of the settings repo and wrap each.
|
||||
- [ ] **Step 6:** Tests: decorator round-trip (raw store holds `enc:v1:`, decorator `Get` returns
|
||||
plaintext); non-sensitive keys pass through unencrypted; legacy-plaintext pass-through on read;
|
||||
`BackfillSensitiveSettings` idempotency.
|
||||
- [ ] **Step 7:** `GOWORK=off go build ./...` compiles; admin settings GET still redacts (now including
|
||||
`redis.sentinel_password`); a sensitive key written via the admin API lands as `enc:v1:` in the DB.
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Inline-encrypt arr keys + delete the `SecretResolver` indirection
|
||||
|
||||
**Files:** Modify `internal/requests/repository.go`, `internal/requests/service.go`,
|
||||
`internal/autoscan/repository.go`, `internal/autoscan/connection.go`,
|
||||
`internal/api/autoscan_wiring.go`, `internal/api/router.go`, `cmd/silo/main.go`.
|
||||
|
||||
> The legacy-data resolution for these two columns is handled by the **resolve-then-encrypt backfill** in
|
||||
> Task 6 (a row may currently hold a `server_settings` key name, not a literal key). The repo write path
|
||||
> below only ever encrypts the literal credential an admin submits going forward.
|
||||
|
||||
- [ ] **Step 1:** `requests.NewRepository` gains a `*secret.Cipher` parameter. In `insertIntegration` and
|
||||
`updateIntegration`, encrypt `i.APIKeyRef` (AAD `"request_integrations:api_key_ref:"+i.ID`) before
|
||||
binding — preserve the existing `CASE WHEN $5 = '' THEN api_key_ref ELSE $5 END` keep-existing
|
||||
semantics (encrypt only the non-empty incoming value). In `scanIntegration`, apply the read-path
|
||||
contract to the scanned `api_key_ref`.
|
||||
- [ ] **Step 2:** Delete `requests.SecretResolver` (interface), the `Service.secrets` field,
|
||||
`Service.SetSecretResolver`, and `Service.resolveAPIKey`. Every former `resolveAPIKey(ctx, integration)`
|
||||
call site uses `strings.TrimSpace(integration.APIKeyRef)` directly (the repo already decrypted it).
|
||||
- [ ] **Step 3:** `autoscan.NewRepository` gains a `*secret.Cipher`. Encrypt `api_key_ref` in
|
||||
`CreateConnection` / `UpdateConnection` (AAD `"autoscan_connections:api_key_ref:"+id`, same
|
||||
keep-existing CASE semantics); apply the read-path contract in `scanConnection`.
|
||||
- [ ] **Step 4:** In `internal/autoscan/connection.go`, delete the `SecretResolver` interface, the
|
||||
`ConnectionResolver.secrets` field, and the secret-resolution branch in `Resolve`. For a linked Requests
|
||||
integration, `RequestIntegrationLookup.Get` returns the requests repo's **already-decrypted** key — use
|
||||
it directly. For a standalone connection, the autoscan repo already decrypted it. `Resolve` now only
|
||||
resolves base URL + linkage and returns plaintext.
|
||||
- [ ] **Step 5:** Remove the `AutoscanSecretResolver` parameter from `BuildAutoscanService`
|
||||
(`internal/api/autoscan_wiring.go`) and drop the `SetSecretResolver(settingsRepo)` calls in
|
||||
`internal/api/router.go` and `cmd/silo/main.go` (including the reconcile-worker service). Thread
|
||||
`dataCipher` into the requests/autoscan repo constructors at all wiring sites.
|
||||
- [ ] **Step 6:** Verify no API response serializes the raw key: the integration/connection response DTOs
|
||||
still emit only `has_api_key`. Add/extend a test asserting the create→store→read round-trip yields the
|
||||
original key, and that the stored column value is `enc:v1:`-prefixed.
|
||||
- [ ] **Step 7:** `GOWORK=off go build ./...`; `GOWORK=off go test ./internal/requests/... ./internal/autoscan/...`.
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Per-table credential columns
|
||||
|
||||
**Files:** Modify `internal/subtitles/pgrepo.go`, `internal/watchsync/repository.go`,
|
||||
`internal/webhooksync/repo.go`, `internal/historyimport/repo_admin.go`, `internal/historyimport/repo.go`,
|
||||
plus their constructor call sites.
|
||||
|
||||
Each repo gains a `*secret.Cipher` constructor arg and follows encrypt-on-write / decrypt-on-read with a
|
||||
row-bound AAD. Preserve existing keep-existing CASE/COALESCE semantics — encrypt only non-empty incoming
|
||||
values. All read paths use the read-path contract (pass through legacy plaintext, hard-fail corrupt
|
||||
ciphertext). These columns **never** had the resolver indirection — they always stored literal
|
||||
credentials — so no resolve-then-encrypt is needed (unlike the arr columns).
|
||||
|
||||
- [ ] **Step 1: subtitles** — encrypt `api_key` + `password` in `UpsertProviderConfig`; decrypt in
|
||||
`ListProviderConfigs` and `GetProviderConfig`. Keep the existing `HasAPIKey`/`HasCredentials` flags
|
||||
(set them from the decrypted values). AAD PK = `provider_name`.
|
||||
- [ ] **Step 2: watchsync** — encrypt `access_token` + `refresh_token` in `UpsertConnection`. Add a
|
||||
`decryptConnection(*Connection)` helper and call it after **every** scan path (list/get/by-provider/etc.
|
||||
— there are several). AAD PK = connection `id`.
|
||||
- [ ] **Step 3: webhooksync** — encrypt `access_token` in `CreateConnection`; decrypt in `scanConnection`.
|
||||
**Do NOT touch `webhook_secret`** — it is equality-looked-up in `GetConnectionBySecret`
|
||||
(`WHERE webhook_secret = $1`) and is carved out for the hashing follow-up. AAD PK = connection `id`.
|
||||
- [ ] **Step 4: historyimport admin token** — encrypt in `SetSourceAdminToken`; decrypt in
|
||||
`GetSourceWithAdminToken`; `ClearSourceAdminToken` (writes NULL) is unchanged. AAD PK = source `id`.
|
||||
- [ ] **Step 5: historyimport session tokens** — in `internal/historyimport/repo.go`, encrypt
|
||||
`connect_access_token` (`history_import_connect_sessions`) and `auth_token`
|
||||
(`history_import_plex_sessions`) on insert/update; decrypt on scan (note the existing
|
||||
`COALESCE(auth_token, '')` read). These are read by session `id`, not by token value. `pin_id`/`pin_code`
|
||||
are transient pairing artifacts — leave them. AAD PK = session `id`.
|
||||
- [ ] **Step 6:** Thread `dataCipher` into each constructor at its wiring site (`router.go` / `main.go`).
|
||||
- [ ] **Step 7:** `GOWORK=off go build ./...`; package tests for each modified repo pass.
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Idempotent startup backfill
|
||||
|
||||
**Files:** Create `internal/secret/backfill.go`, `internal/secret/backfill_test.go`; modify `cmd/silo/main.go`.
|
||||
|
||||
- [ ] **Step 1: Generic in-place backfill** for the literal-credential columns (subtitle ×2, watchsync ×2,
|
||||
webhook `access_token`, historyimport `admin_token`, historyimport session `connect_access_token` +
|
||||
`auth_token`). Per `BackfillTarget {table, column, idColumn, aadPrefix}`: select rows where the column
|
||||
is non-null, non-empty, and `NOT LIKE 'enc:v1:%'`; for each, compute AAD = `aadPrefix + ":" + id`,
|
||||
`EncryptIfPlaintext`, and `UPDATE ... SET col = $1 WHERE id = $2 AND col = $3` (the `AND col = $3` guard
|
||||
makes concurrent multi-node boots converge without double-encrypting). Empty values are skipped (never
|
||||
encrypt `""`).
|
||||
- [ ] **Step 2: Resolve-then-encrypt backfill for the two arr columns** (`request_integrations.api_key_ref`,
|
||||
`autoscan_connections.api_key_ref`). This is **separate** from Step 1 because legacy rows may hold a
|
||||
`server_settings` key name rather than a literal key. For each row whose value is not already
|
||||
`enc:v1:`-prefixed, replicate the old `resolveAPIKey` semantics **once**:
|
||||
1. `resolved := encryptedSettingsRepo.Get(ctx, value)` (the decrypting decorator — handles both a
|
||||
sensitive-and-encrypted target like `requests.radarr.api_key` and a plaintext custom key).
|
||||
2. If `resolved` is non-empty, the row held a *reference* → encrypt `resolved` inline.
|
||||
Else the row held a *literal* key → encrypt `value` inline.
|
||||
3. `UPDATE ... SET api_key_ref = <enc> WHERE id = $2 AND api_key_ref = <original>`, AAD bound to the row id.
|
||||
Run this **after** `BackfillSensitiveSettings` so the referenced settings are consistent (the decorator
|
||||
decrypts regardless, but ordering keeps it deterministic). Optionally log/flag now-orphaned
|
||||
`requests.{radarr,sonarr}.api_key` settings rows for a later cleanup (do not delete in this PR).
|
||||
- [ ] **Step 3: Failure posture (decided):** best-effort. Collect per-row/per-target errors, log each at
|
||||
ERROR, and emit a single startup summary line (`secret backfill: encrypted N, failed M`). A failed
|
||||
encrypt leaves the pre-existing plaintext (no *new* exposure vs. today) and reads still succeed via the
|
||||
read-path pass-through, so it must **not** block boot — reliability over a hard stop. (Read-path decrypt
|
||||
failures on an `enc:v1:` value still hard-fail the individual operation.)
|
||||
- [ ] **Step 4: Ordering in `cmd/silo/main.go`** — gate on migration-running modes only (mirror the
|
||||
existing migration guard; proxy/transcode nodes skip the backfill). Sequence:
|
||||
`LoadBootstrap (read SECRET_KEY) → open pool → RunMigrations → construct dataCipher → wrap settingsRepo →
|
||||
BackfillSensitiveSettings → generic column backfill → arr resolve-then-encrypt backfill → GetAll(decrypts)
|
||||
→ auto-gen jwt_secret (writes encrypted) → [pool recreate? re-wrap settingsRepo] → LoadFromDB(plaintext) → validate`.
|
||||
The settings backfill + `GetAll` must precede `LoadFromDB` so config consumers read decrypted values.
|
||||
- [ ] **Step 5: Concurrency note** — the `AND col = $3` idempotency guard is sufficient for the common
|
||||
single-primary deploy. For multi-primary races, optionally wrap the backfill in a dedicated Postgres
|
||||
advisory lock with a **fresh** lock id (do **not** reuse the migration `legacyBootstrapLocker` id — that
|
||||
would extend the migration lock's held duration unpredictably).
|
||||
- [ ] **Step 6: Tests** (`backfill_test.go`, against a test DB): seed plaintext rows → backfill →
|
||||
all become `enc:v1:` and decrypt to the originals; second run is a no-op (no double-encrypt); mixed
|
||||
plaintext/ciphertext rows handled; empty values skipped; one failing row does not abort the rest;
|
||||
**arr resolve-then-encrypt:** a row holding `requests.radarr.api_key` (with that settings key present)
|
||||
ends up encrypting the *resolved* credential, and a row holding a literal key encrypts the literal.
|
||||
- [ ] **Step 7:** Boot against a DB pre-seeded with plaintext credentials **and** a legacy arr row that
|
||||
references a settings key; confirm the summary log, that every target column is `enc:v1:` afterward, that
|
||||
the arr row decrypts to the real Radarr key (not the setting name), and that a second boot reports
|
||||
`encrypted 0`.
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Verification, docs, follow-up
|
||||
|
||||
- [ ] **Step 1: Regression guard tests:**
|
||||
- API key auth still works — `api_keys.api_key` is stored as the literal `sa_`-prefixed token and
|
||||
`GetByKey` (`WHERE api_key = $1`) still resolves (proves it was *not* routed through encryption).
|
||||
- `auth.jwt_secret` is consistent across two simulated boots (encrypted at rest, plaintext to
|
||||
`LoadFromDB`), and the stored `server_settings` row is `enc:v1:`-prefixed.
|
||||
- Inbound webhook routing still works (`webhook_secret` untouched).
|
||||
- A legacy arr integration that referenced `requests.sonarr.api_key` still fulfills after backfill.
|
||||
- [ ] **Step 2:** `GOWORK=off go build ./...`, `make lint`, `make verify-local-paths`,
|
||||
`cd web && pnpm run lint && pnpm run format:check` (frontend unaffected — confirm green).
|
||||
- [ ] **Step 3:** Write `docs/architecture/secret-encryption.md`: how `SECRET_KEY` is generated, that it
|
||||
must be backed up **separately** from DB dumps (treat like a CA private key), what happens on key loss
|
||||
(encrypted secrets unrecoverable → re-enter integrations), the rollback/downgrade hazard (an old binary
|
||||
reads `enc:v1:auth.jwt_secret` as a literal JWT secret → all sessions invalid; document that downgrade
|
||||
requires decrypting settings back to plaintext or clearing `auth.jwt_secret`), and the envelope/version
|
||||
scheme for future rotation.
|
||||
- [ ] **Step 4: File the follow-up issues:**
|
||||
1. **Hash equality-lookup secrets** (`api_keys.api_key`, `webhook_sync_connections.webhook_secret`) —
|
||||
store a deterministic hash, look up by hash.
|
||||
2. **Encrypt plugin runtime config** (`plugin_runtime_configs.config_value`) — coordinated with the
|
||||
`Silo` host repo and plugin-manifest secret-field metadata; must also re-encrypt/purge the legacy
|
||||
plaintext `introdb.api_key` copy. Note the dead `plex_sync_connections` table as a drop candidate.
|
||||
- [ ] **Step 5:** Update the issue #45 acceptance check: no plaintext arr keys at rest (Requests +
|
||||
Autoscan); `api_key_ref` is unambiguous (inline ciphertext, no literal fallback); existing rows
|
||||
encrypted by the backfill (arr rows resolved-then-encrypted); S3 + the broader server-owned credential
|
||||
set covered; remaining gaps (equality-lookup hashing, plugin JSONB) tracked as follow-ups.
|
||||
|
||||
---
|
||||
|
||||
## Safety checklist (the implementer must not skip)
|
||||
|
||||
1. `SECRET_KEY` is read in `LoadBootstrap` and **fatals** if absent/short — never defaults to a zero key
|
||||
or a hash of `DATABASE_URL`.
|
||||
2. The cipher is constructed once, right after bootstrap, before any `settingsRepo` call — threaded as a
|
||||
dependency, never a global.
|
||||
3. The settings backfill + decryption happen **before** `config.LoadFromDB` consumes the map.
|
||||
4. The auto-generated `auth.jwt_secret` is written **encrypted**, while the in-memory map keeps plaintext
|
||||
for `LoadFromDB`.
|
||||
5. **`settingsRepo` is re-wrapped as the `EncryptedSettingsRepo` after the max-connections pool recreation
|
||||
(main.go ~399)** and at every other construction site — no raw settings repo escapes into later wiring.
|
||||
6. The two **arr columns** use the **resolve-then-encrypt** backfill (not in-place) so legacy
|
||||
`server_settings` references become the real credential before encryption; the other columns use the
|
||||
plain in-place backfill.
|
||||
7. `SensitiveSettingKeys` is **audited from the config loader's real inputs** (incl.
|
||||
`redis.sentinel_password` and the legacy `s3.operational_*` / `recommendations.openai_api_key`
|
||||
aliases), not copied from the old redaction map.
|
||||
8. `api_keys.api_key` and `webhook_sync_connections.webhook_secret` are **never** routed through
|
||||
encryption — they are equality-looked-up and would break auth/routing (separate hashing task).
|
||||
9. Every decrypt site obeys the **read-path contract**: pass through non-prefixed plaintext; **error
|
||||
(never fall back)** on a corrupt `enc:v1:` value. Audit every old `if resolved == ""` site removed in Task 4.
|
||||
10. AAD binds to **row identity** (`table:column:<pk>`); PKs in scope (integration/connection ids, uuids,
|
||||
`provider_name`) are stable. server_settings binds to its key.
|
||||
11. The backfill is prefix-gated and idempotent (`AND col = $3` guard); proxy/transcode nodes skip it.
|
||||
12. `plex_sync_connections` is not a target (dead table); `plugin_runtime_configs.config_value` is a
|
||||
documented deferred gap, not silently ignored; `subtitle_provider_config.username` stays plaintext.
|
||||
@@ -0,0 +1,377 @@
|
||||
# AI Services Core, Metadata Translation & Whisper ASR — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Design:** `docs/superpowers/specs/2026-06-10-ai-translation-and-asr-design.md` (read it first; this plan does not repeat the rationale).
|
||||
|
||||
**Goal:** Extract the subtitle-AI LLM plumbing into shared packages (`internal/ai/llm`, `internal/ai/translate`, `internal/ai/jobrunner`), add AI translation of overviews/taglines into the existing localization tables with field provenance, and add Whisper ASR subtitle generation (`transcribe`, `transcribe_translate`). Single PR.
|
||||
|
||||
**Architecture:** New `internal/ai/*` packages consumed by a refactored `internal/subtitles/ai` and a new `internal/metadata/translation` service. One new Goose migration (jobs table, provenance columns, library auto-translate flag). New `ai.*` settings keys with loader fallback to legacy `subtitle_ai.*` rows (encrypted values are GCM-bound to their key — never rename rows in SQL). Frontend: AI Services settings page, metadata-editor translate action, library toggle, player "generate from audio" mode.
|
||||
|
||||
**Tech stack:** Go (chi, pgx), PostgreSQL via Goose SQL migrations, React + TypeScript (TanStack Query), Vitest.
|
||||
|
||||
Commands assume the repository root is the cwd.
|
||||
|
||||
---
|
||||
|
||||
## Ordering and verification
|
||||
|
||||
Tasks 1–4 are a pure refactor and must leave subtitle translation behaviorally unchanged — run `go build ./... && go test ./internal/...` after each. Tasks 5–11 are backend features; 12–15 frontend; 16 final verification.
|
||||
|
||||
Before opening the MR: `make lint`, `cd web && pnpm run lint && pnpm run format:check`, `make verify-local-paths`.
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
**Create**
|
||||
- `internal/ai/llm/client.go`, `client_test.go`, `config.go` — shared OpenAI-compatible client (chat + transcription)
|
||||
- `internal/ai/llm/transcribe.go` — multipart `/v1/audio/transcriptions`, `verbose_json` types
|
||||
- `internal/ai/translate/translate.go`, `translate_test.go` — generic segment batch translator (package `aitranslate`)
|
||||
- `internal/ai/jobrunner/runner.go`, `runner_test.go` — dispatch/semaphore/heartbeat/reaper/cancel registry
|
||||
- `internal/metadata/translation/{job.go,pgrepo.go,service.go,prompt.go,service_test.go}`
|
||||
- `internal/subtitles/ai/transcriber.go`, `transcriber_test.go` — ASR pipeline
|
||||
- `internal/playback/audio_extract.go` — ffmpeg chunked audio extraction helper
|
||||
- `internal/api/handlers/metadata_ai.go`
|
||||
- `migrations/sql/<timestamp>_ai_metadata_translation_and_asr.sql` (via `make migrate-create`)
|
||||
- `web/src/pages/admin-settings/AIServicesSettings.tsx`
|
||||
|
||||
**Modify**
|
||||
- `internal/subtitles/ai/{client.go→deleted,translator.go,service.go,engine.go,job.go,config.go,pgrepo.go}`
|
||||
- `internal/config/{config.go,db_loader.go}`; `internal/catalog/encrypted_settings_repo.go`
|
||||
- `internal/catalog/{localization_repo.go,detail.go}`; `internal/models/media.go`
|
||||
- `internal/metadata/service.go` (provider provenance + auto-enqueue hook)
|
||||
- `internal/api/handlers/subtitle_ai.go`; router registration; `cmd/silo/main.go` wiring
|
||||
- `web/src/pages/admin-settings/SubtitlesSettings.tsx`, `web/src/components/EditMetadataDialog.tsx`, `web/src/player/components/SubtitleTranslateModal.tsx`, library settings form, `web/src/api/` types/client, admin settings nav/route registration
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `internal/ai/llm` — shared client
|
||||
|
||||
- [ ] **Step 1: Move the chat client.** Create `internal/ai/llm` (package `llm`). Move `Client`, `chat` (export as `Chat`), `chatMessage` (export as `Message`), retry helpers (`sleepCtx`, `rateLimitBackoff`, `truncate`) from `internal/subtitles/ai/client.go`. New `llm.Config`:
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
BaseURL string // chat endpoint, no trailing /v1
|
||||
APIKey string
|
||||
ChatModel string
|
||||
ASRBaseURL string // empty = BaseURL
|
||||
ASRAPIKey string // empty = APIKey
|
||||
ASRModel string
|
||||
MaxConcurrentJobs int
|
||||
}
|
||||
```
|
||||
|
||||
Log lines lose the "subtitle" wording (they are shared now).
|
||||
|
||||
- [ ] **Step 2: Add `Transcribe`.** In `transcribe.go`:
|
||||
|
||||
```go
|
||||
type TranscribeRequest struct {
|
||||
Filename string
|
||||
Audio io.Reader
|
||||
Language string // optional ISO-639-1 hint
|
||||
Timeout time.Duration // per-request; sized to chunk length by the caller
|
||||
}
|
||||
type TranscriptionSegment struct{ Start, End float64; Text string }
|
||||
type Transcription struct{ Language string; Segments []TranscriptionSegment }
|
||||
func (c *Client) Transcribe(ctx context.Context, req TranscribeRequest) (*Transcription, error)
|
||||
```
|
||||
|
||||
Multipart fields: `file`, `model` (=`ASRModel`), `response_format=verbose_json`, `temperature=0`, optional `language`. Reuse the same 429/5xx/transport retry-with-backoff loop as `Chat` (extract a shared `doWithRetry` rather than duplicating it). Empty `segments` in the response is an error (`transcription returned no segments`), not a silent fallback.
|
||||
|
||||
- [ ] **Step 3: Tests** (`client_test.go`, httptest): chat retries on 429 (honors `Retry-After`), 5xx, 200-with-error-object, empty choices; transcribe happy path (multipart fields present, segments parsed), missing-segments error, ASR base-url/key override falling back to chat values.
|
||||
|
||||
- [ ] **Step 4: Commit.** `refactor(ai): extract shared OpenAI-compatible LLM client with transcription support`
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `internal/ai/translate` — generic segment translator
|
||||
|
||||
- [ ] **Step 1: Move the batch logic.** Package `aitranslate`. Move `buildIndexedJSON`, `extractJSONObject`, and the batch loop from `internal/subtitles/ai/translator.go`, generalized:
|
||||
|
||||
```go
|
||||
type Segment struct{ ID, Text string }
|
||||
type Request struct {
|
||||
Segments []Segment
|
||||
SystemPrompt string // caller-supplied; domain-specific
|
||||
BatchSize int
|
||||
ContextNeighbors int // preceding source segments sent untranslated
|
||||
}
|
||||
type ChatFn func(ctx context.Context, system, user string) (string, error)
|
||||
func Translate(ctx context.Context, chat ChatFn, req Request,
|
||||
onBatch func(batch []Segment, done, total int)) ([]Segment, error)
|
||||
```
|
||||
|
||||
Wire protocol unchanged: 1-based indexed JSON per batch, same-keys response, malformed-response retries (`maxRetries = 2`), completeness check. `ChatFn` keeps the package free of an `llm` dependency and trivially testable.
|
||||
|
||||
- [ ] **Step 2: Tests:** batch splitting/boundaries, context neighbors, code-fence tolerance, omitted-key retry then failure, ctx cancellation between batches.
|
||||
|
||||
- [ ] **Step 3: Commit.** `refactor(ai): extract generic batched segment translator`
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `internal/ai/jobrunner` — shared job lifecycle
|
||||
|
||||
- [ ] **Step 1: Extract the runner.** Move dispatch/heartbeat/reaper/cancel mechanics from `internal/subtitles/ai/service.go` (lines ~136–319) behind:
|
||||
|
||||
```go
|
||||
type Store interface {
|
||||
Heartbeat(ctx context.Context, id int64) error
|
||||
ResetStaleJobs(ctx context.Context, before time.Time, message string) (int64, error)
|
||||
MarkCancelled(ctx context.Context, id int64, message string) error
|
||||
}
|
||||
type Runner struct{ /* baseCtx, sem (shared), store, logger, cancels, wg */ }
|
||||
func New(appCtx context.Context, sem chan struct{}, store Store, logger *slog.Logger) *Runner
|
||||
func (r *Runner) Recover() // reap + background reaper loop
|
||||
func (r *Runner) Dispatch(id int64, run func(ctx context.Context)) // semaphore + heartbeat + cancel registry
|
||||
func (r *Runner) Cancel(id int64) bool // true if an in-flight goroutine was cancelled
|
||||
```
|
||||
|
||||
Heartbeat-while-queued behavior (the comment block in `dispatch`) must be preserved verbatim — it is load-bearing for multi-instance safety. Constants (30 s heartbeat, 2 min stale, 1 min reaper) move here.
|
||||
|
||||
- [ ] **Step 2: Shared semaphore.** The semaphore is **constructed by the caller** (`cmd/silo`) at size `ai.max_concurrent_jobs` and passed to every `Runner`, so subtitle + metadata + ASR jobs share one bound.
|
||||
|
||||
- [ ] **Step 3: Tests:** dispatch bounded by a size-1 shared semaphore across two runners; cancel of queued job marks cancelled without running; reaper resets a stale heartbeat row (fake store).
|
||||
|
||||
- [ ] **Step 4: Commit.** `refactor(ai): extract shared job lifecycle runner`
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Refactor `internal/subtitles/ai` onto the shared core
|
||||
|
||||
- [ ] **Step 1:** Delete `client.go`; `LLMTranslator` becomes an adapter: cues → `aitranslate.Segment` (ID = 1-based index, Text = joined lines), subtitle system prompt stays here, `splitCueLines` maps back. `Service` keeps job semantics but delegates lifecycle to `jobrunner.Runner`; `pgrepo` satisfies `jobrunner.Store`.
|
||||
- [ ] **Step 2:** Update `cmd/silo/main.go` wiring (build `llm.Client` + shared semaphore once; pass into the subtitle service).
|
||||
- [ ] **Step 3:** `go build ./... && go test ./internal/...` — subtitle translation behavior unchanged (same prompts, same batching, same job rows).
|
||||
- [ ] **Step 4: Commit.** `refactor(subtitles): consume shared AI core`
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Migration
|
||||
|
||||
- [ ] **Step 1:** `make migrate-create NAME=ai_metadata_translation_and_asr`, then fill the generated file:
|
||||
|
||||
```sql
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
CREATE TABLE public.metadata_translation_jobs (
|
||||
id bigserial PRIMARY KEY,
|
||||
target_kind text NOT NULL, -- 'item' | 'season' | 'episode'
|
||||
content_id text NOT NULL,
|
||||
include_children boolean NOT NULL DEFAULT true,
|
||||
source_language text NOT NULL DEFAULT '',
|
||||
target_language text NOT NULL,
|
||||
engine text NOT NULL DEFAULT 'openai',
|
||||
model text NOT NULL DEFAULT '',
|
||||
status text NOT NULL DEFAULT 'pending', -- pending|running|completed|failed|cancelled
|
||||
progress double precision NOT NULL DEFAULT 0,
|
||||
progress_message text NOT NULL DEFAULT '',
|
||||
fields_done integer NOT NULL DEFAULT 0,
|
||||
fields_total integer NOT NULL DEFAULT 0,
|
||||
force boolean NOT NULL DEFAULT false,
|
||||
error_message text NOT NULL DEFAULT '',
|
||||
idempotency_key text NOT NULL,
|
||||
requested_by integer,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
heartbeat_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE UNIQUE INDEX metadata_translation_jobs_active_idempotency_idx
|
||||
ON public.metadata_translation_jobs (idempotency_key)
|
||||
WHERE status IN ('pending', 'running');
|
||||
CREATE INDEX metadata_translation_jobs_content_idx
|
||||
ON public.metadata_translation_jobs (content_id, created_at DESC);
|
||||
CREATE INDEX metadata_translation_jobs_status_idx
|
||||
ON public.metadata_translation_jobs (status) WHERE status IN ('pending', 'running');
|
||||
|
||||
-- Field provenance: 'provider' | 'ai' | 'manual'. manual > provider > ai.
|
||||
ALTER TABLE media_item_localizations
|
||||
ADD COLUMN overview_source text NOT NULL DEFAULT 'provider'
|
||||
CHECK (overview_source IN ('provider', 'ai', 'manual')),
|
||||
ADD COLUMN tagline_source text NOT NULL DEFAULT 'provider'
|
||||
CHECK (tagline_source IN ('provider', 'ai', 'manual'));
|
||||
ALTER TABLE season_localizations
|
||||
ADD COLUMN overview_source text NOT NULL DEFAULT 'provider'
|
||||
CHECK (overview_source IN ('provider', 'ai', 'manual'));
|
||||
ALTER TABLE episode_localizations
|
||||
ADD COLUMN overview_source text NOT NULL DEFAULT 'provider'
|
||||
CHECK (overview_source IN ('provider', 'ai', 'manual'));
|
||||
|
||||
ALTER TABLE media_folders
|
||||
ADD COLUMN auto_translate_metadata boolean NOT NULL DEFAULT false;
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE media_folders DROP COLUMN auto_translate_metadata;
|
||||
ALTER TABLE episode_localizations DROP COLUMN overview_source;
|
||||
ALTER TABLE season_localizations DROP COLUMN overview_source;
|
||||
ALTER TABLE media_item_localizations DROP COLUMN tagline_source, DROP COLUMN overview_source;
|
||||
DROP TABLE IF EXISTS public.metadata_translation_jobs;
|
||||
-- +goose StatementEnd
|
||||
```
|
||||
|
||||
- [ ] **Step 2:** `make migrate-up` against the local stack (`docker compose up -d postgres redis`), then `make migrate-status`.
|
||||
- [ ] **Step 3: Commit.** `feat(metadata): migration for translation jobs, localization provenance, library auto-translate`
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Settings & config
|
||||
|
||||
- [ ] **Step 1: Config structs** (`internal/config/config.go`): add `AIConfig` (fields mirroring `llm.Config`); slim `SubtitleAIConfig` to `Enabled`, `TranscribeEnabled`, `BatchSize`, `ContextNeighbors`; add `MetadataAIConfig{Enabled bool}`.
|
||||
- [ ] **Step 2: Loader** (`internal/config/db_loader.go`): load `ai.*` with legacy fallback, following the `recommendations.embedding_auth_token` precedent at line ~409:
|
||||
|
||||
```go
|
||||
cfg.AI.BaseURL = stringOr(m, "ai.base_url", stringOr(m, "subtitle_ai.base_url", "https://api.openai.com"))
|
||||
cfg.AI.APIKey = stringOr(m, "ai.api_key", stringOr(m, "subtitle_ai.api_key", ""))
|
||||
cfg.AI.ChatModel = stringOr(m, "ai.chat_model", stringOr(m, "subtitle_ai.chat_model", "gpt-4o-mini"))
|
||||
// ai.max_concurrent_jobs ← subtitle_ai.max_concurrent_jobs ← 2
|
||||
// ai.asr_model (default "whisper-1"), ai.asr_base_url, ai.asr_api_key (default "")
|
||||
// subtitle_ai.transcribe_enabled, metadata_ai.enabled (default false)
|
||||
```
|
||||
|
||||
**Never** rename the legacy rows in SQL — encrypted values are GCM-bound to their setting key (`internal/catalog/encrypted_settings_repo.go`).
|
||||
|
||||
- [ ] **Step 3:** Add `ai.api_key`, `ai.asr_api_key` to `sensitiveSettingKeys`.
|
||||
- [ ] **Step 4:** Readiness helpers: subtitle translate = `SubtitleAI.Enabled && AI chat ready`; ASR = `SubtitleAI.TranscribeEnabled && AI.ASRModel != "" && (asr or chat base URL set)`; metadata = `MetadataAI.Enabled && AI chat ready`.
|
||||
- [ ] **Step 5: Commit.** `feat(config): shared ai.* settings with legacy subtitle_ai fallback`
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Provenance-aware localization writes
|
||||
|
||||
- [ ] **Step 1: Models** (`internal/models/media.go`): add `OverviewSource`/`TaglineSource` to `MediaItemLocalization`, `OverviewSource` to `SeasonLocalization`/`EpisodeLocalization`.
|
||||
- [ ] **Step 2: Provider upsert rules** (`internal/catalog/localization_repo.go`): rewrite the three `Upsert` statements so, per AI-writable field:
|
||||
- existing source `manual` → keep existing value and source;
|
||||
- incoming value empty → keep existing value and source;
|
||||
- otherwise → take incoming value, set source `provider`.
|
||||
|
||||
Single-statement `ON CONFLICT … DO UPDATE` with `CASE` (no read-modify-write). Pattern for one field:
|
||||
|
||||
```sql
|
||||
overview = CASE
|
||||
WHEN media_item_localizations.overview_source = 'manual' THEN media_item_localizations.overview
|
||||
WHEN EXCLUDED.overview = '' THEN media_item_localizations.overview
|
||||
ELSE EXCLUDED.overview END,
|
||||
overview_source = CASE
|
||||
WHEN media_item_localizations.overview_source = 'manual' THEN media_item_localizations.overview_source
|
||||
WHEN EXCLUDED.overview = '' THEN media_item_localizations.overview_source
|
||||
ELSE 'provider' END
|
||||
```
|
||||
|
||||
- [ ] **Step 3: AI upsert methods**: `UpsertAITranslation(ctx, contentID, language string, overview, tagline *string, force bool)` per repo (nil pointer = field not part of this write). Insert path populates only the translated fields (other text columns empty). Update path per field: write when existing source is `ai`, existing value is empty, or `force` — never when `manual`; `force` may overwrite `provider` (the admin asked). Sets source `ai`.
|
||||
- [ ] **Step 4: Serving hardening** (`internal/catalog/detail.go`): verify `LocalizeItemModel` / `LocalizeSeasonModel` / `LocalizeEpisodeModel` only override base fields with **non-empty** localization values (AI rows carry empty titles). Fix any field that clobbers; add a regression test.
|
||||
- [ ] **Step 5: Tests** for the upsert matrix (provider-over-ai, ai-skips-provider-unless-force, manual-untouchable, empty-never-blanks), following the existing repo/service test patterns in `internal/catalog`.
|
||||
- [ ] **Step 6: Commit.** `feat(catalog): field provenance for localizations with provider/ai/manual precedence`
|
||||
|
||||
---
|
||||
|
||||
## Task 8: `internal/metadata/translation` service
|
||||
|
||||
- [ ] **Step 1: Job model + repo** (`job.go`, `pgrepo.go`): mirror `internal/subtitles/ai/{job.go,pgrepo.go}` shapes against `metadata_translation_jobs`; satisfy `jobrunner.Store`. Idempotency key = SHA-256 of `content_id|target_kind|target_language|model`.
|
||||
- [ ] **Step 2: Service** (`service.go`): `Enqueue`, `GetJob`, `ListJobs(contentID)`, `Cancel`, `Recover`, using `jobrunner.Runner` + the shared semaphore. The run loop:
|
||||
1. Expand targets: `item` → item overview+tagline; series item with `include_children` → + every season overview + every episode overview; `season` (+children → its episodes); `episode` → its overview. Source text comes from the **base** rows (default metadata language); `source_language` recorded from the item's `default_metadata_language`.
|
||||
2. Skip-if-filled: drop any field whose target-language localization value is already non-empty, unless `force`. All skipped → complete immediately ("Nothing to translate").
|
||||
3. Build `aitranslate.Segment`s (IDs `item:overview`, `item:tagline`, `season:<content_id>:overview`, `episode:<content_id>:overview`), translate with `prompt.go`'s system prompt (names the title/year, "translate media catalog descriptions", preserve proper nouns/character names/tone, no added information), `BatchSize` = package constant `metadataBatchSize = 10`, `ContextNeighbors = 0`.
|
||||
4. Per batch: provenance-aware AI upserts (Task 7) + `fields_done`/progress update. Persisting per batch means a cancelled job keeps completed fields.
|
||||
- [ ] **Step 3: Tests** (fake repos + fake `ChatFn`): series expansion counts, skip-if-filled short-circuit (zero chat calls), force overwrites `ai`+`provider` but never `manual`, per-batch persistence on cancellation mid-job.
|
||||
- [ ] **Step 4: Commit.** `feat(metadata): AI translation service for overviews and taglines`
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Ingestion hook + library flag
|
||||
|
||||
- [ ] **Step 1:** Switch the provider localization writes in `internal/metadata/service.go` (item ~line 1476, season ~2921, episode ~3080) to the provenance-aware upserts — no other behavior change.
|
||||
- [ ] **Step 2:** Thread `auto_translate_metadata` through the `MediaFolder` model, folder repo scan/update, and the folder settings API payload.
|
||||
- [ ] **Step 3: Auto-enqueue hook.** After an item's refresh persists (single point at the end of the item flow — not per season/episode), when: folder flag set, folder `metadata_language` non-empty and ≠ item `default_metadata_language`, metadata AI ready, and the item's target-language localization is missing overview (or tagline) → `Enqueue` a non-force `item` job with `include_children=true`. Fire-and-forget (log on error); the active-idempotency index plus skip-if-filled make repeat refreshes free.
|
||||
- [ ] **Step 4: Commit.** `feat(metadata): per-library auto-translate fallback on refresh`
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Metadata AI API
|
||||
|
||||
- [ ] **Step 1:** `internal/api/handlers/metadata_ai.go`, mirroring `subtitle_ai.go` shapes:
|
||||
- `GET /api/v1/metadata/ai/status` → `{enabled}`
|
||||
- `POST /api/v1/metadata/ai/translate` `{content_id, target_kind, target_language, include_children, force}` → `{job}` (joins an in-flight duplicate, same as subtitles)
|
||||
- `GET /api/v1/metadata/ai/jobs/{job_id}`, `POST /api/v1/metadata/ai/jobs/{job_id}/cancel`, `GET /api/v1/metadata/ai/jobs?content_id=…`
|
||||
- [ ] **Step 2:** Gate with the same permission as metadata editing (metadata curation permission; see `internal/catalog/update.go` callers). Register routes; wire the service in `cmd/silo/main.go`.
|
||||
- [ ] **Step 3:** Handler tests following `internal/api/handlers` conventions (validation: bad kind, missing language, disabled engine → 400/503 mapping).
|
||||
- [ ] **Step 4: Commit.** `feat(api): metadata AI translation endpoints`
|
||||
|
||||
---
|
||||
|
||||
## Task 11: Whisper ASR
|
||||
|
||||
- [ ] **Step 1: Audio extraction** (`internal/playback/audio_extract.go`), alongside the existing subtitle extraction helpers:
|
||||
|
||||
```go
|
||||
// ExtractAudioChunks extracts one audio track to 16 kHz mono WAV chunks in dir.
|
||||
// Returns ordered chunk paths; chunkSeconds = 600 in production.
|
||||
func ExtractAudioChunks(ctx context.Context, filePath string, audioTrackIndex int,
|
||||
dir, ffmpegPath string, chunkSeconds int) ([]string, error)
|
||||
```
|
||||
|
||||
Single ffmpeg pass: `-vn -map 0:a:<idx> -ac 1 -ar 16000 -c:a pcm_s16le -f segment -segment_time <n> <dir>/chunk%05d.wav`.
|
||||
|
||||
- [ ] **Step 2: Transcriber** (`internal/subtitles/ai/transcriber.go`):
|
||||
|
||||
```go
|
||||
type Transcriber interface {
|
||||
Transcribe(ctx context.Context, req TranscribeJobRequest,
|
||||
onChunk func(cues []SubtitleCue, done, total int)) ([]SubtitleCue, string /*detected lang*/, error)
|
||||
}
|
||||
```
|
||||
|
||||
`WhisperTranscriber` implementation: temp dir (`os.MkdirTemp`, removed on all exit paths) → `ExtractAudioChunks` → per chunk `llm.Client.Transcribe` (per-request timeout ∝ chunk duration; pass the job's language hint) → offset segment times by `chunkIndex*chunkSeconds` → cues (one per segment, wrapped to ≤2 lines ×~42 chars via a `wrapCueText` helper; whitespace-only segments dropped). Chunk processing order starts at the chunk containing `StartPosition`, then wraps (mirrors `reorderFromPosition` semantics).
|
||||
|
||||
- [ ] **Step 3: Wire kinds into the service.** `Service.run` branches on `job.Kind`:
|
||||
- `translate`: existing path.
|
||||
- `transcribe`: resolve audio track (`source_index` = audio index, `-1` → default/first; reject files without audio), transcribe (progress 10–70 %, live cue streaming via the existing notifier callbacks), store SRT as provider `transcribed`, language = hint or detected, release name `"<Language> (AI transcribed)"`, notify `SubtitleReady`.
|
||||
- `transcribe_translate`: `transcribe`, store the transcript track, then run the existing `Translator` on the cues (70–95 %), store provider `translated`, `result_subtitle_id` = translated track.
|
||||
|
||||
Enqueue validation per kind (ASR requires `TranscribeEnabled`; idempotency model component = `asr_model` or `asr_model+chat_model` for the chained kind).
|
||||
|
||||
- [ ] **Step 4:** API surface: `kind` field (optional, default `translate`) on the existing enqueue request in `subtitle_ai.go`; `transcribe_enabled` on the status response.
|
||||
- [ ] **Step 5: Tests** (`transcriber_test.go`, fake client): timestamp stitching across chunks, playhead-first chunk ordering, cue wrapping, empty-segments chunk → job error, temp-dir cleanup on failure (assert via `t.TempDir` layout).
|
||||
- [ ] **Step 6: Commit.** `feat(subtitles): Whisper ASR transcribe and transcribe_translate jobs`
|
||||
|
||||
---
|
||||
|
||||
## Task 12: Frontend — AI Services settings page
|
||||
|
||||
- [ ] **Step 1:** `web/src/pages/admin-settings/AIServicesSettings.tsx`: connection card (`ai.base_url`, `ai.api_key` via the sensitive-key pattern already used in `SubtitlesSettings.tsx:264`, `ai.chat_model`, `ai.asr_model`, optional `ai.asr_base_url`/`ai.asr_api_key`, `ai.max_concurrent_jobs`) + features card (`subtitle_ai.enabled`, `subtitle_ai.transcribe_enabled`, `metadata_ai.enabled`, `subtitle_ai.batch_size`, `subtitle_ai.context_neighbors`). Reads show effective values (new key, falling back to the legacy key when unset — same fallback order as the loader); writes always target the new `ai.*` keys.
|
||||
- [ ] **Step 2:** Remove the AI card from `SubtitlesSettings.tsx` (leave a link/hint to the new page); register the page in the admin settings nav + route following the existing page registrations.
|
||||
- [ ] **Step 3: Commit.** `feat(web): AI services settings page`
|
||||
|
||||
---
|
||||
|
||||
## Task 13: Frontend — metadata translate action
|
||||
|
||||
- [ ] **Step 1:** API client functions + types for the Task 10 endpoints.
|
||||
- [ ] **Step 2:** `EditMetadataDialog.tsx`: "Translate with AI" action (visible when `metadata_ai` status is enabled): target language (default = library `metadata_language`), `include_children` (series only), `force` checkbox; enqueue, poll the job every ~1.5 s, show `progress_message`/`fields_done`, invalidate the item detail query on completion.
|
||||
- [ ] **Step 3: Commit.** `feat(web): translate descriptions from the metadata editor`
|
||||
|
||||
---
|
||||
|
||||
## Task 14: Frontend — library auto-translate toggle
|
||||
|
||||
- [ ] **Step 1:** Add the `auto_translate_metadata` switch to the library settings form next to the metadata-language field (helper text: "When metadata providers have no translation for this library's language, translate descriptions with AI"). Disabled state with hint when `metadata_ai` is off.
|
||||
- [ ] **Step 2: Commit.** `feat(web): library auto-translate toggle`
|
||||
|
||||
---
|
||||
|
||||
## Task 15: Frontend — player "Generate from audio"
|
||||
|
||||
- [ ] **Step 1:** `SubtitleTranslateModal.tsx`: when `transcribe_enabled`, add a source option "Generate from audio (AI)" listing the file's audio tracks; submit with `kind: "transcribe"` (or `transcribe_translate` when the chosen output language differs from the audio language). Surface it prominently when no text subtitle source exists (today's dead end for bitmap-only files).
|
||||
- [ ] **Step 2: Commit.** `feat(web): generate subtitles from audio in the player`
|
||||
|
||||
---
|
||||
|
||||
## Task 16: Verification
|
||||
|
||||
- [ ] `go build ./... && go test ./...`
|
||||
- [ ] `make lint`
|
||||
- [ ] `cd web && pnpm run lint && pnpm run format:check`
|
||||
- [ ] `make verify-local-paths`
|
||||
- [ ] Manual smoke against the local stack: enqueue a metadata translation for a series (verify localization rows + provenance values), re-run a refresh (verify provider values overwrite `ai` rows and `manual` simulation survives), run a `transcribe` job on a short file against a local Whisper-compatible server.
|
||||
- [ ] MR description: problem, approach, link to the design doc, risks (Whisper endpoint variance, chunk-boundary artifacts), AI-use disclosure, screenshots of the new settings page / editor action / player mode.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,809 @@
|
||||
# Core Notifications — Web UI Implementation Plan (Phase 2 of 3)
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Notifications UI in the silo-server web app: unread badge (sidebar + mobile header bell with dropdown), live toasts from the events WebSocket, an inbox page, per-category preference toggles in settings, and an admin announcements page.
|
||||
|
||||
**Architecture:** A `notifications` query-hook module talks to the Phase-1 REST API; one live-wiring hook subscribes to the `notifications` WS channel via the existing `RealtimeEventsProvider` ref-counted `useEventChannel`, firing sonner toasts and patching React Query caches. UI components consume only the hooks. Desktop surface is an `AppSidebar` item with badge (no desktop top bar exists); mobile surface is a bell + popover in the `Layout` header.
|
||||
|
||||
**Tech Stack:** React 19, React Router v7, TanStack Query, sonner, lucide-react, shadcn/ui primitives (Popover, Switch, Dialog), Vitest + RTL.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-10-core-notifications-design.md` (Web UI section). Server API shipped in Phase 1.
|
||||
|
||||
Commands assume the repository root is the cwd; web commands run in `web/`. `pnpm` is the package manager.
|
||||
|
||||
---
|
||||
|
||||
## Server API contract (frozen, Phase 1)
|
||||
|
||||
The `api()` client prefixes `/api/v1` and adds auth + `X-Profile-Id` headers automatically.
|
||||
|
||||
```
|
||||
GET /notifications?unread=1&category=&cursor=&limit= → {"items": Notification[], "next_cursor": number|null}
|
||||
GET /notifications/unread-count → {"count": number}
|
||||
POST /notifications/read {"ids":[...]} | {"all":true} → 204
|
||||
POST /notifications/{id}/dismiss → 204 | 404
|
||||
GET /notifications/preferences → {"preferences":[{"category","enabled"}]}
|
||||
PUT /notifications/preferences {"preferences":[...]} → 204 | 400
|
||||
GET /admin/announcements → {"items": Announcement[]}
|
||||
POST /admin/announcements {title, body, audience:{all|user_ids|library_ids}, expires_at?} → 201
|
||||
DELETE /admin/announcements/{id} → 204 | 404
|
||||
```
|
||||
|
||||
Notification JSON: `{id, user_id, profile_id?, category, type, title, body, link?, item_id?, created_at, read_at?, expires_at?}`. Categories: `request | content | announcement | system | admin`; preference categories additionally `content_digest` (never a notification category). WS: channel `"notifications"`, event `"notification.created"`, `data` = the Notification; subscribe snapshot `data` = `{"unread_count": number}`.
|
||||
|
||||
## File map
|
||||
|
||||
| File | Action | Responsibility |
|
||||
|---|---|---|
|
||||
| `web/src/api/types.ts` | modify | `AppNotification`, `NotificationPreference`, `Announcement`, `AnnouncementAudience` types; add `"notifications"` to `EventChannel` |
|
||||
| `web/src/hooks/queries/keys.ts` | modify | `notificationKeys` factory |
|
||||
| `web/src/hooks/queries/notifications.ts` | create | all query/mutation hooks (user + admin) |
|
||||
| `web/src/hooks/useNotificationsLive.ts` | create | WS subscription → toast + cache patching |
|
||||
| `web/src/hooks/useNotificationsLive.test.ts` | create | live-hook tests |
|
||||
| `web/src/components/NotificationBell.tsx` | create | badge + popover dropdown (mobile header) |
|
||||
| `web/src/components/NotificationBell.test.tsx` | create | bell tests |
|
||||
| `web/src/components/Layout.tsx` | modify | mount bell in mobile header; mount live hook |
|
||||
| `web/src/components/AppSidebar.tsx` | modify | Notifications nav item with badge |
|
||||
| `web/src/pages/Notifications.tsx` | create | inbox page |
|
||||
| `web/src/pages/Notifications.test.tsx` | create | inbox tests |
|
||||
| `web/src/pages/settings/NotificationsSettings.tsx` | create | preference toggles |
|
||||
| `web/src/pages/SettingsLayout.tsx` | modify | nav section entry |
|
||||
| `web/src/pages/AdminAnnouncements.tsx` | create | admin CRUD page |
|
||||
| `web/src/components/AdminSidebar.tsx` | modify | announcements nav item |
|
||||
| `web/src/App.tsx` | modify | routes: `/notifications`, admin `announcements`, settings `notifications` |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: API types and event channel
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/api/types.ts` (EventChannel union ~line 2198; types appended near other domain types)
|
||||
|
||||
- [ ] **Step 1: Add the types**
|
||||
|
||||
Add `"notifications"` to the `EventChannel` union. Then add (named `AppNotification` to avoid colliding with the DOM `Notification` type — verify no existing `AppNotification` symbol first with grep):
|
||||
|
||||
```typescript
|
||||
export type NotificationCategory = "request" | "content" | "announcement" | "system" | "admin";
|
||||
|
||||
export type NotificationPreferenceCategory = NotificationCategory | "content_digest";
|
||||
|
||||
export interface AppNotification {
|
||||
id: number;
|
||||
user_id: number;
|
||||
profile_id?: string;
|
||||
category: NotificationCategory;
|
||||
type: string;
|
||||
title: string;
|
||||
body: string;
|
||||
link?: string;
|
||||
item_id?: string;
|
||||
created_at: string;
|
||||
read_at?: string;
|
||||
expires_at?: string;
|
||||
}
|
||||
|
||||
export interface NotificationListResponse {
|
||||
items: AppNotification[] | null;
|
||||
next_cursor: number | null;
|
||||
}
|
||||
|
||||
export interface NotificationPreference {
|
||||
category: NotificationPreferenceCategory;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface AnnouncementAudience {
|
||||
all?: boolean;
|
||||
user_ids?: number[];
|
||||
library_ids?: number[];
|
||||
}
|
||||
|
||||
export interface Announcement {
|
||||
id: number;
|
||||
title: string;
|
||||
body: string;
|
||||
audience: AnnouncementAudience;
|
||||
created_by?: number;
|
||||
created_at: string;
|
||||
expires_at?: string;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Typecheck and commit**
|
||||
|
||||
```bash
|
||||
cd web && pnpm exec tsc -b --noEmit 2>&1 | head -5
|
||||
git add src/api/types.ts && git commit -m "feat(web): notification API types and event channel"
|
||||
```
|
||||
|
||||
(If the repo's typecheck command differs — check package.json scripts for `typecheck`/`build` — use that; `pnpm run build` also typechecks via `tsc -b`.)
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Query keys and hooks
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/hooks/queries/keys.ts`
|
||||
- Create: `web/src/hooks/queries/notifications.ts`
|
||||
|
||||
- [ ] **Step 1: Keys** — follow the existing factory style in keys.ts:
|
||||
|
||||
```typescript
|
||||
export const notificationKeys = {
|
||||
all: ["notifications"] as const,
|
||||
list: (filters: { unread?: boolean; category?: string }) =>
|
||||
[...notificationKeys.all, "list", filters] as const,
|
||||
unreadCount: () => [...notificationKeys.all, "unread-count"] as const,
|
||||
preferences: () => [...notificationKeys.all, "preferences"] as const,
|
||||
announcements: () => ["admin", "announcements"] as const,
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Hooks** — `web/src/hooks/queries/notifications.ts`. Mirror the conventions in `web/src/hooks/queries/admin/users.ts` (api client import path, toast usage, invalidation):
|
||||
|
||||
```typescript
|
||||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { api } from "@/api/client";
|
||||
import type {
|
||||
Announcement,
|
||||
AnnouncementAudience,
|
||||
AppNotification,
|
||||
NotificationListResponse,
|
||||
NotificationPreference,
|
||||
} from "@/api/types";
|
||||
import { notificationKeys } from "@/hooks/queries/keys";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
export function useNotificationsList(filters: { unread?: boolean; category?: string } = {}) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: notificationKeys.list(filters),
|
||||
queryFn: async ({ pageParam }) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set("limit", String(PAGE_SIZE));
|
||||
if (filters.unread) params.set("unread", "1");
|
||||
if (filters.category) params.set("category", filters.category);
|
||||
if (pageParam) params.set("cursor", String(pageParam));
|
||||
return api<NotificationListResponse>(`/notifications?${params.toString()}`);
|
||||
},
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (last) => last.next_cursor ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUnreadCount() {
|
||||
return useQuery({
|
||||
queryKey: notificationKeys.unreadCount(),
|
||||
queryFn: () => api<{ count: number }>("/notifications/unread-count"),
|
||||
select: (d) => d.count,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMarkRead() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: { ids?: number[]; all?: boolean }) =>
|
||||
api("/notifications/read", { method: "POST", body: JSON.stringify(body) }),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: notificationKeys.all });
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to mark read");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDismissNotification() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => api(`/notifications/${id}/dismiss`, { method: "POST" }),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: notificationKeys.all });
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to dismiss");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useNotificationPreferences() {
|
||||
return useQuery({
|
||||
queryKey: notificationKeys.preferences(),
|
||||
queryFn: () => api<{ preferences: NotificationPreference[] }>("/notifications/preferences"),
|
||||
select: (d) => d.preferences,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetNotificationPreferences() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (preferences: NotificationPreference[]) =>
|
||||
api("/notifications/preferences", { method: "PUT", body: JSON.stringify({ preferences }) }),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: notificationKeys.preferences() });
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to save preferences");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAnnouncements() {
|
||||
return useQuery({
|
||||
queryKey: notificationKeys.announcements(),
|
||||
queryFn: () => api<{ items: Announcement[] | null }>("/admin/announcements"),
|
||||
select: (d) => d.items ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateAnnouncement() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: {
|
||||
title: string;
|
||||
body: string;
|
||||
audience: AnnouncementAudience;
|
||||
expires_at?: string;
|
||||
}) => api<Announcement>("/admin/announcements", { method: "POST", body: JSON.stringify(body) }),
|
||||
onSuccess: () => {
|
||||
toast.success("Announcement published");
|
||||
void queryClient.invalidateQueries({ queryKey: notificationKeys.announcements() });
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to publish");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteAnnouncement() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => api(`/admin/announcements/${id}`, { method: "DELETE" }),
|
||||
onSuccess: () => {
|
||||
toast.success("Announcement deleted");
|
||||
void queryClient.invalidateQueries({ queryKey: notificationKeys.announcements() });
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to delete");
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Check `api()`'s actual error behavior (does it throw on non-2xx with a message? read `web/src/api/client.ts:343-380`) and the import alias style (`@/` vs relative — match the queries directory's existing imports).
|
||||
|
||||
- [ ] **Step 3: Typecheck + commit**
|
||||
|
||||
```bash
|
||||
cd web && pnpm exec tsc -b --noEmit 2>&1 | head -5
|
||||
git add src/hooks/queries/ && git commit -m "feat(web): notification query hooks"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Live wiring hook (WS → toasts + cache)
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/hooks/useNotificationsLive.ts`
|
||||
- Test: `web/src/hooks/useNotificationsLive.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Mock `useEventChannel` (from `@/components/realtimeEventsContext`) to capture handlers, mock `sonner`'s toast, wrap in a QueryClientProvider. Follow the mocking style of `web/src/components/RealtimeEventsProvider.test.tsx` (vi.mock + vi.hoisted).
|
||||
|
||||
```typescript
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { capturedHandlers, toastMock } = vi.hoisted(() => ({
|
||||
capturedHandlers: {} as { onEvent?: (m: unknown) => void; onSnapshot?: (m: unknown) => void },
|
||||
toastMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/realtimeEventsContext", () => ({
|
||||
useEventChannel: (_channel: string, handlers: typeof capturedHandlers) => {
|
||||
capturedHandlers.onEvent = handlers?.onEvent;
|
||||
capturedHandlers.onSnapshot = handlers?.onSnapshot;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({ toast: toastMock }));
|
||||
|
||||
const profileState = vi.hoisted(() => ({ profile: { id: "p-1", is_child: false } }));
|
||||
vi.mock("@/hooks/useAuth", () => ({ useAuth: () => profileState }));
|
||||
|
||||
import { useNotificationsLive } from "./useNotificationsLive";
|
||||
import { notificationKeys } from "@/hooks/queries/keys";
|
||||
|
||||
function setup() {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
queryClient.setQueryData(notificationKeys.unreadCount(), { count: 1 });
|
||||
renderHook(() => useNotificationsLive(), {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
return queryClient;
|
||||
}
|
||||
|
||||
function frame(data: object) {
|
||||
return { type: "event", channel: "notifications", event: "notification.created", data };
|
||||
}
|
||||
|
||||
describe("useNotificationsLive", () => {
|
||||
it("toasts and bumps unread count on notification.created", () => {
|
||||
const qc = setup();
|
||||
capturedHandlers.onEvent?.(
|
||||
frame({ id: 5, category: "request", title: "Request approved", body: "Dune", profile_id: "p-1" }),
|
||||
);
|
||||
expect(toastMock).toHaveBeenCalledWith("Request approved", expect.anything());
|
||||
expect(qc.getQueryData(notificationKeys.unreadCount())).toEqual({ count: 2 });
|
||||
});
|
||||
|
||||
it("ignores frames for another profile", () => {
|
||||
setup();
|
||||
capturedHandlers.onEvent?.(
|
||||
frame({ id: 6, category: "content", title: "New content", body: "", profile_id: "p-other" }),
|
||||
);
|
||||
expect(toastMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("suppresses restricted categories on child profiles", () => {
|
||||
profileState.profile = { id: "p-1", is_child: true };
|
||||
setup();
|
||||
capturedHandlers.onEvent?.(
|
||||
frame({ id: 7, category: "request", title: "Request approved", body: "", profile_id: undefined }),
|
||||
);
|
||||
expect(toastMock).not.toHaveBeenCalled();
|
||||
profileState.profile = { id: "p-1", is_child: false };
|
||||
});
|
||||
|
||||
it("seeds unread count from snapshot", () => {
|
||||
const qc = setup();
|
||||
capturedHandlers.onSnapshot?.({ type: "snapshot", channel: "notifications", data: { unread_count: 9 } });
|
||||
expect(qc.getQueryData(notificationKeys.unreadCount())).toEqual({ count: 9 });
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
(The test file needs `.tsx` extension if JSX is used in the wrapper — name it `useNotificationsLive.test.tsx`.)
|
||||
|
||||
- [ ] **Step 2: Run to verify failure**
|
||||
|
||||
```bash
|
||||
cd web && pnpm exec vitest run src/hooks/useNotificationsLive.test.tsx 2>&1 | tail -5
|
||||
```
|
||||
|
||||
Expected: FAIL — module `./useNotificationsLive` not found.
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
```typescript
|
||||
import { useCallback } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import type { AppNotification } from "@/api/types";
|
||||
import { useEventChannel } from "@/components/realtimeEventsContext";
|
||||
import { notificationKeys } from "@/hooks/queries/keys";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
|
||||
const CHILD_HIDDEN_CATEGORIES = new Set(["request", "system", "admin"]);
|
||||
|
||||
/**
|
||||
* Subscribes to the notifications WS channel: fires a toast for live
|
||||
* notifications addressed to the active profile, keeps the unread-count
|
||||
* cache current, and seeds it from the subscribe snapshot.
|
||||
* Mount exactly once inside the authenticated layout.
|
||||
*/
|
||||
export function useNotificationsLive() {
|
||||
const queryClient = useQueryClient();
|
||||
const { profile } = useAuth();
|
||||
const profileId = profile?.id;
|
||||
const isChild = profile?.is_child ?? false;
|
||||
|
||||
const onEvent = useCallback(
|
||||
(message: unknown) => {
|
||||
const frame = message as { event?: string; data?: AppNotification };
|
||||
if (frame.event !== "notification.created" || !frame.data) return;
|
||||
const n = frame.data;
|
||||
if (n.profile_id && n.profile_id !== profileId) return;
|
||||
if (isChild && CHILD_HIDDEN_CATEGORIES.has(n.category)) return;
|
||||
|
||||
queryClient.setQueryData<{ count: number }>(notificationKeys.unreadCount(), (prev) => ({
|
||||
count: (prev?.count ?? 0) + 1,
|
||||
}));
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [...notificationKeys.all, "list"],
|
||||
refetchType: "none",
|
||||
});
|
||||
toast(n.title, { description: n.body });
|
||||
},
|
||||
[queryClient, profileId, isChild],
|
||||
);
|
||||
|
||||
const onSnapshot = useCallback(
|
||||
(message: unknown) => {
|
||||
const frame = message as { data?: { unread_count?: number } };
|
||||
if (typeof frame.data?.unread_count !== "number") return;
|
||||
queryClient.setQueryData(notificationKeys.unreadCount(), { count: frame.data.unread_count });
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
useEventChannel("notifications", { onEvent, onSnapshot });
|
||||
}
|
||||
```
|
||||
|
||||
NOTE on handler identity: `useEventChannel`'s effect re-subscribes when the handlers object changes — pass a stable object. Check how existing consumers handle this (grep `useEventChannel(` usages); if they memoize the handlers object, do the same: `useMemo(() => ({ onEvent, onSnapshot }), [onEvent, onSnapshot])`.
|
||||
|
||||
The toast call shape (`toast(title, { description })`) must match the test's `expect.anything()` second arg. If `useAuth` returns a different profile field shape, adapt (read `useAuth.tsx`).
|
||||
|
||||
- [ ] **Step 4: Run tests until green**
|
||||
|
||||
```bash
|
||||
cd web && pnpm exec vitest run src/hooks/useNotificationsLive.test.tsx 2>&1 | tail -5
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add web/src/hooks/useNotificationsLive.* && git commit -m "feat(web): live notifications wiring (toasts, unread cache)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: NotificationBell component + Layout mount
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/components/NotificationBell.tsx`
|
||||
- Test: `web/src/components/NotificationBell.test.tsx`
|
||||
- Modify: `web/src/components/Layout.tsx` (mobile header actions ~lines 151-168; also mount `useNotificationsLive()` once in the Layout component body)
|
||||
|
||||
- [ ] **Step 1: Failing test**
|
||||
|
||||
```typescript
|
||||
// Mocks: @/hooks/queries/notifications (useUnreadCount → 3, useNotificationsList → two items,
|
||||
// useMarkRead → mutate spy), react-router (MemoryRouter wrapper).
|
||||
// Asserts: badge renders "3"; opening the popover (click bell button, role="button" name /notifications/i)
|
||||
// shows both item titles; "Mark all read" calls markRead with {all:true};
|
||||
// item with link renders an anchor/Link to that link.
|
||||
```
|
||||
|
||||
Write it concretely following an existing component test's structure (see `web/src/components/AppSidebar.test.tsx` for provider wrapping conventions). Badge cap: 4+ digits render "99+" when count > 99 — include one assertion with count 120.
|
||||
|
||||
- [ ] **Step 2: Implement NotificationBell**
|
||||
|
||||
```tsx
|
||||
import { Bell } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import {
|
||||
useMarkRead,
|
||||
useNotificationsList,
|
||||
useUnreadCount,
|
||||
} from "@/hooks/queries/notifications";
|
||||
|
||||
export function NotificationBell() {
|
||||
const { data: count = 0 } = useUnreadCount();
|
||||
const list = useNotificationsList({});
|
||||
const markRead = useMarkRead();
|
||||
const items = (list.data?.pages ?? []).flatMap((p) => p.items ?? []).slice(0, 10);
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon" aria-label="Notifications" className="relative">
|
||||
<Bell className="h-5 w-5" />
|
||||
{count > 0 && (
|
||||
<Badge className="absolute -top-1 -right-1 h-4 min-w-4 px-1 text-[10px]">
|
||||
{count > 99 ? "99+" : count}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-80 p-0">
|
||||
<div className="flex items-center justify-between border-b px-3 py-2">
|
||||
<span className="text-sm font-medium">Notifications</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={count === 0}
|
||||
onClick={() => markRead.mutate({ all: true })}
|
||||
>
|
||||
Mark all read
|
||||
</Button>
|
||||
</div>
|
||||
<ul className="max-h-96 list-none overflow-y-auto">
|
||||
{items.length === 0 && (
|
||||
<li className="text-muted-foreground px-3 py-6 text-center text-sm">
|
||||
You're all caught up
|
||||
</li>
|
||||
)}
|
||||
{items.map((n) => (
|
||||
<li key={n.id} className={n.read_at ? "opacity-60" : ""}>
|
||||
<Link to={n.link ?? "/notifications"} className="hover:bg-accent block px-3 py-2">
|
||||
<div className="text-sm font-medium">{n.title}</div>
|
||||
{n.body && <div className="text-muted-foreground truncate text-xs">{n.body}</div>}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="border-t px-3 py-2 text-center">
|
||||
<Link to="/notifications" className="text-primary text-sm">
|
||||
View all
|
||||
</Link>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Verify the ui primitives exist with these export names (`badge.tsx`, `button.tsx`, `popover.tsx` under `components/ui/`); adjust imports/props to the actual variants. Match `react-router` import name used elsewhere (the repo uses `react-router` v7 — check whether components import `Link` from `react-router` or `react-router-dom`).
|
||||
|
||||
Dropdown-open toast suppression (spec requirement): export a tiny module-level flag from NotificationBell —
|
||||
|
||||
```tsx
|
||||
let bellOpenFlag = false;
|
||||
export function isNotificationDropdownOpen() {
|
||||
return bellOpenFlag;
|
||||
}
|
||||
// in the component: <Popover onOpenChange={(open) => { bellOpenFlag = open; }}>
|
||||
```
|
||||
|
||||
and in `useNotificationsLive`'s onEvent, before toasting: `if (isNotificationDropdownOpen()) return;` (cache updates still run — only the toast is suppressed; the open dropdown shows the new item via the list invalidation). Add one live-hook test: set the flag via a popover-open simulation or export a test-only setter, assert no toast while open.
|
||||
|
||||
- [ ] **Step 3: Mount in Layout**
|
||||
|
||||
In `Layout.tsx`: call `useNotificationsLive()` at the top of the component (single mount point for the live hook), and render `<NotificationBell />` in the mobile header's right-side action cluster (next to the search icon button).
|
||||
|
||||
- [ ] **Step 4: Tests green, typecheck, commit**
|
||||
|
||||
```bash
|
||||
cd web && pnpm exec vitest run src/components/NotificationBell.test.tsx 2>&1 | tail -5 && pnpm exec tsc -b --noEmit 2>&1 | head -3
|
||||
git add src/components/NotificationBell.* src/components/Layout.tsx src/hooks/
|
||||
git commit -m "feat(web): notification bell with live badge in mobile header"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Sidebar nav item with badge (desktop)
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/components/AppSidebar.tsx` (nav list — insert after the History item, ~line 645)
|
||||
- Test: extend `web/src/components/AppSidebar.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Add the nav item**
|
||||
|
||||
Follow the exact ViewTransitionLink pattern of the History item (active indicator span, icon sizing, `SidebarLabel`):
|
||||
|
||||
```tsx
|
||||
<li>
|
||||
<ViewTransitionLink
|
||||
to="/notifications"
|
||||
onClick={onNavigate}
|
||||
className={navLinkClass("/notifications")}
|
||||
aria-current={isActive("/notifications") ? "page" : undefined}
|
||||
>
|
||||
{isActive("/notifications") && (
|
||||
<span
|
||||
className="absolute top-1/2 left-0 h-[18px] w-[3px] -translate-y-1/2 rounded-r-sm"
|
||||
style={{ background: "var(--primary)" }}
|
||||
/>
|
||||
)}
|
||||
<Bell className="h-[18px] w-[18px] shrink-0" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="bg-primary text-primary-foreground absolute top-1 left-6 flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px]">
|
||||
{unreadCount > 99 ? "99+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
<SidebarLabel show={showLabels}>Notifications</SidebarLabel>
|
||||
</ViewTransitionLink>
|
||||
</li>
|
||||
```
|
||||
|
||||
`const { data: unreadCount = 0 } = useUnreadCount();` at the top of AppSidebar (import from `@/hooks/queries/notifications`; `Bell` from lucide-react — add to the existing lucide import list at line ~56). Badge positioning: verify visually against the collapsed-sidebar state (`showLabels` false) — the absolute chip must remain visible on the icon in both states; tweak offsets if the icon container differs.
|
||||
|
||||
- [ ] **Step 2: Test**
|
||||
|
||||
Extend AppSidebar.test.tsx: mock `@/hooks/queries/notifications` `useUnreadCount` to return `{ data: 5 }`; assert the rendered sidebar contains a link to `/notifications` showing "5". Follow the file's existing mock arrangement for other hooks.
|
||||
|
||||
- [ ] **Step 3: Run, typecheck, commit**
|
||||
|
||||
```bash
|
||||
cd web && pnpm exec vitest run src/components/AppSidebar.test.tsx 2>&1 | tail -4
|
||||
git add src/components/AppSidebar.* && git commit -m "feat(web): notifications sidebar item with unread badge"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Inbox page + route
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/pages/Notifications.tsx`
|
||||
- Test: `web/src/pages/Notifications.test.tsx`
|
||||
- Modify: `web/src/App.tsx` (user routes block ~line 331+)
|
||||
|
||||
- [ ] **Step 1: Failing test**
|
||||
|
||||
Mock the notifications query hooks. Assertions:
|
||||
- renders rows for items across categories with title/body/relative time;
|
||||
- category filter tabs (All, Requests, Content, Announcements, System — admin tab only when `useAuth().user?.role === "admin"`) change the `category` filter passed to `useNotificationsList` (capture via mock);
|
||||
- unread rows visually distinct (e.g. a `data-unread="true"` attribute — assert on it);
|
||||
- per-row dismiss button calls `useDismissNotification().mutate(id)`;
|
||||
- "Load more" appears when `hasNextPage` and calls `fetchNextPage`;
|
||||
- on mount, visible unread ids are marked read: `useMarkRead().mutate({ ids: [...] })` (assert called once with the unread ids from page one).
|
||||
|
||||
- [ ] **Step 2: Implement**
|
||||
|
||||
Page structure (use the repo's page conventions — check an existing simple page like the History/Collections page for header/container classes):
|
||||
|
||||
```tsx
|
||||
const CATEGORY_TABS: { key: string; label: string; adminOnly?: boolean }[] = [
|
||||
{ key: "", label: "All" },
|
||||
{ key: "request", label: "Requests" },
|
||||
{ key: "content", label: "Content" },
|
||||
{ key: "announcement", label: "Announcements" },
|
||||
{ key: "system", label: "System" },
|
||||
{ key: "admin", label: "Admin", adminOnly: true },
|
||||
];
|
||||
```
|
||||
|
||||
- `useState` for active category; `useNotificationsList({ category })` (omit when "");
|
||||
- mark-on-view effect:
|
||||
|
||||
```tsx
|
||||
const firstPage = list.data?.pages?.[0];
|
||||
const markRead = useMarkRead();
|
||||
const markedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (markedRef.current || !firstPage) return;
|
||||
const unreadIds = (firstPage.items ?? []).filter((n) => !n.read_at).map((n) => n.id);
|
||||
if (unreadIds.length > 0) markRead.mutate({ ids: unreadIds });
|
||||
markedRef.current = true;
|
||||
}, [firstPage, markRead]);
|
||||
```
|
||||
|
||||
- rows: title, body, `created_at` rendered with the repo's existing relative-time helper (grep `formatDistance\|relativeTime\|timeAgo` in web/src/lib — use what exists; if nothing, use `Intl.RelativeTimeFormat` inline helper in the page);
|
||||
- dismiss button (X icon) per row; "Mark all read" header button; "Load more" via `fetchNextPage` when `hasNextPage`;
|
||||
- empty state per tab.
|
||||
|
||||
- [ ] **Step 3: Route**
|
||||
|
||||
In `App.tsx`, register alongside the other authenticated user pages (match the guard used by e.g. the favorites/history page — likely inside the layout route with profile required):
|
||||
|
||||
```tsx
|
||||
<Route path="/notifications" element={<Notifications />} />
|
||||
```
|
||||
|
||||
(Lazy-load if neighboring pages are lazy — match the file's import pattern.)
|
||||
|
||||
- [ ] **Step 4: Tests, typecheck, commit**
|
||||
|
||||
```bash
|
||||
cd web && pnpm exec vitest run src/pages/Notifications.test.tsx 2>&1 | tail -5 && pnpm exec tsc -b --noEmit 2>&1 | head -3
|
||||
git add src/pages/Notifications.* src/App.tsx && git commit -m "feat(web): notifications inbox page"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Settings preferences section
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/pages/settings/NotificationsSettings.tsx`
|
||||
- Modify: `web/src/pages/SettingsLayout.tsx` (NAV_SECTIONS), `web/src/App.tsx` (settings child route)
|
||||
|
||||
- [ ] **Step 1: Implement the settings page**
|
||||
|
||||
Template: `web/src/pages/settings/PlaybackSettings.tsx` (SettingsGroup/SettingRow/Switch composition). Content:
|
||||
|
||||
```tsx
|
||||
const PREF_LABELS: Record<string, { label: string; description: string; adminOnly?: boolean }> = {
|
||||
request: { label: "Requests", description: "Updates when your requests are approved, declined, or ready" },
|
||||
content: { label: "New content", description: "New arrivals matching your watchlist, favorites, and shows in progress" },
|
||||
system: { label: "Account & security", description: "Password changes and account notices" },
|
||||
admin: { label: "Admin alerts", description: "Failed jobs and scans (admins only)", adminOnly: true },
|
||||
content_digest: { label: "Daily digest", description: "One daily summary of everything added to your libraries" },
|
||||
};
|
||||
```
|
||||
|
||||
- `useNotificationPreferences()` for current values; render a `Switch` per category in PREF_LABELS order, skipping `adminOnly` entries when `useAuth().user?.role !== "admin"`;
|
||||
- on toggle: `useSetNotificationPreferences().mutate([{ category, enabled }])` (single-category PUT — server upserts per row);
|
||||
- note under the group: "Announcements from your server admin can't be turned off."
|
||||
|
||||
- [ ] **Step 2: Register in SettingsLayout NAV_SECTIONS**
|
||||
|
||||
Add a "Notifications" section (icon `Bell`) pointing at path `notifications`, following the structure at SettingsLayout.tsx lines 39-100. Register the child route in App.tsx where other settings pages are routed (find `PlaybackSettings` route registration and mirror it).
|
||||
|
||||
- [ ] **Step 3: Test**
|
||||
|
||||
Extend `web/src/pages/SettingsLayout.test.tsx` if it asserts nav items (check); add `NotificationsSettings` rendering test only if sibling settings pages have tests (check `ls web/src/pages/settings/*.test.tsx`) — match local convention; if none exist, rely on the layout test + typecheck (note this in the commit message).
|
||||
|
||||
- [ ] **Step 4: Typecheck, test, commit**
|
||||
|
||||
```bash
|
||||
cd web && pnpm exec tsc -b --noEmit 2>&1 | head -3 && pnpm exec vitest run src/pages/SettingsLayout.test.tsx 2>&1 | tail -4
|
||||
git add src/pages/settings/NotificationsSettings.tsx src/pages/SettingsLayout.tsx src/App.tsx
|
||||
git commit -m "feat(web): notification preference toggles in settings"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Admin announcements page
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/pages/AdminAnnouncements.tsx`
|
||||
- Modify: `web/src/components/AdminSidebar.tsx` (nav items ~lines 81-134), `web/src/App.tsx` (admin routes ~lines 363-398)
|
||||
|
||||
- [ ] **Step 1: Implement the page**
|
||||
|
||||
Template: `web/src/pages/AdminUsers.tsx` for the table + dialog patterns (read it first; reuse its Table/Dialog/Button composition). Features:
|
||||
|
||||
- Table of announcements: title, audience summary ("Everyone" / "N users" / "Libraries: a, b"), created date, expires date, delete button with confirm dialog ("Deleting dismisses unread copies from user inboxes.").
|
||||
- "New announcement" button → Dialog with: title (Input, required), body (Textarea), audience radio group (Everyone / Specific users / Libraries) — for "Specific users" a multi-select of users (check how AdminUsers fetches the user list — reuse that hook; if a multi-select primitive is missing, comma-separated ids input is NOT acceptable — use checkboxes in a scrollable list), for Libraries the same against the libraries list hook (grep `useLibraries` in hooks/queries) — optional `expires_at` datetime-local input.
|
||||
- Submit via `useCreateAnnouncement()`; disable submit until title non-empty and a valid audience selection exists.
|
||||
|
||||
- [ ] **Step 2: Nav + route**
|
||||
|
||||
AdminSidebar: add item (icon `Megaphone` from lucide-react) labeled "Announcements" → `/admin/announcements`, following the existing items' structure. App.tsx admin routes: `<Route path="announcements" element={<AdminAnnouncements />} />`.
|
||||
|
||||
- [ ] **Step 3: Test**
|
||||
|
||||
`web/src/pages/AdminAnnouncements.test.tsx` — mock the three admin hooks; assert: table renders rows incl. audience summaries; create dialog validates (submit disabled with empty title); submitting with Everyone calls `useCreateAnnouncement().mutate` with `{audience: {all: true}, ...}`; delete confirm calls `useDeleteAnnouncement().mutate(id)`.
|
||||
|
||||
- [ ] **Step 4: Tests, typecheck, commit**
|
||||
|
||||
```bash
|
||||
cd web && pnpm exec vitest run src/pages/AdminAnnouncements.test.tsx 2>&1 | tail -5 && pnpm exec tsc -b --noEmit 2>&1 | head -3
|
||||
git add src/pages/AdminAnnouncements.* src/components/AdminSidebar.tsx src/App.tsx
|
||||
git commit -m "feat(web): admin announcements management page"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Full verification
|
||||
|
||||
- [ ] **Step 1: Web checks**
|
||||
|
||||
```bash
|
||||
cd web && pnpm run lint && pnpm run format:check && pnpm exec vitest run 2>&1 | tail -6 && pnpm run build 2>&1 | tail -4
|
||||
```
|
||||
|
||||
All green; build also refreshes `web/dist` so the Go embed keeps working.
|
||||
|
||||
- [ ] **Step 2: Backend still green**
|
||||
|
||||
```bash
|
||||
GOWORK=off go build ./... && GOWORK=off go test ./internal/api/handlers/ 2>&1 | tail -2
|
||||
make verify-local-paths
|
||||
```
|
||||
|
||||
- [ ] **Step 3: E2E smoke against the dev stack**
|
||||
|
||||
```bash
|
||||
docker compose up -d postgres redis
|
||||
GOWORK=off go build -o /tmp/silo-web-e2e ./cmd/silo && /tmp/silo-web-e2e &
|
||||
```
|
||||
|
||||
In a browser (or report for manual check): log in, confirm the sidebar Notifications item + mobile bell render; create an announcement in /admin/announcements; verify toast fires live (WS) and badge increments without reload; open inbox, rows mark read, badge clears; toggle a preference off and verify it persists after reload.
|
||||
|
||||
- [ ] **Step 4: Commit anything outstanding; report**
|
||||
|
||||
---
|
||||
|
||||
## Self-review notes
|
||||
|
||||
- Desktop bell+dropdown from the spec adapted to a sidebar nav item + badge (no desktop top bar exists in this app); the dropdown ships on the mobile header bell. Spec intent (always-visible unread indicator + 1-click access) preserved.
|
||||
- Toast child/profile filtering is client-side by necessity (WS frames are user-scoped; see spec Future work on claims enrichment) — `useNotificationsLive` suppresses other-profile and child-restricted frames.
|
||||
- `content_digest` rows arrive as category `content` (server stores digest as content/content.digest) — the inbox Content tab includes them; the preferences page exposes the separate `content_digest` toggle. No UI filters by `content_digest` category (final server review noted this trap).
|
||||
- Mark-on-view marks only page one's unread ids once per mount — deliberate; deeper pages mark via "Mark all read".
|
||||
- Several steps include verify-greps (ui primitive names, Link import source, relative-time helper, settings test conventions, multi-select availability) — implementers must align with what exists rather than invent.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,838 @@
|
||||
# Core Push — Web Client & Admin Config — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make push operable end-to-end from the browser: a Web Push client (service worker, subscribe, receive, per-device control, device list) plus an admin provider-config page (APNs/FCM/WebPush credentials, VAPID keygen, readiness), on top of the frozen push API.
|
||||
|
||||
**Architecture:** Five web units + two small Go endpoints. A static service worker receives pushes; a `usePushDevice` hook owns the browser subscription lifecycle; the existing notifications-settings page gains a push control + device list; a new admin page reuses the existing encrypted-settings write path for credentials. Two net-new server endpoints: VAPID keygen and a self-targeted test push.
|
||||
|
||||
**Tech Stack:** React 19, TanStack Query, sonner, the existing api client (auto device/profile headers), browser Push API / Service Worker / Notifications API, Go (chi), webpush-go (already a dep). Vitest + RTL.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-10-core-push-web-client-design.md`. The push registration API + delivery worker are frozen (Phase-3-server).
|
||||
|
||||
Commands assume the repository root is the cwd; web commands run in `web/` with `pnpm`. Go: `export GOWORK=off`.
|
||||
|
||||
## Frozen API the client calls
|
||||
|
||||
```
|
||||
GET /api/v1/notifications/push/webpush-key → {vapid_public_key}
|
||||
PUT /api/v1/notifications/push/device {transport, token} → 204 (needs X-Profile-Id; api client sends it)
|
||||
DELETE /api/v1/notifications/push/device → 204
|
||||
GET /api/v1/notifications/push/devices → {devices:[{device_id,name,platform,transport,push_enabled,registered_at}]}
|
||||
PUT /api/v1/notifications/push/devices/{device_id} {enabled} → 204
|
||||
GET /api/v1/admin/push/status → {apns,fcm,webpush: bool}
|
||||
PUT /api/v1/admin/settings/{key} {value} → writes an encrypted push.* key (existing)
|
||||
GET /api/v1/admin/settings/sensitive-status → {configured:[keys]} (existing)
|
||||
```
|
||||
|
||||
Net-new this plan: `POST /api/v1/admin/push/generate-vapid-keys` and `POST /api/v1/admin/push/test`.
|
||||
|
||||
## File map
|
||||
|
||||
| File | Action | Responsibility |
|
||||
|---|---|---|
|
||||
| `internal/api/handlers/push.go` | modify | add `HandleGenerateVAPIDKeys`, `HandleSendTestPush`; add a `notifier` dep |
|
||||
| `internal/api/router.go` | modify | register the two admin routes |
|
||||
| `cmd/silo/main.go` | modify | pass the notifications service into the push handler |
|
||||
| `web/public/sw.js` | create | service worker: push, notificationclick, pushsubscriptionchange |
|
||||
| `web/src/lib/pushSw.ts` | create | pure SW logic (notification options, click URL) — tested; mirrored in sw.js |
|
||||
| `web/src/lib/push.ts` | create | `urlBase64ToUint8Array`, VAPID-key cache helpers |
|
||||
| `web/src/main.tsx` | modify | register `/sw.js` when supported |
|
||||
| `web/src/api/types.ts` | modify | `PushDeviceInfo`, `VapidKeyPair` |
|
||||
| `web/src/hooks/queries/push.ts` | create | query/mutation hooks (webpush-key, devices, toggle, register/revoke, admin keygen/test) |
|
||||
| `web/src/hooks/usePushDevice.ts` | create | browser subscription lifecycle state machine |
|
||||
| `web/src/hooks/usePushDevice.test.tsx` | create | hook tests |
|
||||
| `web/src/pages/settings/NotificationsSettings.tsx` | modify | push control + device list |
|
||||
| `web/src/pages/admin-settings/PushSettings.tsx` | create | three credential cards + keygen + test |
|
||||
| `web/src/pages/admin-settings/AdminSettingsLayout.tsx` | modify | nav entry |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Server endpoints — VAPID keygen + test push
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/api/handlers/push.go`, `internal/api/router.go`, `cmd/silo/main.go`
|
||||
- Test: `internal/api/handlers/push_test.go`
|
||||
|
||||
- [ ] **Step 1: Add a notifier dep + the two handlers**
|
||||
|
||||
In `push.go`, add a narrow interface and a field on `PushHandler`:
|
||||
|
||||
```go
|
||||
// pushNotifier creates a notification for the calling user (test push).
|
||||
type pushNotifier interface {
|
||||
CreateSystem(ctx context.Context, userID int, typ, title, body string)
|
||||
}
|
||||
```
|
||||
|
||||
Add `notifier pushNotifier` to `PushHandler` and a parameter to `NewPushHandler` (nil-tolerant — test-push 503s if absent). `*notifications.Service` satisfies `pushNotifier` (it has `CreateSystem(ctx, userID int, typ, title, body string)` from Phase-1).
|
||||
|
||||
Handlers:
|
||||
|
||||
```go
|
||||
func (h *PushHandler) HandleGenerateVAPIDKeys(w http.ResponseWriter, r *http.Request) {
|
||||
priv, pub, err := webpush.GenerateVAPIDKeys()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate keys")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"vapid_public": pub, "vapid_private": priv})
|
||||
}
|
||||
|
||||
func (h *PushHandler) HandleSendTestPush(w http.ResponseWriter, r *http.Request) {
|
||||
if h.notifier == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "notifications unavailable")
|
||||
return
|
||||
}
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
if userID == 0 {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "auth required")
|
||||
return
|
||||
}
|
||||
h.notifier.CreateSystem(r.Context(), userID,
|
||||
"system.test_push", "Test push",
|
||||
"If you can see this on a device, push delivery is working.")
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
}
|
||||
```
|
||||
|
||||
Import `webpush "github.com/SherClockHolmes/webpush-go"`. **Verify the `GenerateVAPIDKeys()` return order** against the installed version — webpush-go returns `(privateKey, publicKey string, err error)`. Confirm by reading the vendored signature or `go doc`; if it's `(public, private, err)` swap accordingly. Get this right — swapping the keys silently breaks push.
|
||||
|
||||
- [ ] **Step 2: Routes**
|
||||
|
||||
In `router.go`, in the admin push block (next to `r.Get("/push/status", ...)`, ~line 2295, behind `deps.PushStore != nil`):
|
||||
|
||||
```go
|
||||
r.Post("/push/generate-vapid-keys", pushHandler.HandleGenerateVAPIDKeys)
|
||||
r.Post("/push/test", pushHandler.HandleSendTestPush)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Wire the notifier**
|
||||
|
||||
Update every `NewPushHandler(...)` call site (find with grep) to pass the notifications service. In `cmd/silo/main.go` and/or `router.go` where the push handler is constructed, add `deps.NotificationsService` (or the in-scope `notificationsSvc`) as the third arg. Build will tell you the sites.
|
||||
|
||||
- [ ] **Step 4: Tests**
|
||||
|
||||
Add to `push_test.go` (reuse the package's fakes / chi-request helper from Task-13 push tests):
|
||||
|
||||
```go
|
||||
func TestPushHandleGenerateVAPIDKeys_ReturnsPair(t *testing.T) {
|
||||
h := NewPushHandler(&fakePushRegistry{}, &fakePushConfig{}, nil)
|
||||
rr := httptest.NewRecorder()
|
||||
h.HandleGenerateVAPIDKeys(rr, httptest.NewRequest(http.MethodPost, "/", nil))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d", rr.Code)
|
||||
}
|
||||
var body map[string]string
|
||||
_ = json.Unmarshal(rr.Body.Bytes(), &body)
|
||||
if body["vapid_public"] == "" || body["vapid_private"] == "" || body["vapid_public"] == body["vapid_private"] {
|
||||
t.Fatalf("bad key pair: %+v", body)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeNotifier struct{ called bool; userID int; typ string }
|
||||
|
||||
func (f *fakeNotifier) CreateSystem(_ context.Context, userID int, typ, _, _ string) {
|
||||
f.called = true
|
||||
f.userID = userID
|
||||
f.typ = typ
|
||||
}
|
||||
|
||||
func TestPushHandleSendTestPush_CreatesForCaller(t *testing.T) {
|
||||
n := &fakeNotifier{}
|
||||
h := NewPushHandler(&fakePushRegistry{}, &fakePushConfig{}, n)
|
||||
r := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
r = r.WithContext(apimw.SetUserID(r.Context(), 7)) // use the package's claims-injection helper if SetUserID differs
|
||||
rr := httptest.NewRecorder()
|
||||
h.HandleSendTestPush(rr, r)
|
||||
if rr.Code != http.StatusAccepted || !n.called || n.userID != 7 || n.typ != "system.test_push" {
|
||||
t.Fatalf("unexpected: code=%d notifier=%+v", rr.Code, n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushHandleSendTestPush_NilNotifier503(t *testing.T) {
|
||||
h := NewPushHandler(&fakePushRegistry{}, &fakePushConfig{}, nil)
|
||||
rr := httptest.NewRecorder()
|
||||
h.HandleSendTestPush(rr, httptest.NewRequest(http.MethodPost, "/", nil))
|
||||
if rr.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("code = %d", rr.Code)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Check how the existing push tests inject the user id into context (Task-13 used a claims/`apimw` helper — mirror it; `apimw.SetUserID` may not exist, use whatever the package's tests already use to set `GetUserID`).
|
||||
|
||||
- [ ] **Step 5: Build, test, commit**
|
||||
|
||||
```bash
|
||||
GOWORK=off go build ./... && GOWORK=off go test ./internal/api/handlers/ -run TestPush -v 2>&1 | tail -8 && GOWORK=off go vet ./internal/api/handlers/
|
||||
git add internal/api/handlers/push.go internal/api/handlers/push_test.go internal/api/router.go cmd/silo/main.go
|
||||
git commit -m "feat(push): admin VAPID keygen and self-test endpoints"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Service worker + registration + helpers
|
||||
|
||||
**Files:**
|
||||
- Create: `web/public/sw.js`, `web/src/lib/pushSw.ts`, `web/src/lib/push.ts`
|
||||
- Test: `web/src/lib/pushSw.test.ts`, `web/src/lib/push.test.ts`
|
||||
- Modify: `web/src/main.tsx`
|
||||
|
||||
- [ ] **Step 1: Pure SW logic (tested) — `web/src/lib/pushSw.ts`**
|
||||
|
||||
```ts
|
||||
export interface PushPayload {
|
||||
id?: number;
|
||||
title?: string;
|
||||
body?: string;
|
||||
link?: string;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
export interface SwNotification {
|
||||
title: string;
|
||||
options: { body: string; data: { link: string }; tag: string };
|
||||
}
|
||||
|
||||
/** Maps a raw push payload to showNotification args; tolerant of partial/missing fields. */
|
||||
export function buildNotification(payload: PushPayload | null): SwNotification {
|
||||
const p = payload ?? {};
|
||||
const link = p.link && p.link.length > 0 ? p.link : "/notifications";
|
||||
const tag = p.id != null ? `n${p.id}` : "n";
|
||||
return {
|
||||
title: p.title && p.title.length > 0 ? p.title : "Silo",
|
||||
options: { body: p.body ?? "", data: { link }, tag },
|
||||
};
|
||||
}
|
||||
|
||||
/** The URL a notification click should open/focus. */
|
||||
export function clickUrl(data: unknown): string {
|
||||
const d = (data ?? {}) as { link?: string };
|
||||
return d.link && d.link.length > 0 ? d.link : "/notifications";
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Test it — `web/src/lib/pushSw.test.ts`**
|
||||
|
||||
```ts
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildNotification, clickUrl } from "./pushSw";
|
||||
|
||||
describe("buildNotification", () => {
|
||||
it("maps a full payload", () => {
|
||||
const n = buildNotification({ id: 5, title: "Approved", body: "Dune", link: "/requests" });
|
||||
expect(n.title).toBe("Approved");
|
||||
expect(n.options).toEqual({ body: "Dune", data: { link: "/requests" }, tag: "n5" });
|
||||
});
|
||||
it("falls back on missing fields", () => {
|
||||
const n = buildNotification(null);
|
||||
expect(n.title).toBe("Silo");
|
||||
expect(n.options.body).toBe("");
|
||||
expect(n.options.data.link).toBe("/notifications");
|
||||
expect(n.options.tag).toBe("n");
|
||||
});
|
||||
});
|
||||
|
||||
describe("clickUrl", () => {
|
||||
it("uses link when present", () => expect(clickUrl({ link: "/items/9" })).toBe("/items/9"));
|
||||
it("falls back to inbox", () => expect(clickUrl({})).toBe("/notifications"));
|
||||
});
|
||||
```
|
||||
|
||||
Run: `pnpm exec vitest run src/lib/pushSw.test.ts` → green.
|
||||
|
||||
- [ ] **Step 3: The static service worker — `web/public/sw.js`**
|
||||
|
||||
Self-contained (served statically, not bundled). Mirror the pure logic exactly (the canonical, tested source is `pushSw.ts`):
|
||||
|
||||
```js
|
||||
// web/public/sw.js — static service worker for Web Push.
|
||||
// NOTE: the buildNotification/clickUrl logic mirrors src/lib/pushSw.ts (the
|
||||
// tested source of truth). Keep them in sync.
|
||||
|
||||
function buildNotification(payload) {
|
||||
const p = payload || {};
|
||||
const link = p.link && p.link.length > 0 ? p.link : "/notifications";
|
||||
const tag = p.id != null ? "n" + p.id : "n";
|
||||
return {
|
||||
title: p.title && p.title.length > 0 ? p.title : "Silo",
|
||||
options: { body: p.body || "", data: { link: link }, tag: tag },
|
||||
};
|
||||
}
|
||||
function clickUrl(data) {
|
||||
const d = data || {};
|
||||
return d.link && d.link.length > 0 ? d.link : "/notifications";
|
||||
}
|
||||
|
||||
self.addEventListener("push", (event) => {
|
||||
let payload = null;
|
||||
try {
|
||||
payload = event.data ? event.data.json() : null;
|
||||
} catch (e) {
|
||||
payload = null;
|
||||
}
|
||||
const n = buildNotification(payload);
|
||||
event.waitUntil(self.registration.showNotification(n.title, n.options));
|
||||
});
|
||||
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close();
|
||||
const url = clickUrl(event.notification.data);
|
||||
event.waitUntil(
|
||||
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((wins) => {
|
||||
for (const c of wins) {
|
||||
if ("focus" in c) {
|
||||
c.postMessage({ type: "notification-click", link: url });
|
||||
return c.focus();
|
||||
}
|
||||
}
|
||||
return self.clients.openWindow(url);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("pushsubscriptionchange", (event) => {
|
||||
// Best-effort re-subscribe using the cached VAPID key; the app re-registers
|
||||
// the new subscription on next load if this fails.
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
try {
|
||||
const cache = await caches.open("silo-push");
|
||||
const res = await cache.match("vapid-public-key");
|
||||
if (!res) return;
|
||||
const vapid = await res.text();
|
||||
const sub = await self.registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: vapid,
|
||||
});
|
||||
await fetch("/api/v1/notifications/push/device", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ transport: "webpush", token: JSON.stringify(sub.toJSON()) }),
|
||||
});
|
||||
} catch (e) {
|
||||
// swallow; app re-registers on next load
|
||||
}
|
||||
})(),
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
Note: the `pushsubscriptionchange` re-register relies on cookie auth (`credentials: include`) and the device/profile headers it can't set from the SW — it is genuinely best-effort and the app heals it on next load (documented in the spec). `applicationServerKey` accepts a base64url string in modern browsers; if the target browsers need a Uint8Array here, the app-side path (Task 4) converts it — the SW path is the fallback and may no-op on stricter browsers, which is acceptable.
|
||||
|
||||
- [ ] **Step 4: App-side helpers — `web/src/lib/push.ts`**
|
||||
|
||||
```ts
|
||||
/** Convert a base64url VAPID public key to the Uint8Array applicationServerKey wants. */
|
||||
export function urlBase64ToUint8Array(base64: string): Uint8Array {
|
||||
const padding = "=".repeat((4 - (base64.length % 4)) % 4);
|
||||
const b64 = (base64 + padding).replace(/-/g, "+").replace(/_/g, "/");
|
||||
const raw = atob(b64);
|
||||
const out = new Uint8Array(raw.length);
|
||||
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Cache the raw VAPID public key so the SW can re-subscribe without the app. */
|
||||
export async function cacheVapidKey(base64: string): Promise<void> {
|
||||
if (!("caches" in self)) return;
|
||||
const cache = await caches.open("silo-push");
|
||||
await cache.put("vapid-public-key", new Response(base64));
|
||||
}
|
||||
|
||||
export function pushSupported(): boolean {
|
||||
return (
|
||||
typeof navigator !== "undefined" &&
|
||||
"serviceWorker" in navigator &&
|
||||
typeof window !== "undefined" &&
|
||||
"PushManager" in window &&
|
||||
"Notification" in window
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Test the converter — `web/src/lib/push.test.ts`**
|
||||
|
||||
```ts
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { urlBase64ToUint8Array } from "./push";
|
||||
|
||||
describe("urlBase64ToUint8Array", () => {
|
||||
it("decodes a url-safe base64 key to bytes", () => {
|
||||
// "AQID" → [1,2,3]
|
||||
expect(Array.from(urlBase64ToUint8Array("AQID"))).toEqual([1, 2, 3]);
|
||||
});
|
||||
it("handles url-safe chars and missing padding", () => {
|
||||
const out = urlBase64ToUint8Array("a-_9"); // '-'→'+', '_'→'/'
|
||||
expect(out.length).toBe(3);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Register the SW — `web/src/main.tsx`**
|
||||
|
||||
After `createRoot(...).render(...)`, add:
|
||||
|
||||
```tsx
|
||||
if ("serviceWorker" in navigator && "PushManager" in window) {
|
||||
window.addEventListener("load", () => {
|
||||
navigator.serviceWorker.register("/sw.js").catch(() => {
|
||||
// registration failure is non-fatal; push just won't be available
|
||||
});
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Run, build, commit**
|
||||
|
||||
```bash
|
||||
cd web && pnpm exec vitest run src/lib/pushSw.test.ts src/lib/push.test.ts 2>&1 | tail -5 && pnpm exec tsc -b --noEmit 2>&1 | head -3
|
||||
git add web/public/sw.js web/src/lib/pushSw.ts web/src/lib/pushSw.test.ts web/src/lib/push.ts web/src/lib/push.test.ts web/src/main.tsx
|
||||
git commit -m "feat(web): web push service worker, registration, and helpers"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Push query hooks + types
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/api/types.ts`
|
||||
- Create: `web/src/hooks/queries/push.ts`
|
||||
- Modify: `web/src/hooks/queries/keys.ts` (add `pushKeys`)
|
||||
|
||||
- [ ] **Step 1: Types** — append to `types.ts`:
|
||||
|
||||
```ts
|
||||
export interface PushDeviceInfo {
|
||||
device_id: string;
|
||||
name: string;
|
||||
platform: string;
|
||||
transport: string;
|
||||
push_enabled: boolean;
|
||||
registered_at?: string;
|
||||
}
|
||||
|
||||
export interface VapidKeyPair {
|
||||
vapid_public: string;
|
||||
vapid_private: string;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Keys** — in `keys.ts`:
|
||||
|
||||
```ts
|
||||
export const pushKeys = {
|
||||
all: ["push"] as const,
|
||||
webpushKey: () => [...pushKeys.all, "webpush-key"] as const,
|
||||
devices: () => [...pushKeys.all, "devices"] as const,
|
||||
status: () => [...pushKeys.all, "status"] as const,
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Hooks — `web/src/hooks/queries/push.ts`**
|
||||
|
||||
Mirror `admin/users.ts` conventions (api import, toast, invalidation).
|
||||
|
||||
```ts
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { api } from "@/api/client";
|
||||
import type { PushDeviceInfo, VapidKeyPair } from "@/api/types";
|
||||
import { pushKeys } from "@/hooks/queries/keys";
|
||||
|
||||
export function useWebPushPublicKey() {
|
||||
return useQuery({
|
||||
queryKey: pushKeys.webpushKey(),
|
||||
queryFn: () => api<{ vapid_public_key: string }>("/notifications/push/webpush-key"),
|
||||
select: (d) => d.vapid_public_key,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePushDevices() {
|
||||
return useQuery({
|
||||
queryKey: pushKeys.devices(),
|
||||
queryFn: () => api<{ devices: PushDeviceInfo[] | null }>("/notifications/push/devices"),
|
||||
select: (d) => d.devices ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
export function useTogglePushDevice() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ deviceId, enabled }: { deviceId: string; enabled: boolean }) =>
|
||||
api(`/notifications/push/devices/${deviceId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ enabled }),
|
||||
}),
|
||||
onSuccess: () => void qc.invalidateQueries({ queryKey: pushKeys.devices() }),
|
||||
onError: (err) => toast.error(err instanceof Error ? err.message : "Failed to update device"),
|
||||
});
|
||||
}
|
||||
|
||||
export function usePushStatus() {
|
||||
return useQuery({
|
||||
queryKey: pushKeys.status(),
|
||||
queryFn: () => api<{ apns: boolean; fcm: boolean; webpush: boolean }>("/admin/push/status"),
|
||||
});
|
||||
}
|
||||
|
||||
export function useGenerateVapidKeys() {
|
||||
return useMutation({
|
||||
mutationFn: () => api<VapidKeyPair>("/admin/push/generate-vapid-keys", { method: "POST" }),
|
||||
onError: (err) => toast.error(err instanceof Error ? err.message : "Failed to generate keys"),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSendTestPush() {
|
||||
return useMutation({
|
||||
mutationFn: () => api("/admin/push/test", { method: "POST" }),
|
||||
onSuccess: () => toast.success("Test push queued — watch for a banner"),
|
||||
onError: (err) => toast.error(err instanceof Error ? err.message : "Failed to send test"),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
(Register/revoke device calls live inside `usePushDevice` (Task 4) since they're tied to the subscription, not standalone query hooks.)
|
||||
|
||||
- [ ] **Step 4: Typecheck + commit**
|
||||
|
||||
```bash
|
||||
cd web && pnpm exec tsc -b --noEmit 2>&1 | head -3
|
||||
git add web/src/api/types.ts web/src/hooks/queries/keys.ts web/src/hooks/queries/push.ts
|
||||
git commit -m "feat(web): push query hooks and types"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: `usePushDevice` lifecycle hook
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/hooks/usePushDevice.ts`, `web/src/hooks/usePushDevice.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Failing tests**
|
||||
|
||||
Mock `navigator.serviceWorker`, `window.PushManager`, `Notification`, the api client, and the push helpers. Assert the status machine and the enable/disable flows.
|
||||
|
||||
```tsx
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { apiMock, cacheVapidMock } = vi.hoisted(() => ({
|
||||
apiMock: vi.fn(),
|
||||
cacheVapidMock: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/api/client", () => ({ api: apiMock }));
|
||||
vi.mock("@/lib/push", async (orig) => ({
|
||||
...(await orig<typeof import("@/lib/push")>()),
|
||||
cacheVapidKey: cacheVapidMock,
|
||||
}));
|
||||
|
||||
import { usePushDevice } from "./usePushDevice";
|
||||
|
||||
function wrap() {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
let subscribeMock: ReturnType<typeof vi.fn>;
|
||||
let getSubMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
apiMock.mockReset();
|
||||
cacheVapidMock.mockReset();
|
||||
subscribeMock = vi.fn(async () => ({ toJSON: () => ({ endpoint: "https://x", keys: {} }) }));
|
||||
getSubMock = vi.fn(async () => null);
|
||||
(globalThis as any).PushManager = function () {};
|
||||
(navigator as any).serviceWorker = {
|
||||
register: vi.fn(),
|
||||
ready: Promise.resolve({ pushManager: { subscribe: subscribeMock, getSubscription: getSubMock } }),
|
||||
};
|
||||
(globalThis as any).Notification = { permission: "default", requestPermission: vi.fn(async () => "granted") };
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe("usePushDevice", () => {
|
||||
it("reports unsupported when PushManager missing", () => {
|
||||
delete (globalThis as any).PushManager;
|
||||
const { result } = renderHook(() => usePushDevice(), { wrapper: wrap() });
|
||||
expect(result.current.status).toBe("unsupported");
|
||||
});
|
||||
|
||||
it("reports blocked when permission denied", () => {
|
||||
(globalThis as any).Notification.permission = "denied";
|
||||
const { result } = renderHook(() => usePushDevice(), { wrapper: wrap() });
|
||||
expect(result.current.status).toBe("blocked");
|
||||
});
|
||||
|
||||
it("enable(): permission → key → subscribe → PUT device, caches key, status on", async () => {
|
||||
apiMock.mockImplementation((path: string) => {
|
||||
if (path.includes("webpush-key")) return Promise.resolve({ vapid_public_key: "AQID" });
|
||||
return Promise.resolve(undefined); // PUT device
|
||||
});
|
||||
const { result } = renderHook(() => usePushDevice(), { wrapper: wrap() });
|
||||
await act(async () => { await result.current.enable(); });
|
||||
expect(subscribeMock).toHaveBeenCalledOnce();
|
||||
expect(cacheVapidMock).toHaveBeenCalledWith("AQID");
|
||||
const putCall = apiMock.mock.calls.find((c) => c[0] === "/notifications/push/device");
|
||||
expect(putCall?.[1]).toMatchObject({ method: "PUT" });
|
||||
expect(JSON.parse(putCall![1].body)).toMatchObject({ transport: "webpush" });
|
||||
await waitFor(() => expect(result.current.status).toBe("on"));
|
||||
});
|
||||
|
||||
it("enable(): permission denied short-circuits, no subscribe", async () => {
|
||||
(globalThis as any).Notification.requestPermission = vi.fn(async () => "denied");
|
||||
const { result } = renderHook(() => usePushDevice(), { wrapper: wrap() });
|
||||
await act(async () => { await result.current.enable(); });
|
||||
expect(subscribeMock).not.toHaveBeenCalled();
|
||||
expect(result.current.status).toBe("blocked");
|
||||
});
|
||||
|
||||
it("disable(): unsubscribe + DELETE, status off", async () => {
|
||||
const unsub = vi.fn(async () => true);
|
||||
getSubMock.mockResolvedValue({ unsubscribe: unsub });
|
||||
apiMock.mockResolvedValue(undefined);
|
||||
const { result } = renderHook(() => usePushDevice(), { wrapper: wrap() });
|
||||
await act(async () => { await result.current.disable(); });
|
||||
expect(unsub).toHaveBeenCalled();
|
||||
const del = apiMock.mock.calls.find((c) => c[0] === "/notifications/push/device" && c[1]?.method === "DELETE");
|
||||
expect(del).toBeTruthy();
|
||||
await waitFor(() => expect(result.current.status).toBe("off"));
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run, verify fail**
|
||||
|
||||
```bash
|
||||
cd web && pnpm exec vitest run src/hooks/usePushDevice.test.tsx 2>&1 | tail -6
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Implement — `web/src/hooks/usePushDevice.ts`**
|
||||
|
||||
```ts
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { api } from "@/api/client";
|
||||
import { cacheVapidKey, pushSupported, urlBase64ToUint8Array } from "@/lib/push";
|
||||
|
||||
export type PushDeviceStatus = "unsupported" | "blocked" | "off" | "on" | "pending";
|
||||
|
||||
export function usePushDevice() {
|
||||
const [status, setStatus] = useState<PushDeviceStatus>("pending");
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!pushSupported()) {
|
||||
setStatus("unsupported");
|
||||
return;
|
||||
}
|
||||
if (Notification.permission === "denied") {
|
||||
setStatus("blocked");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
const sub = await reg.pushManager.getSubscription();
|
||||
setStatus(sub ? "on" : "off");
|
||||
} catch {
|
||||
setStatus("off");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const enable = useCallback(async () => {
|
||||
if (!pushSupported()) {
|
||||
setStatus("unsupported");
|
||||
return;
|
||||
}
|
||||
setStatus("pending");
|
||||
const perm = await Notification.requestPermission();
|
||||
if (perm !== "granted") {
|
||||
setStatus(perm === "denied" ? "blocked" : "off");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
const { vapid_public_key } = await api<{ vapid_public_key: string }>(
|
||||
"/notifications/push/webpush-key",
|
||||
);
|
||||
if (!vapid_public_key) {
|
||||
setStatus("off");
|
||||
return;
|
||||
}
|
||||
await cacheVapidKey(vapid_public_key);
|
||||
const sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapid_public_key),
|
||||
});
|
||||
await api("/notifications/push/device", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ transport: "webpush", token: JSON.stringify(sub.toJSON()) }),
|
||||
});
|
||||
setStatus("on");
|
||||
} catch {
|
||||
setStatus("off");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const disable = useCallback(async () => {
|
||||
setStatus("pending");
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
const sub = await reg.pushManager.getSubscription();
|
||||
if (sub) await sub.unsubscribe();
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
try {
|
||||
await api("/notifications/push/device", { method: "DELETE" });
|
||||
} catch {
|
||||
/* server may already lack the row */
|
||||
}
|
||||
setStatus("off");
|
||||
}, []);
|
||||
|
||||
return { status, enable, disable, refresh };
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run green, typecheck, commit**
|
||||
|
||||
```bash
|
||||
cd web && pnpm exec vitest run src/hooks/usePushDevice.test.tsx 2>&1 | tail -6 && pnpm exec tsc -b --noEmit 2>&1 | head -3
|
||||
git add web/src/hooks/usePushDevice.ts web/src/hooks/usePushDevice.test.tsx
|
||||
git commit -m "feat(web): usePushDevice subscription lifecycle hook"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: NotificationsSettings — push control + device list
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/pages/settings/NotificationsSettings.tsx`
|
||||
- Test: extend the page's test if one exists (check `ls web/src/pages/settings/*.test.tsx`)
|
||||
|
||||
- [ ] **Step 1: Add a push section**
|
||||
|
||||
Read the current `NotificationsSettings.tsx` (it uses `SettingsGroup`/`SettingRow`/`Switch`). Add, below the category-preference group, a new "Push notifications" group:
|
||||
|
||||
- A row driven by `usePushDevice()`:
|
||||
- `status === "unsupported"` → static text "This browser doesn't support push notifications."
|
||||
- `status === "blocked"` → text "Notifications are blocked for this site. Re-enable them in your browser's site settings."
|
||||
- else → a `Switch` checked when `status === "on"`, disabled when `status === "pending"`, `onCheckedChange` calls `enable()` / `disable()`.
|
||||
- Below it, a device list from `usePushDevices()`: one row per device (`name || device_id`, `platform`/`transport`, `registered_at`), each with a `Switch` bound to `useTogglePushDevice().mutate({ deviceId, enabled })` reflecting `push_enabled`. Empty state: "No devices registered for push yet."
|
||||
|
||||
Match the page's existing component composition and classes. Keep `content_digest` and the existing toggles untouched.
|
||||
|
||||
- [ ] **Step 2: Test (if the page is tested)**
|
||||
|
||||
If `NotificationsSettings.test.tsx` exists, mock `usePushDevice` (return `{status:"off", enable, disable}`) and `usePushDevices` (two devices); assert: toggling the device-level switch calls `useTogglePushDevice().mutate` with the device id; the main push switch calls `enable` when off. If no sibling test exists, rely on typecheck + the hook tests and note it in the commit.
|
||||
|
||||
- [ ] **Step 3: Typecheck, test, commit**
|
||||
|
||||
```bash
|
||||
cd web && pnpm exec tsc -b --noEmit 2>&1 | head -3 && pnpm exec vitest run src/pages/settings 2>&1 | tail -4
|
||||
git add web/src/pages/settings/NotificationsSettings.tsx web/src/pages/settings/*.test.tsx
|
||||
git commit -m "feat(web): push on-this-device control and device list in settings"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Admin PushSettings page + nav
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/pages/admin-settings/PushSettings.tsx`
|
||||
- Modify: `web/src/pages/admin-settings/AdminSettingsLayout.tsx`
|
||||
- Test: `web/src/pages/admin-settings/PushSettings.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Build the page**
|
||||
|
||||
Template: `IntegrationsSettings.tsx` (credential card = `CredentialStatus` badge + `SettingField` + `useUpdateServerSetting` + `useAdminSensitiveStatus`). Three cards. Header badge per card from `usePushStatus()`.
|
||||
|
||||
- **Web Push card** (leads): fields `push.webpush.vapid_public`, `push.webpush.vapid_private`, `push.webpush.subject`. A "Generate keys" button → `useGenerateVapidKeys().mutateAsync()` fills the public + private field state (local `useState`, not auto-saved). Subject field hint: `mailto:you@example.com`. Each field saves via `updateSetting.mutateAsync({ key, value })`. A "Send test push" button → `useSendTestPush().mutate()`.
|
||||
- **APNs card**: `push.apns.p8_key` (textarea via `SettingField` multiline if supported, else a `<textarea>`), `push.apns.key_id`, `push.apns.team_id`, `push.apns.bundle_id`.
|
||||
- **FCM card**: `push.fcm.service_account_json` (textarea).
|
||||
|
||||
`configured` per field from `new Set(sensitive?.configured ?? []).has(key)`. Page header + intro like IntegrationsSettings.
|
||||
|
||||
- [ ] **Step 2: Register nav**
|
||||
|
||||
In `AdminSettingsLayout.tsx`, add to the "Connections" group's `items` (import the component + a lucide icon, e.g. `BellRing`):
|
||||
|
||||
```tsx
|
||||
{ id: "push", label: "Push Notifications", icon: BellRing, component: PushSettings },
|
||||
```
|
||||
|
||||
(`id: "push"` is stable URL state.)
|
||||
|
||||
- [ ] **Step 3: Test — `PushSettings.test.tsx`**
|
||||
|
||||
Mock `useUpdateServerSetting` (capture mutate args), `useAdminSensitiveStatus` (some keys configured), `usePushStatus` (webpush true), `useGenerateVapidKeys` (returns a pair), `useSendTestPush`. Assert: three cards render; webpush badge shows ready; saving the VAPID public field calls `updateSetting.mutateAsync({ key: "push.webpush.vapid_public", value })`; "Generate keys" populates the public/private fields; "Send test push" calls the mutation. Follow the admin-settings test conventions (check a sibling `*.test.tsx` in that dir; if none, write a minimal RTL test with the mocks).
|
||||
|
||||
- [ ] **Step 4: Typecheck, test, build, commit**
|
||||
|
||||
```bash
|
||||
cd web && pnpm exec tsc -b --noEmit 2>&1 | head -3 && pnpm exec vitest run src/pages/admin-settings 2>&1 | tail -5
|
||||
git add web/src/pages/admin-settings/PushSettings.tsx web/src/pages/admin-settings/PushSettings.test.tsx web/src/pages/admin-settings/AdminSettingsLayout.tsx
|
||||
git commit -m "feat(web): admin push provider config page"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Full verification + manual smoke
|
||||
|
||||
- [ ] **Step 1: Web gates**
|
||||
|
||||
```bash
|
||||
cd web && pnpm run lint 2>&1 | tail -3 && pnpm run format:check 2>&1 | tail -2 && pnpm exec vitest run 2>&1 | tail -5 && pnpm run build 2>&1 | tail -3
|
||||
```
|
||||
|
||||
All green (lint warnings allowed, errors not). Fix any new-code lint/format issues (prettier --write the new files if format:check flags them). `build` also confirms `public/sw.js` is copied to `dist/` (Vite copies `public/` verbatim).
|
||||
|
||||
- [ ] **Step 2: Backend gates**
|
||||
|
||||
```bash
|
||||
GOWORK=off go build ./... && GOWORK=off go test ./internal/api/handlers/ -run TestPush 2>&1 | tail -3 && make verify-local-paths
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Manual browser smoke (documented; requires a real browser over HTTPS or localhost)**
|
||||
|
||||
Service workers + Push require a secure context — `localhost` qualifies. Build the server with the web assets and run on a localhost port.
|
||||
Commands assume the repository root is the cwd.
|
||||
|
||||
```bash
|
||||
cd web && pnpm run build && cd ..
|
||||
GOWORK=off go build -o ./bin/silo-pushweb ./cmd/silo && (PORT=18080 JF_PORT=18096 ./bin/silo-pushweb > ./silo-pushweb.log 2>&1 &)
|
||||
```
|
||||
|
||||
Then in a Chromium browser at `http://localhost:18080`:
|
||||
1. Log in as admin. Go to Admin → Settings → Push Notifications. Click "Generate keys", Save the VAPID public/private + a `mailto:` subject. Confirm the Web Push badge flips to "Ready" (and `GET /admin/push/status` → webpush:true).
|
||||
2. Go to Settings → Notifications. Toggle "Push on this device" on → grant the browser permission prompt. Confirm a row appears in the device list and the toggle stays on.
|
||||
3. Click "Send test push" on the admin page (or create an announcement). Within ~30–45s (grace + worker tick) a desktop notification banner appears. Close the app tab first to prove background delivery.
|
||||
4. Click the banner → the app opens/focuses at the deep link.
|
||||
5. Toggle push off → confirm `DELETE` and the device drops from the list.
|
||||
|
||||
Record the outcome. (If a real browser isn't available in the harness, run steps 1–2 via curl to confirm the API wiring — register a webpush device, confirm `push_deliveries` row + status — and note the visual steps as operator-verified.)
|
||||
|
||||
- [ ] **Step 4: Commit any verification fixes; report.** Work stays on `feat/core-notifications-server` (or a dedicated branch off it).
|
||||
|
||||
---
|
||||
|
||||
## Self-review notes (resolved during writing)
|
||||
|
||||
- `webpush.GenerateVAPIDKeys()` returns `(privateKey, publicKey, err)` in webpush-go — Task 1 Step 1 flags verifying the order so the keys aren't swapped.
|
||||
- The SW's pure logic is duplicated between `public/sw.js` (untestable static file) and the tested `src/lib/pushSw.ts`; the duplication is small and intentional, with sw.js annotated as mirroring the tested source. The reviewer should diff the two functions for drift.
|
||||
- `usePushDevice` owns register/revoke (tied to the live subscription); standalone query hooks (`push.ts`) cover read/list/toggle/admin. No overlap.
|
||||
- Test push reuses `notifications.Service.CreateSystem` (Phase-1) → a real system notification → exercises the full enqueue→worker→transport pipeline to the caller's own devices. No bespoke push path.
|
||||
- Admin credential writes use the existing `PUT /admin/settings/{key}` (encrypted, redacted) — no new write endpoint; only keygen + test are net-new server surface.
|
||||
- iOS Safari web push (installed-PWA only) is handled by `pushSupported()` returning false in a normal tab → `unsupported` state; documented, not specially coded.
|
||||
- Several steps embed verify-greps (NewPushHandler call sites, GenerateVAPIDKeys order, SettingField multiline support, sibling test conventions, the user-id context injection helper in push_test.go) — align with reality.
|
||||
@@ -0,0 +1,224 @@
|
||||
# Audiobook Player QoL Improvements
|
||||
|
||||
Commands assume the repository root is the cwd. All paths are repository-relative.
|
||||
|
||||
## Goal
|
||||
|
||||
Bring the web audiobook player to feature parity with best-in-class audiobook apps
|
||||
(Audiobookshelf, Audible, BookPlayer, Prologue) on the quality-of-life axis. The current
|
||||
surface architecture (persistent mini bar + expandable Now Listening view, playback
|
||||
survives navigation) is already the de-facto standard and stays as-is.
|
||||
|
||||
Explicitly **out of scope**: Media Session / lock-screen integration, bookmarks (needs a
|
||||
server endpoint — follow-up), offline/download, queue.
|
||||
|
||||
## Features
|
||||
|
||||
1. **Keyboard shortcuts** — space/K play-pause, arrow skips, volume, chapter nav, speed step.
|
||||
2. **Configurable skip intervals** — asymmetric defaults (back 10s, forward 30s), user-tunable.
|
||||
3. **Expanded speed control** — 0.5×–3.0× with fine stepping, presets, and per-book memory.
|
||||
4. **Chapter prev/next buttons** — alongside the seconds-skip buttons.
|
||||
5. **Volume control** — reuse the shared `VolumeControl`, persisted like the video player.
|
||||
6. **Smart rewind** — auto-rewind on resume, scaled by how long playback was paused.
|
||||
7. **Time remaining at current speed** — real-clock remaining display in Now Listening.
|
||||
|
||||
No server/API changes are required; everything is web-frontend only, so no Android/Apple
|
||||
client coordination is needed. (Per-book speed memory is device-local in v1; syncing it
|
||||
server-side via profile settings is a noted follow-up.)
|
||||
|
||||
## Existing seams (read these first)
|
||||
|
||||
| File | Relevance |
|
||||
| --- | --- |
|
||||
| `web/src/pages/audiobooks/player/useAudiobookPlayback.ts` | Playback engine. `togglePlay` (~line 499), `skip` (~534), `setRate` (~541), returned API (~690+). All engine work lands here. |
|
||||
| `web/src/pages/audiobooks/player/MiniBar.tsx` | Bottom bar. Hardcoded `SKIP_*_SECONDS = 30` and `PLAYBACK_RATES` at lines 10–12. |
|
||||
| `web/src/pages/audiobooks/player/NowListening.tsx` | Full-screen view. Same hardcoded constants; dead "More" button (lines 50–56) becomes the settings menu trigger; remaining-time toggle at lines 33–37. |
|
||||
| `web/src/pages/audiobooks/player/AudiobookPlayer.tsx` | Wrapper that owns mini/now-listening mode — mount point for the keyboard shortcuts hook. |
|
||||
| `web/src/player/components/VolumeControl.tsx` | Existing slider + `getPersistedVolume`/`persistVolume` helpers (shared `player-volume`/`player-muted` storage keys). Styled for dark video overlay — needs a surface-toned variant. |
|
||||
| `web/src/player/components/SpeedMenu.tsx` | Current preset-only menu (shared with video player). Audiobook gets a richer `SpeedControl`; video keeps `SpeedMenu` untouched. |
|
||||
| `web/src/player/hooks/useKeyboardShortcuts.ts` | Video player's shortcut hook — pattern to mirror (input-field guard, single document listener). |
|
||||
| `web/src/utils/storage.ts` | Typed localStorage wrapper — add new keys here, never call `localStorage` directly. |
|
||||
| `web/src/player/components/SeekBar.tsx`, `CircleButton.tsx`, `SleepTimerMenu.tsx`, `ChaptersMenu.tsx` | Established player design language; reuse, don't fork. |
|
||||
|
||||
## Design language
|
||||
|
||||
These are additions to an existing, refined system — extend it, don't restyle it:
|
||||
|
||||
- **Surfaces**: mini bar is a themed surface (`bg-background`, `border-t`, token colors).
|
||||
Popovers anchored to it use the established dark-glass treatment
|
||||
(`bg-black/90 backdrop-blur-sm rounded-lg shadow-xl`, white/75 text, `data-active` rows)
|
||||
exactly as `SpeedMenu`/`SleepTimerMenu` do today.
|
||||
- **Type**: time and rate values are always `font-mono`/`tabular-nums` so nothing shifts as
|
||||
digits tick. The new speed readout (`1.45×`) must reserve width for two decimals.
|
||||
- **Buttons**: all transport controls are `CircleButton` (`sm` secondary flanks, `md`/`lg`
|
||||
primary center). Chapter prev/next use `SkipBack`/`SkipForward` lucide glyphs at the same
|
||||
stroke weight (1.6) as the rotate icons, so the cluster reads as one family.
|
||||
- **Micro-interactions**, small and purposeful:
|
||||
- Skip buttons: a quick ~18° icon flick in the skip direction on press
|
||||
(CSS transform transition, ~150ms ease-out) — confirms the action without a toast.
|
||||
- Speed stepper: value crossfades (opacity 120ms) on change; the active preset chip gets
|
||||
the same `bg-white/5 text-white` treatment as current menu rows.
|
||||
- Smart rewind: when it fires, the seek bar playhead animates the small jump back rather
|
||||
than teleporting (SeekBar already animates position; verify it holds for ≤30s deltas).
|
||||
- **Accessibility**: every new control keeps the existing patterns — `aria-label`,
|
||||
`aria-expanded`/`aria-haspopup` on triggers, roving focus + Escape in menus (copy from
|
||||
`SleepTimerMenu`), `role="slider"` semantics from `VolumeControl`/`SeekBar`.
|
||||
|
||||
## Phase 1 — Engine + preferences model
|
||||
|
||||
### 1a. Preferences hook
|
||||
|
||||
New `web/src/pages/audiobooks/player/useAudiobookPrefs.ts`:
|
||||
|
||||
- Add storage keys: `AUDIOBOOK_SKIP_BACK`, `AUDIOBOOK_SKIP_FORWARD`,
|
||||
`AUDIOBOOK_SMART_REWIND` (bool), `AUDIOBOOK_RATES` (JSON map) in `web/src/utils/storage.ts`.
|
||||
- Exposes `{ skipBack, skipForward, smartRewind, setSkipBack, setSkipForward, setSmartRewind }`.
|
||||
- Defaults: back **10s**, forward **30s** (the asymmetric convention — small "what did she
|
||||
say?" hops back, bigger hops forward), smart rewind **on**.
|
||||
- Allowed skip values: 5, 10, 15, 30, 45, 60, 90 (two digits max keeps `SkipIcon`'s inset
|
||||
number legible).
|
||||
- Per-book rate map: single JSON object `{ [contentId]: rate }` under `AUDIOBOOK_RATES`,
|
||||
LRU-capped at 50 entries (store `{ rate, at }` and evict oldest on insert).
|
||||
Helpers `getBookRate(contentId)` / `setBookRate(contentId, rate)` live here too.
|
||||
|
||||
### 1b. Engine extensions (`useAudiobookPlayback.ts`)
|
||||
|
||||
- **Volume/mute**: initialize from `getPersistedVolume()`; apply to the `<audio>` element in
|
||||
an effect; expose `volume`, `muted`, `setVolume`, `setMuted`; persist via `persistVolume`.
|
||||
Sharing the video player's keys is intentional — one volume preference per device.
|
||||
- **Per-book rate**: on mount, initialize `rate` from `getBookRate(contentId)` (fallback 1);
|
||||
in `setRate`, also `setBookRate`. The hook needs `contentId` passed in (it already
|
||||
receives the files/options object — extend that input).
|
||||
- **Chapter navigation**: expose `nextChapter()` and `prevChapter()`.
|
||||
- `next`: seek to start of the chapter after `currentChapter`; no-op at the last chapter.
|
||||
- `prev`: if more than 3s into the current chapter, seek to its start; otherwise seek to
|
||||
the previous chapter's start (the universal music/audiobook convention).
|
||||
- **Smart rewind**: record `pausedAtRef = performance.now()` whenever playback pauses (in
|
||||
the `pause` event handler so it also catches OS-initiated pauses). On resume, before
|
||||
`audio.play()`, compute rewind from pause duration and `seekTo(currentTime - rewind)`:
|
||||
- < 10s paused → 0s; < 1min → 3s; < 10min → 10s; < 1h → 20s; ≥ 1h → 30s.
|
||||
- Clamp at 0; skip entirely when the smart-rewind pref is off or the resume immediately
|
||||
follows a user seek (seeking sets a short suppress flag so explicit jumps aren't undone).
|
||||
- Also apply on **cold resume**: when starting with `initialPositionSeconds > 0` from the
|
||||
detail page, rewind by the same schedule using the age of the saved progress if the
|
||||
API exposes an updated-at timestamp; otherwise apply a flat 10s. Keep this logic in one
|
||||
pure function `smartRewindSeconds(pauseMs)` so it's unit-testable.
|
||||
- **Skip intervals**: no engine change — `skip(delta)` already takes a signed delta; the UI
|
||||
passes the configured values.
|
||||
|
||||
### 1c. Tests
|
||||
|
||||
Vitest, colocated like existing `*.test.tsx`:
|
||||
|
||||
- `smartRewindSeconds` schedule boundaries.
|
||||
- Per-book rate LRU (cap, eviction, malformed-JSON tolerance).
|
||||
- `prevChapter` 3s threshold behavior and first/last chapter edges
|
||||
(chapter math is pure — test against `buildChapterList` fixtures from
|
||||
`web/src/lib/audiobooks/chapters.ts`).
|
||||
|
||||
## Phase 2 — Controls UI
|
||||
|
||||
### 2a. SpeedControl (new, audiobook-specific)
|
||||
|
||||
New `web/src/pages/audiobooks/player/SpeedControl.tsx`, replacing `SpeedMenu` usage in both
|
||||
audiobook views (video player keeps `SpeedMenu`):
|
||||
|
||||
- Trigger: same `player-utility-btn` showing the current rate (`1.45×`, tabular-nums).
|
||||
- Popover (dark-glass, bottom-anchored like today):
|
||||
- A stepper row: `−` / big mono readout / `+`, stepping **0.05** per tap, clamped to
|
||||
**0.5–3.0**. Press-and-hold repeats (250ms initial, 80ms repeat).
|
||||
- A preset chip row: 1× · 1.25× · 1.5× · 1.75× · 2× · 2.5× · 3×.
|
||||
- Footnote line: "Remembered for this book" (quiet `text-white/40`, 11px) — makes the
|
||||
per-book memory discoverable instead of magical.
|
||||
- Keyboard: ArrowUp/Down step inside the popover; Escape closes (copy menu plumbing from
|
||||
`SleepTimerMenu`).
|
||||
|
||||
### 2b. Chapter prev/next buttons
|
||||
|
||||
- Add `SkipBack`/`SkipForward` `CircleButton size="sm" variant="secondary"` on the outside
|
||||
of the transport cluster in both `MiniBar` and `NowListening`:
|
||||
`⏮ ↺10 ▶ ↻30 ⏭`. Wire to `prevChapter`/`nextChapter`; render only when
|
||||
`playback.chapters.length > 0`; disable at the ends (prev stays enabled mid-chapter).
|
||||
- In `MiniBar`, hide the chapter buttons below `sm:` — the 3-column grid is already tight
|
||||
on phones and the chapters menu remains available.
|
||||
|
||||
### 2c. Volume
|
||||
|
||||
- Add a `tone` prop to `web/src/player/components/VolumeControl.tsx`
|
||||
(`"overlay"` = current white-on-dark, `"surface"` = theme tokens: track `bg-muted`,
|
||||
fill `bg-foreground`, focus ring `ring-ring`). Default `"overlay"` so the video player is
|
||||
untouched.
|
||||
- Mount in `MiniBar`'s right cluster (before the sleep timer) at `md:` and up, and in
|
||||
`NowListening`'s utility row. Wire to the new engine `volume`/`muted` state.
|
||||
|
||||
### 2d. Player settings menu
|
||||
|
||||
New `web/src/pages/audiobooks/player/PlayerSettingsMenu.tsx` (dark-glass popover):
|
||||
|
||||
- **Skip back** / **Skip forward**: rows of small value chips (5/10/15/30/45/60/90s).
|
||||
- **Smart rewind**: toggle row with a one-line description ("Backs up a little after a pause").
|
||||
- Triggers: the currently-dead "More" (`MoreHorizontal`) button in `NowListening`
|
||||
(lines 50–56) and a matching trigger in `MiniBar`'s right cluster.
|
||||
- `MiniBar`/`NowListening` read `useAudiobookPrefs()` and pass the configured seconds to
|
||||
the skip buttons and `SkipIcon` (which already renders a dynamic number) — delete the
|
||||
`SKIP_*_SECONDS` constants.
|
||||
|
||||
## Phase 3 — Shortcuts + time display + polish
|
||||
|
||||
### 3a. Keyboard shortcuts
|
||||
|
||||
New `web/src/pages/audiobooks/player/useAudiobookKeyboardShortcuts.ts`, mounted in
|
||||
`AudiobookPlayer.tsx` (so it's active in both mini and expanded modes, on every route):
|
||||
|
||||
| Key | Action |
|
||||
| --- | --- |
|
||||
| Space / K | play-pause |
|
||||
| ← / → | skip by configured back/forward seconds |
|
||||
| ↑ / ↓ | volume ±5% |
|
||||
| M | mute toggle |
|
||||
| N / P | next / previous chapter |
|
||||
| Shift+. / Shift+, | speed +0.05 / −0.05 (YouTube convention) |
|
||||
| E | expand / collapse Now Listening |
|
||||
| Esc | collapse Now Listening (expanded mode only) |
|
||||
|
||||
- Mirror `useKeyboardShortcuts.ts` guards: ignore when target is input/textarea/
|
||||
contentEditable, single document listener, cleanup on unmount.
|
||||
- Additional guard: **do nothing while a video session is active** — the video player binds
|
||||
the same keys. Check `WatchPlaybackProvider` state (or simply whether a `<video>` watch
|
||||
route is mounted) and bail; the audiobook bar is backgrounded in that case anyway. Space
|
||||
must also not fire when a button has focus (let the focused control handle Enter/Space).
|
||||
- Add `title` tooltips with the shortcut hint to transport buttons
|
||||
(e.g. `Back 10 seconds (←)`) for discoverability.
|
||||
|
||||
### 3b. Time remaining at current speed
|
||||
|
||||
In `NowListening`, the right time label currently toggles total ↔ remaining. Make it cycle
|
||||
three states (persist last choice in component state only):
|
||||
|
||||
1. total (`12:04:00`)
|
||||
2. remaining (`−3:21:09`)
|
||||
3. remaining at speed (`−2:14:06 at 1.5×`) — `remaining / rate`, hidden (skipped in the
|
||||
cycle) when `rate === 1`.
|
||||
|
||||
Keep the `data-testid="now-listening-right-time"` hook and update its test.
|
||||
|
||||
### 3c. Polish pass
|
||||
|
||||
- Skip-flick and speed-crossfade micro-interactions from the design section.
|
||||
- Verify mini bar layout on 360px-wide viewports with all new controls (chapter buttons and
|
||||
volume hidden, settings menu accessible).
|
||||
- `cd web && pnpm run lint && pnpm run format:check`; run the player test suites.
|
||||
|
||||
## Risks / notes
|
||||
|
||||
- `useAudiobookPlayback.ts` is 707 lines and growing — extract smart rewind + prefs-coupled
|
||||
logic into small pure modules (`smartRewind.ts`) rather than inflating the hook; consider
|
||||
splitting volume/rate concerns into a `useAudioElementSettings` helper if the hook passes
|
||||
~800 lines.
|
||||
- Smart rewind must not fight the 10s progress reporter: rewinding on resume changes
|
||||
`currentTime`, which triggers a progress write — that's correct (the rewound position is
|
||||
the truth), just confirm no oscillation with the suppress-after-seek flag.
|
||||
- Per-book rate uses `contentId`; switching narrators navigates to a different `contentId`,
|
||||
so each narration remembers its own speed — acceptable, arguably desirable.
|
||||
- Cross-device sync of prefs/per-book speed is a candidate follow-up once a profile
|
||||
settings endpoint exists; the `useAudiobookPrefs` API is the seam to swap storage behind.
|
||||
@@ -0,0 +1,447 @@
|
||||
# Audiobook UI Redesign — Design Spec
|
||||
|
||||
**Date:** 2026-05-24
|
||||
**Branch context:** `feat/audiobooks` in `silo-server`
|
||||
**Status:** Approved — ready for implementation plan
|
||||
**Related spec:** [`2026-05-24-audiobooks-absorption-design.md`](./2026-05-24-audiobooks-absorption-design.md)
|
||||
|
||||
## Goal
|
||||
|
||||
Bring the audiobook detail page and audiobook player to visual and
|
||||
interaction parity with Silo's existing video player, translated for
|
||||
audio. Today the audiobook surfaces work but feel generic, hide useful
|
||||
information, and re-implement primitives the video player already has
|
||||
in a polished form.
|
||||
|
||||
User-visible outcome:
|
||||
|
||||
- The audiobook player has the same two-mode shape as the video player
|
||||
(compact HUD + immersive full-screen mode), so listening can be
|
||||
background or foreground depending on intent.
|
||||
- The detail page foregrounds the information audiobook listeners care
|
||||
about — current chapter, narrator, listened progress — instead of
|
||||
burying it.
|
||||
- The audiobook-only affordances that don't exist today (sleep timer,
|
||||
speed menu, bookmarks, narrator emphasis) have a home.
|
||||
|
||||
## Hard constraints
|
||||
|
||||
- **Reuse video-player primitives.** `SeekBar`, `ChaptersMenu`, and the
|
||||
glass-disc button visual treatment all live in `web/src/player/` and
|
||||
are already wired up correctly. The audiobook player must consume
|
||||
them, not re-implement them.
|
||||
- **One audio element.** Switching between mini and Now Listening must
|
||||
not remount the audio element or restart the stream. Both modes are
|
||||
chrome layered over the same playback state.
|
||||
- **No new top-level routes.** The Now Listening view is an overlay on
|
||||
whatever route the user is on, not a `/audiobooks/listen` page. This
|
||||
mirrors how fullscreen video works — fullscreen is a mode, not a
|
||||
destination.
|
||||
- **Player state must be liftable.** The split between
|
||||
`useAudiobookPlayback` (state hook) and `MiniBar` / `NowListening`
|
||||
(chrome) must allow a future v1.1 change to lift the hook into an
|
||||
app-level provider so the mini bar can survive page navigation.
|
||||
v1 itself ships with the player tied to the detail route, same as
|
||||
today.
|
||||
|
||||
## Scope
|
||||
|
||||
### In
|
||||
|
||||
- New mini-bar layout (cover tile, chapter title, glass-disc transport,
|
||||
utility rail with sleep / chapters / speed / expand / close).
|
||||
- New Now Listening full-overlay mode (large cover, chapter heading,
|
||||
tall seek bar, sleep / chapters / speed / bookmark / car-mode row,
|
||||
remaining-time toggle, overflow menu).
|
||||
- Restructured audiobook detail page (progress + chapter-aware Resume
|
||||
action, chapters expanded by default with currently-playing
|
||||
highlight, narrator card, embedding-based "Similar audiobooks"
|
||||
rail, "Also by author", "In this series").
|
||||
- Extraction of `CircleButton` and a new `SpeedMenu` into
|
||||
`web/src/player/components/` as shared primitives between video and
|
||||
audiobook players.
|
||||
- Split of today's monolithic `AudiobookPlayer.tsx` into a state hook
|
||||
(`useAudiobookPlayback`) plus two chrome components (`MiniBar`,
|
||||
`NowListening`) under `web/src/pages/audiobooks/player/`.
|
||||
- Sleep timer (client-side only; pauses + short fade-out when the
|
||||
timer fires).
|
||||
|
||||
### Out (deferred to v1.1)
|
||||
|
||||
- **Bookmarks.** UI affordances (mini-bar button, Now Listening
|
||||
utility-row button, "Bookmarks (n)" detail-page action) ship
|
||||
**hidden** in v1. Full feature needs `audiobook_bookmarks` table +
|
||||
CRUD endpoints, which is its own sub-spec.
|
||||
- **"Also by narrator" / cross-book "In this series" rails.** Render
|
||||
only when the backend already exposes the data; otherwise hide.
|
||||
Server-side joins to power them are a follow-up.
|
||||
- **Car mode.** Hidden in v1; v1.1 will add a huge-buttons layout
|
||||
variant reached from the utility row.
|
||||
- **Audiobook library page redesign.** Out of scope; current grid
|
||||
stays.
|
||||
- **Persistent player across page navigation as a lifted React
|
||||
context.** The current detail page owns the player state; lifting it
|
||||
to an app-level provider so the bar survives navigating away from
|
||||
the detail page is a follow-up. v1 ships with the bar tied to the
|
||||
detail route, same as today, but visually and structurally ready
|
||||
for that lift.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Three connected surfaces
|
||||
|
||||
```
|
||||
detail page ──opens──► mini bar ──expand──► Now Listening
|
||||
▲ │
|
||||
└─────── collapse ──────┘
|
||||
```
|
||||
|
||||
- **Detail page** (`/audiobooks/book/:id`) is the landing surface.
|
||||
Stays mounted while the player is open.
|
||||
- **Mini bar** is a fixed bottom strip (current behavior) that appears
|
||||
once the user clicks Play/Resume.
|
||||
- **Now Listening** is a full-viewport overlay (z-index above the mini
|
||||
bar, below modals) reached by clicking the cover-art tile or the
|
||||
expand chevron in the mini bar. Dismissed via a collapse chevron.
|
||||
|
||||
The mini ↔ Now Listening transition uses a CSS view transition keyed
|
||||
on the cover art element so the small tile appears to grow into the
|
||||
big cover. The same primitive already used by `ViewTransitionLink`.
|
||||
|
||||
### Shared player primitives
|
||||
|
||||
`web/src/player/components/` already houses the visual building blocks
|
||||
used by the video player. Two of them get extracted so both players
|
||||
consume the same source of truth:
|
||||
|
||||
| File | Status | Purpose |
|
||||
|---|---|---|
|
||||
| `SeekBar.tsx` | reused as-is | already shared |
|
||||
| `ChaptersMenu.tsx` | reused as-is | already shared |
|
||||
| `CircleButton.tsx` | **new** — extracted from `PlayerControls.tsx` | the glass-disc primary/secondary button used in both transport clusters |
|
||||
| `SpeedMenu.tsx` | **new** | popover speed menu matching the styling of `ChaptersMenu`; replaces the raw `<select>` today; video player can adopt later |
|
||||
| `SleepTimerMenu.tsx` | **new** | popover with off / 5 / 15 / 30 / 45 / 60 / end-of-chapter; audiobook-only but lives here for symmetry |
|
||||
|
||||
Extracting `CircleButton` removes a divergence rather than adding a
|
||||
layer — it's the kind of focused improvement that earns its keep
|
||||
because today the video player and the audiobook player each render
|
||||
their own visually-similar-but-not-identical disc buttons.
|
||||
|
||||
### Audiobook player component tree
|
||||
|
||||
```
|
||||
web/src/pages/audiobooks/
|
||||
AudiobookDetail.tsx (restructured per "Detail page" below)
|
||||
AudiobookLibrary.tsx (unchanged)
|
||||
player/ (new folder)
|
||||
AudiobookPlayer.tsx (top level: owns audio element and state)
|
||||
MiniBar.tsx (compact chrome)
|
||||
NowListening.tsx (overlay chrome)
|
||||
CoverExpandTile.tsx (left-edge tile inside MiniBar — also the view-transition anchor)
|
||||
useAudiobookPlayback.ts (state hook: audio events, progress reporting, sleep timer)
|
||||
```
|
||||
|
||||
`AudiobookPlayer` becomes a thin shell:
|
||||
|
||||
```tsx
|
||||
function AudiobookPlayer(props) {
|
||||
const playback = useAudiobookPlayback(props);
|
||||
const [mode, setMode] = useState<"mini" | "now-listening">("mini");
|
||||
return (
|
||||
<>
|
||||
<audio ref={playback.audioRef} src={playback.streamUrl} preload="metadata" hidden />
|
||||
{mode === "mini"
|
||||
? <MiniBar playback={playback} onExpand={() => setMode("now-listening")} onClose={props.onClose} />
|
||||
: <NowListening playback={playback} onCollapse={() => setMode("mini")} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The audio element lives in the parent so a mode swap never touches it.
|
||||
|
||||
### useAudiobookPlayback responsibilities
|
||||
|
||||
Lifts today's monolithic `AudiobookPlayer.tsx` into a single hook
|
||||
returning a stable shape:
|
||||
|
||||
```ts
|
||||
{
|
||||
audioRef, // ref<HTMLAudioElement>
|
||||
streamUrl, // string
|
||||
playing, currentTime, duration, buffered, rate,
|
||||
chapters, // PlayerChapter[] (flattened across files)
|
||||
currentChapter, // PlayerChapter | null
|
||||
sleep, // { remainingMs: number | null, end: SleepEndCondition | null }
|
||||
togglePlay, seekTo, skip,
|
||||
setRate,
|
||||
setSleep, // arms the timer
|
||||
// bookmarks (v1.1 — initially returns []/no-op stubs)
|
||||
bookmarks, addBookmark, removeBookmark,
|
||||
}
|
||||
```
|
||||
|
||||
The hook owns: audio event wiring, periodic progress reporting (the
|
||||
existing 10s `useReportAudiobookProgress` cadence), sleep-timer state
|
||||
+ fade-out, computing `currentChapter` from `currentTime`, and the
|
||||
unmount-time pause + final report. The chrome components stay pure
|
||||
presentational.
|
||||
|
||||
## Mini bar
|
||||
|
||||
Layout (one seek-bar row above a controls row):
|
||||
|
||||
```
|
||||
[ full-width SeekBar with chapter ticks ]
|
||||
[ cover tile | title + chapter + time | ◀30 ▶❚❚ 30▶ | ⌛ ☰ 1× ⌃ ✕ ]
|
||||
↑ left col middle center right rail
|
||||
36×54px truncating shared CircleButton cluster
|
||||
sleep, chapters, speed,
|
||||
expand chevron, close
|
||||
(bookmark hidden in v1)
|
||||
```
|
||||
|
||||
Differences from today's implementation:
|
||||
|
||||
- **CoverExpandTile** added at the left edge. 36×54 (portrait
|
||||
aspect-2:3). On hover: subtle scale + a small "expand" chevron
|
||||
overlay. Clicking it triggers the view transition to Now Listening.
|
||||
- **Left text column** shows title (line 1) and current chapter title
|
||||
(line 2). Time row stays where it is. Today the chapter title isn't
|
||||
shown anywhere while playing — this is the single most useful
|
||||
"where am I" cue and it's missing.
|
||||
- **Center cluster** swaps the bespoke `CircleButton` defined inline
|
||||
in the current `AudiobookPlayer` for the extracted shared one. Same
|
||||
three controls (back 30 / play-pause / forward 30).
|
||||
- **Right rail** replaces the raw `<select>` for speed with
|
||||
`SpeedMenu` (popover, same styling as `ChaptersMenu`). Adds
|
||||
`SleepTimerMenu`, an expand-up chevron (`ChevronUp`), and the
|
||||
existing close `X`. The bookmark button is **hidden in v1** (mini
|
||||
bar has no bookmark control until v1.1 lands).
|
||||
|
||||
Mini bar height is unchanged from today.
|
||||
|
||||
## Now Listening overlay
|
||||
|
||||
Full-viewport overlay, dark surface (`bg-background`). Z-index above
|
||||
the mini bar, below modals.
|
||||
|
||||
Layout on desktop (width ≥ md):
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ ⌄ (collapse, top-left) ⋯ More (top-right) │
|
||||
│ │
|
||||
│ ┌──────────────────────┐ │
|
||||
│ │ │ PROJECT HAIL MARY │
|
||||
│ │ [ COVER ] │ Andy Weir │
|
||||
│ │ ~360 × 540 │ Narrated by Ray Porter │
|
||||
│ │ │ │
|
||||
│ │ │ ── CHAPTER 7 ── │
|
||||
│ │ │ The Astrophage │
|
||||
│ └──────────────────────┘ │
|
||||
│ │
|
||||
│ [ tall SeekBar with chapter ticks ] │
|
||||
│ 12:43 -4:08:35 │
|
||||
│ │
|
||||
│ ◀30 ▶❚❚ (large) 30▶ │
|
||||
│ │
|
||||
│ ⌛ Sleep ☰ Chapters (24) 1× Speed │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Layout on mobile (portrait): same elements stacked top-to-bottom —
|
||||
header row, cover (centered, smaller), metadata block, seek bar,
|
||||
transport, utility row. No horizontal split.
|
||||
|
||||
Key behaviors:
|
||||
|
||||
- **Tap right time** toggles between `-remaining` and the wall-clock
|
||||
end time. The toggle preference is in-memory only (resets per
|
||||
session) — no persistence concern.
|
||||
- **Sleep timer** opens `SleepTimerMenu`. When armed, the trigger
|
||||
shows the countdown (`Sleep 04:32`). When the timer hits zero the
|
||||
player fades volume to 0 over 5s and pauses.
|
||||
- **Car mode** and **bookmark** buttons are **hidden in v1** — the
|
||||
utility row ships with sleep / chapters / speed only. Car mode +
|
||||
bookmark slots are documented here so v1.1 has a clear home for
|
||||
them.
|
||||
- **Overflow ⋯** menu items for v1: "Go to detail page", "Report a
|
||||
sync problem" (opens a mailto/issue link). Future additions live
|
||||
here.
|
||||
|
||||
The same `<audio>` element backs both modes, so all controls in Now
|
||||
Listening are bound to the same `useAudiobookPlayback` state.
|
||||
|
||||
## Detail page restructure
|
||||
|
||||
The page keeps its current top-level shape (hero band + content
|
||||
below) and adds/rearranges these blocks:
|
||||
|
||||
### Hero band
|
||||
|
||||
Today: `DetailHero` with a stack of `Play` / `Play from Start`
|
||||
actions and a small progress bar that appears below the buttons only
|
||||
when progress exists.
|
||||
|
||||
New version (still using `DetailHero` but with a richer `actions`
|
||||
slot):
|
||||
|
||||
```
|
||||
[ progress bar — full width of actions column, always shown if any progress ]
|
||||
[ "3h 12m listened · 19%" caption ]
|
||||
|
||||
▶ Resume Ch 7 · The Astrophage ↻ Start Over 🔖 Bookmarks (3)
|
||||
```
|
||||
|
||||
- Progress bar moves up *above* the buttons so the listener sees
|
||||
position context first.
|
||||
- Resume button label includes the chapter the listener will land
|
||||
in. Pulled from the same `buildChapterList` already in the file.
|
||||
- "Bookmarks (n)" button visible only when n > 0; opens a side sheet
|
||||
listing them. v1 ships with an empty stub (always 0, button never
|
||||
shown) — full feature in v1.1.
|
||||
|
||||
### Chapters section
|
||||
|
||||
Today: collapsed-by-default disclosure showing a chapter list.
|
||||
|
||||
New version:
|
||||
|
||||
- Expanded by default. (Collapsed in this position made sense when
|
||||
the page was sparse; once we're foregrounding chapter awareness
|
||||
everywhere else, hiding the list here is contradictory.)
|
||||
- Currently-playing chapter row gets a left-edge accent and a
|
||||
"▶ playing" badge on the right.
|
||||
- Lightweight sort menu in the section header: "By position" (default),
|
||||
"Longest first" — no UI for "By title" since chapter titles rarely
|
||||
sort meaningfully. Sort preference is in-memory only for v1.
|
||||
- Clicking a chapter row still calls `openPlayer(absoluteStart)` as
|
||||
today; if the player is open, it seeks rather than reopens.
|
||||
|
||||
### Narrator card
|
||||
|
||||
New section between Chapters and the cross-rails. Renders only when
|
||||
`data.narrator` is non-empty.
|
||||
|
||||
```
|
||||
── NARRATOR ──
|
||||
[ avatar 64×64 ] Ray Porter
|
||||
47 audiobooks in your library →
|
||||
```
|
||||
|
||||
Avatar source: if the absorbed audiobook data model exposes a
|
||||
narrator photo, use it; otherwise an initials avatar in a circle.
|
||||
The `→` link goes to a narrator detail page if one exists, or a
|
||||
search-results page filtered by narrator name as a fallback.
|
||||
|
||||
### Cross-book rails
|
||||
|
||||
Three sections, each rendered only when its data is present, ordered
|
||||
top-to-bottom:
|
||||
|
||||
- **"Similar audiobooks"** — embedding-based recommendations. Ranked
|
||||
by vector similarity against this book; ordering and inclusion
|
||||
decided server-side. Renders with a subtitle "Based on listening
|
||||
patterns" to give the rail a small bit of provenance copy that the
|
||||
metadata rails don't need.
|
||||
- **"Also by {author}"** — horizontal scroller of cover tiles, same
|
||||
pattern as `MediaRow` used elsewhere in Silo.
|
||||
- **"In this series"** — same pattern, with "Book n of m" subtitle and
|
||||
the current book non-clickable / highlighted.
|
||||
|
||||
All three rails ship behind feature-detection: if the API response
|
||||
for the book includes the related array, render it; if not, hide the
|
||||
section without showing a placeholder. This avoids blocking v1 on any
|
||||
specific backend rail support — each lights up as the data lands.
|
||||
|
||||
### What's *not* in the new detail page
|
||||
|
||||
- Reviews / ratings — out of scope, not in the data model.
|
||||
- Tabs — page is short enough that one scroll surface beats tabs.
|
||||
- Social / cohost listening — no signal it's wanted.
|
||||
|
||||
## Data & API impact
|
||||
|
||||
Most of the work is pure frontend, but three small backend-adjacent
|
||||
items to call out:
|
||||
|
||||
| Item | Impact |
|
||||
|---|---|
|
||||
| Sleep timer | Frontend-only. No API change. |
|
||||
| Speed menu | Frontend-only — already a client-side audio setting. |
|
||||
| Bookmarks | **Deferred to v1.1.** Needs `audiobook_bookmarks` table (`content_id`, `profile_id`, `position_seconds`, `note`, `created_at`) plus CRUD endpoints. v1 ships with the button hidden and `useAudiobookPlayback` exposing no-op bookmark stubs. |
|
||||
| Narrator card with "n audiobooks in library" count | Needs narrator-aware query. If the detail endpoint already returns it, render; otherwise hide the count line and show only the name. |
|
||||
| "Also by author" / "In this series" rails | Render only when arrays present. v1 doesn't require backend changes — it just adapts to whatever the response contains. |
|
||||
| "Similar audiobooks" rail (embedding-based) | Render only when `similar_audiobooks` array present on the detail response. Backend computes similarity from per-book embeddings server-side and includes the top-N already ranked. v1 frontend has no embedding logic — it just renders whatever ordered list the API returns. |
|
||||
|
||||
## Routing & view-transition wiring
|
||||
|
||||
- `/audiobooks/book/:contentId` continues to be the only audiobook
|
||||
route added by this work. Mini bar and Now Listening are layered
|
||||
inside this route's component tree.
|
||||
- View transition name: `audiobook-cover-{contentId}`. Set on the
|
||||
cover tile inside `CoverExpandTile` and on the large cover element
|
||||
inside `NowListening`. Same `contentId` on both = the browser
|
||||
animates the rect change automatically.
|
||||
- The detail page's hero cover and the mini bar's cover tile share
|
||||
the same view-transition name so the *initial* open (clicking
|
||||
Play/Resume on the hero) also animates the cover into the mini bar
|
||||
position.
|
||||
|
||||
## Accessibility
|
||||
|
||||
- All transport controls have `aria-label`s (already true today; keep
|
||||
parity).
|
||||
- The expand chevron and the cover tile are both labeled "Expand
|
||||
player" / "Open Now Listening" — two ways in, both discoverable to
|
||||
screen readers.
|
||||
- `SleepTimerMenu`, `SpeedMenu`, `ChaptersMenu` use the existing
|
||||
`Popover` primitive with proper roving focus + Escape-to-close
|
||||
(inherits from `ChaptersMenu` behavior).
|
||||
- Now Listening overlay traps focus while open and restores focus to
|
||||
the mini bar's expand chevron on close.
|
||||
- Color contrast for the chapter-title text in the mini bar must
|
||||
meet WCAG AA against the bar's background (use `text-foreground`
|
||||
with a `text-muted-foreground` chapter title is the existing
|
||||
pattern and passes).
|
||||
|
||||
## Testing
|
||||
|
||||
- **Unit:** `useAudiobookPlayback` — chapter computation from current
|
||||
time across multi-file audiobooks, sleep-timer arm/disarm/fire,
|
||||
progress reporting cadence and pause/seek/end triggers.
|
||||
- **Component:** `MiniBar` and `NowListening` render expected
|
||||
elements given a mocked playback object; expand/collapse calls the
|
||||
right callbacks; speed menu / sleep menu open and emit the right
|
||||
values.
|
||||
- **Integration / Playwright** (if the repo has Playwright wired for
|
||||
these flows): start playback from detail page → mini bar appears
|
||||
with chapter title → click cover tile → Now Listening overlay
|
||||
appears → seek bar works in both modes → collapse returns to mini
|
||||
bar with playback uninterrupted (key assertion: `audio.currentTime`
|
||||
monotonically advances across the mode swap).
|
||||
|
||||
## Out of scope (recap)
|
||||
|
||||
- Bookmarks backend (v1.1 sub-spec)
|
||||
- Cross-book rails backend joins
|
||||
- Car mode full layout
|
||||
- Audiobook library grid redesign
|
||||
- Lifting the player to an app-level provider so it survives
|
||||
navigation away from the detail page
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
- **Multi-file audiobooks.** Today's player only plays `files[0]` —
|
||||
multi-file audiobooks display all chapters but can't actually cross
|
||||
file boundaries. This redesign doesn't fix that; it inherits the
|
||||
limitation. Worth flagging in the implementation plan whether to
|
||||
fold a fix into v1 or leave it for a separate ticket.
|
||||
- **View transition browser support.** CSS view transitions are
|
||||
supported in modern Chromium and Safari Tech Preview; Firefox is
|
||||
not there yet. The mini ↔ Now Listening swap must work without
|
||||
the transition (graceful fallback to a plain unmount/mount).
|
||||
- **Sleep-timer fade.** A 5s gain ramp on `HTMLAudioElement.volume`
|
||||
works but is not as smooth as a Web Audio API gain node. Starting
|
||||
simple; revisit if it feels janky.
|
||||
@@ -0,0 +1,262 @@
|
||||
# ABS Bookmarks — Phase 1 Sub-Project 1
|
||||
|
||||
**Status:** Approved 2026-05-26. Ready for implementation plan.
|
||||
**Scope:** First sub-project of Phase 1 (per `2026-05-26-abs-implementation-fix-design.md`).
|
||||
**Predecessor spec:** `docs/superpowers/specs/2026-05-26-abs-implementation-fix-design.md` §"Phase 1 — Feature surface completion" → Bookmarks bullet.
|
||||
|
||||
Commands in this document assume the repository root is the cwd.
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Let an Audiobookshelf mobile client (official Android, official iOS, and well-behaved third parties such as Plappa) create, edit, and delete in-book bookmarks against a silo audiobook library, and see those bookmarks sync in real time to the user's other connected devices.
|
||||
|
||||
## 2. Non-goals
|
||||
|
||||
- No ebook bookmarks (audiobook only — matches ABS's own surface).
|
||||
- No bulk bookmark import / export, no merge-by-title, no fuzzy time matching.
|
||||
- No `/api/me/bookmarks` aggregate-list endpoint. The Android client only calls per-item endpoints; adding the aggregate would be dead code today.
|
||||
- No client UI work — silo's web admin doesn't ship a bookmarks view yet, and the mobile clients already render bookmarks from these endpoints.
|
||||
- No socket-server overhaul; Phase 1 just publishes events through the existing `Handler.publish` wrapper. Phase 2 covers the full socket event surface.
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
Three ABS-compatible REST handlers + one socket event family + one migration.
|
||||
|
||||
- **HTTP layer:** new `internal/audiobooks/abs/bookmarks_handler.go` alongside existing `items_handler.go` / `progress.go`. Handler-method pattern mirrors `handleSetItemProgress`. Routes registered under both `/abs/api` and `/api` prefixes inside the existing bearerAuth group.
|
||||
- **Storage layer:** new `BookmarkStore` interface in `internal/audiobooks/abs/bookmarks.go` (split out rather than crowd `progress.go`) plus a concrete `ABSBookmarkStore` Postgres implementation in `internal/audiobooks/abs_bookmark_store.go` — naming mirrors `abs_playback_session_store.go`. Wiring lives in `internal/audiobooks/service.go`, alongside the other store constructions in `BuildABSHandler`.
|
||||
- **Migration:** `migrations/148_abs_bookmarks.up.sql` + `.down.sql`.
|
||||
- **Socket events:** the existing nil-safe `Handler.publish(userID, event, payload)` wrapper. Each handler fires `user_updated` with a `reason` discriminator on success — three lines per handler. No new event publisher code.
|
||||
|
||||
## 4. Endpoint surface
|
||||
|
||||
All mounted at both `/abs/api/*` and `/api/*` inside the bearerAuth group.
|
||||
|
||||
| Verb | Path | Body | Returns |
|
||||
|---|---|---|---|
|
||||
| POST | `/me/item/{itemId}/bookmark` | `{title, time}` | full item bookmark list |
|
||||
| PATCH | `/me/item/{itemId}/bookmark` | `{title, time}` | full item bookmark list (upserts at `time`) |
|
||||
| DELETE | `/me/item/{itemId}/bookmark/{time}` | — | full item bookmark list (idempotent) |
|
||||
|
||||
All three handlers fire `user_updated` on success with the relevant `reason`:
|
||||
|
||||
| Handler | `reason` value |
|
||||
|---|---|
|
||||
| Create | `bookmark_created` |
|
||||
| Update | `bookmark_updated` |
|
||||
| Delete | `bookmark_deleted` |
|
||||
|
||||
Socket event payload shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"reason": "bookmark_created",
|
||||
"bookmark": { "id": "01K…", "libraryItemId": "126887…", "time": 1234.5, "title": "Cliffhanger", "createdAt": 1779786284823, "updatedAt": 1779786284823 }
|
||||
}
|
||||
```
|
||||
|
||||
For `bookmark_deleted`, `bookmark.title` is the pre-delete title; for `bookmark_created` / `bookmark_updated` it is the freshly-persisted row.
|
||||
|
||||
### 4.1 Wire shape
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "01KSHR…",
|
||||
"libraryItemId": "126887440911695876",
|
||||
"time": 1234.5,
|
||||
"title": "Good cliffhanger",
|
||||
"createdAt": 1779786284823,
|
||||
"updatedAt": 1779786284823
|
||||
}
|
||||
```
|
||||
|
||||
- All six fields always present (never `omitempty`).
|
||||
- `time` is fractional seconds (`double`).
|
||||
- `createdAt` / `updatedAt` are JS-epoch milliseconds (`int64`), matching every other ABS handler's timestamp emission.
|
||||
- Empty title serializes as `"title": ""`.
|
||||
|
||||
### 4.2 Why no GET-list endpoint
|
||||
|
||||
The canonical continuum plugin handler, the Android `BookmarksModal.vue`, booklore-ng's per-item route, and the official ABS server all return the full item bookmark list from the mutating endpoints. A separate `GET /me/item/{id}/bookmark` would be dead code.
|
||||
|
||||
## 5. Data model
|
||||
|
||||
### 5.1 Migration 148_abs_bookmarks
|
||||
|
||||
`migrations/148_abs_bookmarks.up.sql`:
|
||||
|
||||
```sql
|
||||
CREATE TABLE abs_bookmarks (
|
||||
id text PRIMARY KEY,
|
||||
user_id integer NOT NULL,
|
||||
profile_id uuid,
|
||||
library_item_id text NOT NULL,
|
||||
time_seconds double precision NOT NULL,
|
||||
title text NOT NULL DEFAULT '',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX abs_bookmarks_user_profile_item_time_uniq
|
||||
ON abs_bookmarks (
|
||||
user_id,
|
||||
COALESCE(profile_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
library_item_id,
|
||||
time_seconds
|
||||
);
|
||||
|
||||
CREATE INDEX abs_bookmarks_user_item_idx
|
||||
ON abs_bookmarks (user_id, library_item_id);
|
||||
```
|
||||
|
||||
`migrations/148_abs_bookmarks.down.sql`:
|
||||
|
||||
```sql
|
||||
DROP TABLE abs_bookmarks;
|
||||
```
|
||||
|
||||
### 5.2 Schema rationale
|
||||
|
||||
- **`id` text (ULID), not UUID** — matches every other ABS surface ID emitted by silo (`abs_tokens.id`, `abs_playback_sessions.id`). Clients get a consistent shape; ULIDs are also lexicographically sortable by creation time, which simplifies debug output.
|
||||
- **`profile_id` nullable, with COALESCE sentinel in the unique index** — silo's primary-profile convention is `NULL` profile. Postgres treats raw `NULL` as distinct for uniqueness, so the COALESCE-to-fixed-UUID trick collapses NULL to a single bucket per user, which is what makes "one bookmark per timestamp per profile" work.
|
||||
- **Unique on `(user, profile, item, time)`** — encodes the "one bookmark per timestamp" invariant directly, which is what makes `Upsert(...,time,title)` the natural primitive: PATCH at an existing time updates the title; PATCH at a new time creates.
|
||||
- **Secondary index on `(user_id, library_item_id)`** — covers the hot-path `List` query that runs after every mutation. Avoids a sort or full scan when a user has many bookmarks across many items.
|
||||
- **`title` NOT NULL DEFAULT ''** — Android lets users create quick bookmarks without a title; empty string stores cleanly. Avoids null-guard branches in client iteration code.
|
||||
|
||||
### 5.3 Go model
|
||||
|
||||
```go
|
||||
type Bookmark struct {
|
||||
ID string // ULID
|
||||
LibraryItemID string
|
||||
Time float64 // fractional seconds
|
||||
Title string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
```
|
||||
|
||||
Handlers convert to the wire shape (camelCase JSON keys, timestamps as `UnixMilli()`).
|
||||
|
||||
## 6. Storage contract
|
||||
|
||||
```go
|
||||
type BookmarkStore interface {
|
||||
// List returns all bookmarks for (user, profile, item) ordered by time
|
||||
// ASC. Empty slice (never nil) when none exist.
|
||||
List(ctx context.Context, userID, profileID, itemID string) ([]Bookmark, error)
|
||||
|
||||
// Upsert inserts a bookmark or updates the title at the exact
|
||||
// (user, profile, item, time) tuple. ID is generated on insert and
|
||||
// preserved on update. Returns the resulting row.
|
||||
Upsert(ctx context.Context, userID, profileID, itemID string, time float64, title string) (Bookmark, error)
|
||||
|
||||
// Delete removes the bookmark at (user, profile, item, time). Returns
|
||||
// nil when no row matched — DELETE is idempotent (a UX convenience,
|
||||
// not a 404 surface).
|
||||
Delete(ctx context.Context, userID, profileID, itemID string, time float64) error
|
||||
}
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
|
||||
- `Upsert` uses a single `INSERT ... ON CONFLICT (user_id, COALESCE(profile_id,'…'), library_item_id, time_seconds) DO UPDATE SET title = EXCLUDED.title, updated_at = now() RETURNING *` — one round-trip, no read-then-write race.
|
||||
- `List` sort happens in SQL (`ORDER BY time_seconds ASC`), not in Go.
|
||||
- Profile mapping: handlers pass `a.ProfileID` (string, empty = primary) straight through to the store; the store maps empty → NULL on writes and uses the same COALESCE-to-sentinel-UUID trick on reads. Mirrors the pattern already used by `abs_session_store.go` (`*string` round-trip) — pick whichever pgx approach reads cleanest in the impl, just keep the empty-as-primary convention consistent with the rest of `internal/audiobooks/`.
|
||||
- Time precision: exact float64 equality on PATCH/DELETE-at-time. Plappa and the official client always echo back the exact value they received, so epsilon comparison would be complexity nothing exercises.
|
||||
- `Delete` on a non-existent row returns `nil`. The caller still re-fetches the list and returns it, so double-tapped deletes produce consistent state.
|
||||
|
||||
## 7. Error model
|
||||
|
||||
| Condition | Status | Body |
|
||||
|---|---|---|
|
||||
| Missing/invalid bearer | 401 | handled by `bearerAuth` middleware |
|
||||
| Body decode failure (POST/PATCH) | 400 | `invalid body` |
|
||||
| `time` missing or NaN | 400 | `time required` |
|
||||
| `itemId` not in `MediaStore` | 404 | `item not found` |
|
||||
| Store upsert / mutate fails | 500 | sanitized `bookmark persist failed`; err logged via `slog.Error` |
|
||||
| Store delete fails (DB error, not missing row) | 500 | sanitized `bookmark delete failed` |
|
||||
| List fetch fails after a successful mutation | 200 + empty `[]` + `slog.Warn` | mutation already committed; returning empty list beats 500 |
|
||||
|
||||
**Cross-cutting:**
|
||||
|
||||
- **No leaked-existence side channel.** DELETE for a bookmark belonging to another user/profile returns 200 with the caller's list (which doesn't contain the target) — indistinguishable from DELETE for a non-existent bookmark. No 403, no enumeration vector.
|
||||
- **Item validation.** Validate `itemId` via `MediaStore.GetAudiobookByID` before touching the store (same pattern as `handleItem`). Catches typos and deleted items early; avoids orphan bookmark rows whose item no longer exists.
|
||||
- **Body size limit.** Request body wrapped in `io.LimitReader(r.Body, 1<<20)` — same 1 MiB cap as `handleStandaloneLogin`.
|
||||
- **Socket publish never fails the request.** `h.publish(...)` is nil-safe and fire-and-forget; if the Publisher is unwired the response still completes.
|
||||
|
||||
## 8. Testing
|
||||
|
||||
### 8.1 Unit tests — `bookmarks_handler_test.go`
|
||||
|
||||
In-memory fake `BookmarkStore` (mirrors `memTokenStore` / `fakePlaybackSessionStore` pattern already used in this package).
|
||||
|
||||
| Test | Asserts |
|
||||
|---|---|
|
||||
| `Create_NewBookmark_ReturnsListContainingIt` | POST → 200, response is `[{id, time, title, …}]`, list length 1, fields populated |
|
||||
| `Create_TwoAtDifferentTimes_ListOrderedByTime` | POST × 2 (times 100 and 50), list returned `[{time:50},{time:100}]` |
|
||||
| `Upsert_SameTime_UpdatesTitleNoDuplicate` | POST then PATCH at same time with new title → list length 1, title updated, id preserved |
|
||||
| `Delete_ExistingBookmark_RemovedFromList` | POST then DELETE same time → 200, list empty |
|
||||
| `Delete_NonExistentTime_IdempotentReturnsEmptyList` | DELETE without prior POST → 200, empty list |
|
||||
| `Delete_OtherUserBookmark_NoOpAndNoExistenceLeak` | seed bookmark for user B, user A DELETEs same item+time → 200, user B's bookmark untouched |
|
||||
| `ProfileIsolation_BookmarksScopedPerProfile` | (user, profile A) POST → (user, profile B) List returns empty |
|
||||
| `MissingItem_404` | POST against unknown itemId → 404 |
|
||||
| `InvalidBody_400` | POST with malformed JSON → 400, no DB write |
|
||||
| `MissingTime_400` | POST with body `{title:"x"}` (no time) → 400 |
|
||||
| `SocketEvent_FiredOnCreate` | mock `EventPublisher` captures the publish; assert userID + `user_updated` + `reason:"bookmark_created"` |
|
||||
| `SocketEvent_FiredOnUpdate` | same for `bookmark_updated` |
|
||||
| `SocketEvent_FiredOnDelete` | same for `bookmark_deleted` |
|
||||
|
||||
### 8.2 Wire-shape test — `bookmarks_envelope_test.go`
|
||||
|
||||
One marshal test that takes a `Bookmark` (including the empty-title case) and asserts every required JSON key is present and uses the camelCase spelling: `id`, `libraryItemId`, `time`, `title`, `createdAt`, `updatedAt`. Mirrors the existing `TestSiloItemToMetadata_JSONKeysAlwaysPresent` and `TestLoginEnvelope_HasRequiredKeys` patterns.
|
||||
|
||||
### 8.3 Live integration smoke (post-deploy)
|
||||
|
||||
```
|
||||
TOKEN=$(curl -s -X POST -H 'Content-Type: application/json' -H 'x-return-tokens: true' \
|
||||
-d '{"username":"<u>","password":"<p>"}' http://127.0.0.1:13378/login \
|
||||
| python3 -c "import sys,json;print(json.load(sys.stdin)['accessToken'])")
|
||||
|
||||
ITEM=$(curl -s -H "Authorization: Bearer $TOKEN" \
|
||||
http://127.0.0.1:13378/api/libraries/<lid>/items?limit=1 \
|
||||
| python3 -c "import sys,json;print(json.load(sys.stdin)['results'][0]['id'])")
|
||||
|
||||
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||||
-d '{"title":"smoke","time":42.5}' \
|
||||
http://127.0.0.1:13378/api/me/item/$ITEM/bookmark | python3 -m json.tool
|
||||
|
||||
curl -s -X PATCH -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||||
-d '{"title":"updated","time":42.5}' \
|
||||
http://127.0.0.1:13378/api/me/item/$ITEM/bookmark | python3 -m json.tool
|
||||
|
||||
curl -s -X DELETE -H "Authorization: Bearer $TOKEN" \
|
||||
http://127.0.0.1:13378/api/me/item/$ITEM/bookmark/42.5 | python3 -m json.tool
|
||||
```
|
||||
|
||||
Expected: each call returns the updated list; the final DELETE returns `[]`.
|
||||
|
||||
### 8.4 Explicitly out-of-scope
|
||||
|
||||
- No real-Postgres integration test for the SQL. The `ON CONFLICT` clause and unique-index COALESCE behavior are small enough that the in-memory fake covers the semantics; CI does not currently run a Postgres for the ABS subdomain.
|
||||
- No load test. Bookmark mutations are user-driven and rare (handful per session).
|
||||
|
||||
## 9. Risks & open questions
|
||||
|
||||
- **Time-precision collision risk.** If a future client rounds `time` differently between POST and DELETE (e.g. POST sends `42.500001`, DELETE sends `42.5`), the DELETE would silently no-op. Acceptable for the current client set (Android and official iOS echo exactly), but worth a comment on the `Delete` method so future-us doesn't burn time chasing a ghost.
|
||||
- **No migration rollback test.** The `.down.sql` is one `DROP TABLE` — low risk — but the migration suite doesn't currently exercise rolls. Same posture as recent migrations; not a blocker.
|
||||
- **Bookmark fan-out on socket events.** `Handler.publish(userID, ...)` targets the user room. If the user has many connected sockets, the publish fans out per-socket. Volume here is trivial (manual user action). No backpressure concern at Phase 1 scale.
|
||||
|
||||
## 10. Out-of-scope follow-ups
|
||||
|
||||
These are intentionally deferred — they belong to later Phase 1 sub-projects or Phase 2:
|
||||
|
||||
- Aggregate `GET /api/me/bookmarks` (caller's full bookmark inventory). Adds value only when a "Bookmarks" tab lands in a client that needs it.
|
||||
- Hydrating `user.bookmarks` in the `/login` envelope. Currently emits `[]` placeholder; populating costs a join on every login and only matters to clients that read from there at startup.
|
||||
- Embedding `bookmarks` on item-detail (`GET /api/items/{id}`). The Android player fetches its bookmarks via the per-item bookmark endpoint on modal-open, not from item-detail.
|
||||
|
||||
## 11. References
|
||||
|
||||
- Spec parent: `docs/superpowers/specs/2026-05-26-abs-implementation-fix-design.md`.
|
||||
- Wire-shape reference (canonical, working against real ABS Android): the continuum-plugin-audiobooks `bookmarks_handler.go` + the `r.Post/Patch/Delete` mounts in its `handler.go`. Diff against silo before flagging response-shape concerns.
|
||||
- Client wire usage: `audiobookshelf-app/components/modals/BookmarksModal.vue` (the only place the official client builds bookmark requests).
|
||||
- Booklore-ng has its own `me/bookmarks` aggregate endpoint that the official client does not use; ignored here.
|
||||
@@ -0,0 +1,523 @@
|
||||
# ABS Collections + Playlists — Phase 1 Sub-Project 2
|
||||
|
||||
**Status:** Approved 2026-05-26. Ready for implementation plan.
|
||||
**Scope:** Second sub-project of Phase 1 (per `2026-05-26-abs-implementation-fix-design.md`).
|
||||
**Predecessor spec:** `docs/superpowers/specs/2026-05-26-abs-implementation-fix-design.md` §"Phase 1 — Feature surface completion" → Manual Collections + Playlists bullets.
|
||||
**Sibling sub-project:** `docs/superpowers/specs/2026-05-26-abs-bookmarks-design.md` (sub-project 1; the bookmarks data model, in-memory test fakes, store wiring, and error-handling conventions established there are reused here without re-justification).
|
||||
|
||||
Commands in this document assume the repository root is the cwd.
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Land manual user collections and ordered playlists on the silo audiobook surface so the official Audiobookshelf Android, iOS, and Plappa clients can create, browse, mutate, and share named groupings of audiobooks. "Manual collection" means a user-curated unordered set of audiobooks with a name + description (think: "Favorites", "To Read"). "Playlist" means an ordered queue with a cover image (think: "Wind-down listening"). Both are owned by a profile, optionally public to other users on the same silo instance.
|
||||
|
||||
## 2. Non-goals
|
||||
|
||||
- **No podcast episode hydration.** Playlist items accept and echo `episodeId`, but only audiobook items are looked up in `MediaStore`. A future sub-project will wire `internal/audiobooks/podcastfeed` hydration.
|
||||
- **No reorder API.** Clients simulate reorder via remove+add (the re-added item lands at the end).
|
||||
- **No smart collections.** Those are sub-project 3 (separate `query_def` storage + DSL evaluator).
|
||||
- **No silo-web UI.** The web admin doesn't ship a collections/playlists view yet; ABS mobile clients render these.
|
||||
- **No socket-server overhaul.** Playlists publish three existing-pattern events; collections publish none. Full socket parity is Phase 2 of the parent spec.
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
Two REST surfaces sharing a uniform shape, mounted under `/abs/api/*` and `/api/*` inside the existing `bearerAuth` group.
|
||||
|
||||
- **HTTP layer:**
|
||||
- `internal/audiobooks/abs/collections_handler.go` — 7 routes (list, create, get, update, delete, add item, remove item).
|
||||
- `internal/audiobooks/abs/playlists_handler.go` — 9 routes (list, create, get, update, delete, add single item, batch add, batch remove, remove single item; remove-episode variant uses two URL params).
|
||||
- **Storage layer:**
|
||||
- New interfaces in `internal/audiobooks/abs/collections.go` and `playlists.go` (one file each, parallel to `bookmarks.go`):
|
||||
- `CollectionStore` — `ListUserCollections / GetCollection / CreateCollection / UpdateCollection / DeleteCollection / ListCollectionItems / AddCollectionItem / RemoveCollectionItem`.
|
||||
- `PlaylistStore` — `ListUserPlaylists / GetPlaylist / CreatePlaylist / UpdatePlaylist / DeletePlaylist / ListPlaylistItems / AddPlaylistItem / RemovePlaylistItem`.
|
||||
- Concrete pgx impls in `internal/audiobooks/abs_collection_store.go` and `abs_playlist_store.go` (parallel to `abs_bookmark_store.go`).
|
||||
- **Migrations** (paired up/down per `CLAUDE.md`):
|
||||
- `149_abs_user_collections`, `150_abs_collection_items`, `151_abs_playlists`, `152_abs_playlist_items`.
|
||||
- **Socket events:** Playlists publish `playlist_added` / `playlist_updated` / `playlist_removed` (continuum-canonical event names). Collections publish nothing. All publishes go through the existing nil-safe `Handler.publish(...)` wrapper.
|
||||
- **Service wiring:** Both new stores wired in `BuildABSHandler` (mirrors the BookmarkStore wiring landed in sub-project 1).
|
||||
|
||||
## 4. Endpoint surface
|
||||
|
||||
All routes mounted under both `/abs/api/*` and `/api/*` inside `bearerAuth`. Success status code is **200 OK** in every row of the tables below, except where the `Returns` column explicitly says **204 No Content** (DELETE collection / DELETE playlist themselves; the item-mutation DELETEs return 200 with the parent's full-shape body, matching continuum).
|
||||
|
||||
### 4.1 Collections (7 routes)
|
||||
|
||||
| Verb | Path | Body | Returns |
|
||||
|---|---|---|---|
|
||||
| GET | `/collections` | — | `{"collections": [Collection list-shape]}` |
|
||||
| POST | `/collections` | `{name, description, isPublic?}` | Collection full-shape |
|
||||
| GET | `/collections/{id}` | — | Collection full-shape when caller is owner, OR when `is_public=true`; otherwise 404 |
|
||||
| PATCH | `/collections/{id}` | `{name?, description?, isPublic?}` | Collection full-shape |
|
||||
| DELETE | `/collections/{id}` | — | 204 No Content |
|
||||
| POST | `/collections/{id}/book/{bookId}` | — | Collection full-shape (with updated `books[]`) |
|
||||
| DELETE | `/collections/{id}/book/{bookId}` | — | Collection full-shape |
|
||||
|
||||
### 4.2 Playlists (9 routes)
|
||||
|
||||
| Verb | Path | Body | Returns |
|
||||
|---|---|---|---|
|
||||
| GET | `/playlists` | — | `{"playlists": [Playlist list-shape]}` |
|
||||
| POST | `/playlists` | `{name, description, cover_item?, isPublic?}` | Playlist full-shape |
|
||||
| GET | `/playlists/{id}` | — | Playlist full-shape when caller is owner, OR when `is_public=true`; otherwise 404 |
|
||||
| PATCH | `/playlists/{id}` | `{name?, description?, cover_item?, isPublic?}` | Playlist full-shape |
|
||||
| DELETE | `/playlists/{id}` | — | 204 No Content |
|
||||
| POST | `/playlists/{id}/item` | `{libraryItemId, episodeId?}` | Playlist full-shape |
|
||||
| POST | `/playlists/{id}/batch/add` | `{items: [{libraryItemId, episodeId?}]}` | Playlist full-shape |
|
||||
| POST | `/playlists/{id}/batch/remove` | `{items: [{libraryItemId, episodeId?}]}` | Playlist full-shape |
|
||||
| DELETE | `/playlists/{id}/item/{libraryItemId}` | — | Playlist full-shape |
|
||||
| DELETE | `/playlists/{id}/item/{libraryItemId}/{episodeId}` | — | Playlist full-shape |
|
||||
|
||||
### 4.3 Wire shape — Collection
|
||||
|
||||
**List shape (no `books[]`):**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "01HXXX",
|
||||
"userId": "1",
|
||||
"name": "Favorites",
|
||||
"description": "My top picks",
|
||||
"isPublic": false,
|
||||
"lastUpdate": 1779786284823,
|
||||
"createdAt": 1779786284823
|
||||
}
|
||||
```
|
||||
|
||||
**Full shape (with `books[]`):**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "01HXXX",
|
||||
"userId": "1",
|
||||
"name": "Favorites",
|
||||
"description": "My top picks",
|
||||
"isPublic": false,
|
||||
"lastUpdate": 1779786284823,
|
||||
"createdAt": 1779786284823,
|
||||
"books": [
|
||||
{
|
||||
"id": "126887...",
|
||||
"libraryId": "9",
|
||||
"media": { "metadata": { "title": "Book Title", "authors": [...] } }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
All seven top-level keys always present (`description` is `""` when unset, never omitted — fixes the continuum-reference bug where description was always emitted as empty regardless of stored value). `books[]` is always an array (possibly empty) on the full shape; omitted on the list shape.
|
||||
|
||||
### 4.4 Wire shape — Playlist
|
||||
|
||||
**List shape (no `items[]`):**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "01HXXX",
|
||||
"userId": "1",
|
||||
"name": "My Queue",
|
||||
"description": "",
|
||||
"isPublic": false,
|
||||
"coverPath": "126887...",
|
||||
"createdAt": 1779786284823,
|
||||
"lastUpdate": 1779786284823
|
||||
}
|
||||
```
|
||||
|
||||
**Full shape (with `items[]`):**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "01HXXX",
|
||||
"userId": "1",
|
||||
"name": "My Queue",
|
||||
"description": "",
|
||||
"isPublic": false,
|
||||
"coverPath": "126887...",
|
||||
"createdAt": 1779786284823,
|
||||
"lastUpdate": 1779786284823,
|
||||
"items": [
|
||||
{ "libraryItemId": "126887...", "position": 0, "title": "Book Title", "libraryId": "9" },
|
||||
{ "libraryItemId": "podcast-x", "episodeId": "ep-1", "position": 1 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`coverPath` is emitted when set; omitted when unset. `description` always present. Items always sorted by `position` ASC. Entries with `episodeId` set are emitted with the field; audiobook entries omit it. Audiobook items hydrate `title` + `libraryId` via `MediaStore.GetAudiobookByID`; episode items emit the bare reference.
|
||||
|
||||
### 4.5 Hydration semantics
|
||||
|
||||
- `MediaStore.GetAudiobookByID(libraryItemId)` is called per book on the **full** shape only (the list shape skips it to keep response sizes small).
|
||||
- On miss (item deleted between collection/playlist mutation and list-fetch), the entry degrades to `{id, libraryId}` only — clients render a placeholder.
|
||||
- `libraryId` resolution: same `resolveDefaultLibrary` helper from `items_handler.go` (returns numeric library ID as decimal string).
|
||||
|
||||
### 4.6 Socket events
|
||||
|
||||
| Event | When | Payload |
|
||||
|---|---|---|
|
||||
| `playlist_added` | After POST `/playlists` | `{id, name}` |
|
||||
| `playlist_updated` | After PATCH or any item mutation (single or batch) | `{id}` |
|
||||
| `playlist_removed` | After DELETE `/playlists/{id}` | `{id}` |
|
||||
|
||||
Collections fire no socket events (matches continuum; Phase 2 of the parent spec covers full event parity).
|
||||
|
||||
## 5. Data model
|
||||
|
||||
### 5.1 Migration 149 — `abs_user_collections`
|
||||
|
||||
`migrations/149_abs_user_collections.up.sql`:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS public.abs_user_collections (
|
||||
id text PRIMARY KEY,
|
||||
user_id integer NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||
profile_id uuid,
|
||||
name text NOT NULL,
|
||||
description text NOT NULL DEFAULT '',
|
||||
is_public boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS abs_user_collections_user_profile_idx
|
||||
ON public.abs_user_collections (
|
||||
user_id,
|
||||
COALESCE(profile_id, '00000000-0000-0000-0000-000000000000'::uuid)
|
||||
);
|
||||
```
|
||||
|
||||
`migrations/149_abs_user_collections.down.sql`:
|
||||
|
||||
```sql
|
||||
DROP INDEX IF EXISTS public.abs_user_collections_user_profile_idx;
|
||||
DROP TABLE IF EXISTS public.abs_user_collections;
|
||||
```
|
||||
|
||||
### 5.2 Migration 150 — `abs_collection_items`
|
||||
|
||||
`migrations/150_abs_collection_items.up.sql`:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS public.abs_collection_items (
|
||||
collection_id text NOT NULL REFERENCES public.abs_user_collections(id) ON DELETE CASCADE,
|
||||
library_item_id text NOT NULL REFERENCES public.media_items(content_id) ON DELETE CASCADE,
|
||||
added_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (collection_id, library_item_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS abs_collection_items_library_item_idx
|
||||
ON public.abs_collection_items (library_item_id);
|
||||
```
|
||||
|
||||
`migrations/150_abs_collection_items.down.sql`:
|
||||
|
||||
```sql
|
||||
DROP INDEX IF EXISTS public.abs_collection_items_library_item_idx;
|
||||
DROP TABLE IF EXISTS public.abs_collection_items;
|
||||
```
|
||||
|
||||
### 5.3 Migration 151 — `abs_playlists`
|
||||
|
||||
`migrations/151_abs_playlists.up.sql`:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS public.abs_playlists (
|
||||
id text PRIMARY KEY,
|
||||
user_id integer NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||
profile_id uuid,
|
||||
name text NOT NULL,
|
||||
description text NOT NULL DEFAULT '',
|
||||
cover_item text REFERENCES public.media_items(content_id) ON DELETE SET NULL,
|
||||
is_public boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS abs_playlists_user_profile_idx
|
||||
ON public.abs_playlists (
|
||||
user_id,
|
||||
COALESCE(profile_id, '00000000-0000-0000-0000-000000000000'::uuid)
|
||||
);
|
||||
```
|
||||
|
||||
`migrations/151_abs_playlists.down.sql`:
|
||||
|
||||
```sql
|
||||
DROP INDEX IF EXISTS public.abs_playlists_user_profile_idx;
|
||||
DROP TABLE IF EXISTS public.abs_playlists;
|
||||
```
|
||||
|
||||
### 5.4 Migration 152 — `abs_playlist_items`
|
||||
|
||||
`migrations/152_abs_playlist_items.up.sql`:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS public.abs_playlist_items (
|
||||
playlist_id text NOT NULL REFERENCES public.abs_playlists(id) ON DELETE CASCADE,
|
||||
library_item_id text NOT NULL,
|
||||
episode_id text NOT NULL DEFAULT '',
|
||||
position integer NOT NULL,
|
||||
added_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (playlist_id, library_item_id, episode_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS abs_playlist_items_playlist_position_idx
|
||||
ON public.abs_playlist_items (playlist_id, position);
|
||||
```
|
||||
|
||||
`migrations/152_abs_playlist_items.down.sql`:
|
||||
|
||||
```sql
|
||||
DROP INDEX IF EXISTS public.abs_playlist_items_playlist_position_idx;
|
||||
DROP TABLE IF EXISTS public.abs_playlist_items;
|
||||
```
|
||||
|
||||
### 5.5 Schema rationale
|
||||
|
||||
- **`is_public boolean`** — cross-user-public read semantics. `false` default. List endpoints never expose other users' rows; GET-by-id allows non-owner reads only when `is_public = true`.
|
||||
- **Collection items: FK + CASCADE on `library_item_id`** — when a book is deleted from the library, drop it from all collections (clean orphan-free state). Same pattern as migration 143's `abs_playback_sessions.content_id`.
|
||||
- **Collection items: composite PK on `(collection_id, library_item_id)`** — enforces "a book appears at most once in a collection" without a synthetic ID.
|
||||
- **Playlist items: `library_item_id` NOT FK'd** — collections enforce one-shot membership; playlists are ordered queues that may legitimately reference items the user hasn't bookmarked. Decoupling lets a future migration FK it when episode support lands properly. Today: handler validates via MediaStore (same pattern as bookmarks).
|
||||
- **Playlist items: `episode_id text NOT NULL DEFAULT ''`** — empty string for audiobook items, populated for podcast episodes. Empty-string-default lets `(playlist_id, library_item_id, episode_id)` be a clean unique key without COALESCE.
|
||||
- **Playlist items: no synthetic ID** — `(playlist_id, library_item_id, episode_id)` is naturally unique; `position` is sort hint (gaps allowed when items are removed).
|
||||
- **`cover_item REFERENCES media_items(content_id) ON DELETE SET NULL`** — playlist survives cover-item deletion; cover gracefully becomes nothing.
|
||||
|
||||
### 5.6 Go models
|
||||
|
||||
```go
|
||||
type Collection struct {
|
||||
ID string
|
||||
UserID string
|
||||
ProfileID string
|
||||
Name string
|
||||
Description string
|
||||
IsPublic bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type CollectionItem struct {
|
||||
CollectionID string
|
||||
LibraryItemID string
|
||||
AddedAt time.Time
|
||||
}
|
||||
|
||||
type Playlist struct {
|
||||
ID string
|
||||
UserID string
|
||||
ProfileID string
|
||||
Name string
|
||||
Description string
|
||||
CoverItem string // empty when unset
|
||||
IsPublic bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type PlaylistItem struct {
|
||||
PlaylistID string
|
||||
LibraryItemID string
|
||||
EpisodeID string // empty for audiobook items
|
||||
Position int
|
||||
AddedAt time.Time
|
||||
}
|
||||
```
|
||||
|
||||
Handlers convert these to the wire shape (camelCase JSON keys, timestamps as `UnixMilli()`).
|
||||
|
||||
## 6. Storage contract
|
||||
|
||||
### 6.1 `CollectionStore` (in `internal/audiobooks/abs/collections.go`)
|
||||
|
||||
```go
|
||||
type CollectionStore interface {
|
||||
ListUserCollections(ctx context.Context, userID, profileID string) ([]Collection, error)
|
||||
GetCollection(ctx context.Context, id string) (Collection, error)
|
||||
CreateCollection(ctx context.Context, c Collection) error
|
||||
UpdateCollection(ctx context.Context, c Collection) error
|
||||
DeleteCollection(ctx context.Context, id string) error
|
||||
ListCollectionItems(ctx context.Context, collectionID string) ([]CollectionItem, error)
|
||||
AddCollectionItem(ctx context.Context, collectionID, libraryItemID string) error
|
||||
RemoveCollectionItem(ctx context.Context, collectionID, libraryItemID string) error
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 `PlaylistStore` (in `internal/audiobooks/abs/playlists.go`)
|
||||
|
||||
```go
|
||||
type PlaylistStore interface {
|
||||
ListUserPlaylists(ctx context.Context, userID, profileID string) ([]Playlist, error)
|
||||
GetPlaylist(ctx context.Context, id string) (Playlist, error)
|
||||
CreatePlaylist(ctx context.Context, p Playlist) error
|
||||
UpdatePlaylist(ctx context.Context, p Playlist) error
|
||||
DeletePlaylist(ctx context.Context, id string) error
|
||||
ListPlaylistItems(ctx context.Context, playlistID string) ([]PlaylistItem, error)
|
||||
AddPlaylistItem(ctx context.Context, playlistID, libraryItemID, episodeID string) error
|
||||
RemovePlaylistItem(ctx context.Context, playlistID, libraryItemID, episodeID string) error
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 Behavior
|
||||
|
||||
- **List ordering:** Collections list ordered by `created_at DESC`. Playlists list ordered by `created_at DESC`. Playlist items ordered by `position ASC`. Collection items ordered by `added_at ASC`. All sorting happens in SQL, not in Go.
|
||||
- **Empty results:** All List methods return an empty slice (never nil) when no rows match.
|
||||
- **`GetCollection` / `GetPlaylist`:** Return `ErrNotFound` when absent. NO owner check — caller authorizes via the response's `UserID` and `IsPublic`.
|
||||
- **`AddPlaylistItem` position assignment:** Single SQL statement computing `position = COALESCE(MAX(position), 0) + 1 WHERE playlist_id = $1` inside the INSERT. No read-before-write race.
|
||||
- **`AddCollectionItem` / `AddPlaylistItem` idempotency:** `INSERT ... ON CONFLICT (...) DO NOTHING`. Re-adding an existing tuple is a silent no-op (no error, no row mutation, no `updated_at` bump beyond what the handler does separately).
|
||||
- **`RemoveCollectionItem` / `RemovePlaylistItem` idempotency:** Return `nil` on no-match.
|
||||
- **Profile mapping:** Handlers pass `a.ProfileID` straight through (empty = primary). Store uses `profileArg` helper (returns `nil` for empty, the string otherwise). `COALESCE(profile_id, '00000000-...'::uuid)` in WHERE clauses on both column and bind value, mirroring the BookmarkStore convention.
|
||||
- **`updated_at` bump on item mutations:** Both `Add*Item` and `Remove*Item` execute the item mutation and an `UPDATE abs_user_collections SET updated_at = now() WHERE id = $1` (or `abs_playlists`) in the same transaction. Wire field `lastUpdate` reflects this.
|
||||
- **Cascade cleanup:** Deleting a collection drops all its `abs_collection_items` via FK CASCADE. Deleting a playlist drops all its `abs_playlist_items`. Deleting a `media_items` row drops it from all `abs_collection_items` (via FK CASCADE); playlists surface stale references on the next List, where the handler degrades to bare-id entries.
|
||||
|
||||
### 6.4 Cross-user public read
|
||||
|
||||
Handler pattern after `Get*`:
|
||||
|
||||
```go
|
||||
c, err := store.GetCollection(ctx, id)
|
||||
if errors.Is(err, ErrNotFound) || (c.UserID != a.UserID && !c.IsPublic) {
|
||||
http.Error(w, "collection not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
Same pattern for playlists. The condition collapses unknown-id and not-authorized into one branch so existence-leak vectors don't open up.
|
||||
|
||||
## 7. Error model
|
||||
|
||||
| Condition | Status | Body |
|
||||
|---|---|---|
|
||||
| Missing/invalid bearer | 401 | handled by `bearerAuth` middleware |
|
||||
| Body decode failure (POST/PATCH) | 400 | `invalid body` |
|
||||
| Required field missing (POST: `name`; POST item: `libraryItemId`) | 400 | `<field> required` |
|
||||
| Unknown collection/playlist on GET (owner or not) | 404 | `collection not found` / `playlist not found` |
|
||||
| Non-owner GET on private collection/playlist | 404 | same body — no existence leak |
|
||||
| Non-owner PATCH/DELETE/item-mutation | 404 | same body |
|
||||
| `library_item_id` not in MediaStore (audiobook add-item) | 404 | `item not found` |
|
||||
| Store insert/update/delete fails | 500 | sanitized `<collection\|playlist> persist failed`; err logged via `slog.Error` |
|
||||
| List fetch fails after a successful mutation | 200 + best-effort response (in-memory state) + `slog.Warn` | mutation already committed; falling back beats 500 |
|
||||
|
||||
### 7.1 Cross-cutting
|
||||
|
||||
- **Anti-enumeration.** All non-owner-or-private paths funnel to the same `<type> not found` 404 (never 403, never "private collection"). Indistinguishable from real not-found.
|
||||
- **Item validation on add.** `handleAddCollectionBook` and `handleAddPlaylistItem` call `MediaStore.GetAudiobookByID(libraryItemID)` before touching the store. Catches typos and orphan refs early. **Exception:** `handleAddPlaylistItem` with non-empty `episodeId` SKIPS item validation (audiobook-only hydration scope; podcast episodes are stored opaque-id-style).
|
||||
- **Body size limit.** All POST/PATCH wrap `r.Body` in `io.LimitReader(r.Body, 1<<20)` before `json.NewDecoder`. Same as bookmarks.
|
||||
- **Batch endpoints.** `POST /playlists/{id}/batch/add` and `/batch/remove` tolerate per-item failures silently (matching continuum's `_ = h.store.Add...`). Total failure (e.g., body decode error) is still a 400. Item validation on the batch path: each item validated individually; failed validations skipped with `slog.Debug` (no 404 — the operation as a whole succeeds with the remaining items).
|
||||
- **Socket event on batch mutation.** A batch add/remove fires exactly one `playlist_updated` event, regardless of how many items succeeded or failed. Clients re-render from the response.
|
||||
- **Socket publish never fails the request.** `h.publish(...)` is nil-safe and fire-and-forget; if the Publisher is unwired the response still completes.
|
||||
|
||||
## 8. Testing
|
||||
|
||||
### 8.1 Unit tests — `collections_handler_test.go`
|
||||
|
||||
In-memory fake `memCollectionStore` (parallel to `memBookmarkStore`). Reuses `stubMediaStore` and `recordingPublisher` from `bookmarks_handler_test.go`. The `dispatchBookmark` helper is generalized in-place (or a sibling `dispatchABS` is added) so the same wiring (URL params + `ctxAuth` injection) drives both new surfaces.
|
||||
|
||||
| Test | Asserts |
|
||||
|---|---|
|
||||
| `Collection_Create_ReturnsFullShape` | POST `{name:"x"}` → 200, response carries all 7 top-level keys + empty `books[]`, ID is a ULID |
|
||||
| `Collection_Create_NameRequired_400` | POST `{description:"only"}` → 400 |
|
||||
| `Collection_List_ReturnsWrappedEnvelope` | GET → 200, body is `{"collections":[…]}`, list-shape omits `books` |
|
||||
| `Collection_List_DoesNotLeakOtherUsers` | User 1 creates; User 2 GETs → empty list |
|
||||
| `Collection_Get_Owner_ReturnsFullShape` | POST → owner GET → 200 + `books[]` |
|
||||
| `Collection_Get_NonOwner_Public_OK` | User 1 creates with `isPublic:true`; User 2 GET → 200 |
|
||||
| `Collection_Get_NonOwner_Private_404` | User 1 creates (default private); User 2 GET → 404 |
|
||||
| `Collection_Patch_OwnerUpdatesNameAndDescription` | POST → PATCH `{name:"y", description:"d"}` → 200, fields updated, `lastUpdate` advanced |
|
||||
| `Collection_Patch_NonOwner_404` | User 2 PATCH on User 1's collection → 404 (no existence leak) |
|
||||
| `Collection_Delete_OwnerRemovesItAndItems` | POST + add book → DELETE → 204; subsequent GET → 404; items table empty |
|
||||
| `Collection_Delete_NonOwner_404` | User 2 DELETE → 404, User 1's collection still present |
|
||||
| `Collection_AddBook_Owner_HydratesInResponse` | POST + add book → 200 + `books[]` contains the entry with `media.metadata.title` |
|
||||
| `Collection_AddBook_Idempotent` | Add same book twice → 200 both times, `books[]` length stays at 1 |
|
||||
| `Collection_AddBook_UnknownItem_404` | Add a non-existent item → 404 `item not found` |
|
||||
| `Collection_RemoveBook_Idempotent` | Remove book not in collection → 200, `books[]` unchanged |
|
||||
| `Collection_ProfileIsolation` | Profile A creates; Profile B's list returns empty |
|
||||
| `Collection_Envelope_HasRequiredKeys` | Marshal-test: 7 top-level keys present even when description and books are empty |
|
||||
|
||||
### 8.2 Unit tests — `playlists_handler_test.go`
|
||||
|
||||
In-memory fake `memPlaylistStore`.
|
||||
|
||||
| Test | Asserts |
|
||||
|---|---|
|
||||
| `Playlist_Create_ReturnsFullShape` | POST `{name:"x", description:"d", isPublic:true}` → 200, all 8 keys present, ID is ULID |
|
||||
| `Playlist_Create_NameRequired_400` | POST `{description:"only"}` → 400 |
|
||||
| `Playlist_Create_FiresPlaylistAddedEvent` | POST → `recordingPublisher` captures `playlist_added` with `{id, name}` |
|
||||
| `Playlist_List_WrappedEnvelope` | GET → 200, body `{"playlists":[…]}`, list-shape omits `items` |
|
||||
| `Playlist_AddItem_AppendsAtNextPosition` | POST + add 3 items → response `items[]` positions are 1,2,3 in insertion order |
|
||||
| `Playlist_AddItem_Idempotent` | Add same `(libraryItemId, episodeId)` twice → list length 1 |
|
||||
| `Playlist_AddItem_AudiobookHydrates` | Add audiobook item → response item has `title` |
|
||||
| `Playlist_AddItem_Episode_AcceptsAndEchoes` | Add `{libraryItemId, episodeId:"ep-1"}` → response item has `episodeId`, no `title` (un-hydrated by design) |
|
||||
| `Playlist_AddItem_UnknownAudiobook_404` | Add non-existent audiobook → 404; episode adds skip validation, so episode-only adds succeed |
|
||||
| `Playlist_BatchAdd_TolerantOfPartialFailures` | Batch of `[valid, invalid, valid]` → 200, response has 2 items, no 404 |
|
||||
| `Playlist_BatchAdd_FiresOneUpdatedEvent` | Batch add → single `playlist_updated` event (not one per item) |
|
||||
| `Playlist_BatchRemove` | Seed 3 items, batch-remove 2 → response items[] length 1 |
|
||||
| `Playlist_RemoveItem_Single` | DELETE /item/{libraryItemId} → 200, item gone |
|
||||
| `Playlist_RemoveItem_WithEpisode` | DELETE /item/{libraryItemId}/{episodeId} → 200, episode-keyed item removed; audiobook-keyed item with same libraryItemId still present |
|
||||
| `Playlist_Patch_UpdatesCover` | PATCH `{cover_item:"id"}` → 200, `coverPath` updated; `playlist_updated` event fires |
|
||||
| `Playlist_Delete_FiresPlaylistRemovedEvent` | DELETE → 204 + `playlist_removed` event |
|
||||
| `Playlist_Get_NonOwner_Public_OK` | User 1 creates `isPublic:true`; User 2 GET → 200 |
|
||||
| `Playlist_Get_NonOwner_Private_404` | Private playlist; non-owner GET → 404 |
|
||||
| `Playlist_NonOwner_Mutation_404` | User 2 add-item / remove-item / PATCH / DELETE → 404 each, User 1's playlist intact |
|
||||
| `Playlist_ProfileIsolation` | Profile A creates; Profile B's list returns empty |
|
||||
| `Playlist_Envelope_HasRequiredKeys` | Marshal-test: 8 top-level keys present; `coverPath` omitted when empty |
|
||||
|
||||
### 8.3 Shared envelope tests
|
||||
|
||||
One marshal test per surface (`collections_envelope_test.go`, `playlists_envelope_test.go`) asserting the wire shape end-to-end. Parallel to `bookmarks_envelope_test.go`.
|
||||
|
||||
### 8.4 Live integration smoke (post-deploy, operator)
|
||||
|
||||
```bash
|
||||
TOKEN=$(curl ... /login | jq -r .accessToken)
|
||||
ITEM=$(curl ... /api/libraries/9/items?limit=1 | jq -r .results[0].id)
|
||||
|
||||
# Collections
|
||||
COLL=$(curl -X POST -d '{"name":"smoke"}' ... /api/collections | jq -r .id)
|
||||
curl -X POST ... /api/collections/$COLL/book/$ITEM
|
||||
curl ... /api/collections/$COLL # books[] contains $ITEM
|
||||
curl -X DELETE ... /api/collections/$COLL/book/$ITEM
|
||||
curl -X DELETE ... /api/collections/$COLL # 204
|
||||
|
||||
# Playlists
|
||||
PL=$(curl -X POST -d '{"name":"queue"}' ... /api/playlists | jq -r .id)
|
||||
curl -X POST -d "{\"libraryItemId\":\"$ITEM\"}" ... /api/playlists/$PL/item
|
||||
curl ... /api/playlists/$PL # items[] contains $ITEM at position 1
|
||||
curl -X DELETE ... /api/playlists/$PL/item/$ITEM
|
||||
curl -X DELETE ... /api/playlists/$PL # 204
|
||||
```
|
||||
|
||||
Expected: each call returns the wire shape from §4; `playlist_added` / `_updated` / `_removed` events appear on the user's Socket.io channel.
|
||||
|
||||
### 8.5 Explicitly out of scope
|
||||
|
||||
- **No real-Postgres SQL test.** Same posture as bookmarks (§8.4 of that spec): in-memory fakes cover the semantics; CI does not run Postgres for the ABS subdomain.
|
||||
- **No episode hydration.** Episode IDs accepted and echoed but not resolved to podcast metadata. A future podcast-playlist sub-project will plug in `podcastfeed` hydration.
|
||||
- **No reorder API.** Future follow-up.
|
||||
- **No two-user smoke.** Cross-user-public visibility is unit-tested but the operator smoke uses one user.
|
||||
|
||||
## 9. Risks & open questions
|
||||
|
||||
- **Position gaps after remove.** Removing the middle item from a playlist leaves a `position` gap (1, 3, 4 instead of 1, 2, 3). Clients sort by position; gaps are harmless. If a future reorder API lands, it'll compact positions in a single statement.
|
||||
- **Cross-user public + profile scoping interaction.** A public collection owned by `(user A, profile primary)` is visible to user B regardless of B's active profile. The collection's `userId` field discloses A's user identity. Acceptable for v1; if A wanted to hide who owns the collection, that's a separate feature.
|
||||
- **No migration rollback test.** Down migrations are `DROP INDEX + DROP TABLE` — low risk, same posture as bookmarks. Not a blocker.
|
||||
- **`cover_item` references a `media_items.content_id` but the playlist owner may not have access to that library** — silo's library visibility isn't enforced at the schema level. The cover image hydrator (when added) will need to check visibility. For now, the field round-trips opaquely.
|
||||
- **Concurrent `AddPlaylistItem` position collision.** Two parallel appends compute `MAX(position)+1` concurrently and may both produce the same value. The UNIQUE constraint on `(playlist_id, library_item_id, episode_id)` prevents collisions for the SAME item, but distinct items could land at the same position. Acceptable — clients tolerate equal positions and break ties by insertion order. A SERIALIZABLE transaction would close the race; not warranted for a low-traffic UX surface.
|
||||
|
||||
## 10. Out-of-scope follow-ups
|
||||
|
||||
These are intentionally deferred — they belong to later Phase 1 sub-projects or Phase 2:
|
||||
|
||||
- **Smart collections** — sub-project 3 (`abs_smart_collections` + `query_def` JSONB + DSL evaluator).
|
||||
- **RSS feeds** — sub-project 4 (`abs_rss_feeds`).
|
||||
- **Listening stats** — sub-project 4 (aggregations on `abs_playback_sessions`).
|
||||
- **Author / series detail endpoints** — small sub-project, separate.
|
||||
- **Continue-listening toggles** — small sub-project, separate.
|
||||
- **Reorder API for playlists** — compaction + drag-reorder UX. Probably warrants its own design pass.
|
||||
- **Cover-image hydration** — currently the wire emits `coverPath: <content_id>` opaquely. A future enhancement resolves it through `DetailService.PresignURL` to a fully-qualified cover URL.
|
||||
- **Episode hydration in playlists** — when the podcast-playlist sub-project lands.
|
||||
- **Collection socket events** — Phase 2 of the parent spec.
|
||||
|
||||
## 11. References
|
||||
|
||||
- Spec parent: `docs/superpowers/specs/2026-05-26-abs-implementation-fix-design.md`.
|
||||
- Sibling sub-project spec: `docs/superpowers/specs/2026-05-26-abs-bookmarks-design.md`.
|
||||
- Wire-shape reference: `continuum-plugin-audiobooks/internal/abs/collections_handler.go` and `playlists_handler.go` (canonical, diff against silo's adaptation before flagging response-shape concerns).
|
||||
- Client wire usage: `audiobookshelf-app/components/...` Collections and Playlists modal/page components.
|
||||
@@ -0,0 +1,384 @@
|
||||
# ABS Implementation Fix — Design
|
||||
|
||||
**Status:** Approved (brainstorming complete, awaiting writing-plans handoff)
|
||||
**Date:** 2026-05-26
|
||||
**Scope:** Bring silo-server's Audiobookshelf-compatible API to full parity with the canonical `continuum-plugin-audiobooks` implementation so that official ABS iOS, Android, and 3rd-party (Plappa, AudioBookShelfFully) clients work end-to-end against silo.
|
||||
**Out of scope:** silo's native audiobook surface (`/api/v1/audiobooks/*`), silo-android/silo-apple/silo-plugin-sdk repos, Continuum's plugin-host RPC layer, the "standalone listener" port (silo has no separate process), cover transcoding pipelines, transcribed-audio search.
|
||||
|
||||
Commands assume the repository root is the cwd.
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem
|
||||
|
||||
silo's ABS-compat layer at `internal/audiobooks/abs/` exposes ~20% of the surface that the canonical Continuum plugin implements (`continuum-plugin-audiobooks` reference). Real ABS mobile clients cannot complete the basic flow today — login is reported broken, and even when it succeeds many subsequent calls (filterdata, resume position, author/series IDs) return data shapes that break the client. Bookmarks, collections, playlists, smart collections, RSS feeds, author/series detail, and listening stats are entirely absent. Socket.io publishes only 3 of the ~30 events real clients subscribe to.
|
||||
|
||||
The user's directive: every feature of ABS clients must function. Source of truth for behavior is the Continuum plugin; the booklore-ng reference docs (`BOOKLORE_ABS_IMPLEMENTATION_ISSUES.md`, `src/lib/socket/events.ts`) document specific response-shape bugs and the canonical socket event list.
|
||||
|
||||
## 2. Goals & Non-goals
|
||||
|
||||
### Goals
|
||||
- Official ABS iOS app, ABS Android app, and 3rd-party clients (Plappa, AudioBookShelfFully) work end-to-end: add server → login → browse libraries → play → progress sync → bookmark → collection → playlist.
|
||||
- Token lifecycle is complete: login, refresh, logout, revocation, multi-device tracking.
|
||||
- Socket.io publishes the full event surface real clients subscribe to.
|
||||
- New data lives in ABS-scoped tables that don't entangle silo's existing collection system.
|
||||
|
||||
### Non-goals
|
||||
- silo's native audiobook UI changes.
|
||||
- silo-android / silo-apple client changes.
|
||||
- Importing real audiobook content via ABS-protocol requests (silo has its own `internal/requests/` system that is not being bridged here).
|
||||
- Watch-together, podcast download queues, server-side backup event streams.
|
||||
|
||||
## 3. Architecture & Strategy
|
||||
|
||||
**Source of truth:** `continuum-plugin-audiobooks` (canonical). Port file-by-file, adapt to silo's data model (catalog repos, `auth.Service`, profile model).
|
||||
|
||||
**Topology:** silo is monolithic; the ABS layer reads/writes the local DB directly. The Continuum plugin's `HostClient → backend plugin RPC` layer has no analog and is dropped.
|
||||
|
||||
**Package layout:** keep silo's existing `internal/audiobooks/{abs,abssocket,podcastfeed}` — it already mirrors the Continuum plugin's structure.
|
||||
|
||||
**Listener:** continue using the dedicated `:13378` ABS-compat HTTP server (configured in `internal/config/db_loader.go` as `audiobookshelf_compat.listen`). Routes mounted on a fresh chi router; no SPA fallback collisions.
|
||||
|
||||
**Data isolation:** new ABS surface uses dedicated tables (`abs_bookmarks`, `abs_user_collections`, `abs_collection_items`, `abs_playlists`, `abs_playlist_items`, `abs_smart_collections`, `abs_rss_feeds`). silo's existing `library_collections` / `user_personal_collections` / `user_smart_collections` / collection groups are untouched. Future bridging is possible but out of scope here.
|
||||
|
||||
## 4. Phasing
|
||||
|
||||
Four phases, each independently mergeable and independently verifiable against a real mobile client.
|
||||
|
||||
### Phase 0 — Login + critical bug fixes
|
||||
|
||||
**Goal:** iOS/Android app can `add server → login → browse library → tap book → play` end-to-end. No bookmarks/collections/etc. yet.
|
||||
|
||||
**Concrete changes:**
|
||||
|
||||
| File:line | Change |
|
||||
|---|---|
|
||||
| `internal/audiobooks/abs/login.go:195-234` | Add to login envelope: `user.itemTagsAccessible: []`, `user.itemTagsSelected: []`, `user.lastSeen: <epoch ms>`, `user.createdAt: <epoch ms>`. Enrich `serverSettings` with `coverAspectRatio`, `dateFormat`, `timeFormat`, `storeCoverWithItem`, `scannerDisableWatcher`, `metadataFileFormat`, `chromecastEnabled`. Mirror Continuum's `completeLogin` (`continuum-plugin-audiobooks/internal/abs/handler.go:591-697`) verbatim. |
|
||||
| `internal/audiobooks/abs/login.go:274-315` (`handleABSAuthorize`) | Return the identical envelope as `/login`, including `accessToken` and `refreshToken`. Currently omits them, which breaks resume-on-launch. |
|
||||
| `internal/audiobooks/abs/handler.go:353-391` (bearerAuth) | Audit JTI lookup against `abs_session_store.go:GetTokenByJTI` to confirm the freshly-minted JTI is found. Add `slog.Debug` lines at each rejection branch so the next 401 is traceable. Confirm `?token=` query-param fallback parses correctly (iOS AVPlayer requirement). |
|
||||
| `internal/audiobooks/abs/libraries_handler.go:499-549` (`siloItemToMetadata`) | Include `id` on every `authors[]` entry (use `item_people.id` UUID). Include `id` on every `series[]` entry (slugified name until a series table lands). Populate `genres` and `tags` from real catalog data instead of empty arrays. Cross-reference `booklore-ng/BOOKLORE_ABS_IMPLEMENTATION_ISSUES.md` lines 9-100. |
|
||||
| `internal/audiobooks/abs/play_response.go:119` | Replace hardcoded `currentTime: 0` with `ProgressStore.GetItemProgress(userID, profileID, libraryItemID)` lookup; emit the persisted `currentTime` and `progress` fields. |
|
||||
| `internal/audiobooks/abs/libraries_handler.go:56-66` | When `?include=filterdata`, hydrate `authors`, `series`, `narrators`, `genres`, `languages`, `tags` aggregations so iOS filter UI populates. |
|
||||
| `internal/audiobooks/abs/login.go` (new handler) | Add `POST /auth/refresh`. Validate refresh token type, check JTI not revoked, mint new access+refresh pair, persist new JTIs. Port from `continuum-plugin-audiobooks/internal/abs/handler.go:handleRefresh`. |
|
||||
| `internal/audiobooks/abs/login.go` (new handler) | Add `POST /logout`. Marks caller's JTIs revoked in `abs_sessions`. Port from Continuum's `handleLogout`. |
|
||||
| `internal/audiobooks/abs/handler.go:225` (`mountRoutes`) | Mount the new `/auth/refresh` and `/logout` routes (the latter inside `bearerAuth`). |
|
||||
|
||||
**Deliverable:** silo build where official ABS iOS app smoke test passes the core flow.
|
||||
|
||||
**Size:** ~600-800 lines across ~6 files + 2 new endpoints.
|
||||
|
||||
### Phase 1 — Feature surface completion
|
||||
|
||||
**Goal:** Bookmarks, manual collections, playlists, smart collections, RSS feeds, author/series detail, and listening stats functional in mobile clients.
|
||||
|
||||
**Endpoints to add** (all under `bearerAuth` unless noted; mounted at both `/abs/api/*` and `/api/*`):
|
||||
|
||||
**Bookmarks** (port `continuum-plugin-audiobooks/internal/abs/bookmarks_handler.go`)
|
||||
- `POST /api/me/item/{itemId}/bookmark` — body `{title, time}` → array of all bookmarks for the item. Fires socket `user_updated` (`reason: "bookmark_created"`).
|
||||
- `PATCH /api/me/item/{itemId}/bookmark` — upsert at time position. Fires `user_updated` (`reason: "bookmark_updated"`).
|
||||
- `DELETE /api/me/item/{itemId}/bookmark/{time}` — fires `user_updated` (`reason: "bookmark_deleted"`).
|
||||
|
||||
**Manual Collections** (port `collections_handler.go`)
|
||||
- `GET /api/collections` — list with embedded `libraryItems[]`.
|
||||
- `POST /api/collections` — `{name, description}` → single collection.
|
||||
- `GET/PATCH/DELETE /api/collections/{id}`.
|
||||
- `POST /api/collections/{id}/book/{bookId}` — add item.
|
||||
- `DELETE /api/collections/{id}/book/{bookId}` — remove item.
|
||||
|
||||
**Playlists** (port `playlists_handler.go`)
|
||||
- `GET /api/playlists` → `{playlists: [...]}`.
|
||||
- `POST /api/playlists` — `{name, description, cover_item, is_public}`.
|
||||
- `GET/PATCH/DELETE /api/playlists/{id}`.
|
||||
- `POST /api/playlists/{id}/item` — `{libraryItemId, episodeId?}`.
|
||||
- `POST /api/playlists/{id}/batch/add` — `{items: [...]}`.
|
||||
- `POST /api/playlists/{id}/batch/remove`.
|
||||
- `DELETE /api/playlists/{id}/item/{libraryItemId}[/{episodeId}]`.
|
||||
|
||||
**Smart Collections** (port `smart_collection_handler.go` + the entire `internal/smartcoll/` DSL package; adapt SQL to silo's `media_items` schema)
|
||||
- `GET /api/me/smart-collections` → `{items: [...]}`.
|
||||
- `POST /api/me/smart-collections` — `{name, description, color, is_public, is_pinned, query_def}`.
|
||||
- `GET /api/me/smart-collections/{id}`.
|
||||
- `GET /api/me/smart-collections/{id}/items` — evaluates `query_def` against the catalog.
|
||||
- `PATCH/DELETE /api/me/smart-collections/{id}`.
|
||||
|
||||
**Author detail**
|
||||
- `GET /api/authors/{id}` — single author with `books[]`.
|
||||
- `GET /api/authors/{id}/image` — wire to `item_people.poster_path` via `DetailService.PresignURL` (currently 404s).
|
||||
|
||||
**Series detail**
|
||||
- `GET /api/series/{id}` — single series with `books[]` sorted by `series_sequence`.
|
||||
|
||||
**RSS Feeds** (port `rss_feed_handler.go` + `internal/podcastfeed/`)
|
||||
- `POST /api/feeds` — `{itemId, slug, minified}` → `{success, feed: {id, slug, url, ...}}`.
|
||||
- `GET /api/feeds` — caller's feeds.
|
||||
- `DELETE /api/feeds/{id}`.
|
||||
- `GET /abs/public/feed/{slug}` — public RSS XML (no auth, slug is capability token).
|
||||
- `GET /abs/public/feed/{slug}/cover`.
|
||||
- `GET /abs/public/feed/{slug}/item/{itemId}/{fileIdx}`.
|
||||
|
||||
**Listening stats** (port `internal/abs/handler.go:handleListeningStats` + `handleListeningSessions`)
|
||||
- `GET /api/me/listening-stats` → `{totalTime, items, days, dayOfWeek, monthly}` from `abs_playback_sessions` aggregation.
|
||||
- `GET /api/me/listening-sessions?limit=...` — paginated session history.
|
||||
- `GET /api/me/listening-sessions/{sid}` — single session detail.
|
||||
|
||||
**Continue-listening toggles** (port `continue_listening.go`)
|
||||
- `GET /api/me/progress/{itemId}/remove-from-continue-listening`.
|
||||
- `GET /api/me/progress/{itemId}/readd-to-continue-listening`.
|
||||
- Adds `hide_from_continue boolean` column to `user_watch_progress` (the table the ABS layer writes via `abs_progress_store.go`).
|
||||
|
||||
**Migrations** (numbered after silo's current max, paired up/down per `CLAUDE.md`):
|
||||
- `148_abs_bookmarks` — `(id ULID PK, user_id, profile_id, library_item_id, time_seconds, title, created_at, updated_at)` + unique `(user_id, profile_id, library_item_id, time_seconds)`.
|
||||
- `149_abs_user_collections` + `150_abs_collection_items`.
|
||||
- `151_abs_playlists` + `152_abs_playlist_items`.
|
||||
- `153_abs_smart_collections` — includes `query_def jsonb`.
|
||||
- `154_abs_rss_feeds` — slug is unique capability token.
|
||||
- `155_abs_progress_hide_from_continue` — adds `hide_from_continue boolean default false`.
|
||||
|
||||
**Deliverable:** ABS iOS/Android Library, Collections, Playlists, Bookmarks, RSS, Stats tabs all functional.
|
||||
|
||||
**Size:** ~3000-4000 lines across ~15 new files + 7 migration pairs. Splittable into 4 sub-commits (bookmarks, collections+playlists, smart collections, RSS+stats).
|
||||
|
||||
### Phase 2 — Socket.io full event parity
|
||||
|
||||
**Goal:** A logged-in mobile client sees real-time updates when another device on the same account changes progress, when a server scan adds items, when collections/playlists are mutated remotely, and when an admin posts notifications.
|
||||
|
||||
**Event surface** (union of Continuum + booklore-ng — what real ABS iOS source subscribes to):
|
||||
|
||||
**Server → Client** (~30 events):
|
||||
- Lifecycle: `init`, `auth_failed`, `user_online`, `user_offline`, `listener_count`.
|
||||
- Progress/session: `user_item_progress_updated`, `user_session_open`, `user_session_updated`, `user_session_closed`, `user_stream_update`, `user_stream_end`.
|
||||
- User-scoped: `user_updated` (bookmark reasons).
|
||||
- Library: `library_added`, `library_updated`, `library_removed`.
|
||||
- Items: `item_added`, `item_updated`, `item_removed`, `items_added`.
|
||||
- People/series: `author_added`, `author_updated`, `author_removed`, `series_added`, `series_updated`, `series_removed`.
|
||||
- Collections/playlists: `collection_added/updated/removed`, `playlist_added/updated/removed`.
|
||||
- RSS: `rss_feed_open`, `rss_feed_closed`.
|
||||
- Tasks: `scan_start`, `scan_progress`, `scan_complete`, `task_started`, `task_progress`, `task_finished`.
|
||||
- Misc: `notification`.
|
||||
|
||||
**Client → Server** (10 events):
|
||||
- `auth` (exists), `ping`, `join_library`, `leave_library`, `playback_start`, `playback_sync`, `playback_end`, `stream_open`, `stream_close`, `sync_progress`.
|
||||
|
||||
**Architecture changes to `internal/audiobooks/abssocket/`:**
|
||||
- Add room types: existing `user:<userID>`, new `lib:<libraryID>` (joined via `join_library`), new `admin:*`.
|
||||
- New file `abssocket/publisher.go` exposing `Publisher` interface (`PublishUser`, `PublishLibrary`, `Broadcast`). Replaces ad-hoc `h.publish/h.broadcast` helpers on the Handler struct (currently silent no-ops when `SocketIO == nil`). Tests pass a recording Publisher.
|
||||
|
||||
**Cross-package event-source hooks** (via a new `audiobooks.EventBroadcaster` interface in `api.Dependencies` so the consumer packages don't import `abssocket` directly):
|
||||
- `internal/scanner/` — emit `scan_*`, `item_*`, `items_added` on audiobook-library scan operations.
|
||||
- `internal/taskmanager/` — emit `task_*` for audiobook-scoped tasks.
|
||||
- `internal/api/handlers/libraries.go` (or wherever library CRUD lives) — emit `library_*`.
|
||||
|
||||
**Auth/lifecycle improvements:**
|
||||
- Richer `init` payload: `{userId, connectedAt, libraries[], itemTagsAccessible[]}`.
|
||||
- Heartbeat: respond to `ping` with `pong`.
|
||||
- On disconnect, decrement listener count and broadcast `user_offline` if this was the user's last device.
|
||||
- Reconnect replay (last 30s of user-scoped events) is out of scope for v1; leave a hook.
|
||||
|
||||
**Migrations:** none — sockets are stateless except for in-memory connection registry.
|
||||
|
||||
**Size:** ~1500-2000 lines: ~600 in `abssocket/`, ~400 publisher wiring across audiobook handlers, ~500 cross-package hooks.
|
||||
|
||||
### Phase 3 — Hardening
|
||||
|
||||
**Media token signing layer** (port `continuum-plugin-audiobooks/internal/mediatoken/`):
|
||||
- Separate HS256 secret (`audiobooks.abs.media_signing_secret`), 15-min TTL, claims bound to `(user_id, profile_id, book_id, file_idx)`.
|
||||
- `file_handler.go` mints when building stream URLs; validates on file request.
|
||||
- Defense-in-depth: a leaked stream URL grants access to ONE file for 15 minutes instead of full account.
|
||||
- Secret stored in `server_settings`, auto-generated on first read.
|
||||
|
||||
**Device tracking** (currently `abs_sessions.device_id = JTI`, useless for UI):
|
||||
- Parse `User-Agent` on `/login` → derive `device_name`, `client_name`, `client_version`, store on `abs_sessions` insert.
|
||||
- `GET /api/me/sessions` — list user's sessions (so they see "iPhone 15 Pro — Sep 1").
|
||||
- `DELETE /api/me/sessions/{jti}` — selective revocation.
|
||||
|
||||
**Audit log:**
|
||||
- New table `abs_audit_log`: `(id, user_id, action, ip, user_agent, metadata jsonb, created_at)`.
|
||||
- Logged events: login success/failure, logout, token refresh, session revoke, smart-collection-rule changes.
|
||||
- Optional `GET /api/admin/audit-log` for ops visibility.
|
||||
|
||||
**Rate limiting beyond login:**
|
||||
- Per-user limit on `POST /me/progress` (1/sec — clients sometimes spam this).
|
||||
- Per-user limit on `POST /auth/refresh` (5/hour — prevent JTI accumulation).
|
||||
|
||||
**Size:** ~800-1200 lines + 2 migration pairs.
|
||||
|
||||
## 5. Data Model
|
||||
|
||||
### New tables (all in Phase 1 unless noted)
|
||||
|
||||
```
|
||||
abs_bookmarks (
|
||||
id text PRIMARY KEY, -- ULID
|
||||
user_id integer NOT NULL,
|
||||
profile_id uuid, -- nullable: primary profile
|
||||
library_item_id text NOT NULL,
|
||||
time_seconds double precision NOT NULL,
|
||||
title text,
|
||||
created_at timestamptz DEFAULT now(),
|
||||
updated_at timestamptz DEFAULT now(),
|
||||
UNIQUE (user_id, profile_id, library_item_id, time_seconds)
|
||||
)
|
||||
|
||||
abs_user_collections (
|
||||
id text PRIMARY KEY, -- ULID
|
||||
user_id integer NOT NULL,
|
||||
profile_id uuid,
|
||||
name text NOT NULL,
|
||||
description text,
|
||||
is_public boolean DEFAULT false,
|
||||
created_at timestamptz DEFAULT now(),
|
||||
updated_at timestamptz DEFAULT now()
|
||||
)
|
||||
|
||||
abs_collection_items (
|
||||
collection_id text NOT NULL REFERENCES abs_user_collections(id) ON DELETE CASCADE,
|
||||
library_item_id text NOT NULL,
|
||||
added_at timestamptz DEFAULT now(),
|
||||
PRIMARY KEY (collection_id, library_item_id)
|
||||
)
|
||||
|
||||
abs_playlists (
|
||||
id text PRIMARY KEY,
|
||||
user_id integer NOT NULL,
|
||||
profile_id uuid,
|
||||
name text NOT NULL,
|
||||
description text,
|
||||
cover_item text, -- library_item_id for cover
|
||||
is_public boolean DEFAULT false,
|
||||
created_at timestamptz DEFAULT now(),
|
||||
updated_at timestamptz DEFAULT now()
|
||||
)
|
||||
|
||||
abs_playlist_items (
|
||||
playlist_id text NOT NULL REFERENCES abs_playlists(id) ON DELETE CASCADE,
|
||||
position integer NOT NULL,
|
||||
library_item_id text NOT NULL,
|
||||
episode_id text, -- nullable: book item vs podcast episode
|
||||
added_at timestamptz DEFAULT now(),
|
||||
PRIMARY KEY (playlist_id, position)
|
||||
)
|
||||
|
||||
abs_smart_collections (
|
||||
id text PRIMARY KEY,
|
||||
user_id integer NOT NULL,
|
||||
profile_id uuid,
|
||||
name text NOT NULL,
|
||||
description text,
|
||||
color text,
|
||||
is_public boolean DEFAULT false,
|
||||
is_pinned boolean DEFAULT false,
|
||||
query_def jsonb NOT NULL,
|
||||
created_at timestamptz DEFAULT now(),
|
||||
updated_at timestamptz DEFAULT now()
|
||||
)
|
||||
|
||||
abs_rss_feeds (
|
||||
id text PRIMARY KEY,
|
||||
user_id integer NOT NULL,
|
||||
slug text UNIQUE NOT NULL, -- capability token in URL
|
||||
item_id text NOT NULL,
|
||||
minified boolean DEFAULT false,
|
||||
created_at timestamptz DEFAULT now()
|
||||
)
|
||||
|
||||
-- Phase 3
|
||||
abs_audit_log (
|
||||
id bigserial PRIMARY KEY,
|
||||
user_id integer,
|
||||
action text NOT NULL,
|
||||
ip inet,
|
||||
user_agent text,
|
||||
metadata jsonb,
|
||||
created_at timestamptz DEFAULT now()
|
||||
)
|
||||
```
|
||||
|
||||
### Modifications to existing tables
|
||||
|
||||
```
|
||||
-- Phase 1: hide-from-continue toggle
|
||||
ALTER TABLE user_watch_progress ADD COLUMN hide_from_continue boolean DEFAULT false;
|
||||
|
||||
-- Phase 3: real device tracking
|
||||
ALTER TABLE abs_sessions
|
||||
ALTER COLUMN device_id DROP DEFAULT,
|
||||
ADD COLUMN parsed_user_agent text;
|
||||
```
|
||||
|
||||
## 6. Testing Strategy
|
||||
|
||||
**Unit tests** (Go, in-package):
|
||||
- Table-driven per handler in `internal/audiobooks/abs/`: happy path, missing field, wrong field type, IDOR (other user's session/bookmark/collection).
|
||||
- `abssocket/` `Publisher` recording mock — verify events fire on the right operations with the right payload shape.
|
||||
- Smart collection DSL evaluator — port Continuum's `smartcoll_test.go`.
|
||||
|
||||
**Integration tests** (against real Postgres + Redis via silo's `internal/audiobooks/testutil` harness):
|
||||
- Full login → browse → play → progress → close-session flow.
|
||||
- Token refresh — old token rejected, new token accepted.
|
||||
- Bookmark CRUD round-trip with socket event capture.
|
||||
- Smart collection rule evaluation against seeded catalog.
|
||||
|
||||
**End-to-end (manual)**:
|
||||
- iOS official ABS app: add server → login → browse → play offline downloads → progress sync across devices.
|
||||
- Plappa: same flow. Strictest client for response shape; if Plappa works, everything works.
|
||||
- Android official: same.
|
||||
|
||||
**Migration safety:**
|
||||
- Each migration includes `.down.sql`.
|
||||
- New helper `scripts/test-abs-migrations.sh` runs up → down → up against a fresh DB.
|
||||
|
||||
## 7. Rollout
|
||||
|
||||
Single-shot per phase, no human review gate. **Each phase gets its own implementation plan** (separate `writing-plans` cycle) since a combined plan would be unwieldy at this size. Start with Phase 0; subsequent phases planned only after the prior phase merges and verifies.
|
||||
|
||||
| Phase | PR | Verify |
|
||||
|---|---|---|
|
||||
| Phase 0 | One PR | `make build` → restart silo → curl `/ping` → iOS app smoke test |
|
||||
| Phase 1 | One PR (or 4 sub-PRs if reviewable size matters) | iOS app: Bookmarks, Collections, Playlists, Stats tabs functional |
|
||||
| Phase 2 | One PR | Two devices on same account: progress sync visible in real time |
|
||||
| Phase 3 | One PR | `/api/me/sessions` shows real device names; leaked stream URL expires |
|
||||
|
||||
**Cross-repo coordination:** silo-android and silo-apple are NOT touched. The ABS-compat surface targets OFFICIAL ABS clients; silo's own clients use silo's native API.
|
||||
|
||||
**Rollback:** every migration has a working `.down.sql`. New tables don't break existing functionality; silo's native audiobook API (`/api/v1/audiobooks/*`) is untouched.
|
||||
|
||||
## 8. Open Follow-ups (Explicitly Out of Scope)
|
||||
|
||||
- ABS server-side import requests (`/api/me/request`) — Continuum had this; silo has its own `internal/requests/` system. Bridging is a future decision.
|
||||
- Podcast episode-download queue events (`episode_download_*`) — only relevant if silo gains podcast download capability beyond RSS refresh.
|
||||
- Backup events — silo's backup story is separate.
|
||||
- Watch-Together over the ABS socket — silo has `internal/watchtogether/`, deferred.
|
||||
- Reconnect replay buffer for socket events — Phase 2 leaves a hook but does not implement.
|
||||
|
||||
## 9. References
|
||||
|
||||
- Source-of-truth implementation: `continuum-plugin-audiobooks` (sibling worktree). Key files: `internal/abs/handler.go`, `bookmarks_handler.go`, `collections_handler.go`, `playlists_handler.go`, `smart_collection_handler.go`, `rss_feed_handler.go`, `continue_listening.go`, `abssocket/server.go`, `mediatoken/`.
|
||||
- Bug catalog for response shape: `booklore-ng/BOOKLORE_ABS_IMPLEMENTATION_ISSUES.md`.
|
||||
- Canonical socket event list: `booklore-ng/src/lib/socket/events.ts`.
|
||||
- ABS API documentation: `booklore-ng/AUDIOBOOKSHELF_API_DOCUMENTATION.md`.
|
||||
|
||||
## 10. Phase 0 — Status
|
||||
|
||||
**Implemented:** 2026-05-26. Plan: `docs/superpowers/plans/2026-05-26-abs-phase-0-login-and-critical-fixes.md`. All 11 plan tasks committed on `feat/audiobooks` and deployed to the production silo container. Endpoint smoke tests confirm correct mount + status codes for `/ping`, `/login`, `/auth/refresh`, `/logout`, `/me`, `/authorize` at both root and `/api`/`/abs/api` prefixes. End-to-end iOS/Plappa app validation pending operator hand-off.
|
||||
|
||||
**Phase 0 commits (13 total — implementation + review-feedback fixes):**
|
||||
|
||||
| # | SHA | Subject |
|
||||
|---|-----|---------|
|
||||
| 1 | `6c9c721` | feat(audiobooks): diagnostic logging to ABS bearer auth and login |
|
||||
| 1+ | `d5edb24` | chore(audiobooks): normalize slog "err" key + add path to secret-fetch log |
|
||||
| 2 | `877120f` | fix(audiobooks): enrich ABS login envelope |
|
||||
| 2+ | `47c04f1` | chore(audiobooks): reuse existing now var in login envelope |
|
||||
| 3 | `045984c` | fix(audiobooks): /authorize returns identical envelope to /login |
|
||||
| 3+ | `0306cfe` | docs(audiobooks): restore x-return-tokens and displayName fallback comments |
|
||||
| 4 | `e163858` | fix(audiobooks): emit IDs on authors/series and stable genres/tags arrays |
|
||||
| 4+ | `470a483` | fix(audiobooks): proper pagination total + tags key in play session |
|
||||
| 5 | `7f600b2` | fix(audiobooks): hydrate filterdata authors and series in library detail |
|
||||
| 5+ | `2e2b411` | chore(audiobooks): rename const cap to fetchCap in buildFilterData |
|
||||
| 6 | `7e956f1` | fix(audiobooks): seed currentTime from ProgressStore so resume works |
|
||||
| 7 | `93e321f` | feat(audiobooks): POST /auth/refresh for ABS token rotation |
|
||||
| 8 | `278f815` | feat(audiobooks): POST /logout for ABS sign-out |
|
||||
|
||||
**Tests added:** 15 (6 metadata + 4 resume + 5 refresh + 4 logout — note 4 logout overlaps with refresh's memTokenStore). `go test ./internal/audiobooks/... -count=1` all pass.
|
||||
|
||||
**Next:** Phase 1 (bookmarks, collections, playlists, smart collections, RSS, author/series detail, listening stats) will start with its own brainstorming → writing-plans cycle after operator confirms Phase 0 works against real clients.
|
||||
@@ -0,0 +1,344 @@
|
||||
# ABS Phase 1 Close-out — Sub-Project 4
|
||||
|
||||
**Status:** Approved 2026-05-26. Ready for implementation plan.
|
||||
**Scope:** Final sub-project of Phase 1 (per `2026-05-26-abs-implementation-fix-design.md`). Closes out the four remaining feature surfaces in one combined spec/plan to ship in a single push.
|
||||
**Predecessor specs:** bookmarks, collections+playlists, smart collections. Established conventions (anti-enumeration 404, `io.LimitReader(1<<20)`, `errors.Is(err, pgx.ErrNoRows)`, profile-scoped + cross-user-public, 200-on-POST, in-package test fakes, `dispatchABSWithParams`) carry over without re-justification.
|
||||
|
||||
Commands assume the repository root is the cwd.
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Land the four remaining Phase 1 surfaces:
|
||||
- **Listening stats** — totalTime / days / dayOfWeek / monthly aggregations + paginated session history + per-session detail.
|
||||
- **Author detail** — `GET /authors/{id}` and `GET /authors/{id}/image` (the latter currently 404s; wire to people poster image).
|
||||
- **Series detail** — `GET /series/{id}` with embedded books sorted by series_sequence.
|
||||
- **Continue-listening toggles** — `/me/progress/{itemId}/remove-from-continue-listening` + `/readd-to-continue-listening`, backed by a new `hide_from_continue` column on `user_watch_progress`.
|
||||
- **RSS feeds** — minimal viable surface: open a feed for a library item, list caller's feeds, close a feed, and serve the public RSS XML to podcast clients. Series/collection feed variants deferred.
|
||||
|
||||
## 2. Non-goals
|
||||
|
||||
- **No socket events.** Continue-listening toggles, stats, author/series, and RSS feed mutations fire no events (Phase 2 covers full event parity).
|
||||
- **No series/collection RSS feeds.** Only single-item feeds in v1. The continuum `/api/feeds/series/{id}/open` and `/api/feeds/collection/{id}/open` variants are explicitly deferred.
|
||||
- **No RSS cover / per-track endpoints.** `GET /feed/{slug}/cover` and `GET /feed/{slug}/item/{itemId}/{idx}` are deferred — podcast clients can fetch covers from the enclosure URL embedded in the RSS XML.
|
||||
- **No stable series IDs.** silo stores series denormalised on `audiobook_series.content_id` (one row per book); a "series" is identified by `LOWER(series_name)`. The detail handler accepts URL-decoded series names; URL slugs use the lowercased name. Continuum's stable series-row schema is out of scope.
|
||||
- **No author write surface.** Author detail is read-only.
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
Five surfaces, sharing the established structure. Three new HTTP handler files; one new migration; minimal extensions to existing stores; one new pgx store for RSS.
|
||||
|
||||
- **HTTP layer (`internal/audiobooks/abs/`):**
|
||||
- `listening_stats_handler.go` — three handlers (stats / list / detail).
|
||||
- `author_series_handler.go` — three handlers (author / author-image / series).
|
||||
- `continue_listening_handler.go` — two handlers.
|
||||
- `rss_feeds_handler.go` — four authenticated handlers + one public XML handler.
|
||||
- **Store extensions:**
|
||||
- `ABSPlaybackSessionStore.AggregateStats(ctx, userID, profileID) (Stats, error)` — single SQL with grouped aggregates.
|
||||
- `ABSPlaybackSessionStore.ListClosedSessions(ctx, userID, profileID, limit, offset) ([]ABSPlaybackSession, int, error)` — paginated history.
|
||||
- `ProgressStore.SetHideFromContinue(ctx, userID, profileID, contentID string, hide bool) error` — toggle column.
|
||||
- `MediaStore.GetAuthorByID(ctx, authorID string) (Author, error)` — author detail + books list.
|
||||
- `MediaStore.GetSeriesByName(ctx, seriesName string) (Series, error)` — series detail + books list ordered by series_index.
|
||||
- **New store interface + concrete:**
|
||||
- `RSSFeedStore` interface (CRUD + lookup-by-slug) in `internal/audiobooks/abs/rss_feeds.go`.
|
||||
- `ABSRSSFeedStore` (pgx) in `internal/audiobooks/abs_rss_feed_store.go`.
|
||||
- **Migration:**
|
||||
- `154_user_watch_progress_hide_from_continue.up.sql` — `ALTER TABLE user_watch_progress ADD COLUMN hide_from_continue boolean NOT NULL DEFAULT false`.
|
||||
- `155_abs_rss_feeds.up.sql` — feed rows (id, user_id, profile_id, library_item_id, slug, minified, created_at, closed_at).
|
||||
- **Service wiring + route registration:** new fields on `abs.Dependencies` + new routes in `mountRoutes` (auth-gated group plus a new unauth public group for `/feed/{slug}.xml`).
|
||||
|
||||
## 4. Endpoint surface
|
||||
|
||||
All authenticated routes mount under both `/abs/api/*` and `/api/*` inside the existing `bearerAuth` group. Success status is **200 OK** except where the `Returns` column says otherwise.
|
||||
|
||||
### 4.1 Listening stats
|
||||
|
||||
| Verb | Path | Returns |
|
||||
|---|---|---|
|
||||
| GET | `/me/listening-stats` | `{totalTime, items, days, dayOfWeek, monthly}` aggregated from `abs_playback_sessions` |
|
||||
| GET | `/me/listening-sessions` (?limit=, ?page=) | `pagedEnvelope` of session rows (most-recent-first) |
|
||||
| GET | `/me/listening-sessions/{sid}` | single session detail (404 when not found or not owned) |
|
||||
|
||||
Stats wire shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"totalTime": 36000,
|
||||
"items": 12,
|
||||
"days": [{"date": "2026-05-26", "seconds": 1800}, ...],
|
||||
"dayOfWeek": {"0": 1200, "1": 3600, ..., "6": 900},
|
||||
"monthly": [{"month": "2026-05", "seconds": 18000}, ...]
|
||||
}
|
||||
```
|
||||
|
||||
Session row shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "01HSESS...",
|
||||
"libraryItemId": "126887...",
|
||||
"userId": "1",
|
||||
"startedAt": 1779786284823,
|
||||
"lastUpdate": 1779786884823,
|
||||
"duration": 1500,
|
||||
"currentTime": 1234.5,
|
||||
"timeListening": 600
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Author + Series detail
|
||||
|
||||
| Verb | Path | Returns |
|
||||
|---|---|---|
|
||||
| GET | `/authors/{id}` | `{id, name, numBooks, books: [LibraryItem]}` |
|
||||
| GET | `/authors/{id}/image` | redirect to PresignURL for `item_people.poster_path` when set; otherwise 404 |
|
||||
| GET | `/series/{id}` | `{id, name, numBooks, books: [LibraryItem]}` ordered by series_index ASC |
|
||||
|
||||
`{id}` for authors is the stringified `people.id`. `{id}` for series is the URL-decoded series name (case-insensitive lookup). The author-image route was already mounted unauthenticated in handler.go but currently returns 404 — this spec wires the body.
|
||||
|
||||
### 4.3 Continue-listening toggles
|
||||
|
||||
| Verb | Path | Returns |
|
||||
|---|---|---|
|
||||
| GET | `/me/progress/{itemId}/remove-from-continue-listening` | 200 `{ok: true}` after setting `hide_from_continue = true` |
|
||||
| GET | `/me/progress/{itemId}/readd-to-continue-listening` | 200 `{ok: true}` after setting `hide_from_continue = false` |
|
||||
|
||||
Both endpoints are idempotent. Item validation via `MediaStore.GetAudiobookByID` (404 on unknown item). The existing `MediaStore.ListContinueListening` SQL is updated to add `AND uwp.hide_from_continue = false` to the WHERE clause.
|
||||
|
||||
### 4.4 RSS feeds
|
||||
|
||||
| Verb | Path | Auth | Body | Returns |
|
||||
|---|---|---|---|---|
|
||||
| GET | `/api/feeds` | bearer | — | `{feeds: [Feed]}` for caller (owner + profile scope, only open feeds) |
|
||||
| POST | `/api/feeds/item/{itemId}/open` | bearer | `{slug?, minified?}` | created Feed (full shape) |
|
||||
| POST | `/api/feeds/{id}/close` | bearer | — | 204 (sets closed_at) |
|
||||
| GET | `/feed/{slug}.xml` | none | — | RSS 2.0 XML; 404 on unknown / closed |
|
||||
| GET | `/feed/{slug}` | none | — | same as above (some podcast clients omit the `.xml`) |
|
||||
|
||||
Feed wire shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "01HFEED...",
|
||||
"userId": "1",
|
||||
"libraryItemId": "126887...",
|
||||
"slug": "abc-randomsluggenerated",
|
||||
"minified": false,
|
||||
"createdAt": 1779786284823,
|
||||
"url": "https://silo.example.com/feed/abc-randomsluggenerated.xml"
|
||||
}
|
||||
```
|
||||
|
||||
`slug` is a 16-char URL-safe random string when caller omits it from the body. Manual slugs accepted as long as they match `^[a-z0-9-]{4,64}$`. Slugs are globally unique (UNIQUE index).
|
||||
|
||||
RSS XML uses standard RSS 2.0 + iTunes namespace; each `<item>` is one media_file (chapter) of the underlying audiobook. URLs are absolute (`absBaseURL` + `/public/session/...` style — but RSS feeds need a stable public path, so v1 uses a new public file route below). For v1 simplicity, the `<enclosure url>` points at the existing `/abs/public/session/{sid}/track/{idx}` style URL — but feeds don't have an associated session. So we use a new public route mounted in tandem:
|
||||
|
||||
**Additional public RSS support route:**
|
||||
|
||||
| Verb | Path | Auth | Returns |
|
||||
|---|---|---|---|
|
||||
| GET | `/feed/{slug}/file/{ino}` | none | streams the media file for the feed's library_item_id (404 if ino doesn't belong to the item) |
|
||||
|
||||
This is a feed-scoped public file route — slug is the capability token, so no Bearer needed. Mirrors the bookmarked-session pattern from sub-project 1.
|
||||
|
||||
## 5. Data model
|
||||
|
||||
### 5.1 Migration 154 — `hide_from_continue` column
|
||||
|
||||
`migrations/154_user_watch_progress_hide_from_continue.up.sql`:
|
||||
|
||||
```sql
|
||||
ALTER TABLE public.user_watch_progress
|
||||
ADD COLUMN IF NOT EXISTS hide_from_continue boolean NOT NULL DEFAULT false;
|
||||
```
|
||||
|
||||
`migrations/154_user_watch_progress_hide_from_continue.down.sql`:
|
||||
|
||||
```sql
|
||||
ALTER TABLE public.user_watch_progress
|
||||
DROP COLUMN IF EXISTS hide_from_continue;
|
||||
```
|
||||
|
||||
### 5.2 Migration 155 — `abs_rss_feeds`
|
||||
|
||||
`migrations/155_abs_rss_feeds.up.sql`:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS public.abs_rss_feeds (
|
||||
id text PRIMARY KEY,
|
||||
user_id integer NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||
profile_id uuid,
|
||||
library_item_id text NOT NULL REFERENCES public.media_items(content_id) ON DELETE CASCADE,
|
||||
slug text NOT NULL,
|
||||
minified boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
closed_at timestamptz
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS abs_rss_feeds_slug_uniq
|
||||
ON public.abs_rss_feeds (slug);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS abs_rss_feeds_user_profile_idx
|
||||
ON public.abs_rss_feeds (
|
||||
user_id,
|
||||
COALESCE(profile_id, '00000000-0000-0000-0000-000000000000'::uuid)
|
||||
);
|
||||
```
|
||||
|
||||
`migrations/155_abs_rss_feeds.down.sql`:
|
||||
|
||||
```sql
|
||||
DROP INDEX IF EXISTS public.abs_rss_feeds_user_profile_idx;
|
||||
DROP INDEX IF EXISTS public.abs_rss_feeds_slug_uniq;
|
||||
DROP TABLE IF EXISTS public.abs_rss_feeds;
|
||||
```
|
||||
|
||||
### 5.3 Go models
|
||||
|
||||
```go
|
||||
// Stats aggregates per spec §4.1.
|
||||
type Stats struct {
|
||||
TotalTime int // seconds
|
||||
Items int // distinct content_ids listened to
|
||||
Days []DayStat // last 30 days
|
||||
DayOfWeek [7]int // index 0=Sunday
|
||||
Monthly []MonthStat // last 12 months
|
||||
}
|
||||
type DayStat struct{ Date string; Seconds int } // "2026-05-26"
|
||||
type MonthStat struct{ Month string; Seconds int } // "2026-05"
|
||||
|
||||
// Author is the detail-shape row.
|
||||
type Author struct {
|
||||
ID string
|
||||
Name string
|
||||
PosterPath string // empty when unset; resolved via CoverResolver on emit
|
||||
Books []*models.MediaItem
|
||||
}
|
||||
|
||||
// Series is the detail-shape row.
|
||||
type Series struct {
|
||||
ID string // lowercased series_name
|
||||
Name string // canonical series_name
|
||||
Books []*models.MediaItem // ordered by series_index ASC
|
||||
}
|
||||
|
||||
// RSSFeed mirrors an abs_rss_feeds row.
|
||||
type RSSFeed struct {
|
||||
ID string
|
||||
UserID string
|
||||
ProfileID string
|
||||
LibraryItemID string
|
||||
Slug string
|
||||
Minified bool
|
||||
CreatedAt time.Time
|
||||
ClosedAt *time.Time
|
||||
}
|
||||
```
|
||||
|
||||
## 6. Storage contracts
|
||||
|
||||
```go
|
||||
// Extensions to existing interfaces:
|
||||
|
||||
// On ABSPlaybackSessionStore (existing):
|
||||
AggregateStats(ctx context.Context, userID, profileID string) (Stats, error)
|
||||
ListClosedSessions(ctx context.Context, userID, profileID string, limit, offset int) ([]ABSPlaybackSession, int, error)
|
||||
|
||||
// On ProgressStore (existing):
|
||||
SetHideFromContinue(ctx context.Context, userID, profileID, contentID string, hide bool) error
|
||||
|
||||
// On MediaStore (existing):
|
||||
GetAuthorByID(ctx context.Context, authorID string) (Author, error)
|
||||
GetSeriesByName(ctx context.Context, seriesName string) (Series, error)
|
||||
|
||||
// New store interface — RSSFeedStore (defined in internal/audiobooks/abs/rss_feeds.go):
|
||||
type RSSFeedStore interface {
|
||||
ListUserFeeds(ctx context.Context, userID, profileID string) ([]RSSFeed, error)
|
||||
GetFeedBySlug(ctx context.Context, slug string) (RSSFeed, error)
|
||||
GetFeed(ctx context.Context, id string) (RSSFeed, error)
|
||||
CreateFeed(ctx context.Context, f RSSFeed) error
|
||||
CloseFeed(ctx context.Context, id string) error
|
||||
}
|
||||
```
|
||||
|
||||
Behavior:
|
||||
- `AggregateStats` runs one SQL with three CTE subqueries (days / day_of_week / monthly aggregates) plus a `SUM(time_listening_seconds)` and `COUNT(DISTINCT content_id)`.
|
||||
- `SetHideFromContinue` writes `UPDATE user_watch_progress SET hide_from_continue = $4 WHERE user_id = $1 AND profile_id = $2 AND media_item_id = $3`. Returns `nil` even when no row matched (idempotent — no-progress means nothing to hide; client can readd later).
|
||||
- `GetAuthorByID` parses `authorID` as int (`strconv.Atoi`), then `SELECT name FROM people WHERE id = $1` for the author row + `SELECT mi.* FROM item_people ip JOIN media_items mi ON mi.content_id = ip.content_id WHERE ip.person_id = $1 AND ip.kind = 7 AND mi.type = 'audiobook' ORDER BY mi.title`. Returns `ErrNotFound` when the people row is missing.
|
||||
- `GetSeriesByName` does case-insensitive lookup on `audiobook_series.series_name`: `SELECT DISTINCT series_name FROM audiobook_series WHERE LOWER(series_name) = LOWER($1) LIMIT 1` → name; then `SELECT mi.* FROM audiobook_series s JOIN media_items mi ON mi.content_id = s.content_id WHERE LOWER(s.series_name) = LOWER($1) AND mi.type = 'audiobook' ORDER BY s.series_index NULLS LAST`. Returns `ErrNotFound` when no rows.
|
||||
- `RSSFeedStore.CreateFeed` inserts with the slug as-is; UNIQUE constraint on slug surfaces as a 409 client-side. `GetFeedBySlug` only returns rows where `closed_at IS NULL` (closed feeds 404 to podcast clients). `CloseFeed` sets `closed_at = now()` — idempotent on already-closed rows.
|
||||
|
||||
## 7. Error model
|
||||
|
||||
| Condition | Status | Body |
|
||||
|---|---|---|
|
||||
| 401 missing/invalid bearer | 401 | bearerAuth |
|
||||
| Body decode (POST `/feeds/.../open`) | 400 | `invalid body` |
|
||||
| Slug fails `^[a-z0-9-]{4,64}$` | 400 | `invalid slug` |
|
||||
| Item not found on author/series/feed POST | 404 | `<type> not found` |
|
||||
| Author/Series unknown ID | 404 | `<author\|series> not found` |
|
||||
| RSS slug collision on POST | 409 | `slug taken` |
|
||||
| Non-owner DELETE/close feed | 404 | `feed not found` (anti-enumeration) |
|
||||
| Public `/feed/{slug}` unknown or closed slug | 404 | `feed not found` (plain text body — podcast clients ignore it) |
|
||||
| Store mutate fails | 500 | sanitized; err logged |
|
||||
|
||||
Cross-cutting:
|
||||
- Same `io.LimitReader(1<<20)` on POST/PATCH bodies.
|
||||
- Continue-listening toggles are idempotent and return 200 even on no-row-matched.
|
||||
- The public RSS routes (`/feed/{slug}.xml`, `/feed/{slug}`, `/feed/{slug}/file/{ino}`) mount OUTSIDE the bearerAuth group — they're capability-gated by the slug. Mount alongside the existing public-session routes in `mountRoutes`.
|
||||
|
||||
## 8. Testing
|
||||
|
||||
### 8.1 Listening stats
|
||||
|
||||
- `TestStats_TotalAggregation` — seed 3 closed sessions, assert `totalTime == sum(time_listening_seconds)` and `items == 2` (3 sessions across 2 distinct items).
|
||||
- `TestStats_DayOfWeekBucketing` — sessions on a Monday + a Wednesday + another Monday, assert `dayOfWeek[1] == 2 sessions worth, dayOfWeek[3] == 1`.
|
||||
- `TestSessions_List_PaginatedAndScoped` — paged response shape, other-user sessions excluded.
|
||||
- `TestSession_Detail_Owner_OK` + `TestSession_Detail_NonOwner_404`.
|
||||
|
||||
### 8.2 Author + Series
|
||||
|
||||
- `TestAuthor_Detail_ReturnsBooks` — seed author with 2 books; GET returns both, name matches.
|
||||
- `TestAuthor_Unknown_404`.
|
||||
- `TestAuthor_Image_PresignWired` — when CoverResolver returns a URL, image route 302s; when poster_path is empty, 404.
|
||||
- `TestSeries_Detail_OrderedByIndex` — books returned in series_index ASC.
|
||||
- `TestSeries_Unknown_404` (name doesn't match any audiobook_series row).
|
||||
|
||||
### 8.3 Continue-listening
|
||||
|
||||
- `TestContinue_Remove_SetsHideTrue` — seed progress row, GET remove-from-continue, assert column becomes true.
|
||||
- `TestContinue_Readd_SetsHideFalse` — inverse.
|
||||
- `TestContinue_UnknownItem_404`.
|
||||
- `TestContinue_NoProgressRow_OK_Idempotent` — no progress row exists; remove-from-continue still returns 200 (idempotent no-op).
|
||||
|
||||
### 8.4 RSS
|
||||
|
||||
- `TestFeed_Open_Item_GeneratesSlug` — POST `/api/feeds/item/{id}/open` returns a feed with a random 16-char slug.
|
||||
- `TestFeed_Open_AcceptsCustomSlug`.
|
||||
- `TestFeed_Open_RejectsInvalidSlug_400`.
|
||||
- `TestFeed_Open_RejectsCollision_409`.
|
||||
- `TestFeed_List_OwnerOnly` — only caller's open feeds, profile-scoped.
|
||||
- `TestFeed_Close_Owner_204`.
|
||||
- `TestFeed_Close_NonOwner_404`.
|
||||
- `TestPublicFeed_UnknownSlug_404`.
|
||||
- `TestPublicFeed_ClosedSlug_404`.
|
||||
- `TestPublicFeed_HappyPath_RSSXML` — open a feed with a known item; fetch `/feed/{slug}.xml`; assert Content-Type is `application/rss+xml`, body contains `<rss`, `<channel>`, `<item>`, `<enclosure url=`.
|
||||
|
||||
### 8.5 Out of scope
|
||||
|
||||
- No real-Postgres SQL test.
|
||||
- No live smoke for the public RSS endpoints (operator can hit them; v1 doesn't include the curl snippet).
|
||||
- No RSS validator test against a third-party tool.
|
||||
|
||||
## 9. References
|
||||
|
||||
- Continuum reference: `continuum-plugin-audiobooks/internal/abs/rss_feed_handler.go` (port routing names + slug generation; adapt store/auth to silo).
|
||||
- silo's existing public-route precedent: `handlePublicTrack` in `internal/audiobooks/abs/file_handler.go` — same slug-as-capability pattern.
|
||||
- Item-people kinds: 7 = author per `media_store.go:ListLibraryAuthors`. Narrator kind not used here.
|
||||
|
||||
## 10. Out-of-scope follow-ups
|
||||
|
||||
- RSS feed variants: series feeds, collection feeds, cover endpoint, per-track public route is included but is the minimum.
|
||||
- Listening-stats `/all-time` vs `/last-N-days` filters — current spec returns a single aggregate.
|
||||
- Author edit / merge surface.
|
||||
- Series metadata edit.
|
||||
- Stats charts beyond the three buckets (per-genre, per-author, per-narrator) — defer.
|
||||
@@ -0,0 +1,377 @@
|
||||
# ABS Smart Collections — Phase 1 Sub-Project 3
|
||||
|
||||
**Status:** Approved 2026-05-26. Ready for implementation plan.
|
||||
**Scope:** Third sub-project of Phase 1 (per `2026-05-26-abs-implementation-fix-design.md`).
|
||||
**Predecessor specs:** `2026-05-26-abs-bookmarks-design.md`, `2026-05-26-abs-collections-playlists-design.md`. Conventions established there (anti-enumeration 404, `io.LimitReader(1<<20)` body cap, `errors.Is(err, pgx.ErrNoRows)` store pattern, profile-scoped + cross-user-public read, 200 OK on POST, in-package test fakes + `dispatchABSWithParams`) are reused without re-justification.
|
||||
|
||||
Commands in this document assume the repository root is the cwd.
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Land rule-based "smart" audiobook collections on the silo ABS surface so the official Audiobookshelf clients can define dynamic groupings ("books from 2020-2024 by Brandon Sanderson", "audiobooks I've started but haven't finished", "5-star fantasy under 12 hours"). Rules are stored as a JSON DSL; the items endpoint evaluates the rules against the catalog at request time and returns paginated `LibraryItem` results.
|
||||
|
||||
## 2. Non-goals
|
||||
|
||||
- **No SQL pushdown.** Evaluation walks the candidate list in Go. silo's audiobook library is small enough (hundreds of items) that linear scan per request is fine. SQL pushdown is a Phase 4 follow-up if the library grows past ~5000 items.
|
||||
- **No rule-builder UI.** Clients ship their own; silo just stores and evaluates `query_def`.
|
||||
- **No podcast smart collections.** The DSL catalog is audiobook-domain only (title/author/narrator/series/genre/...). Podcast smart collections, if ever needed, get their own DSL.
|
||||
- **No socket events.** Matches the manual-collections decision in sub-project 2. Phase 2 covers full event parity.
|
||||
- **No saved-search aliases.** A smart collection is fully described by its `query_def`; there's no separate "saved query" entity.
|
||||
- **No background materialisation.** Each `/items` call re-evaluates. If perf matters later, add a 30s in-memory result cache keyed on `(collectionID, userID)`.
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
Three layers, sharing the established silo ABS structure:
|
||||
|
||||
- **DSL layer (new package `internal/audiobooks/smartcoll/`):**
|
||||
- `query.go` — `QueryDefinition`, `QueryGroup`, `QueryRule`, `QuerySort` types + field/sort catalogs + `Normalize`/`Validate`/`MarshalJSON`. Ported from continuum's `internal/smartcoll/query.go` verbatim except for the import path.
|
||||
- `evaluator.go` — `Candidate`, `EvaluateOptions`, `Evaluate(ctx, qd, candidates, opts) []Candidate`. Pure function, no I/O. Ported from continuum.
|
||||
- `query_test.go` + `evaluator_test.go` — port continuum's test suites; remove any references to continuum-specific backend types.
|
||||
- **HTTP layer (`internal/audiobooks/abs/`):**
|
||||
- `smart_collections_handler.go` — six handlers: `handleListSmartCollections`, `handleCreateSmartCollection`, `handleGetSmartCollection`, `handleSmartCollectionItems`, `handleUpdateSmartCollection`, `handleDeleteSmartCollection`.
|
||||
- `smart_collections_handler_test.go` — in-memory `memSmartCollectionStore` + handler tests reusing `dispatchABSWithParams`, `stubMediaStore`, `recordingPublisher` from prior sub-projects' test files.
|
||||
- `smart_collections_envelope_test.go` — wire-shape marshal test.
|
||||
- **Storage layer:**
|
||||
- `internal/audiobooks/abs/smart_collections.go` — `SmartCollectionStore` interface + `SmartCollection` Go model + `smartCollectionToABS` serialiser.
|
||||
- `internal/audiobooks/abs_smart_collection_store.go` — pgx-backed `ABSSmartCollectionStore` (parallel to `abs_collection_store.go`).
|
||||
- **Migration:** `153_abs_smart_collections.up.sql` + `.down.sql`.
|
||||
- **Bookmark store extension:**
|
||||
- `BookmarkStore.CountByUser(ctx, userID, profileID) (map[string]int, error)` returning `{libraryItemID: count}` for batch hydration of the personalized `bookmark_count` rule. Single SQL: `SELECT library_item_id, COUNT(*) FROM abs_bookmarks WHERE user_id = $1 AND COALESCE(profile_id, sentinel) = COALESCE($2, sentinel) GROUP BY library_item_id`.
|
||||
- **Service wiring:** `BuildABSHandler` constructs the new store; field lands on `abs.Dependencies` next to `PlaylistStore`.
|
||||
|
||||
## 4. Endpoint surface
|
||||
|
||||
All routes mounted under both `/abs/api/*` and `/api/*` inside the existing `bearerAuth` group. Success status is **200 OK** unless the `Returns` column says otherwise.
|
||||
|
||||
| Verb | Path | Body | Returns |
|
||||
|---|---|---|---|
|
||||
| GET | `/me/smart-collections` | — | `{"items": [SmartCollection list-shape]}` |
|
||||
| POST | `/me/smart-collections` | `{name, description?, color?, isPublic?, isPinned?, query_def}` | SmartCollection full-shape |
|
||||
| GET | `/me/smart-collections/{id}` | — | SmartCollection full-shape when owner OR `isPublic=true`; otherwise 404 |
|
||||
| GET | `/me/smart-collections/{id}/items` | (query: `?limit=`, `?page=`) | Paged envelope of `LibraryItem` results from rule evaluation |
|
||||
| PATCH | `/me/smart-collections/{id}` | `{name?, description?, color?, isPublic?, isPinned?, query_def?}` | SmartCollection full-shape |
|
||||
| DELETE | `/me/smart-collections/{id}` | — | 204 No Content |
|
||||
|
||||
### 4.1 Wire shape — SmartCollection
|
||||
|
||||
**List-shape and full-shape are identical** (unlike manual collections / playlists where the list-shape omits child items). Smart collections have no stored items; the items are computed by the separate `/items` route. So one shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "01HSC...",
|
||||
"userId": "1",
|
||||
"name": "Recent Fantasy",
|
||||
"description": "fantasy added in the last 6 months",
|
||||
"color": "#3b82f6",
|
||||
"isPublic": false,
|
||||
"isPinned": true,
|
||||
"queryDef": {
|
||||
"library_ids": [9],
|
||||
"match": "all",
|
||||
"groups": [{
|
||||
"match": "all",
|
||||
"rules": [
|
||||
{"field": "genre", "op": "contains", "value": "Fantasy"},
|
||||
{"field": "added_at", "op": "in_last", "value": "180d"}
|
||||
]
|
||||
}],
|
||||
"sort": {"field": "added_at", "order": "desc"},
|
||||
"limit": null
|
||||
},
|
||||
"createdAt": 1779786284823,
|
||||
"updatedAt": 1779786284823
|
||||
}
|
||||
```
|
||||
|
||||
All ten top-level keys always present (`description`, `color` default to `""`; `isPublic`, `isPinned` default to `false`; `queryDef` is the parsed JSON, never raw bytes on the wire). Timestamps are JS-epoch milliseconds.
|
||||
|
||||
### 4.2 Wire shape — `/items` envelope
|
||||
|
||||
Standard ABS paged envelope (`pagedEnvelope` helper already in `handler.go`):
|
||||
|
||||
```json
|
||||
{
|
||||
"results": [ /* hydrated LibraryItem objects */ ],
|
||||
"total": 42,
|
||||
"limit": 30,
|
||||
"page": 0,
|
||||
"sortBy": "added_at",
|
||||
"sortDesc": true,
|
||||
"filterBy": "",
|
||||
"minified": false,
|
||||
"include": ""
|
||||
}
|
||||
```
|
||||
|
||||
`results` items use the same `siloItemToLibraryItem` shape as `/api/libraries/{id}/items`. Pagination is post-eval slice (eval order is preserved; pages are stable for a given `(collection, user)` as long as the underlying catalog doesn't change).
|
||||
|
||||
### 4.3 List-envelope wrap key
|
||||
|
||||
Smart-collection LIST uses `{"items": [...]}` — matches continuum's wrap key (which differs from manual collections' `{"collections": [...]}`). Clients pattern-match on the wrap key when distinguishing surfaces; don't override.
|
||||
|
||||
## 5. DSL surface (full vocabulary)
|
||||
|
||||
### 5.1 Fields (15 total)
|
||||
|
||||
**Non-personalized (10):**
|
||||
|
||||
| Field | Type | Valid ops | Source |
|
||||
|---|---|---|---|
|
||||
| `title` | scalar string | `is`, `is_not`, `contains` | `media_items.title` |
|
||||
| `author` | array of strings | `is`, `is_not`, `contains` | item people: role=author |
|
||||
| `narrator` | array of strings | `is`, `is_not`, `contains` | item people: role=narrator |
|
||||
| `series` | array of strings | `is`, `is_not`, `contains` | `audiobook_series` |
|
||||
| `genre` | array of strings | `is`, `is_not`, `contains` | `media_items.genres` |
|
||||
| `year` | int | `is`, `is_not`, `gt`, `gte`, `lt`, `lte`, `between` | `media_items.release_year` |
|
||||
| `rating` | float | `gt`, `gte`, `lt`, `lte`, `between` | `media_items.rating_imdb` (or whichever rating silo populates) |
|
||||
| `language` | scalar string | `is`, `is_not` | `media_items.original_language` |
|
||||
| `publisher` | scalar string | `is`, `is_not`, `contains` | `media_items.publisher` |
|
||||
| `added_at` | timestamp | `gt`, `lt`, `between`, `in_last` | `media_items.added_at` |
|
||||
| `duration_seconds` | int | `gt`, `gte`, `lt`, `lte`, `between` | sum of media_files.duration for the item |
|
||||
|
||||
**Personalized (5)** — require `AllowPersonalized: true` (only when caller is the collection's owner):
|
||||
|
||||
| Field | Valid ops | Source |
|
||||
|---|---|---|
|
||||
| `finished` | `is` | `user_watch_progress.is_finished` |
|
||||
| `in_progress` | `is` | progress exists AND `is_finished == false` AND `current_seconds > 0` |
|
||||
| `last_played` | `gt`, `gte`, `lt`, `lte`, `between`, `in_last` | `user_watch_progress.updated_at` |
|
||||
| `abandoned` | `is` | `in_progress == true` AND `last_played < now() - 60d` (configurable via `EvaluateOptions.AbandonedAfter`) |
|
||||
| `bookmark_count` | `gt`, `gte`, `lt`, `lte`, `between` | count of `abs_bookmarks` rows for (user, profile, item) |
|
||||
|
||||
### 5.2 Sort fields
|
||||
|
||||
`title` (asc), `added_at` (desc), `year` (desc), `duration_seconds` (desc), `rating` (desc), `random` (deterministic shuffle seeded by `userID + ":" + collectionID`), and personalized: `progress` (desc), `last_played` (desc), `plays` (desc).
|
||||
|
||||
### 5.3 Operators
|
||||
|
||||
- `is` / `is_not` — equality (case-insensitive for strings; numeric otherwise). For array fields, `is` matches when ANY element equals the value.
|
||||
- `contains` — substring (case-insensitive) for scalar strings; element-substring for array fields.
|
||||
- `gt` / `gte` / `lt` / `lte` — numeric or timestamp comparison.
|
||||
- `between` — `value` is a 2-element array `[low, high]`, inclusive bounds.
|
||||
- `in_last` — relative window. `value` is a duration string (`"7d"`, `"180d"`, `"24h"`, `"4w"`). Evaluator parses to `time.Duration`; field must be ≥ `opts.Now - duration`.
|
||||
|
||||
### 5.4 Aliases
|
||||
|
||||
`authors` → `author`, `narrators` → `narrator`, `genres` → `genre` for fields. `sort_title` → `title`, `recently_added` → `added_at`, `duration` → `duration_seconds` for sorts. Applied in `Normalize()`; older clients with plural / verbose names keep working.
|
||||
|
||||
## 6. Data model
|
||||
|
||||
### 6.1 Migration 153
|
||||
|
||||
`migrations/153_abs_smart_collections.up.sql`:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS public.abs_smart_collections (
|
||||
id text PRIMARY KEY,
|
||||
user_id integer NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||
profile_id uuid,
|
||||
name text NOT NULL,
|
||||
description text NOT NULL DEFAULT '',
|
||||
color text NOT NULL DEFAULT '',
|
||||
is_public boolean NOT NULL DEFAULT false,
|
||||
is_pinned boolean NOT NULL DEFAULT false,
|
||||
query_def jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS abs_smart_collections_user_profile_idx
|
||||
ON public.abs_smart_collections (
|
||||
user_id,
|
||||
COALESCE(profile_id, '00000000-0000-0000-0000-000000000000'::uuid)
|
||||
);
|
||||
```
|
||||
|
||||
`migrations/153_abs_smart_collections.down.sql`:
|
||||
|
||||
```sql
|
||||
DROP INDEX IF EXISTS public.abs_smart_collections_user_profile_idx;
|
||||
DROP TABLE IF EXISTS public.abs_smart_collections;
|
||||
```
|
||||
|
||||
### 6.2 Schema rationale
|
||||
|
||||
- **`query_def jsonb`** — JSONB so future GIN-indexed JSON path probes are an option; default `'{}'::jsonb` so rows with no rules are queryable without NULL guards.
|
||||
- **No items table** — smart collections have no stored children; items are computed at eval time.
|
||||
- **`color`, `is_pinned`** — UI decorations. silo doesn't validate the color format.
|
||||
- **No `library_ids` column** — that list is part of the DSL, lives inside `query_def`.
|
||||
|
||||
### 6.3 Go model
|
||||
|
||||
```go
|
||||
type SmartCollection struct {
|
||||
ID string
|
||||
UserID string
|
||||
ProfileID string
|
||||
Name string
|
||||
Description string
|
||||
Color string
|
||||
IsPublic bool
|
||||
IsPinned bool
|
||||
QueryDef []byte // raw JSONB bytes; decoded on the items path
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
```
|
||||
|
||||
## 7. Storage contract
|
||||
|
||||
```go
|
||||
type SmartCollectionStore interface {
|
||||
ListUserSmartCollections(ctx context.Context, userID, profileID string) ([]SmartCollection, error)
|
||||
GetSmartCollection(ctx context.Context, id string) (SmartCollection, error)
|
||||
CreateSmartCollection(ctx context.Context, c SmartCollection) error
|
||||
UpdateSmartCollection(ctx context.Context, c SmartCollection) error
|
||||
DeleteSmartCollection(ctx context.Context, id string) error
|
||||
}
|
||||
```
|
||||
|
||||
Behavior follows the established silo pattern: ordered by `created_at DESC`, empty slice never nil, `GetSmartCollection` returns `ErrNotFound` without owner check (handler authorizes), `Update` sets `updated_at = now()`, `Delete` returns nil even on no-match.
|
||||
|
||||
**Extension to existing `BookmarkStore`:**
|
||||
|
||||
```go
|
||||
// CountByUser returns a map of library_item_id → bookmark count for
|
||||
// the given (user, profile). Empty map (never nil) when none.
|
||||
// Used by the smart-collection items evaluator to hydrate the
|
||||
// personalized `bookmark_count` rule in one SQL pass.
|
||||
CountByUser(ctx context.Context, userID, profileID string) (map[string]int, error)
|
||||
```
|
||||
|
||||
SQL: `SELECT library_item_id, COUNT(*) FROM abs_bookmarks WHERE user_id = $1 AND COALESCE(profile_id, sentinel) = COALESCE($2::uuid, sentinel) GROUP BY library_item_id`.
|
||||
|
||||
## 8. Items evaluation flow
|
||||
|
||||
`handleSmartCollectionItems` runs:
|
||||
|
||||
1. Auth + store-nil + URL `{id}` checks (same boilerplate as other handlers).
|
||||
2. `GetSmartCollection(ctx, id)` → 404 on ErrNotFound OR `(non-owner AND !isPublic)`.
|
||||
3. Decode `c.QueryDef` → `smartcoll.QueryDefinition`. 500 on malformed (shouldn't happen — `Validate` is called on persist; defensive guard).
|
||||
4. Resolve target libraries: `qd.LibraryIDs` if non-empty, else every audiobook library from `MediaStore.ListAudiobookLibraries`.
|
||||
5. For each target library, fetch the audiobook list via `MediaStore.ListAudiobooks(ctx, libID, 5000, 0)`. (5000 cap matches continuum's; silo's libraries are smaller.)
|
||||
6. **Build Candidates.** For each item:
|
||||
- `Candidate.Item` = the `*models.MediaItem`.
|
||||
- If `c.UserID == a.UserID` (owner): hydrate per-user state.
|
||||
- Fetch all progress rows: `ProgressStore.ListProgressForAudiobooks(ctx, a.UserID, a.ProfileID, 10000)`. Build `map[contentID]ProgressRow`.
|
||||
- Fetch bookmark counts: `BookmarkStore.CountByUser(ctx, a.UserID, a.ProfileID)`. Map `contentID → count`.
|
||||
- Populate `IsFinished`, `ProgressPct`, `CurrentSeconds`, `LastPlayedAt`, `BookmarkCount`.
|
||||
- Non-owner viewing public collection: skip hydration; personalized rules eval against zero-values.
|
||||
7. `smartcoll.Evaluate(ctx, qd, candidates, EvaluateOptions{AllowPersonalized: c.UserID == a.UserID, UserSeed: a.UserID + ":" + c.ID, Now: time.Now(), AbandonedAfter: 60*24*time.Hour})` → matched + sorted.
|
||||
8. Paginate: `limit, page := readPagedQuery(r, 30)`. Override default limit with `qd.Limit` when `?limit=` query param is absent. Slice `matched[page*limit : (page+1)*limit]`.
|
||||
9. Hydrate each result via the existing `siloItemToLibraryItem` helper (same as `/api/libraries/{id}/items`).
|
||||
10. Wrap in `pagedEnvelope`. Write 200.
|
||||
|
||||
### 8.1 Performance budget
|
||||
|
||||
- Library fetch: one SQL query per library (~hundreds of rows).
|
||||
- Progress hydration: one SQL query for the user's full progress list.
|
||||
- Bookmark hydration: one SQL query for the aggregated counts.
|
||||
- Total: 2 + N (libraries) queries, all paginated/limited at the store layer. silo's current 1-library config means 3 total queries per `/items` request. Fast enough.
|
||||
|
||||
## 9. Error model
|
||||
|
||||
| Condition | Status | Body |
|
||||
|---|---|---|
|
||||
| Missing/invalid bearer | 401 | handled by `bearerAuth` middleware |
|
||||
| Body decode failure (POST/PATCH) | 400 | `invalid body` |
|
||||
| `name` missing on POST | 400 | `name required` |
|
||||
| `query_def` fails `Validate(allowPersonalized=true)` | 400 | `invalid query_def: <reason>` (reason from validator — safe to surface) |
|
||||
| Unknown collection on GET (owner or not) | 404 | `smart collection not found` |
|
||||
| Non-owner GET on private | 404 | same body — no leak |
|
||||
| Non-owner PATCH/DELETE/items | 404 | same body |
|
||||
| Store mutate fails | 500 | `smart collection persist failed` (delete: `smart collection delete failed`); err logged via `slog.Error` |
|
||||
| `/items` eval — `MediaStore.ListAudiobooks` fails for a library | log `slog.Warn` and skip that library | partial results beat 500 |
|
||||
| Personalized rule on non-owner-public eval | silently dropped at `smartcoll.Evaluate` via `AllowPersonalized: false` | no error to the client |
|
||||
|
||||
**Cross-cutting:** Body size limit `io.LimitReader(r.Body, 1<<20)` on all POST/PATCH. `validate(allowPersonalized=true)` runs on every persist call; on read, the items handler passes `AllowPersonalized = (c.UserID == a.UserID)` so saved personalized rules are non-fatally dropped when viewed by non-owners.
|
||||
|
||||
## 10. Testing
|
||||
|
||||
### 10.1 DSL unit tests (`internal/audiobooks/smartcoll/`)
|
||||
|
||||
Port continuum's `query_test.go` and `evaluator_test.go` verbatim. The continuum reference covers:
|
||||
|
||||
- `Normalize`: aliasing, lowercase, dedupe library_ids, default-match `"all"`, default-sort `"added_at"`.
|
||||
- `Validate`: unknown field, invalid op for field, personalized rule without scope, invalid sort field, invalid sort order, negative limit.
|
||||
- `Evaluate`: each operator for each field type, AND/OR combinator for groups and definition, personalized rules drop when `AllowPersonalized: false`, `random` sort stability per `UserSeed`, `Limit` honored, empty rules match-everything.
|
||||
|
||||
### 10.2 Handler tests (`smart_collections_handler_test.go`)
|
||||
|
||||
In-memory `memSmartCollectionStore` (parallel to `memCollectionStore`); reuses `dispatchABSWithParams`, `stubMediaStore`, `recordingPublisher`. The bookmark store extension (`CountByUser`) needs a matching method on `memBookmarkStore` from sub-project 1's test file — extend that fake.
|
||||
|
||||
| Test | Asserts |
|
||||
|---|---|
|
||||
| `SmartCollection_Create_ReturnsFullShape` | POST → 200, 10 top-level keys present, ULID, queryDef round-tripped as a nested object (not raw bytes) |
|
||||
| `SmartCollection_Create_NameRequired_400` | empty name → 400 |
|
||||
| `SmartCollection_Create_InvalidBody_400` | malformed JSON → 400 |
|
||||
| `SmartCollection_Create_InvalidQueryDef_400` | `{"groups":[{"rules":[{"field":"nonsense","op":"is","value":1}]}]}` → 400 with reason in body |
|
||||
| `SmartCollection_Create_PersonalizedRuleAllowed` | rule with `field:"finished"` accepted (persist always runs with `allowPersonalized=true`) |
|
||||
| `SmartCollection_List_WrappedAsItems` | GET → `{"items": [...]}` envelope key (NOT `"collections"`) |
|
||||
| `SmartCollection_List_DoesNotLeakOtherUsers` | user 1 creates; user 2 lists → empty |
|
||||
| `SmartCollection_List_ProfileIsolation` | profile A creates; profile B lists same user → empty |
|
||||
| `SmartCollection_Get_Owner_ReturnsFullShape` | owner GET → 200 + full-shape |
|
||||
| `SmartCollection_Get_NonOwner_Public_OK` | public GET by other user → 200 |
|
||||
| `SmartCollection_Get_NonOwner_Private_404` | private GET by other user → 404 (anti-enumeration) |
|
||||
| `SmartCollection_Get_Unknown_404` | unknown ID → 404 |
|
||||
| `SmartCollection_Patch_Owner_UpdatesFields` | partial PATCH updates only present fields |
|
||||
| `SmartCollection_Patch_NonOwner_404` | non-owner PATCH → 404, original untouched |
|
||||
| `SmartCollection_Patch_InvalidQueryDef_400` | PATCH with bad query_def → 400 |
|
||||
| `SmartCollection_Delete_Owner_204` | DELETE → 204; subsequent GET → 404 |
|
||||
| `SmartCollection_Delete_NonOwner_404` | non-owner DELETE → 404, original still present |
|
||||
| `SmartCollection_Items_Owner_EvaluatesRules` | seed 3 audiobooks (two match the rule, one doesn't); GET /items returns 2 results |
|
||||
| `SmartCollection_Items_PersonalizedDroppedForNonOwner` | public collection with `field:"finished"` rule — non-owner sees ALL matching books (personalized rule silently dropped); owner sees only finished |
|
||||
| `SmartCollection_Items_PaginatedEnvelope` | GET /items → standard pagedEnvelope with 9 fields |
|
||||
| `SmartCollection_Items_RespectsQueryDefLimit` | qd.Limit=5; GET without ?limit returns 5; GET with ?limit=10 returns 10 (request overrides) |
|
||||
| `SmartCollection_Items_NonOwner_Private_404` | private collection /items by non-owner → 404 |
|
||||
| `SmartCollection_Envelope_HasRequiredKeys` | marshal-test for 10 top-level keys with empty defaults |
|
||||
|
||||
### 10.3 Live integration smoke (operator)
|
||||
|
||||
```bash
|
||||
TOKEN=$(curl ... /login | jq -r .accessToken)
|
||||
|
||||
# Create a smart collection: "fantasy added in the last 6 months"
|
||||
SC=$(curl -s -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||||
-d '{"name":"recent fantasy","query_def":{"match":"all","groups":[{"match":"all","rules":[{"field":"genre","op":"contains","value":"Fantasy"},{"field":"added_at","op":"in_last","value":"180d"}]}],"sort":{"field":"added_at","order":"desc"}}}' \
|
||||
http://127.0.0.1:13378/api/me/smart-collections | jq -r .id)
|
||||
|
||||
# Evaluate
|
||||
curl ... /api/me/smart-collections/$SC/items | jq '.results | length'
|
||||
|
||||
# Patch name
|
||||
curl -X PATCH ... -d '{"name":"renamed"}' /api/me/smart-collections/$SC | jq .name
|
||||
|
||||
# Delete
|
||||
curl -X DELETE ... /api/me/smart-collections/$SC # 204
|
||||
```
|
||||
|
||||
### 10.4 Out of scope
|
||||
|
||||
- No real-Postgres SQL test (same posture as prior sub-projects).
|
||||
- No GIN-index on `query_def`. Phase 4 follow-up if rule-querying becomes a feature.
|
||||
- No performance test. Library is small; linear scan is fine.
|
||||
|
||||
## 11. Risks & open questions
|
||||
|
||||
- **5000-item per-library cap.** If a library exceeds this, the over-fetch silently truncates. silo's libraries are well below this today; if it changes, switch to paged ListAudiobooks + accumulate.
|
||||
- **Personalized rule eval against non-owners is silent.** A non-owner viewing a public collection sees results that don't honor the owner's personalized rules. This is the privacy-correct behavior, but a confused user might wonder why "books I haven't finished" shows everything when viewed by their friend. Document in the parent spec's UX notes.
|
||||
- **`random` sort + pagination.** Two requests with the same `(user, collection)` produce the same shuffle (seeded). Browser-back / page-2 stays consistent. If the catalog mutates between requests, the seed still works but new items land in unpredictable positions — acceptable.
|
||||
- **`query_def` migration on field-catalog changes.** If silo renames a field or removes an op in the future, existing rows persist the old vocab. `Validate` would reject them, but Normalize won't drop them — we'd surface stale rules as a runtime warning in the items handler. Acceptable for v1.
|
||||
- **`description` length.** No cap; relies on the 1 MiB body limit. Same posture as other surfaces.
|
||||
|
||||
## 12. Out-of-scope follow-ups
|
||||
|
||||
- SQL-pushdown evaluator for large libraries.
|
||||
- GIN-indexed JSON path queries on `query_def`.
|
||||
- Background materialisation cache for hot smart collections.
|
||||
- Cross-collection composition ("collection A + collection B").
|
||||
- Result count badges on `/me/smart-collections` (would require running eval on every list call — expensive).
|
||||
- Audiobook recommender hooks (the `relevance` sort placeholder in continuum).
|
||||
|
||||
## 13. References
|
||||
|
||||
- Spec parent: `docs/superpowers/specs/2026-05-26-abs-implementation-fix-design.md`.
|
||||
- Sibling sub-project specs: `2026-05-26-abs-bookmarks-design.md`, `2026-05-26-abs-collections-playlists-design.md`.
|
||||
- DSL reference: `continuum-plugin-audiobooks/internal/smartcoll/{query,evaluator}.go` + matching tests (port verbatim).
|
||||
- HTTP reference: `continuum-plugin-audiobooks/internal/abs/smart_collection_handler.go`.
|
||||
@@ -0,0 +1,295 @@
|
||||
# Unified User Collections (Audiobooks + Movies + TV) Design
|
||||
|
||||
**Status:** design — pending implementation plan.
|
||||
|
||||
**Source brainstorm:** in-session, 2026-05-27. Driven by audiobook coverage gaps in `page_sections`, `library_collections`, and `user_personal_collections`, plus a parallel `abs_*` collection/playlist/smart-collection stack that doesn't intersect with the silo-canonical tables.
|
||||
|
||||
**Commands assume the repository root is the cwd.**
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem
|
||||
|
||||
Audiobooks today live in a parallel collections world from movies and TV:
|
||||
|
||||
| Surface | Audiobook coverage today |
|
||||
|---|---|
|
||||
| `page_sections` (homepage rails) | None. Recipes reference only `movie`/`series`. |
|
||||
| `library_collections` (admin-owned, library-scoped) | Schema supports `MediaAudiobook` kind but nothing surfaces audiobook-typed admin collections in practice. |
|
||||
| `user_personal_collections` (user-owned) | Zero references to audiobooks anywhere in `internal/usercollections/`. |
|
||||
| `abs_user_collections` + `abs_collection_items` | Full audiobook-only user-curated collections behind the ABS-compat API. |
|
||||
| `abs_smart_collections` | Audiobook-only smart collections with a rule DSL (port of continuum-plugin-audiobooks). |
|
||||
| `abs_playlists` + `abs_playlist_items` | Audiobook + podcast-episode ordered playlists. |
|
||||
|
||||
Net effect: audiobooks don't appear in any of silo's first-party discovery surfaces, the ABS-app's collection/playlist features don't intersect with silo's native ones, and we maintain two parallel storage stacks.
|
||||
|
||||
The data inside the `abs_*` tables today is essentially empty (1 playlist, 0 user collections, 0 smart collections), so unification can land via hard cutover with minimal data migration cost.
|
||||
|
||||
---
|
||||
|
||||
## 2. Goals
|
||||
|
||||
- One canonical storage for user-owned lists across all media types.
|
||||
- Audiobook items eligible for `page_sections` recipes, `library_collections`, and `user_personal_collections`.
|
||||
- ABS-compat endpoints (`/api/collections`, `/api/playlists`, `/api/smart-collections`) continue to work and return identical wire shapes, but their persistence collapses into the canonical store.
|
||||
- One smart-collection engine evaluated against all media types.
|
||||
- Podcast-episode-level granularity preserved (ABS playlists can name a specific episode within a library item).
|
||||
|
||||
## 3. Non-goals
|
||||
|
||||
- Admin-curated audiobook collections via the ABS API surface. `library_collections` already supports audiobooks; whether ABS clients should see those admin lists is a separate question deferred for a later design.
|
||||
- Frontend changes. Audiobook detail/library pages already exist (per `2026-05-24-audiobook-ui-redesign`) and will consume the unified endpoints when they ship. An admin section-builder media-type selector and any UI surfacing of audiobook sections/collections are follow-up plans.
|
||||
- Cross-user shared collections, public sharing, social features. Out of scope.
|
||||
- Episode-level granularity for movies/TV. The new `sub_item_id` column is nullable and only populated by podcast playlists.
|
||||
|
||||
## 4. Architecture
|
||||
|
||||
### 4.1 Storage model
|
||||
|
||||
One canonical user-owned-lists table — `user_personal_collections` — extended to discriminate four flavors via `collection_type`:
|
||||
|
||||
| `collection_type` | Semantics |
|
||||
|---|---|
|
||||
| `manual` | Existing. Hand-curated list of items. Order optional. |
|
||||
| `synced` | Existing. External sync (Trakt etc.). Items derived from external source. |
|
||||
| `playlist` | **New.** Ordered sequence; `position` column on items is meaningful. |
|
||||
| `smart` | **New.** Rule-based; items materialized at read time from `query_definition` via `internal/smartcoll/`. |
|
||||
|
||||
`user_personal_collection_items` gains one nullable column:
|
||||
|
||||
```sql
|
||||
ALTER TABLE user_personal_collection_items ADD COLUMN sub_item_id text NOT NULL DEFAULT '';
|
||||
```
|
||||
|
||||
`sub_item_id` is populated only when the entry refers to a sub-item of a library item (today: podcast episode within a podcast library item). All other entries leave it empty. Audiobook entries always use empty `sub_item_id` because an audiobook is a single `media_items` row.
|
||||
|
||||
The CHECK constraint on `collection_type` is widened to admit the new values:
|
||||
|
||||
```sql
|
||||
ALTER TABLE user_personal_collections
|
||||
DROP CONSTRAINT IF EXISTS user_personal_collections_type_check;
|
||||
ALTER TABLE user_personal_collections
|
||||
ADD CONSTRAINT user_personal_collections_type_check
|
||||
CHECK (collection_type IN ('manual', 'synced', 'playlist', 'smart'));
|
||||
```
|
||||
|
||||
Admin-curated lists (`library_collections`, `library_collection_items`, etc.) are unchanged. They already support audiobooks through the existing `MediaKind` enum.
|
||||
|
||||
### 4.2 Smart-collection engine
|
||||
|
||||
The current audiobook-only smart-collection engine in `internal/audiobooks/smartcoll/` is promoted to `internal/smartcoll/`:
|
||||
|
||||
```
|
||||
internal/audiobooks/smartcoll/ → internal/smartcoll/
|
||||
```
|
||||
|
||||
Rationale: smart collections are no longer audiobook-only. The DSL operates on `media_items` regardless of type; audiobook-specific rule kinds (e.g. `narrator`, `series_position`) stay registered but evaluate to no-op predicates on non-audiobook items.
|
||||
|
||||
The existing simple-shape `query_definition` on `user_personal_collections` (mostly `library_ids` filter) is a strict subset of the smartcoll DSL — existing rows continue to evaluate correctly because their library-id filter maps to the new DSL's library-id predicate.
|
||||
|
||||
### 4.3 ABS-compat handler mapping
|
||||
|
||||
The three ABS store adapter files in `internal/audiobooks/` get their bodies rewritten to query the canonical tables. Method signatures stay the same so the ABS HTTP handlers above them don't change.
|
||||
|
||||
| ABS endpoint | Backing query after cutover |
|
||||
|---|---|
|
||||
| `GET /api/libraries/{id}/collections` | `SELECT … FROM user_personal_collections WHERE collection_type='manual'` joined to items in this library |
|
||||
| `POST /api/collections` | INSERT `user_personal_collections` with `collection_type='manual'`, scoping via `query_definition.library_ids` |
|
||||
| `GET /api/libraries/{id}/playlists` | `SELECT … FROM user_personal_collections WHERE collection_type='playlist'` joined to items in this library |
|
||||
| `POST /api/playlists` | INSERT `user_personal_collections` with `collection_type='playlist'` |
|
||||
| `GET /api/libraries/{id}/smart-collections` | `SELECT … FROM user_personal_collections WHERE collection_type='smart'`; items materialized via `internal/smartcoll` |
|
||||
| `episode_id` in playlist items wire shape | maps to `sub_item_id` column |
|
||||
|
||||
### 4.4 Page sections (homepage rails)
|
||||
|
||||
`page_sections` rows gain a media-types filter. The cleanest carrier is a typed column:
|
||||
|
||||
```sql
|
||||
ALTER TABLE page_sections
|
||||
ADD COLUMN media_types text[] NOT NULL DEFAULT ARRAY['movie','series'];
|
||||
```
|
||||
|
||||
Default preserves the current "movies and TV" behavior on existing rows so the migration doesn't change any user-visible rails.
|
||||
|
||||
Each recipe declaration in `internal/sections/recipes/` gains a `SupportedMediaTypes` field. Fetchers add `WHERE mi.type = ANY($media_types)` to their queries. Existing recipes set `SupportedMediaTypes = ['movie','series']`; they keep their current behavior. Recipes that work for audiobooks declare `['movie','series','audiobook']` (or `['audiobook']` for audiobook-only ones).
|
||||
|
||||
Two new recipes ship at the same time:
|
||||
|
||||
- **`continue_listening`** — analog of `continue_watching`; audiobook items with non-zero progress and not finished.
|
||||
- **`by_audiobook_series`** — book series rail, drawing from the `audiobook_series` table; mirrors `by_show`.
|
||||
|
||||
More audiobook-flavored recipes (top narrators, by genre, new from followed authors) are deferred until the baseline two are exercised.
|
||||
|
||||
### 4.5 ABS app contract preservation
|
||||
|
||||
Wire-shape compatibility is part of the contract — the ABS Android/iOS apps cannot break. The store-adapter rewrites must produce byte-for-byte identical JSON for the existing endpoints. Concretely:
|
||||
|
||||
- IDs remain stable for the moved row(s). The migration preserves `abs_playlists.id` as the new `user_personal_collections.id`.
|
||||
- `episode_id` field is emitted from `sub_item_id` (empty string when null/empty).
|
||||
- `is_public` field on playlists/smart collections is emitted from `is_shared`.
|
||||
- `cover_item` on playlists — needs decision (see §6).
|
||||
|
||||
### 4.6 Module structure after the change
|
||||
|
||||
```
|
||||
internal/
|
||||
smartcoll/ -- NEW (promoted from audiobooks/smartcoll/)
|
||||
types.go
|
||||
evaluator.go
|
||||
rule_registry.go
|
||||
usercollections/ -- existing; extended for playlist/smart kinds
|
||||
listing.go -- gains Kind filter
|
||||
types.go -- adds CollectionKind = playlist|smart
|
||||
smartfetch.go -- new; materializes smart-collection items via internal/smartcoll
|
||||
sections/
|
||||
recipes/
|
||||
*.go -- each gains SupportedMediaTypes
|
||||
continue_listening.go -- new
|
||||
by_audiobook_series.go -- new
|
||||
audiobooks/
|
||||
abs_collection_store.go -- rewritten: canonical SQL
|
||||
abs_playlist_store.go -- rewritten: canonical SQL
|
||||
abs_smart_collection_store.go -- rewritten: canonical SQL + smartcoll
|
||||
smartcoll/ -- REMOVED (lifted to internal/smartcoll/)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Migration
|
||||
|
||||
Single pair: `migrations/156_unify_user_collections.up.sql` / `.down.sql`. Hard cutover.
|
||||
|
||||
### 5.1 Up
|
||||
|
||||
```sql
|
||||
-- 1. Extend canonical items table with sub-item granularity.
|
||||
ALTER TABLE user_personal_collection_items
|
||||
ADD COLUMN sub_item_id text NOT NULL DEFAULT '';
|
||||
|
||||
-- 2. Widen the collection_type enum.
|
||||
ALTER TABLE user_personal_collections
|
||||
DROP CONSTRAINT IF EXISTS user_personal_collections_type_check;
|
||||
ALTER TABLE user_personal_collections
|
||||
ADD CONSTRAINT user_personal_collections_type_check
|
||||
CHECK (collection_type IN ('manual', 'synced', 'playlist', 'smart'));
|
||||
|
||||
-- 3. Move the single existing abs_playlists row.
|
||||
INSERT INTO user_personal_collections
|
||||
(id, user_id, profile_id, name, description, collection_type,
|
||||
is_shared, created_at, updated_at, creator_profile_id)
|
||||
SELECT
|
||||
id, user_id, COALESCE(profile_id::text, ''), name, description, 'playlist',
|
||||
is_public, created_at, updated_at, COALESCE(profile_id::text, '')
|
||||
FROM abs_playlists;
|
||||
|
||||
INSERT INTO user_personal_collection_items
|
||||
(user_id, collection_id, media_item_id, sub_item_id, position, added_at)
|
||||
SELECT p.user_id, i.playlist_id, i.library_item_id, i.episode_id, i.position, i.added_at
|
||||
FROM abs_playlist_items i
|
||||
JOIN abs_playlists p ON p.id = i.playlist_id;
|
||||
|
||||
-- 4. Drop the abs_* collection tables.
|
||||
DROP TABLE abs_playlist_items;
|
||||
DROP TABLE abs_playlists;
|
||||
DROP TABLE abs_collection_items;
|
||||
DROP TABLE abs_user_collections;
|
||||
DROP TABLE abs_smart_collections;
|
||||
|
||||
-- 5. Extend page_sections with media-type filter.
|
||||
ALTER TABLE page_sections
|
||||
ADD COLUMN media_types text[] NOT NULL DEFAULT ARRAY['movie','series'];
|
||||
```
|
||||
|
||||
### 5.2 Down
|
||||
|
||||
```sql
|
||||
-- Reverse the page_sections column.
|
||||
ALTER TABLE page_sections DROP COLUMN media_types;
|
||||
|
||||
-- Recreate the abs_* tables empty by re-applying their CREATE TABLE
|
||||
-- statements verbatim from the original migration files (no data restored):
|
||||
-- migrations/149_abs_user_collections.up.sql → abs_user_collections
|
||||
-- migrations/150_abs_collection_items.up.sql → abs_collection_items
|
||||
-- migrations/151_abs_playlists.up.sql → abs_playlists
|
||||
-- migrations/152_abs_playlist_items.up.sql → abs_playlist_items
|
||||
-- migrations/153_abs_smart_collections.up.sql → abs_smart_collections
|
||||
-- The implementer copies the CREATE TABLE bodies (and indexes) into this
|
||||
-- down migration; constraint/index names must match the originals so a
|
||||
-- subsequent up-down-up cycle is idempotent.
|
||||
|
||||
-- Remove rows we promoted.
|
||||
DELETE FROM user_personal_collection_items
|
||||
WHERE collection_id IN (
|
||||
SELECT id FROM user_personal_collections WHERE collection_type IN ('playlist','smart')
|
||||
);
|
||||
DELETE FROM user_personal_collections WHERE collection_type IN ('playlist','smart');
|
||||
|
||||
-- Restore narrow CHECK constraint.
|
||||
ALTER TABLE user_personal_collections
|
||||
DROP CONSTRAINT IF EXISTS user_personal_collections_type_check;
|
||||
ALTER TABLE user_personal_collections
|
||||
ADD CONSTRAINT user_personal_collections_type_check
|
||||
CHECK (collection_type IN ('manual', 'synced'));
|
||||
|
||||
-- Drop the sub_item_id column.
|
||||
ALTER TABLE user_personal_collection_items DROP COLUMN sub_item_id;
|
||||
```
|
||||
|
||||
The down migration is symmetrically structured but lossy in reverse: rolling back loses any playlist/smart rows created after the cutover. That's acceptable because the abs_* tables were near-empty before the up.
|
||||
|
||||
### 5.3 Risk
|
||||
|
||||
| Risk | Probability | Mitigation |
|
||||
|---|---|---|
|
||||
| Down-migration loses real user data | Low (one row today; grows over time) | Snapshot the table before running down in any prod context |
|
||||
| ABS app sees wire-shape regressions | Medium | Snapshot tests on JSON output of each ABS endpoint before/after |
|
||||
| smartcoll DSL gap (silo-native query_definition row that doesn't fit DSL subset) | Low | Pre-migration query enumerates non-conforming rows; spec asserts subset compat |
|
||||
| `library_collections` UI gains audiobook rows it can't render | Low | UI changes deferred; admin doesn't currently create audiobook-typed library collections |
|
||||
|
||||
---
|
||||
|
||||
## 6. Open questions
|
||||
|
||||
1. **Playlist `cover_item` field.** `abs_playlists.cover_item` is a FK to `media_items.content_id`. `user_personal_collections.poster_url` is a plain text URL. Two options for the migration: (a) drop `cover_item`, regenerate poster URLs from the first item in the playlist; or (b) add a `cover_content_id` column to `user_personal_collections`. Recommend (a) for simplicity — the existing playlist row has no `cover_item` set.
|
||||
|
||||
2. **`is_pinned` on smart collections.** `abs_smart_collections.is_pinned` has no analog in `user_personal_collections`. Likely needs a new boolean column; defer until smart-collection UI work names a use case.
|
||||
|
||||
3. **`color` on smart collections.** `abs_smart_collections.color` has no analog. Same call as `is_pinned` — defer; the existing 0 rows means no data to preserve.
|
||||
|
||||
4. **Admin-curated audiobook collections in the ABS API.** Out of scope per §3, but flagged for a future design.
|
||||
|
||||
---
|
||||
|
||||
## 7. Testing
|
||||
|
||||
- **Migration round-trip test.** Run `up.sql` → `down.sql` → `up.sql` on a fresh test DB seeded with one of each `abs_*` row. Assert canonical-table contents are identical before and after.
|
||||
- **ABS endpoint wire-shape snapshots.** Generate JSON for `GET /api/libraries/{id}/collections|playlists|smart-collections` before the cutover; assert byte-equal after.
|
||||
- **smartcoll engine portability.** Move the existing `internal/audiobooks/smartcoll/*_test.go` into `internal/smartcoll/` and add three new tests:
|
||||
- audiobook-specific rule (`narrator` filter) evaluated against an audiobook → expected items.
|
||||
- audiobook-specific rule evaluated against a movie → empty set (no-op predicate).
|
||||
- simple library-id filter (silo-native query_definition shape) evaluated under the unified DSL → expected items.
|
||||
- **Section recipe regression.** Existing recipes with default `media_types=['movie','series']` return identical results to today. New `continue_listening` and `by_audiobook_series` recipes covered with focused unit tests.
|
||||
|
||||
---
|
||||
|
||||
## 8. Sub-projects
|
||||
|
||||
This design is decomposed for implementation:
|
||||
|
||||
1. **Schema migration + canonical storage extensions** — migration 156, CHECK/column additions, no behavior changes yet.
|
||||
2. **Smart-collection engine lift** — move `internal/audiobooks/smartcoll/` to `internal/smartcoll/`, update imports, no logic changes.
|
||||
3. **ABS store adapter rewrites** — collection/playlist/smart-collection stores query canonical tables.
|
||||
4. **Section recipes for audiobooks** — recipe `SupportedMediaTypes` field, two new recipes, fetcher type filter.
|
||||
5. **Documentation + follow-up tickets** — open-question call-outs (cover_item, is_pinned, color, admin collections in ABS).
|
||||
|
||||
Each sub-project has a single-MR scope and ships independently of the others. Sub-project 1 lands first; sub-projects 2–4 can land in any order after it.
|
||||
|
||||
---
|
||||
|
||||
## 9. Source references
|
||||
|
||||
- Existing canonical tables: `internal/usercollections/`, `internal/collections/templates/templates.go`
|
||||
- Existing smart-collection engine: `internal/audiobooks/smartcoll/`
|
||||
- Existing section recipes: `internal/sections/recipes/`, `internal/api/handlers/sections_preview.go`, `internal/api/handlers/sections_bulk.go`, `internal/api/handlers/recipes.go`
|
||||
- Existing ABS store adapters: `internal/audiobooks/abs_collection_store.go`, `internal/audiobooks/abs_playlist_store.go`, `internal/audiobooks/abs_smart_collection_store.go`
|
||||
- Related shipped specs: `docs/superpowers/specs/2026-05-26-abs-smart-collections-design.md`, `docs/superpowers/specs/2026-05-24-audiobooks-absorption-design.md`
|
||||
@@ -0,0 +1,311 @@
|
||||
# AI services: shared core, metadata translation, Whisper ASR — design
|
||||
|
||||
**Date:** 2026-06-10
|
||||
**Status:** Draft, pending review
|
||||
**Scope:** Extract the AI subtitle translation engine into a shared AI core, add AI translation of movie/series/season/episode descriptions into the existing localization tables, and add Whisper ASR subtitle generation (`transcribe`, `transcribe_translate`). One PR.
|
||||
|
||||
Commands assume the repository root is the cwd.
|
||||
|
||||
## Problem
|
||||
|
||||
`internal/subtitles/ai` ships an on-demand LLM subtitle translator. The LLM plumbing inside
|
||||
it (OpenAI-compatible chat client with retry/backoff, batched indexed-JSON translation,
|
||||
job lifecycle with idempotency/heartbeat/reaper/bounded concurrency) is generic, but it is
|
||||
buried in a subtitle-specific package, so nothing else can use it.
|
||||
|
||||
Two features want exactly that plumbing:
|
||||
|
||||
1. **Metadata translation.** Libraries whose `metadata_language` the providers cannot fully
|
||||
serve end up with overviews/taglines in the item's default language (usually English).
|
||||
The localization tables (`media_item_localizations`, `season_localizations`,
|
||||
`episode_localizations`) and the serving path (`LocalizeItemModel` and friends in
|
||||
`internal/catalog/detail.go`) already exist — the gap is producing rows for languages the
|
||||
providers don't have.
|
||||
2. **Whisper ASR.** `subtitle_ai_jobs.kind` already reserves `transcribe` and
|
||||
`transcribe_translate`; media with no usable text subtitle track (foreign audio with only
|
||||
bitmap subs, or no subs at all) currently has no AI path to a subtitle track.
|
||||
|
||||
## Goals
|
||||
|
||||
- One shared AI core: a single OpenAI-compatible endpoint configuration (chat + audio
|
||||
transcription), one retry/backoff implementation, one generic batched text translator,
|
||||
one job-lifecycle runner — consumed by subtitle translation, metadata translation, and ASR.
|
||||
- AI-translated overviews and taglines stored in the **existing** localization tables, so
|
||||
web, Android, Apple, and jellycompat clients get them through the current serving path
|
||||
with **zero client changes**.
|
||||
- Provenance on AI-writable localization fields so refreshes never regress data quality:
|
||||
`manual` beats `provider` beats `ai`. A later provider localization overwrites an AI
|
||||
translation; the reverse never happens.
|
||||
- Two metadata-translation triggers: a manual admin action, and an opt-in per-library
|
||||
fallback ("provider had no localization for the library language → AI-translate") wired
|
||||
into metadata ingestion.
|
||||
- ASR jobs that produce ordinary `downloaded_subtitles` rows (provider `transcribed`),
|
||||
with `transcribe_translate` chaining the existing LLM translator for a target-language
|
||||
track; live cue streaming to the requesting playback session, same as translation today.
|
||||
- Total AI load on the configured endpoint bounded by **one** shared semaphore.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- **Episode/movie/series title translation.** Titles often have official localized forms
|
||||
that are not literal translations; provider data only.
|
||||
- **Collection names or descriptions.** Deferred: collections have no localization table
|
||||
yet. The metadata translation service is written against content-ID + field-list inputs,
|
||||
so a `collection_localizations` table can be added in a follow-up without reshaping it.
|
||||
- **Genre translation.** If ever wanted, that is a static mapping, not an LLM call.
|
||||
- **Per-profile metadata language.** Presentation language stays library-scoped
|
||||
(`media_folders.metadata_language`); changing that is an orthogonal feature.
|
||||
- **A localization editor UI.** The `manual` provenance value is honored by the write
|
||||
rules but no UI sets it yet (the existing metadata editor edits base fields, not
|
||||
localizations).
|
||||
- **Whisper word-level timing, diarization, VAD-aligned chunking.** v1 uses fixed-length
|
||||
chunks; boundary-word artifacts are an accepted limitation noted for follow-up.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Package layout
|
||||
|
||||
```
|
||||
internal/ai/llm Client: OpenAI-compatible /v1/chat/completions (moved from
|
||||
internal/subtitles/ai/client.go) + new /v1/audio/transcriptions.
|
||||
Shared retry/backoff/429 handling. Config for the endpoint(s).
|
||||
internal/ai/translate Generic batched text translation: []Segment{ID, Text} in,
|
||||
translated segments out. Indexed-JSON protocol, context
|
||||
neighbors, malformed-response retries — moved from
|
||||
internal/subtitles/ai/translator.go and de-subtitled.
|
||||
internal/ai/jobrunner Generic job lifecycle: bounded dispatch off a shared semaphore,
|
||||
heartbeat loop, stale-job reaper, cancel registry — extracted from
|
||||
internal/subtitles/ai/service.go behind a small repo interface.
|
||||
internal/subtitles/ai Stays: subtitle job semantics. Translate path becomes a thin
|
||||
adapter (cues ↔ segments); gains the transcribe paths.
|
||||
internal/metadata/translation
|
||||
New: metadata translation job service + repo + provenance-aware
|
||||
writes into the localization repos.
|
||||
```
|
||||
|
||||
Package names: `llm`, `aitranslate`, `jobrunner` (import paths above; `aitranslate` avoids
|
||||
a name collision when imported from package `ai`).
|
||||
|
||||
### Shared semaphore
|
||||
|
||||
`cmd/silo` builds one `chan struct{}` of size `ai.max_concurrent_jobs` and hands it to both
|
||||
job services via `jobrunner`. Subtitle, metadata, and ASR jobs all draw from it, so the
|
||||
operator's endpoint sees a bounded number of concurrent jobs regardless of job mix.
|
||||
(An ASR job holds one slot for its whole pipeline: extract → transcribe → translate.)
|
||||
|
||||
## Settings
|
||||
|
||||
Connection settings move to a shared `ai.*` namespace. Because encrypted values are
|
||||
GCM-bound to their setting key (see `internal/catalog/encrypted_settings_repo.go`), the
|
||||
legacy rows are **not renamed**; the loader falls back to them, following the precedent of
|
||||
`recommendations.embedding_auth_token` → `recommendations.openai_api_key` in
|
||||
`internal/config/db_loader.go`.
|
||||
|
||||
| Key | Default | Fallback | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `ai.base_url` | `https://api.openai.com` | `subtitle_ai.base_url` | |
|
||||
| `ai.api_key` | `""` | `subtitle_ai.api_key` | sensitive |
|
||||
| `ai.chat_model` | `gpt-4o-mini` | `subtitle_ai.chat_model` | |
|
||||
| `ai.max_concurrent_jobs` | `2` | `subtitle_ai.max_concurrent_jobs` | shared semaphore size |
|
||||
| `ai.asr_model` | `whisper-1` | — | |
|
||||
| `ai.asr_base_url` | `""` (= `ai.base_url`) | — | separate local Whisper server |
|
||||
| `ai.asr_api_key` | `""` (= `ai.api_key`) | — | sensitive |
|
||||
| `subtitle_ai.enabled` | `false` | — | existing; subtitle translation toggle |
|
||||
| `subtitle_ai.transcribe_enabled` | `false` | — | new; ASR toggle |
|
||||
| `subtitle_ai.batch_size` | `40` | — | existing; cues per chat request |
|
||||
| `subtitle_ai.context_neighbors` | `2` | — | existing |
|
||||
| `metadata_ai.enabled` | `false` | — | new; metadata translation toggle |
|
||||
|
||||
`ai.api_key` and `ai.asr_api_key` join `sensitiveSettingKeys`. The admin UI reads through
|
||||
the same fallback (effective values come from the loaded config) and writes the new keys.
|
||||
|
||||
Config structs: `AIConfig` (connection + concurrency), `SubtitleAIConfig` (slims down to
|
||||
toggles + batch tuning), new `MetadataAIConfig`. Readiness: subtitle translate requires
|
||||
`subtitle_ai.enabled` + chat config; ASR requires `subtitle_ai.transcribe_enabled` + ASR
|
||||
config; metadata requires `metadata_ai.enabled` + chat config.
|
||||
|
||||
## Metadata translation
|
||||
|
||||
### What gets translated
|
||||
|
||||
| Target | Fields |
|
||||
| --- | --- |
|
||||
| movie / series (`media_item_localizations`) | `overview`, `tagline` |
|
||||
| season (`season_localizations`) | `overview` |
|
||||
| episode (`episode_localizations`) | `overview` |
|
||||
|
||||
Titles, sort titles, and artwork columns in those rows are never written by AI.
|
||||
|
||||
### Provenance
|
||||
|
||||
New columns (default `'provider'`, check-constrained to `provider|ai|manual`):
|
||||
|
||||
- `media_item_localizations.overview_source`, `media_item_localizations.tagline_source`
|
||||
- `season_localizations.overview_source`
|
||||
- `episode_localizations.overview_source`
|
||||
|
||||
Write rules, enforced in the localization repos (single-statement upserts with CASE):
|
||||
|
||||
| Writer | May overwrite | Sets source | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| provider ingestion | `provider`, `ai` | `provider` | a non-empty provider value always replaces an AI value; an **empty** provider value never blanks an existing one |
|
||||
| AI translation | `ai`, empty | `ai` | with `force`, may also overwrite `provider` (admin explicitly re-translating) — never `manual` |
|
||||
| manual (future editor) | anything | `manual` | |
|
||||
|
||||
Serving hardening: `LocalizeItemModel` / `LocalizeSeasonModel` / `LocalizeEpisodeModel`
|
||||
must only override base fields with **non-empty** localization values, since AI-created
|
||||
rows legitimately carry empty titles. (Verify current behavior; fix if it clobbers.)
|
||||
|
||||
### Job model
|
||||
|
||||
New table `metadata_translation_jobs`, mirroring `subtitle_ai_jobs` conventions
|
||||
(status enum, progress, idempotency partial-unique index, heartbeat):
|
||||
|
||||
- `target_kind` (`item` | `season` | `episode`), `content_id`, `include_children`
|
||||
- `target_language`, `source_language` (default language of the base row)
|
||||
- `engine`, `model`, `status`, `progress`, `progress_message`, `fields_done`, `fields_total`
|
||||
- `error_message`, `idempotency_key`, `requested_by`, `force`, timestamps, `heartbeat_at`
|
||||
|
||||
A job for a series item with `include_children` expands to: item overview + tagline, every
|
||||
season overview, every episode overview. All fields become segments
|
||||
(`item:overview`, `season:<content_id>:overview`, `episode:<content_id>:overview`, …) fed
|
||||
to the generic translator in batches (metadata batch size constant, smaller than the
|
||||
subtitle one — paragraphs, not cue lines), so episode descriptions share terminology in one
|
||||
prompt context. The system prompt names the series/movie and year for grounding.
|
||||
|
||||
Skip logic in the run loop (not at enqueue): a field whose localization value is already
|
||||
non-empty is skipped unless `force` — so the auto trigger costs zero LLM calls on repeat
|
||||
refreshes. If every field is skipped the job completes immediately.
|
||||
|
||||
Idempotency key: `content_id | target_kind | target_language | model`, active-only partial
|
||||
unique index (completed/failed rows never block a retry).
|
||||
|
||||
### Triggers
|
||||
|
||||
1. **Manual.** Admin action on the item detail metadata editor: pick target language
|
||||
(defaults to the library's `metadata_language`), `include_children`, `force`. Gated by
|
||||
the same permission as metadata editing.
|
||||
2. **Auto fallback.** New `media_folders.auto_translate_metadata boolean NOT NULL DEFAULT
|
||||
false`. At the end of a metadata refresh for an item (after provider localizations are
|
||||
persisted in `internal/metadata/service.go`), if the folder has the flag, has a
|
||||
`metadata_language`, that language differs from the item's default metadata language,
|
||||
and the localization row for it is missing a translated field — enqueue a non-force job
|
||||
for the item (children included for series). Enqueue is fire-and-forget; refresh latency
|
||||
is unaffected.
|
||||
|
||||
### API
|
||||
|
||||
Admin-gated, mirroring the subtitle AI handler shapes:
|
||||
|
||||
- `GET /api/v1/metadata/ai/status`
|
||||
- `POST /api/v1/metadata/ai/translate` `{content_id, target_kind, target_language, include_children, force}`
|
||||
- `GET /api/v1/metadata/ai/jobs/{job_id}` / `POST …/cancel`
|
||||
- `GET /api/v1/metadata/ai/jobs?content_id=…`
|
||||
|
||||
Progress UI polls the job endpoint (jobs are short; no websocket events in v1).
|
||||
|
||||
## Whisper ASR
|
||||
|
||||
### Kinds
|
||||
|
||||
`subtitle_ai_jobs.kind` gains its reserved values:
|
||||
|
||||
- `transcribe` — audio track → subtitle track in the spoken language.
|
||||
- `transcribe_translate` — `transcribe`, then the existing LLM translation chained to
|
||||
`target_language`. Both the transcript track and the translated track are stored (the
|
||||
transcript is a cache: a second target language skips ASR entirely because the transcript
|
||||
is now a selectable text source for a plain `translate` job). `result_subtitle_id`
|
||||
points at the translated track.
|
||||
|
||||
For ASR kinds, `source_index` holds the **audio** track index (`-1` = default/first audio
|
||||
track); `source_language` is an optional hint passed to Whisper, else taken from the
|
||||
detected `verbose_json.language`.
|
||||
|
||||
### Pipeline
|
||||
|
||||
1. Resolve the audio track from `media_files`; reject files with no audio.
|
||||
2. Extract once with ffmpeg into fixed chunks in a job temp dir
|
||||
(`-vn -map 0:a:<idx> -ac 1 -ar 16000 -c:a pcm_s16le -f segment -segment_time 600`):
|
||||
16 kHz mono WAV, 10-minute chunks (~19 MB, under typical 25 MB API limits). Temp dir
|
||||
removed on every exit path.
|
||||
3. Per chunk: `POST /v1/audio/transcriptions` (multipart: file, model,
|
||||
`response_format=verbose_json`, optional `language`, `temperature=0`) via the shared
|
||||
`llm.Client` with the shared retry/backoff. Segment timestamps are offset by the chunk
|
||||
start and merged.
|
||||
4. Cue building: one cue per Whisper segment; text wrapped to at most two lines
|
||||
(~42 chars/line). Empty/whitespace segments dropped.
|
||||
5. Store as `downloaded_subtitles` (provider `transcribed`, SRT, release name
|
||||
`"<Language> (AI transcribed)"`), notify `SubtitleReady`.
|
||||
6. `transcribe_translate`: feed the cues to the existing `Translator`; store the translated
|
||||
track (provider `translated`) and complete.
|
||||
|
||||
Chunks are processed starting from the chunk containing the viewer's playhead, then
|
||||
wrapping — same UX as translation — and cues stream live to the requesting session through
|
||||
the existing notifier. Progress bands: extract 0–10 %, transcribe 10–70 % (per chunk),
|
||||
translate 70–95 %.
|
||||
|
||||
Known v1 limitation: fixed chunk boundaries can clip a word at the seam; acceptable, noted
|
||||
for a silence-aligned follow-up.
|
||||
|
||||
### API / UI
|
||||
|
||||
The existing enqueue endpoint `POST /api/v1/subtitles/ai/translate` gains an optional
|
||||
`kind` (default `translate`; backward compatible). `GET /api/v1/subtitles/ai/status` gains
|
||||
`transcribe_enabled`. The player's translate modal gains a "Generate from audio" mode that
|
||||
lists audio tracks and a target language — the natural path when the only subtitle tracks
|
||||
are bitmap (today's hard error) or none exist.
|
||||
|
||||
## Frontend
|
||||
|
||||
- **AI Services settings page** (`web/src/pages/admin-settings/`): connection card
|
||||
(base URL, API key, chat model, ASR model, optional ASR base URL/key), features card
|
||||
(subtitle translation, ASR, metadata translation toggles; concurrency; batch tuning).
|
||||
The AI card currently inside `SubtitlesSettings.tsx` moves here; subtitle settings keep
|
||||
the download-provider config.
|
||||
- **Metadata editor**: "Translate with AI" action in `EditMetadataDialog.tsx` (language,
|
||||
include children, force), with job-poll progress and query invalidation on completion.
|
||||
- **Library settings**: `auto_translate_metadata` toggle next to the metadata-language
|
||||
field.
|
||||
- **Player**: "Generate from audio" mode in `SubtitleTranslateModal.tsx`.
|
||||
|
||||
## Client (Android / Apple) impact
|
||||
|
||||
None required. Translated overviews/taglines arrive through the existing localized detail
|
||||
responses; ASR/translated tracks arrive as ordinary downloaded subtitles. Optional
|
||||
follow-ups in the client repos: surface "Generate from audio" in their subtitle menus
|
||||
(the jobs API is shared) and the library auto-translate toggle in their admin surfaces.
|
||||
|
||||
## Reliability
|
||||
|
||||
- All jobs: idempotency dedup of in-flight work, heartbeat + stale reaper (crash-safe,
|
||||
multi-instance safe), terminal-state handling on cancel/shutdown — one implementation in
|
||||
`jobrunner`, no copy-paste between the two services.
|
||||
- Provider-wins/manual-wins provenance is enforced in SQL (single-statement upserts), not
|
||||
in racy read-modify-write Go code.
|
||||
- The shared semaphore bounds total endpoint load; ASR temp space is cleaned on success,
|
||||
failure, cancel, and shutdown.
|
||||
- Auto-translate re-runs are free: skip-if-filled happens before any LLM call.
|
||||
|
||||
## Migrations
|
||||
|
||||
One Goose migration (`make migrate-create NAME=ai_metadata_translation_and_asr`):
|
||||
|
||||
1. `metadata_translation_jobs` table + active-idempotency / content / status indexes.
|
||||
2. Provenance columns on the three localization tables (+ check constraints).
|
||||
3. `media_folders.auto_translate_metadata`.
|
||||
|
||||
Down: drop the table, columns, and constraint.
|
||||
|
||||
## Risks / open questions
|
||||
|
||||
- **Whisper-endpoint compatibility.** OpenAI, Groq, faster-whisper-server/speaches all
|
||||
speak `verbose_json`, but segment quality varies; the cue builder must tolerate missing
|
||||
segment arrays (fall back to one cue per chunk worth of `text` is *not* acceptable —
|
||||
fail the job with a clear message instead).
|
||||
- **`LocalizeItemModel` empty-field semantics** must be verified before AI rows (empty
|
||||
titles) ship; this is called out as an explicit plan task.
|
||||
- **Long-running ASR on slow local Whisper servers** can exceed the 10-minute HTTP client
|
||||
timeout per chunk; the transcription call uses a per-request timeout sized for chunk
|
||||
duration rather than the client default.
|
||||
- **Settings sprawl.** The `ai.*`/legacy fallback leaves old `subtitle_ai.*` connection
|
||||
rows in place indefinitely; harmless, but a future settings-GC could prune them once the
|
||||
new keys are written.
|
||||
@@ -0,0 +1,188 @@
|
||||
# Core Notifications System — Design
|
||||
|
||||
Date: 2026-06-10
|
||||
Status: approved (design); implementation plan to follow
|
||||
|
||||
## Summary
|
||||
|
||||
A first-party, whole-system notification layer for Silo: persisted per-user notifications with an inbox, read state, per-category preferences, real-time toasts over the existing event WebSocket, and admin announcements. Ships across the server, web UI, and both mobile apps (in-app only — no APNs/FCM push in this project). External delivery (Discord, email, webhooks) remains the job of the existing `silo.notifications` plugin, which consumes the same events.
|
||||
|
||||
## Goals
|
||||
|
||||
- Persisted per-user notifications: inbox, unread badge, read/dismiss state.
|
||||
- Real-time in-app delivery to connected clients via the existing `events.Hub` / `/api/v1/events/ws` pipeline.
|
||||
- Five categories: `request`, `content`, `announcement`, `system`, `admin` (operational alerts for admin users), plus opt-in `content_digest`.
|
||||
- Per-category user preferences (announcements not mutable).
|
||||
- Admin announcements with audience targeting and expiry.
|
||||
- A documented `notifications.send` event contract so any plugin can create in-app notifications (and, via the existing external plugin's subscription to the same event, external ones).
|
||||
- Core request lifecycle events (`request.*`) — net-new publishing from `internal/requests`, consumed by notifications and available to the requests UI.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Mobile push (APNs/FCM) — future project; nothing here blocks it.
|
||||
- Request-system unification (`request_router.v1`, migrating ebook/audiobook request plugins onto `media_requests`) — separate future project. When it lands, the book plugins' `notifications.send` publishes are replaced by core `request.*` events with no notifications schema/API change.
|
||||
- Absorbing external delivery channels into core.
|
||||
- Fine-grained per-show/per-author subscription rules (watchlist/favorites are the v1 signal).
|
||||
|
||||
## Architecture
|
||||
|
||||
New package `internal/notifications` in silo-server:
|
||||
|
||||
1. **Materializer** — in-process consumer registered on `events.Hub` at startup (same pattern as `internal/plugins/event_dispatcher.go`). Runs events through per-category matchers; writes `notifications` rows for each recipient.
|
||||
2. **Store** — Postgres repository: insert (idempotent), list (cursor), unread count, mark read, dismiss, preferences CRUD.
|
||||
3. **Notifier** — after insert, publishes `notification.created` on a new `ChannelNotifications` event channel, user-scoped like `ChannelUserState`. The WS handler (`internal/api/handlers/events_ws.go`) adds this channel to the per-user subscribe set; frames carry the full notification object. Subscribe-time snapshot carries unread count only.
|
||||
4. **API handlers** — REST endpoints below.
|
||||
5. **Service API** — `notifications.Create(...)` for direct in-process callers (system/security events).
|
||||
|
||||
Creation is DB-first and synchronous: a notification exists when the row commits. WS fan-out is best-effort; clients that miss frames reconcile on next fetch. No delivery queue or retry in core — "delivery" is reading your own table.
|
||||
|
||||
## Data model
|
||||
|
||||
One migration, three tables:
|
||||
|
||||
```sql
|
||||
notifications (
|
||||
id bigserial PRIMARY KEY,
|
||||
user_id int NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
profile_id text NULL REFERENCES user_profiles(id) ON DELETE CASCADE, -- NULL = user-wide
|
||||
category text NOT NULL, -- 'request' | 'content' | 'announcement' | 'system' | 'admin'
|
||||
type text NOT NULL, -- e.g. 'request.approved', 'content.watchlist_episode'
|
||||
title text NOT NULL,
|
||||
body text NOT NULL,
|
||||
link text NULL, -- in-app deep link
|
||||
item_id bigint NULL, -- optional content reference (artwork, dedup)
|
||||
source_event text NULL, -- originating event name (audit)
|
||||
dedup_ref text NULL, -- idempotency: UNIQUE (user_id, type, dedup_ref) WHERE dedup_ref IS NOT NULL
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
read_at timestamptz NULL,
|
||||
dismissed_at timestamptz NULL,
|
||||
expires_at timestamptz NULL
|
||||
);
|
||||
-- index: (user_id, created_at DESC) partial WHERE dismissed_at IS NULL
|
||||
-- partial unique index for dedup as above; inserts use ON CONFLICT DO NOTHING
|
||||
|
||||
notification_preferences (
|
||||
user_id int NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
category text NOT NULL, -- 'request' | 'content' | 'system' | 'admin' | 'content_digest'
|
||||
enabled bool NOT NULL,
|
||||
PRIMARY KEY (user_id, category)
|
||||
);
|
||||
-- absent row = enabled, except content_digest which defaults OFF
|
||||
-- 'announcement' is not a valid category here (not mutable)
|
||||
|
||||
announcements (
|
||||
id bigserial PRIMARY KEY,
|
||||
title text NOT NULL,
|
||||
body text NOT NULL,
|
||||
audience jsonb NOT NULL, -- {"all":true} | {"user_ids":[...]} | {"library_ids":[...]}
|
||||
created_by int REFERENCES users(id),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
expires_at timestamptz NULL
|
||||
);
|
||||
```
|
||||
|
||||
Announcements fan out to `notifications` rows at publish time (audience resolved then), so the inbox is always a single-table query and read state is per-user.
|
||||
|
||||
Child profiles: rows with `category IN ('request','system','admin')` are filtered out of child-profile sessions server-side; content matching never targets a child profile unless the matched watchlist/favorite belongs to that profile.
|
||||
|
||||
Retention (nightly job, existing scheduler pattern): dismissed rows purged after 30 days; all rows after 90 days; unread rows from expired announcements at expiry.
|
||||
|
||||
## API
|
||||
|
||||
User endpoints (authenticated, profile from claims):
|
||||
|
||||
```
|
||||
GET /api/v1/notifications ?unread=1&category=&cursor=&limit=
|
||||
GET /api/v1/notifications/unread-count → {count}
|
||||
POST /api/v1/notifications/read {ids:[...]} | {all:true}
|
||||
POST /api/v1/notifications/{id}/dismiss
|
||||
GET /api/v1/notifications/preferences
|
||||
PUT /api/v1/notifications/preferences {category: enabled, ...}
|
||||
```
|
||||
|
||||
List returns user-wide rows plus active-profile rows, newest first, cursor-paginated in the standard envelope. Child-profile filtering server-side.
|
||||
|
||||
Admin endpoints (RequireAdmin group):
|
||||
|
||||
```
|
||||
GET /api/v1/admin/announcements
|
||||
POST /api/v1/admin/announcements {title, body, audience, expires_at?}
|
||||
DELETE /api/v1/admin/announcements/{id} -- also dismisses unread fanned-out rows
|
||||
```
|
||||
|
||||
## Events
|
||||
|
||||
### New: core request lifecycle events
|
||||
|
||||
`internal/requests/service.go` and the reconciliation task publish on a new `ChannelRequests`:
|
||||
|
||||
- `request.submitted`, `request.approved`, `request.declined`, `request.completed`, `request.failed`
|
||||
- Payload: `request_id, user_id, profile_id, title, media_type, quality, status`
|
||||
|
||||
`ChannelRequests` is added to WS subscribe sets (requesting user + admins), giving the requests UI live updates as a side benefit. This is the only change to the requests subsystem in this project.
|
||||
|
||||
### New: `notifications.send` plugin contract
|
||||
|
||||
The materializer consumes `notifications.send` events:
|
||||
|
||||
```json
|
||||
{ "user_id": 1, "profile_id": null, "category": "request",
|
||||
"type": "request.fulfilled", "title": "...", "body": "...",
|
||||
"link": null, "dedup_ref": "ebook-req-123:fulfilled" }
|
||||
```
|
||||
|
||||
Payloads are validated; malformed events are dropped with a logged warning. The external `silo.notifications` plugin already subscribes to this event name, so one publish reaches both in-app and external channels. The ebook/audiobook request plugins each add one `PublishEvent("notifications.send", ...)` at their acknowledged/fulfilled/failed transitions (their existing status events do not carry `user_id`; the plugins own that context).
|
||||
|
||||
### Matchers
|
||||
|
||||
- **request** — consumes core `request.*` events only. Dedup ref `(request_id, status)`.
|
||||
- **content** — consumes `library.item_added` / `catalog.library.changed`. For each added item: resolve series/author; match per profile against watchlist, favorites (author/series link), and in-progress series (last play within 21 days); union, dedup, filter by library access. Burst guard: one notification per series per profile per hour (season imports collapse).
|
||||
- **content_digest** — nightly job (not event-driven), opt-in: one row per user summarizing the previous day's additions to accessible libraries.
|
||||
- **system** — not matched from the hub; emitted via `notifications.Create()` directly at the source (password change, new-device login, guest-pass redemption). Sites also publish their events as today; the explicit call means security notices never depend on matcher wiring.
|
||||
- **announcement** — fan-out at publish time.
|
||||
- **admin** — consumes existing `ChannelJobs` / `ChannelScans` / `ChannelTasks` failure events plus plugin-host crash/install failures; targets admin users; repeat-failure throttle (same source, max one per hour).
|
||||
|
||||
Preference checks run in the materializer before insert (cached per-user prefs lookup).
|
||||
|
||||
## Web UI
|
||||
|
||||
1. **Bell + dropdown** in the top bar: unread badge from `unread-count`, kept live by `ChannelNotifications` frames via the existing `RealtimeEventsProvider` socket. Dropdown: latest ~10, mark-all-read, deep links.
|
||||
2. **Toasts** — WS notification frames fire sonner toasts; suppressed while the dropdown is open or when the frame targets another profile.
|
||||
3. **Inbox page** (`/notifications`) — paginated list, category filter tabs, dismiss, mark-read on view.
|
||||
4. **Settings** — per-category toggles (`content_digest` default off; `admin` visible to admins only). Admin: Announcements page (list/compose/expire) under admin settings.
|
||||
|
||||
## Mobile (in-app, both platforms)
|
||||
|
||||
Same three features in silo-apple and silo-android against the frozen REST API: inbox screen (list, mark-read, dismiss), unread badge on existing navigation, preferences screen. Transport: REST refresh of unread-count + first page on app foreground and pull-to-refresh. No WS subscription in v1 (current app sockets are playback-scoped; no background delivery exists without push anyway). TV variants: badge + read-only inbox, no preferences editing.
|
||||
|
||||
## Error handling
|
||||
|
||||
- Matcher isolation: a panic/error in one matcher logs and skips that event for that matcher only; never blocks the hub or other matchers (same containment as the plugin event dispatcher).
|
||||
- Idempotent inserts via the dedup partial unique index + `ON CONFLICT DO NOTHING`; replayed events cannot double-notify.
|
||||
- WS fan-out is fire-and-forget; DB rows are the source of truth.
|
||||
- `notifications.send` validation failures: drop + warn (plugin bugs must not break core paths).
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit tests per matcher with synthetic events; content-matcher edge cases: child profiles, library access, burst guard, dedup.
|
||||
- Store tests against Postgres (repo convention).
|
||||
- One integration test: event → materializer → row → WS frame through a real hub.
|
||||
- API handler tests: auth, profile scoping, child filtering, admin gates.
|
||||
- Web: component tests for bell/inbox; extend the RealtimeEventsProvider test pattern for the new channel.
|
||||
|
||||
## Implementation sequencing
|
||||
|
||||
1. **Server** — migration, store, materializer + matchers, request event publishing, API, WS channel, retention job.
|
||||
2. **Web** — bell, toasts, inbox, settings, announcements admin.
|
||||
3. **Mobile** — apple + android in-app surfaces against the frozen API.
|
||||
4. (Separate projects, later: request unification via `request_router.v1`; mobile push.)
|
||||
|
||||
## Future work
|
||||
|
||||
- Request unification: core `request.*` events replace the book plugins' `notifications.send` publishes; notifications unchanged.
|
||||
- Push: APNs/FCM device registry; the notifier gains a push fan-out alongside WS.
|
||||
- Digest scheduling preferences (weekly, time-of-day).
|
||||
- Profile claims enrichment: embed `is_child` in profile-scoped auth claims. Fixes two implementation-time findings: the WS pipeline cannot child-filter (snapshot and live frames use client-asserted profile identity; REST remains authoritative and fails closed — see the snapshot comment in `internal/api/handlers/events_ws.go`), and the REST `childSafe` check costs one profile lookup per request.
|
||||
- Admin-alert coverage: only `job.failed` and `scan.failed` events exist today. `task.failed` and plugin-host crash events are not published by their subsystems yet; when they are, the admin matcher needs only a map entry.
|
||||
- Dedicated `item_added` catalog event: the content matcher keys on `catalog.item.changed` (`metadata_updated`) plus a 48-hour `media_items.created_at` recency gate, because the scanner publishes no per-item add event. An explicit ingest-time event would remove the proxy heuristic.
|
||||
- New-device login notifications: deferred from v1 (no device-fingerprint store exists); password-change notifications shipped.
|
||||
@@ -0,0 +1,173 @@
|
||||
# Core Push Notifications — Design
|
||||
|
||||
Date: 2026-06-10
|
||||
Status: approved (design); implementation plan to follow
|
||||
|
||||
## Summary
|
||||
|
||||
A server-side push subsystem that mirrors the in-app notifications created in Phase 1 out to registered devices via APNs (Apple), FCM (Android), and Web Push (browsers). Delivery is durable (a DB-backed queue with a retry worker) and presence-gated (a notification only pushes to devices whose user has no live WebSocket connection after a short grace delay). This is a core-server project: the mobile clients have no push code yet, so the deliverable is the registry, queue, worker, transports, and APIs — built transport-agnostic so APNs/FCM are drop-in alongside the immediately-testable Web Push.
|
||||
|
||||
## Goals
|
||||
|
||||
- Deliver Phase-1 notifications to devices over APNs, FCM, and Web Push.
|
||||
- Durable delivery: survive restarts, retry with backoff, prune dead tokens, observable in the admin task view.
|
||||
- Presence-gated: don't buzz a device for a notification the user is actively seeing in-app.
|
||||
- Reuse the existing per-category mute preferences; add per-device push on/off. No new per-channel matrix.
|
||||
- Encrypted provider credentials via the existing settings mechanism.
|
||||
- Transport-agnostic core: one `Transport` interface, three implementations, selected by device platform.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Mobile/web client integration (separate per-client projects; this ships the server contract).
|
||||
- A separate in-app-vs-push preference matrix (category mutes + per-device toggle only).
|
||||
- Rich push (images, action buttons) — title/body/link/category payload only in v1.
|
||||
- New-device-login / security pushes beyond what the notification categories already produce.
|
||||
|
||||
## Architecture
|
||||
|
||||
New `internal/push` package and a new `internal/presence` package, plus touches to three existing seams (`notifications.Service.Create`, the events WS handler, `cmd/silo/main.go` wiring). Five independently-testable units:
|
||||
|
||||
1. **Device/token registry** — extends `user_devices` with push columns; a registration endpoint clients call to enroll/refresh/revoke a token and toggle per-device push. Owns "which devices want push and how to reach them."
|
||||
2. **Presence registry** (`internal/presence`) — process-local `user_id → live-connection refcount`, incremented/decremented by the events WS handler on connect/disconnect. Answers "does this user have a live connection right now?" Cluster-aware via a short-TTL Redis key; degrades to per-node if Redis is absent.
|
||||
3. **Enqueuer** — called from `notifications.Service.Create()` after the row commits. Resolves push-eligible devices, writes one `push_deliveries` row per device with `not_before = now + grace`.
|
||||
4. **Delivery worker** — a `PushDeliveryTask` on the existing task framework; claims due rows, re-checks presence, dispatches via the platform's transport, records outcome, prunes dead tokens.
|
||||
5. **Transports** — a `Transport` interface (`Send(ctx, target, payload) → Result`) with APNs, FCM, and Web Push implementations selected by device platform; credentials from the encrypted settings repo.
|
||||
|
||||
Data flow: `Create()` → enqueuer writes deliveries → worker (interval) claims due & un-present → transport sends → outcome / dead-token prune. The in-app path (immediate WS publish) is untouched; push is the deferred, gated mirror.
|
||||
|
||||
### Seam reference (current code)
|
||||
|
||||
- Fan-out hook: `internal/notifications/service.go` `Create()` — after `created, err := s.store.Insert(...)` succeeds and before/alongside the `hub.PublishJSON(... "notification.created" ...)` call. Enqueue is best-effort and must not block or fail `Create()`.
|
||||
- Presence increment: `internal/api/handlers/events_ws.go` — after a successful subscribe, `release := presence.Add(claims.UserID)`, `defer release()`.
|
||||
- No presence tracking exists today (confirmed); the registry is net-new.
|
||||
- `user_devices` exists (`migrations/sql/180_user_devices.sql`, PK `user_id, profile_id, device_id`) with no token column.
|
||||
- Credential encryption: `internal/catalog/encrypted_settings_repo.go` `SensitiveSettingKeys`.
|
||||
- Background-worker precedent: `internal/taskmanager/tasks/*` (e.g. the request reconciler, notifications digest/retention).
|
||||
|
||||
## Data model
|
||||
|
||||
Extend `user_devices` (a push token belongs to a device):
|
||||
|
||||
```sql
|
||||
ALTER TABLE user_devices
|
||||
ADD COLUMN push_token text NULL,
|
||||
ADD COLUMN push_transport text NULL, -- 'apns' | 'fcm' | 'webpush'
|
||||
ADD COLUMN push_enabled boolean NOT NULL DEFAULT true,
|
||||
ADD COLUMN push_token_at timestamptz NULL,
|
||||
ADD COLUMN push_failures integer NOT NULL DEFAULT 0;
|
||||
```
|
||||
|
||||
A device is push-eligible when `push_token IS NOT NULL AND push_enabled`. For `webpush`, `push_token` holds the JSON subscription object (endpoint + p256dh + auth); APNs/FCM store the bare token string. Each transport interprets its own format.
|
||||
|
||||
Delivery queue:
|
||||
|
||||
```sql
|
||||
CREATE TABLE push_deliveries (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
notification_id bigint NOT NULL REFERENCES notifications(id) ON DELETE CASCADE,
|
||||
user_id integer NOT NULL,
|
||||
device_id text NOT NULL,
|
||||
transport text NOT NULL,
|
||||
status text NOT NULL DEFAULT 'pending', -- pending|sent|failed|skipped|dead
|
||||
attempts integer NOT NULL DEFAULT 0,
|
||||
not_before timestamptz NOT NULL,
|
||||
last_error text NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX push_deliveries_claim_idx
|
||||
ON push_deliveries (not_before)
|
||||
WHERE status IN ('pending','failed');
|
||||
```
|
||||
|
||||
Worker claim: `status IN ('pending','failed') AND not_before <= now()`, ordered by `not_before`, `FOR UPDATE SKIP LOCKED` (safe across nodes/concurrent workers).
|
||||
|
||||
Outcomes:
|
||||
- **sent** — terminal success.
|
||||
- **skipped** — user was present (live WS) at claim time; the in-app delivery sufficed. Terminal.
|
||||
- **failed** — soft error (timeout, 5xx, rate-limit); bump `attempts`, push `not_before` out by the backoff schedule (1m, 5m, 30m). Once the schedule is exhausted (4 total attempts) → `dead`.
|
||||
- **dead** — hard error / unregistered token; terminal. Also clears `push_token` and sets `push_enabled=false` on the `user_devices` row so the dead device stops being enqueued.
|
||||
|
||||
Deliveries do not duplicate notification content — the worker joins `notifications` at send time so an edited/expired notification sends current content (and an expired/dismissed one can be skipped). Retention: terminal deliveries older than 7 days are deleted by a pass on the existing notifications retention task.
|
||||
|
||||
Provider config (added to `SensitiveSettingKeys`, encrypted at rest):
|
||||
`push.apns.p8_key`, `push.apns.key_id`, `push.apns.team_id`, `push.apns.bundle_id`, `push.fcm.service_account_json`, `push.webpush.vapid_public`, `push.webpush.vapid_private`, `push.webpush.subject`. A transport with missing/blank config reports itself unconfigured; its deliveries short-circuit to `skipped` (reason: unconfigured) rather than erroring in a loop.
|
||||
|
||||
## API
|
||||
|
||||
Device push registration (authenticated user routes; device identified by the existing `X-Silo-Device-Id` header + `user_id` from claims — never from the body):
|
||||
|
||||
```
|
||||
PUT /api/v1/notifications/push/device {transport, token} → 204
|
||||
enroll/refresh this device's token (upsert onto the user_devices row);
|
||||
resets push_failures, sets push_enabled=true, push_token_at=now
|
||||
DELETE /api/v1/notifications/push/device → 204
|
||||
revoke: clear token, disable push for this device
|
||||
GET /api/v1/notifications/push/devices → {devices:[{device_id,name,platform,push_enabled,registered_at}]}
|
||||
PUT /api/v1/notifications/push/devices/{device_id} {enabled} → 204
|
||||
per-device on/off toggle
|
||||
GET /api/v1/notifications/push/webpush-key → {vapid_public_key} (empty when web push unconfigured)
|
||||
```
|
||||
|
||||
Admin provider config uses the existing admin settings mechanism (encrypted `push.*` keys) — no bespoke write endpoints. One read for the admin UI:
|
||||
|
||||
```
|
||||
GET /api/v1/admin/push/status → {apns:{configured:bool}, fcm:{configured:bool}, webpush:{configured:bool}}
|
||||
```
|
||||
|
||||
Status reports booleans only; secrets are never returned.
|
||||
|
||||
## Presence registry
|
||||
|
||||
`presence.Registry` interface: `Connected(userID int) bool` and `Add(userID int) (release func())`. The events WS handler calls `release := registry.Add(claims.UserID)` after a successful subscribe and `defer release()` on disconnect.
|
||||
|
||||
- In-process implementation: `map[int]int` refcount under a mutex.
|
||||
- Cluster-aware implementation: `INCR push:presence:{userID}` in Redis with a 60s TTL refreshed on the WS heartbeat; `DECR` on release; `Connected` is key `> 0`. Redis is already used by the events hub.
|
||||
- Presence failure (Redis down) fails **open** — we push — because a missed push is worse than a redundant one.
|
||||
|
||||
The worker re-checks `Connected` at claim time; a present user's due delivery becomes `skipped`. The `not_before` grace (default 30s) means an actively-in-app user almost never gets a redundant push, while a user who closed the app moments ago does.
|
||||
|
||||
## Error handling & delivery semantics
|
||||
|
||||
- Enqueuer is best-effort; failure to write deliveries logs and returns. `Create()` is never blocked or failed by push.
|
||||
- Worker isolates per-delivery: one device's failure never blocks the batch.
|
||||
- Soft errors → `failed` + exponential backoff on `not_before` (1m, 5m, 30m), then `dead` after 4 total attempts.
|
||||
- Hard errors (`Unregistered`/`InvalidToken`/FCM `NOT_FOUND`) → immediate `dead` + clear token + disable push on `user_devices`.
|
||||
- Unconfigured transport → its deliveries short-circuit to `skipped` (reason logged once, not per-row spam).
|
||||
- Rate-limit responses park that transport briefly (honor `Retry-After`).
|
||||
- Child-profile suppression applied at enqueue: categories in {request, system, admin} are never enqueued for a child profile (same rule as the web toast suppression).
|
||||
- APNs/FCM payloads set a collapse/thread key = notification id to avoid duplicate banners across retries.
|
||||
|
||||
## Security
|
||||
|
||||
- Provider secrets live only in the encrypted settings repo; never logged, never returned by any read endpoint. `/admin/push/status` reports booleans.
|
||||
- Token registration is authenticated and device-scoped to the caller's `user_id` (from claims).
|
||||
- Push payload carries title/body/link/category + notification id — no content beyond what the notification already holds.
|
||||
|
||||
## Testing
|
||||
|
||||
- Worker tested with a fake `Transport` asserting outcome transitions: sent, skipped (present), failed-with-backoff, dead-prunes-token. No network.
|
||||
- Each real transport unit-tested against a mocked HTTP endpoint (request shape, auth header, error-code mapping) — standard repo pattern.
|
||||
- Enqueuer tested with a fake device store + presence stub: eligible-device resolution, child suppression, per-device disable, `not_before` grace.
|
||||
- Presence registry: refcount add/release, Connected transitions, Redis-absent fallback.
|
||||
- Store tests against Postgres: claim query with `SKIP LOCKED`, dead-token prune, retention.
|
||||
- One integration test: `Create() → delivery row → worker → fake transport → sent`.
|
||||
- Handler tests: registration auth-scoping, per-device toggle, `/admin/push/status` unconfigured states, `webpush-key` empty when unconfigured.
|
||||
|
||||
## Implementation sequencing
|
||||
|
||||
1. Migration (user_devices columns + push_deliveries) and domain types.
|
||||
2. `internal/presence` registry (in-process + Redis) and WS-handler wiring.
|
||||
3. Device registry store + registration/toggle endpoints.
|
||||
4. Enqueuer + hook into `Create()`.
|
||||
5. `Transport` interface + fake; delivery worker + task registration; outcome/backoff/dead-prune.
|
||||
6. Real transports (Web Push first — end-to-end testable today; then APNs, then FCM) + encrypted config keys + `/admin/push/status`.
|
||||
7. Retention pass + full verification.
|
||||
|
||||
## Future work
|
||||
|
||||
- Client integration: Apple (APNs token registration + handling), Android (FCM), web (service worker + VAPID subscription). Each binds to the frozen registration API.
|
||||
- Presence-aware in-app→push escalation tuning (per-category grace, quiet hours).
|
||||
- Rich push (images from `item_id` artwork, action buttons).
|
||||
- Per-channel preference matrix if category+device proves too coarse.
|
||||
- Shared-device push semantics: a physical device registers one OS token but one user_devices row per profile. For user-wide notifications, EligibleDevices currently returns one delivery per profile-row → the same token is sent to more than once (the per-notification collapse key dedupes the on-device banner, so the user sees one, but the sends are redundant). And child-category suppression on a device shared by a child and an adult profile is ambiguous because push cannot know the active profile at send time. Both resolve with the active-profile/claims-enrichment work also needed for WS child-filtering: carry the active profile identity so EligibleDevices can dedupe by token and apply the correct profile's suppression.
|
||||
@@ -0,0 +1,120 @@
|
||||
# Core Push — Web Client & Admin Config — Design
|
||||
|
||||
Date: 2026-06-10
|
||||
Status: approved (design); implementation plan to follow
|
||||
|
||||
## Summary
|
||||
|
||||
The core-buildable slice of Phase 3 (client work): a Web Push browser client and an admin push-provider config UI, both living in the silo-server web app plus one ~10-line Go endpoint. This makes the push subsystem operable end to end from the browser — an operator enters provider credentials in an admin page, a user enables push on their browser, and notifications arrive as OS banners with the app closed. The mobile clients (silo-apple APNs, silo-android FCM) remain out of scope; they are separate per-repo projects that consume the same frozen registration API.
|
||||
|
||||
This delivers user-facing notification delivery to browsers. It is not a plugin-communication channel; plugins relate to notifications only via the existing `notifications.send` event contract (a plugin produces a user notification, which then flows to in-app + push).
|
||||
|
||||
## Goals
|
||||
|
||||
- Web Push delivery to browsers: service worker, subscription, receive, click-through.
|
||||
- A per-device "push on this device" control and a device-management list in user settings.
|
||||
- An admin page to configure APNs / FCM / Web Push provider credentials with readiness status.
|
||||
- VAPID key generation from the admin UI.
|
||||
- Reuse everything already built: the frozen push registration API, the encrypted settings write path, the Phase-2 notifications settings page and api client.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Mobile client integration (silo-apple / silo-android) — separate projects against the frozen API.
|
||||
- A PWA framework / offline caching (vite-plugin-pwa) — a static service worker covers push; offline is out of scope.
|
||||
- Any change to the push delivery worker, transports, or registration API (all frozen).
|
||||
- Rich push (images, action buttons).
|
||||
|
||||
## Frozen dependencies (already on this branch)
|
||||
|
||||
Push registration API (`internal/api/handlers/push.go`, routes in `router.go`):
|
||||
|
||||
```
|
||||
PUT /api/v1/notifications/push/device {transport, token} → 204 (RequireProfile)
|
||||
DELETE /api/v1/notifications/push/device → 204 (RequireProfile)
|
||||
GET /api/v1/notifications/push/devices → {devices:[DeviceInfo]}
|
||||
PUT /api/v1/notifications/push/devices/{device_id} {enabled} → 204
|
||||
GET /api/v1/notifications/push/webpush-key → {vapid_public_key}
|
||||
GET /api/v1/admin/push/status → {apns,fcm,webpush: bool}
|
||||
```
|
||||
|
||||
`DeviceInfo`: `{device_id, name, platform, transport, push_enabled, registered_at}`. The api client (`web/src/api/client.ts`) auto-attaches `X-Silo-Device-Id` and `X-Profile-Id`. WebPush tokens are stored as the JSON subscription object.
|
||||
|
||||
Admin settings write path: `PUT /api/v1/admin/settings/{key}` `{value}` (`admin.go` HandleUpdateSetting) — all `push.*` keys are pre-registered sensitive in `encrypted_settings_repo.go` (encrypted at rest, value redacted from reads). No new write endpoint needed. Sensitive-status hook (`useAdminSensitiveStatus`) reports which keys are stored.
|
||||
|
||||
Web push server transport already sends payload `{id, title, body, link, category}` with `Topic: "n"+base36(notificationID)` for collapse.
|
||||
|
||||
## Architecture
|
||||
|
||||
Six units (five web, one tiny Go endpoint):
|
||||
|
||||
1. **Service worker** — `web/public/sw.js` (static). `push` → `showNotification` from payload; `notificationclick` → focus an open app tab (postMessage the link) or `openWindow(link)`; `pushsubscriptionchange` → re-subscribe with the cached VAPID key and re-register (best-effort token refresh).
|
||||
2. **SW registration** — in `web/src/main.tsx`, registers `/sw.js` when `serviceWorker` + `PushManager` are supported; no-op otherwise.
|
||||
3. **`usePushDevice` hook** — `web/src/hooks/usePushDevice.ts`. Owns the browser-push lifecycle and exposes a status state machine + `enable()`/`disable()`.
|
||||
4. **Settings UI** — extend `web/src/pages/settings/NotificationsSettings.tsx`: "Push on this device" control + a device-management list (per-device enable/disable).
|
||||
5. **Admin push-config page** — `web/src/pages/admin-settings/PushSettings.tsx`: three credential cards + VAPID keygen + readiness badges.
|
||||
6. **VAPID keygen endpoint** — `POST /api/v1/admin/push/generate-vapid-keys` (RequireAdmin), ~10 lines in `push.go`. The only net-new server code.
|
||||
|
||||
## Push client data flow
|
||||
|
||||
`usePushDevice` reports `status`:
|
||||
- **unsupported** — no serviceWorker/PushManager, or iOS Safari not installed as a PWA. UI explains; no action.
|
||||
- **blocked** — `Notification.permission === "denied"`. UI explains how to re-enable in site settings (cannot un-deny programmatically).
|
||||
- **off** — supported, not subscribed. Toggle → `enable()`.
|
||||
- **on** — subscribed and registered. Toggle → `disable()`.
|
||||
- **pending** — transition in flight; control disabled.
|
||||
|
||||
`enable()`: `Notification.requestPermission()` → if granted, `navigator.serviceWorker.ready` → `GET /notifications/push/webpush-key` → `pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(vapidPublic) })` → `PUT /notifications/push/device { transport: "webpush", token: JSON.stringify(subscription.toJSON()) }`. The api client supplies device + profile headers (satisfies the route's RequireProfile). Cache the VAPID public key (Cache Storage / IndexedDB) so the SW can re-subscribe on `pushsubscriptionchange` without the app open. Success → `on`, toast.
|
||||
|
||||
`disable()`: `pushManager.getSubscription()` → `unsubscribe()` (best-effort) → `DELETE /notifications/push/device` → `off`, toast.
|
||||
|
||||
Receiving (SW): payload `{id,title,body,link,category}` → `showNotification(title, { body, data:{link}, tag:"n"+id })`. The `tag` is an on-device display-dedup keyed on the notification id (a redelivery of the same notification replaces the visible banner rather than stacking); it is independent of the server's Web Push `Topic` header (which dedups in the push-service queue). `notificationclick` → focus matching-origin client + postMessage `{type:"notification-click", link}`, else `openWindow(link ?? "/notifications")`.
|
||||
|
||||
Idempotency / healing: `enable()` is safe to re-run (server upserts); a stale local subscription with no server row heals on re-enable. Permission-granted-but-subscribe-fails → toast, stay `off`.
|
||||
|
||||
## Admin config page
|
||||
|
||||
`PushSettings.tsx`, registered in `AdminSettingsLayout` `SETTINGS_GROUPS` (Connections group). Pattern copied from `IntegrationsSettings.tsx` (`SettingField` + `CredentialStatus` + `useUpdateServerSetting`). Cards:
|
||||
|
||||
- **Web Push** (leads — usable today): `push.webpush.vapid_public`, `push.webpush.vapid_private`, `push.webpush.subject`. "Generate keys" button → `POST /admin/push/generate-vapid-keys` → fills public/private fields (not auto-saved; admin reviews then saves each). Subject defaults to a `mailto:` hint.
|
||||
- **APNs**: `push.apns.p8_key` (textarea), `push.apns.key_id`, `push.apns.team_id`, `push.apns.bundle_id`.
|
||||
- **FCM**: `push.fcm.service_account_json` (textarea).
|
||||
|
||||
Readiness: card header badge from `GET /admin/push/status`; per-field "configured, leave blank to keep" from the sensitive-status hook (secrets never returned). Each save = `PUT /admin/settings/{key}` `{value}` via `useUpdateServerSetting`; encryption automatic; badges refetch after save.
|
||||
|
||||
VAPID keygen endpoint: generates and returns `{vapid_public, vapid_private}`; does not persist. Handler test: non-empty pair, admin-gated.
|
||||
|
||||
### Open decision — "Send test push" button
|
||||
|
||||
OPTION (admin's call at spec review): a "Send test push" button that creates a one-off system notification addressed to the admin themselves to verify config→subscribe→receive end to end from the UI. Requires one more net-new server endpoint (`POST /api/v1/admin/push/test` → `notifications.Create` a system notification for the caller). Recommended for operability (turns "did I configure this right?" into one click) but it is the only other net-new server surface. **Decision: include it** unless cut at review. (Implementation plan will gate this behind the decision.)
|
||||
|
||||
## Error handling
|
||||
|
||||
- Capability gating (unsupported, denied) are UI states, not errors; iOS Safari shows "Add to Home Screen to enable push."
|
||||
- Transient subscribe/register failures → toast, leave `off`; nothing half-committed (server PUT is source of truth, idempotent).
|
||||
- SW resilience: tolerate partial payload (generic title fallback); `notificationclick` falls back to `/notifications`; failed `pushsubscriptionchange` re-register logs and retries on next app load (browser stops receiving until reopened — never a crash).
|
||||
- Admin page: save failures toast; readiness badges always reflect server truth (refetch after save); partial config shows "Not configured" until complete; generate-keys failure leaves fields untouched.
|
||||
|
||||
## Testing
|
||||
|
||||
- `usePushDevice` — unit tests with mocked serviceWorker/PushManager/Notification/api client: status derivation, enable() happy path (permission→key→subscribe→PUT body), permission-denied short-circuit, disable() (unsubscribe + DELETE), idempotent re-enable.
|
||||
- Settings UI — component tests: each status renders the right control; device-list rows + per-device toggle mutation.
|
||||
- Service worker — vitest over the pure handlers: payload→showNotification-args mapping, click→target-URL logic. DOM wiring exercised in the manual smoke.
|
||||
- Admin page — component tests: three cards render, badges reflect `/admin/push/status`, save calls `PUT /admin/settings/{key}` with the right key, generate-keys fills fields.
|
||||
- Server — VAPID keygen handler test (non-empty pair, admin-gated); test-push endpoint handler test (if included).
|
||||
- Manual smoke (documented in plan): real browser — configure VAPID, enable push, trigger a notification, confirm OS banner with app closed, click → deep link.
|
||||
|
||||
## Implementation sequencing
|
||||
|
||||
1. Server: VAPID keygen endpoint (+ test-push endpoint if kept).
|
||||
2. Web: service worker + registration + VAPID-key caching.
|
||||
3. Web: `usePushDevice` hook + tests.
|
||||
4. Web: NotificationsSettings push control + device list.
|
||||
5. Web: admin PushSettings page + nav.
|
||||
6. Verification + manual browser smoke.
|
||||
|
||||
## Future work
|
||||
|
||||
- Mobile clients (APNs/FCM) against the frozen API.
|
||||
- Shared-device token dedup + active-profile child-suppression (recorded in the push server spec) — informs whether multiple browsers/profiles dedupe.
|
||||
- Per-category push targeting if category mutes prove too coarse.
|
||||
- PWA/offline (vite-plugin-pwa) if the app later wants installable offline behavior.
|
||||
@@ -0,0 +1,78 @@
|
||||
# On-view description translation + per-profile metadata language — design
|
||||
|
||||
**Date:** 2026-06-10
|
||||
**Status:** Approved direction (per-profile preference; auto with button fallback; any user behind an opt-in setting)
|
||||
**Builds on:** `docs/superpowers/specs/2026-06-10-ai-translation-and-asr-design.md`
|
||||
|
||||
Commands assume the repository root is the cwd.
|
||||
|
||||
## Problem
|
||||
|
||||
Metadata translation currently triggers from the admin editor or the per-library
|
||||
refresh fallback, always targeting the library's metadata language. A viewer whose
|
||||
language differs from the library's (or whose library was never auto-translated) has
|
||||
no way to get descriptions in their language, and there is no per-person language
|
||||
preference at all — presentation language is library-scoped.
|
||||
|
||||
## Design
|
||||
|
||||
### Per-profile preferred metadata language
|
||||
|
||||
- New column `user_profiles.preferred_metadata_language text NOT NULL DEFAULT ''`
|
||||
(empty = inherit the library's metadata language). Threaded through the profile
|
||||
model/repo/API exactly like the existing `language` (UI language) field, plus a
|
||||
picker in the profile settings UI with an "Inherit library default" option.
|
||||
- `access.Scope` gains `PreferredMetadataLanguage`, populated when the access
|
||||
resolver loads the profile, so every request already carries it.
|
||||
- `catalog.AccessFilter` gains `ProfilePreferredLanguage`, and presentation language
|
||||
resolution becomes: explicit `PresentationLanguage` (request param) →
|
||||
**profile preference** → library `metadata_language`. Applied wherever the scope
|
||||
is available (native API and jellycompat), so all clients get profile-localized
|
||||
metadata server-side.
|
||||
|
||||
### "Not in your language" signal
|
||||
|
||||
`ItemDetail` gains `pending_translation_language` (omitted when empty): set when the
|
||||
resolved presentation language differs from the item's default metadata language,
|
||||
the base overview is non-empty, and no localization overview exists for that
|
||||
language. It is pure data — clients combine it with the AI feature status to decide
|
||||
what to render.
|
||||
|
||||
### On-view trigger
|
||||
|
||||
- New setting `metadata_ai.on_view`: `off` (default) | `button` | `auto`, surfaced
|
||||
on `GET /api/v1/metadata/ai/status` as `on_view`.
|
||||
- New authenticated endpoint `POST /api/v1/items/{id}/translate-description`
|
||||
`{target_language}` — any profile, gated by `metadata_ai` readiness +
|
||||
`on_view != off`, item access enforced with the same `EnsureAccessible` check the
|
||||
watched-state endpoints use. The client echoes back the language the server
|
||||
reported in `pending_translation_language`.
|
||||
- The endpoint goes through a new `Service.RequestOnView`, which adds one guard on
|
||||
top of the existing pipeline: if the latest job for (content, language) failed
|
||||
within a cooldown window, return it instead of enqueuing — a broken endpoint must
|
||||
not be retried on every page view. Existing guarantees do the rest: in-flight
|
||||
idempotency collapses concurrent viewers onto one job, skip-if-filled makes
|
||||
repeats free, and the shared semaphore caps endpoint load.
|
||||
|
||||
### Web UX
|
||||
|
||||
On the detail page, when `pending_translation_language` is set:
|
||||
|
||||
- `on_view=auto`: fire the request on view (once per item), pulse-animate the
|
||||
description while waiting, and poll the detail query until the flag clears (the
|
||||
job's first batch translates the item's own overview, so this lands in seconds
|
||||
even when a series job continues episodes in the background). Time out after ~45 s
|
||||
and show the original text — no error states in the viewer's face.
|
||||
- `on_view=button`: a small "Translate" chip near the description triggers the same
|
||||
flow on click.
|
||||
|
||||
No job-status endpoint for viewers: completion is observed as the flag clearing on
|
||||
refetch; failures surface only as the shimmer timing out (the cooldown stops
|
||||
re-triggering).
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Per-profile language in the Android/Apple UIs (server-side serving means they
|
||||
already *receive* localized metadata; surfacing the trigger there is a client
|
||||
follow-up using the same endpoint).
|
||||
- Translating titles, or any admin-side behavior changes.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Home Notification Bars — Design
|
||||
|
||||
Date: 2026-06-11
|
||||
Status: approved (design); implementation to follow
|
||||
|
||||
## Summary
|
||||
|
||||
Surface notifications at the top of the Home page so they're seen without opening the inbox tab, with system-level **announcements** visually distinguished from personal notifications. Two slim, dismissible bars stack at the top of Home: an **announcement bar** (distinct accent + 📢 + "Announcement" label) above a **notification strip** (the most recent unread personal notification). Both reuse existing hooks; no server changes. Web-only, additive.
|
||||
|
||||
## Goals
|
||||
|
||||
- Show the most recent unread notification high on the Home page, not only behind the bell/inbox.
|
||||
- Make admin **announcements** clearly distinct from request/content/system/admin notifications, via a separate styled bar.
|
||||
- Dismiss (✕) marks read and advances to the next unread; bars hide when nothing's unread.
|
||||
- Reuse the Phase-2 notifications hooks; inherit per-profile + child filtering and live WebSocket updates for free.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No server/API changes (the data already distinguishes `category: "announcement"`).
|
||||
- No change to the bell, inbox page, or toast (carrying the 📢 styling there is a possible follow-up, out of scope here).
|
||||
- Not app-wide — Home page only.
|
||||
|
||||
## Components
|
||||
|
||||
Two components in `web/src/components/home/` (or alongside Home), each small and independently testable, plus one mount edit.
|
||||
|
||||
### AnnouncementBar (`web/src/components/AnnouncementBar.tsx`)
|
||||
- Data: `useNotificationsList({ unread: true, category: "announcement" })` → the first (newest) item. Reliably finds the latest unread announcement regardless of how many other notifications are unread.
|
||||
- Render: a distinct bar using the app's accent tokens (reads as a system notice, not a personal row) — a 📢 icon, an "ANNOUNCEMENT" label, the title, truncated body, relative time, and a ✕.
|
||||
- Empty: renders nothing when there's no unread announcement.
|
||||
- ✕: `useMarkRead().mutate({ ids: [id] })` → the unread query updates → the next unread announcement shows, or the bar hides. Marking read also drops the bell badge (shared cache).
|
||||
- Click (anywhere but ✕): navigate to the notification's `link` (or `/notifications` when it has none) and mark it read.
|
||||
|
||||
### NotificationStrip (`web/src/components/NotificationStrip.tsx`)
|
||||
- Data: `useNotificationsList({ unread: true })` → the first item whose `category !== "announcement"` (so announcements live only in the bar above, never duplicated here).
|
||||
- Render: the slim strip — 🔔 icon, title, truncated body, relative time, ✕. Lighter styling than the announcement bar.
|
||||
- Empty: renders nothing when there's no unread non-announcement.
|
||||
- ✕ / click: same behavior as the bar (mark read + advance / navigate + mark read).
|
||||
|
||||
### Mount (`web/src/pages/Home.tsx`)
|
||||
At the top of the Home content region (near the existing `heroSlot` / `TasteSeedBanner`), render `<AnnouncementBar />` then `<NotificationStrip />` as the first elements, so they sit above the content rows. When both are empty, Home renders exactly as today.
|
||||
|
||||
## Data flow & shared behavior
|
||||
|
||||
- Both bars read through TanStack Query; the announcement bar's query (`{unread:true, category:"announcement"}`) and the strip's (`{unread:true}`) are distinct cache keys — two small fetches, both already used elsewhere.
|
||||
- Live updates: `useNotificationsLive` already invalidates the notifications list on `notification.created`, so both bars reactively show the newest unread without a reload.
|
||||
- Filtering: the list endpoint already scopes to the active profile and suppresses child-restricted categories server-side, so the bars inherit correct per-profile behavior.
|
||||
- The relative-time helper is the existing `timeAgo` (with the absolute-date fallback for old timestamps).
|
||||
|
||||
## Error handling
|
||||
|
||||
- While the query is loading, render nothing (no skeleton flash for a slim bar).
|
||||
- A mark-read failure surfaces the existing mutation `onError` toast; the bar stays until the next successful read.
|
||||
- A notification with no `link` navigates to `/notifications` rather than a dead link.
|
||||
|
||||
## Testing
|
||||
|
||||
- `AnnouncementBar.test.tsx`: renders the latest unread announcement with the 📢/label styling; ✕ calls `markRead({ids:[id]})`; clicking navigates (mocked `useNavigate`) to the link and marks read; renders nothing when no unread announcement.
|
||||
- `NotificationStrip.test.tsx`: shows the newest unread non-announcement (skips an announcement at the head of the list); ✕ marks read; click navigates + marks read; renders nothing when no unread non-announcement.
|
||||
- Existing suites stay green; tsc + lint clean.
|
||||
|
||||
## Future work
|
||||
|
||||
- Carry the 📢 announcement styling into the bell dropdown and inbox rows for a consistent identity.
|
||||
- Optional: a per-user "hide the home bars" preference if they prove noisy.
|
||||
@@ -1,6 +1,6 @@
|
||||
module github.com/Silo-Server/silo-server
|
||||
|
||||
go 1.26.3
|
||||
go 1.26.4
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5
|
||||
@@ -14,46 +14,73 @@ require (
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/redis/go-redis/v9 v9.18.0
|
||||
golang.org/x/crypto v0.50.0
|
||||
golang.org/x/sys v0.43.0
|
||||
golang.org/x/crypto v0.52.0
|
||||
golang.org/x/sys v0.45.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0
|
||||
github.com/abadojack/whatlanggo v1.0.1
|
||||
github.com/go-chi/cors v1.2.2
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/h2non/bimg v1.1.9
|
||||
github.com/hashicorp/go-hclog v1.6.3
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/mmcdole/gofeed v1.3.0
|
||||
github.com/oklog/ulid/v2 v2.1.0
|
||||
github.com/pgvector/pgvector-go v0.3.0
|
||||
github.com/pressly/goose/v3 v3.27.1
|
||||
github.com/sideshow/apns2 v0.25.0
|
||||
github.com/zishang520/socket.io/v2 v2.5.0
|
||||
go.n16f.net/thumbhash v1.1.0
|
||||
golang.org/x/image v0.39.0
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529
|
||||
golang.org/x/image v0.41.0
|
||||
golang.org/x/net v0.55.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
github.com/PuerkitoBio/goquery v1.8.0 // indirect
|
||||
github.com/andybalholm/brotli v1.2.1 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dunglas/httpsfv v1.1.0 // indirect
|
||||
github.com/fatih/color v1.13.0 // indirect
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/gookit/color v1.5.4 // indirect
|
||||
github.com/hashicorp/yamux v0.1.2 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
github.com/mattn/go-colorable v0.1.12 // indirect
|
||||
github.com/mattn/go-isatty v0.0.21 // indirect
|
||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/oklog/run v1.1.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.60.0 // indirect
|
||||
github.com/quic-go/webtransport-go v0.10.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.41.0 // indirect
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect
|
||||
github.com/zishang520/engine.io-go-parser v1.3.2 // indirect
|
||||
github.com/zishang520/engine.io/v2 v2.5.0 // indirect
|
||||
github.com/zishang520/socket.io-go-parser/v2 v2.5.0 // indirect
|
||||
github.com/zishang520/webtransport-go v0.9.1 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.5.0
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.6.0
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
|
||||
@@ -77,9 +104,11 @@ require (
|
||||
github.com/sony/sonyflake v1.3.0
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
golang.org/x/sync v0.20.0
|
||||
golang.org/x/text v0.36.0
|
||||
golang.org/x/time v0.14.0
|
||||
google.golang.org/grpc v1.80.0
|
||||
golang.org/x/sync v0.21.0
|
||||
golang.org/x/text v0.37.0
|
||||
golang.org/x/time v0.15.0
|
||||
google.golang.org/grpc v1.81.1
|
||||
google.golang.org/protobuf v1.36.11
|
||||
)
|
||||
|
||||
replace github.com/zishang520/webtransport-go => ./internal/compat/zishang520-webtransport-go
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
entgo.io/ent v0.14.3 h1:wokAV/kIlH9TeklJWGGS7AYJdVckr0DloWjIcO9iIIQ=
|
||||
entgo.io/ent v0.14.3/go.mod h1:aDPE/OziPEu8+OWbzy4UlvWmD2/kbRuWfK2A40hcxJM=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.5.0 h1:0yJpD0RP1AetGsWVCXoPih2ggLjN8hVp8blS+2RkIWU=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.5.0/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA=
|
||||
github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U=
|
||||
github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI=
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.6.0 h1:Gi9TdH9kt7b8X4xRXH493/nSYb9n0GO4VCWmlll0hKI=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.6.0/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA=
|
||||
github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4=
|
||||
github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc=
|
||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/units v0.0.0-20201120081800-1786d5ef83d4/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE=
|
||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
|
||||
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
|
||||
@@ -38,14 +50,16 @@ github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/
|
||||
github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
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/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/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/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dunglas/httpsfv v1.1.0 h1:Jw76nAyKWKZKFrpMMcL76y35tOpYHqQPzHQiwDvpe54=
|
||||
github.com/dunglas/httpsfv v1.1.0/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg=
|
||||
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/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
|
||||
@@ -62,14 +76,22 @@ github.com/go-pg/pg/v10 v10.11.0 h1:CMKJqLgTrfpE/aOVeLdybezR2om071Vh38OLZjsyMI0=
|
||||
github.com/go-pg/pg/v10 v10.11.0/go.mod h1:4BpHRoxE61y4Onpof3x1a2SQvi9c+q1dJnrNdMjsroA=
|
||||
github.com/go-pg/zerochecker v0.2.0 h1:pp7f72c3DobMWOb2ErtZsnrPaSvHd2W4o9//8HtF4mU=
|
||||
github.com/go-pg/zerochecker v0.2.0/go.mod h1:NJZ4wKL0NmTtz0GKCoJ8kym6Xn/EQzXRl2OnAe7MmDo=
|
||||
github.com/golang-jwt/jwt/v4 v4.4.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
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.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
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/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0=
|
||||
github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/h2non/bimg v1.1.9 h1:WH20Nxko9l/HFm4kZCA3Phbgu2cbHvYzxwxn9YROEGg=
|
||||
@@ -98,6 +120,8 @@ github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g=
|
||||
github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
|
||||
@@ -121,6 +145,15 @@ github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp
|
||||
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||
github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4=
|
||||
github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE=
|
||||
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 h1:Zr92CAlFhy2gL+V1F+EyIuzbQNbSgP4xhTODZtrXUtk=
|
||||
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23/go.mod h1:v+25+lT2ViuQ7mVxcncQ8ch1URund48oH+jhjiwEgS8=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
@@ -132,8 +165,9 @@ github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNs
|
||||
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
|
||||
github.com/pgvector/pgvector-go v0.3.0 h1:Ij+Yt78R//uYqs3Zk35evZFvr+G0blW0OUN+Q2D1RWc=
|
||||
github.com/pgvector/pgvector-go v0.3.0/go.mod h1:duFy+PXWfW7QQd5ibqutBO4GxLsUZ9RVXhFZGIBsWSA=
|
||||
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/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4=
|
||||
github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
@@ -144,6 +178,14 @@ github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9Z
|
||||
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
||||
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
|
||||
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
||||
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
|
||||
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0=
|
||||
github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk=
|
||||
github.com/quic-go/webtransport-go v0.10.0 h1:LqXXPOXuETY5Xe8ITdGisBzTYmUOy5eSj+9n4hLTjHI=
|
||||
github.com/quic-go/webtransport-go v0.10.0/go.mod h1:LeGIXr5BQKE3UsynwVBeQrU1TPrbh73MGoC6jd+V7ow=
|
||||
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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
@@ -156,10 +198,13 @@ github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEV
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
||||
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
||||
github.com/sideshow/apns2 v0.25.0 h1:XOzanncO9MQxkb03T/2uU2KcdVjYiIf0TMLzec0FTW4=
|
||||
github.com/sideshow/apns2 v0.25.0/go.mod h1:7Fceu+sL0XscxrfLSkAoH6UtvKefq3Kq1n4W3ayQZqE=
|
||||
github.com/sony/sonyflake v1.3.0 h1:tiB4Dlp0lnmKp/h6BLXA14P8Qi+LYS9+0QRpcrKHvg4=
|
||||
github.com/sony/sonyflake v1.3.0/go.mod h1:LORtCywH/cq10ZbyfhKrHYgAUGH7mOBa76enV9txy/Y=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
@@ -174,16 +219,29 @@ github.com/uptrace/bun/driver/pgdriver v1.1.12 h1:3rRWB1GK0psTJrHwxzNfEij2MLibgg
|
||||
github.com/uptrace/bun/driver/pgdriver v1.1.12/go.mod h1:ssYUP+qwSEgeDDS1xm2XBip9el1y9Mi5mTAvLoiADLM=
|
||||
github.com/vmihailenco/bufpool v0.1.11 h1:gOq2WmBrq0i2yW5QJ16ykccQ4wH9UyEsgLm6czKAd94=
|
||||
github.com/vmihailenco/bufpool v0.1.11/go.mod h1:AFf/MOy3l2CFTKbxwt0mp2MwnqjNEs5H/UxrkA5jxTQ=
|
||||
github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9znI5mJU=
|
||||
github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc=
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
|
||||
github.com/vmihailenco/tagparser v0.1.2 h1:gnjoVuB/kljJ5wICEEOpx98oXMWPLj22G67Vbd1qPqc=
|
||||
github.com/vmihailenco/tagparser v0.1.2/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 h1:QldyIu/L63oPpyvQmHgvgickp1Yw510KJOqX7H24mg8=
|
||||
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs=
|
||||
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.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
github.com/zishang520/engine.io-go-parser v1.3.2 h1:aEVrhQVhfk99Ct6htNffgHydUBC4dGclO/OXPz5CSy0=
|
||||
github.com/zishang520/engine.io-go-parser v1.3.2/go.mod h1:fg/R4V7aytYwUTu4lGcPdjenDSXFWLlkDAGewWVOo3o=
|
||||
github.com/zishang520/engine.io/v2 v2.5.0 h1:0ayZCt51c8lntxG5AWoM2mX40ryZlvRodAULXB1XK/s=
|
||||
github.com/zishang520/engine.io/v2 v2.5.0/go.mod h1:ohfMsnzOCA9NEklEGiQ5Y9j6cWvzLNeVFaB+Bkn1KcQ=
|
||||
github.com/zishang520/socket.io-go-parser/v2 v2.5.0 h1:uGKTwcH2qrrs9uwzfCo3ems+qUJZo9beZLK8ZXOwKYo=
|
||||
github.com/zishang520/socket.io-go-parser/v2 v2.5.0/go.mod h1:GK9GIIs/KQbBKfnxgZJMBYSlsFemB2rL7EFUn0kmTfM=
|
||||
github.com/zishang520/socket.io/v2 v2.5.0 h1:+KdZLbl4wWVzyI84RG+h21t8OY4ifz/Oo6qwgt9zHl8=
|
||||
github.com/zishang520/socket.io/v2 v2.5.0/go.mod h1:+GyoPyakXDS6KsW81RAQpDA9+mJBXbcYcQ+Itx2D+rU=
|
||||
go.n16f.net/thumbhash v1.1.0 h1:aBEvuAd4yiwzeQ7Sm4BZoHJYbrQ1ewjrmrRlCE79snk=
|
||||
go.n16f.net/thumbhash v1.1.0/go.mod h1:mo9pP7WtfdV9ojIamGFR/Vc0PaPA2l0CUtmYQf/SweU=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
@@ -192,52 +250,126 @@ go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||
go.opentelemetry.io/otel/sdk v1.41.0 h1:YPIEXKmiAwkGl3Gu1huk1aYWwtpRLeskpV+wPisxBp8=
|
||||
go.opentelemetry.io/otel/sdk v1.41.0/go.mod h1:ahFdU0G5y8IxglBf0QBJXgSe7agzjE4GiTJ6HT9ud90=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.41.0 h1:siZQIYBAUd1rlIWQT2uCxWJxcCO7q3TriaMlf08rXw8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.41.0/go.mod h1:HNBuSvT7ROaGtGI50ArdRLUnvRTRGniSUZbxiWxSO8Y=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||
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.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
|
||||
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
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=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
|
||||
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/crypto v0.0.0-20170512130425-ab89591268e0/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
|
||||
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220403103023-749bd193bc2b/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 h1:XF8+t6QQiS0o9ArVan/HW8Q7cycNPGsJf6GA2nXxYAg=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
||||
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
|
||||
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
|
||||
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/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -73,6 +73,7 @@ func (r *Resolver) Resolve(ctx context.Context, input ResolveInput) (Scope, erro
|
||||
|
||||
scope.MaxContentRating = profile.MaxContentRating
|
||||
scope.MaxPlaybackQuality = MinQuality(scope.MaxPlaybackQuality, NormalizePlaybackQuality(profile.MaxPlaybackQuality))
|
||||
scope.PreferredMetadataLanguage = profile.PreferredMetadataLanguage
|
||||
scope.AllowedLibraryIDs, scope.LibrariesRestricted = effectiveLibraries(user.LibraryIDs, profile)
|
||||
scope.ProfileVerified = profile.PINHash == "" || input.SkipPINVerification
|
||||
|
||||
|
||||
@@ -9,8 +9,11 @@ type Scope struct {
|
||||
LibrariesRestricted bool
|
||||
MaxContentRating string
|
||||
MaxPlaybackQuality string
|
||||
PolicyRevision int64
|
||||
ProfileVerified bool
|
||||
// PreferredMetadataLanguage is the profile's metadata (presentation)
|
||||
// language; "" inherits the library's metadata language.
|
||||
PreferredMetadataLanguage string
|
||||
PolicyRevision int64
|
||||
ProfileVerified bool
|
||||
}
|
||||
|
||||
// ResolveInput is the request input for resolving a viewer access scope.
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
// Package jobrunner owns the lifecycle mechanics shared by Silo's AI job
|
||||
// services (subtitle translation/ASR, metadata translation): bounded dispatch
|
||||
// off a semaphore shared across services, a heartbeat loop that keeps a row
|
||||
// alive even while the job is queued, a stale-job reaper for rows orphaned by
|
||||
// a crashed worker, and a per-job cancel registry. The services keep their own
|
||||
// job tables, validation, and run logic; this package keeps them honest about
|
||||
// concurrency and crash recovery without copy-pasting the trickiest code.
|
||||
package jobrunner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// A running job refreshes its heartbeat every HeartbeatInterval; one whose
|
||||
// heartbeat has not advanced for StaleJobThreshold is treated as orphaned
|
||||
// by a crashed worker and reaped. The margin over HeartbeatInterval avoids
|
||||
// reaping a job that is merely mid–LLM-call.
|
||||
HeartbeatInterval = 30 * time.Second
|
||||
StaleJobThreshold = 2 * time.Minute
|
||||
// How often the background reaper scans for orphaned jobs.
|
||||
ReaperInterval = time.Minute
|
||||
)
|
||||
|
||||
// Store is the minimal persistence surface the runner needs. Both AI job
|
||||
// repositories satisfy it.
|
||||
type Store interface {
|
||||
Heartbeat(ctx context.Context, id int64) error
|
||||
// ResetStaleJobs marks pending/running jobs whose heartbeat predates
|
||||
// `before` as failed with the given message. Returns rows reset.
|
||||
ResetStaleJobs(ctx context.Context, before time.Time, message string) (int64, error)
|
||||
}
|
||||
|
||||
// NewSemaphore builds the dispatch semaphore shared across runners, so the
|
||||
// configured endpoint sees one global bound regardless of job mix. size <= 0
|
||||
// falls back to 2.
|
||||
func NewSemaphore(size int) chan struct{} {
|
||||
if size <= 0 {
|
||||
size = 2
|
||||
}
|
||||
return make(chan struct{}, size)
|
||||
}
|
||||
|
||||
// Runner executes jobs with bounded concurrency, heartbeats, cancellation,
|
||||
// and crash recovery. One Runner per job table; the semaphore may be shared
|
||||
// across Runners.
|
||||
type Runner struct {
|
||||
// baseCtx is the application context; dispatched jobs and the reaper
|
||||
// derive from it so they stop when the server shuts down.
|
||||
baseCtx context.Context
|
||||
sem chan struct{}
|
||||
store Store
|
||||
// label names the job family in log lines ("subtitle ai", "metadata translation").
|
||||
label string
|
||||
logger *slog.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
cancels map[int64]context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// New wires a runner. A nil appCtx falls back to context.Background(); a nil
|
||||
// sem gets a private default-size semaphore (used by tests; production passes
|
||||
// the shared one).
|
||||
func New(appCtx context.Context, sem chan struct{}, store Store, label string, logger *slog.Logger) *Runner {
|
||||
if appCtx == nil {
|
||||
appCtx = context.Background()
|
||||
}
|
||||
if sem == nil {
|
||||
sem = NewSemaphore(0)
|
||||
}
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &Runner{
|
||||
baseCtx: appCtx,
|
||||
sem: sem,
|
||||
store: store,
|
||||
label: label,
|
||||
logger: logger,
|
||||
cancels: make(map[int64]context.CancelFunc),
|
||||
}
|
||||
}
|
||||
|
||||
// Recover clears jobs orphaned by a crashed worker and starts a background
|
||||
// reaper that keeps doing so. Reaping is heartbeat-based (not "every active
|
||||
// job"), so it is safe when multiple instances share one database: a job
|
||||
// still being heartbeat-updated by a live worker is never reset. Call once at
|
||||
// startup; jobs and the reaper derive from the application context, so they
|
||||
// stop on shutdown.
|
||||
func (r *Runner) Recover() {
|
||||
r.reapStaleJobs()
|
||||
go r.reaperLoop()
|
||||
}
|
||||
|
||||
func (r *Runner) reaperLoop() {
|
||||
ticker := time.NewTicker(ReaperInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-r.baseCtx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
r.reapStaleJobs()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) reapStaleJobs() {
|
||||
before := time.Now().Add(-StaleJobThreshold)
|
||||
n, err := r.store.ResetStaleJobs(context.WithoutCancel(r.baseCtx), before, "interrupted by server restart")
|
||||
if err != nil {
|
||||
r.logger.Warn("failed to reset stale jobs", "jobs", r.label, "error", err)
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
r.logger.Info("reset stale jobs", "jobs", r.label, "count", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Dispatch launches a bounded background goroutine for job id. run executes
|
||||
// once a semaphore slot is acquired; onAbort runs instead if the job is
|
||||
// cancelled (user cancel or shutdown) while still waiting for a slot. Both
|
||||
// receive a context derived from the application context that is cancelled by
|
||||
// Cancel(id) or server shutdown.
|
||||
func (r *Runner) Dispatch(id int64, run func(ctx context.Context), onAbort func(ctx context.Context)) {
|
||||
runCtx, cancel := context.WithCancel(r.baseCtx)
|
||||
r.mu.Lock()
|
||||
r.cancels[id] = cancel
|
||||
r.mu.Unlock()
|
||||
|
||||
r.wg.Add(1)
|
||||
go func() {
|
||||
defer r.wg.Done()
|
||||
defer func() {
|
||||
r.mu.Lock()
|
||||
delete(r.cancels, id)
|
||||
r.mu.Unlock()
|
||||
cancel()
|
||||
}()
|
||||
|
||||
// Heartbeat for the whole lifetime — crucially including while queued
|
||||
// behind the semaphore — so the stale-job reaper never reaps a job that
|
||||
// is alive but merely waiting for a slot (which would otherwise mark it
|
||||
// failed and let it resurrect itself on acquire, or admit a duplicate).
|
||||
stopHeartbeat := make(chan struct{})
|
||||
defer close(stopHeartbeat)
|
||||
go r.heartbeatLoop(runCtx, id, stopHeartbeat)
|
||||
|
||||
select {
|
||||
case r.sem <- struct{}{}:
|
||||
case <-runCtx.Done():
|
||||
if onAbort != nil {
|
||||
onAbort(context.WithoutCancel(runCtx))
|
||||
}
|
||||
return
|
||||
}
|
||||
defer func() { <-r.sem }()
|
||||
|
||||
run(runCtx)
|
||||
}()
|
||||
}
|
||||
|
||||
// Cancel cancels the in-flight goroutine for id, returning false when none is
|
||||
// registered (job on another node, or never dispatched) so the caller can
|
||||
// fall back to a best-effort terminal transition in the database.
|
||||
func (r *Runner) Cancel(id int64) bool {
|
||||
r.mu.Lock()
|
||||
cancel := r.cancels[id]
|
||||
r.mu.Unlock()
|
||||
if cancel == nil {
|
||||
return false
|
||||
}
|
||||
cancel()
|
||||
return true
|
||||
}
|
||||
|
||||
// heartbeatLoop keeps a job's heartbeat_at fresh until the job ends or the
|
||||
// context is cancelled, so the stale-job reaper only ever reaps jobs orphaned
|
||||
// by a crashed worker.
|
||||
func (r *Runner) heartbeatLoop(ctx context.Context, jobID int64, stop <-chan struct{}) {
|
||||
ticker := time.NewTicker(HeartbeatInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
_ = r.store.Heartbeat(context.WithoutCancel(ctx), jobID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package jobrunner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type fakeStore struct {
|
||||
mu sync.Mutex
|
||||
heartbeats int
|
||||
resets int
|
||||
lastBefore time.Time
|
||||
}
|
||||
|
||||
func (s *fakeStore) Heartbeat(context.Context, int64) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.heartbeats++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeStore) ResetStaleJobs(_ context.Context, before time.Time, _ string) (int64, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.resets++
|
||||
s.lastBefore = before
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (s *fakeStore) snapshot() (int, time.Time) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.resets, s.lastBefore
|
||||
}
|
||||
|
||||
func TestRecoverReapsImmediatelyWithStaleCutoff(t *testing.T) {
|
||||
store := &fakeStore{}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
approxNow := time.Now()
|
||||
New(ctx, nil, store, "test", nil).Recover()
|
||||
|
||||
resets, before := store.snapshot()
|
||||
if resets < 1 {
|
||||
t.Fatalf("Recover did not reap immediately: resets=%d", resets)
|
||||
}
|
||||
want := approxNow.Add(-StaleJobThreshold)
|
||||
if diff := before.Sub(want); diff > 2*time.Second || diff < -2*time.Second {
|
||||
t.Errorf("stale cutoff = %v, want ~%v", before, want)
|
||||
}
|
||||
}
|
||||
|
||||
// One shared semaphore bounds jobs across BOTH runners: with size 1, the
|
||||
// second runner's job must not start until the first runner's job finishes.
|
||||
func TestSharedSemaphoreBoundsAcrossRunners(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
sem := NewSemaphore(1)
|
||||
r1 := New(ctx, sem, &fakeStore{}, "one", nil)
|
||||
r2 := New(ctx, sem, &fakeStore{}, "two", nil)
|
||||
|
||||
release := make(chan struct{})
|
||||
aStarted := make(chan struct{})
|
||||
var bStarted atomic.Bool
|
||||
bDone := make(chan struct{})
|
||||
|
||||
r1.Dispatch(1, func(context.Context) {
|
||||
close(aStarted)
|
||||
<-release
|
||||
}, nil)
|
||||
<-aStarted
|
||||
|
||||
r2.Dispatch(2, func(context.Context) {
|
||||
bStarted.Store(true)
|
||||
close(bDone)
|
||||
}, nil)
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if bStarted.Load() {
|
||||
t.Fatal("job B ran while job A held the only slot")
|
||||
}
|
||||
close(release)
|
||||
select {
|
||||
case <-bDone:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("job B never ran after the slot freed")
|
||||
}
|
||||
}
|
||||
|
||||
// Cancelling a job that is queued behind the semaphore aborts it without
|
||||
// running it, via the onAbort callback.
|
||||
func TestCancelWhileQueuedCallsOnAbort(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
sem := NewSemaphore(1)
|
||||
r := New(ctx, sem, &fakeStore{}, "test", nil)
|
||||
|
||||
release := make(chan struct{})
|
||||
defer close(release)
|
||||
started := make(chan struct{})
|
||||
r.Dispatch(1, func(context.Context) {
|
||||
close(started)
|
||||
<-release
|
||||
}, nil)
|
||||
<-started
|
||||
|
||||
var ran atomic.Bool
|
||||
aborted := make(chan struct{})
|
||||
r.Dispatch(2, func(context.Context) {
|
||||
ran.Store(true)
|
||||
}, func(context.Context) {
|
||||
close(aborted)
|
||||
})
|
||||
|
||||
if !r.Cancel(2) {
|
||||
t.Fatal("Cancel(2) found no in-flight goroutine")
|
||||
}
|
||||
select {
|
||||
case <-aborted:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("onAbort never ran")
|
||||
}
|
||||
if ran.Load() {
|
||||
t.Fatal("cancelled queued job still ran")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelUnknownJobReturnsFalse(t *testing.T) {
|
||||
r := New(context.Background(), nil, &fakeStore{}, "test", nil)
|
||||
if r.Cancel(99) {
|
||||
t.Fatal("Cancel(99) = true for unknown job")
|
||||
}
|
||||
}
|
||||
|
||||
// The run context is cancelled by Cancel(id) so an in-flight job can stop.
|
||||
func TestCancelRunningJobCancelsContext(t *testing.T) {
|
||||
r := New(context.Background(), NewSemaphore(1), &fakeStore{}, "test", nil)
|
||||
started := make(chan struct{})
|
||||
stopped := make(chan struct{})
|
||||
r.Dispatch(7, func(ctx context.Context) {
|
||||
close(started)
|
||||
<-ctx.Done()
|
||||
close(stopped)
|
||||
}, nil)
|
||||
<-started
|
||||
if !r.Cancel(7) {
|
||||
t.Fatal("Cancel(7) found no in-flight goroutine")
|
||||
}
|
||||
select {
|
||||
case <-stopped:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("running job did not observe cancellation")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package jobrunner
|
||||
|
||||
// Status is the lifecycle state shared by every AI job table.
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusPending Status = "pending"
|
||||
StatusRunning Status = "running"
|
||||
StatusCompleted Status = "completed"
|
||||
StatusFailed Status = "failed"
|
||||
StatusCancelled Status = "cancelled"
|
||||
)
|
||||
|
||||
// Terminal reports whether a status is final.
|
||||
func (s Status) Terminal() bool {
|
||||
switch s {
|
||||
case StatusCompleted, StatusFailed, StatusCancelled:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
// Package llm provides the shared OpenAI-compatible API client used by every
|
||||
// AI feature in Silo (subtitle translation, metadata translation, Whisper ASR).
|
||||
// One endpoint configuration, one retry/backoff implementation; the operator
|
||||
// can point it at OpenAI, Groq, a local Ollama/llama.cpp/faster-whisper
|
||||
// server, etc.
|
||||
package llm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config holds the connection settings for the shared OpenAI-compatible
|
||||
// endpoints. The ASR fields are optional overrides for operators who run a
|
||||
// separate Whisper-compatible server next to their chat endpoint; when empty,
|
||||
// transcription uses the chat endpoint's base URL and key.
|
||||
type Config struct {
|
||||
BaseURL string // e.g. "https://api.openai.com" (no trailing /v1)
|
||||
APIKey string // empty for keyless local servers
|
||||
ChatModel string // chat-completions model used for translation
|
||||
|
||||
ASRBaseURL string // optional; empty = BaseURL
|
||||
ASRAPIKey string // optional; empty = APIKey
|
||||
ASRModel string // audio-transcription model, e.g. "whisper-1"
|
||||
}
|
||||
|
||||
func (c Config) asrBaseURL() string {
|
||||
if c.ASRBaseURL != "" {
|
||||
return c.ASRBaseURL
|
||||
}
|
||||
return c.BaseURL
|
||||
}
|
||||
|
||||
func (c Config) asrAPIKey() string {
|
||||
if c.ASRAPIKey != "" {
|
||||
return c.ASRAPIKey
|
||||
}
|
||||
return c.APIKey
|
||||
}
|
||||
|
||||
// ChatConfigured reports whether chat completions are minimally configured.
|
||||
func (c Config) ChatConfigured() bool { return c.BaseURL != "" && c.ChatModel != "" }
|
||||
|
||||
// ASRConfigured reports whether audio transcription is minimally configured.
|
||||
func (c Config) ASRConfigured() bool { return c.asrBaseURL() != "" && c.ASRModel != "" }
|
||||
|
||||
// Client is a minimal OpenAI-compatible API client with shared retry/backoff
|
||||
// conventions (429 with Retry-After, 5xx, transport errors, and gateway "200
|
||||
// with embedded error object" responses are retried; other 4xx fail fast).
|
||||
// The config is held behind an atomic pointer so admin settings changes apply
|
||||
// to subsequent requests without rebuilding the client; each request snapshots
|
||||
// the config once so its URL, key, and model stay consistent.
|
||||
type Client struct {
|
||||
cfg atomic.Pointer[Config]
|
||||
// chatHTTP caps a single chat completion at 10 minutes. asrHTTP has no
|
||||
// client-level timeout: a transcription upload's deadline is set per request
|
||||
// (sized to the chunk duration), which a client timeout would silently cap.
|
||||
chatHTTP *http.Client
|
||||
asrHTTP *http.Client
|
||||
}
|
||||
|
||||
// NewClient builds a client from the endpoint config.
|
||||
func NewClient(cfg Config) *Client {
|
||||
c := &Client{
|
||||
chatHTTP: &http.Client{Timeout: 10 * time.Minute},
|
||||
asrHTTP: &http.Client{},
|
||||
}
|
||||
c.UpdateConfig(cfg)
|
||||
return c
|
||||
}
|
||||
|
||||
// UpdateConfig swaps the endpoint config. Safe for concurrent use; in-flight
|
||||
// requests finish with the config they started with.
|
||||
func (c *Client) UpdateConfig(cfg Config) {
|
||||
c.cfg.Store(&cfg)
|
||||
}
|
||||
|
||||
// Config returns a snapshot of the current endpoint config.
|
||||
func (c *Client) Config() Config {
|
||||
return *c.cfg.Load()
|
||||
}
|
||||
|
||||
// Message is one chat-completions message.
|
||||
type Message struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type chatResponseFormat struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type chatCompletionRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []Message `json:"messages"`
|
||||
Temperature float32 `json:"temperature"`
|
||||
ResponseFormat *chatResponseFormat `json:"response_format,omitempty"`
|
||||
}
|
||||
|
||||
type chatCompletionResponse struct {
|
||||
Choices []struct {
|
||||
Message Message `json:"message"`
|
||||
} `json:"choices"`
|
||||
// Some OpenAI-compatible gateways (e.g. OpenRouter) return a 200 with an
|
||||
// error object instead of an HTTP error status when an upstream provider
|
||||
// fails. We surface and retry on it.
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
// Chat performs one chat completion and returns the first choice's content.
|
||||
// When jsonObject is true it requests response_format=json_object; providers
|
||||
// that ignore the field still work because the prompt itself demands JSON.
|
||||
func (c *Client) Chat(ctx context.Context, messages []Message, jsonObject bool) (string, error) {
|
||||
cfg := c.Config()
|
||||
reqBody := chatCompletionRequest{
|
||||
Model: cfg.ChatModel,
|
||||
Messages: messages,
|
||||
Temperature: 0.2,
|
||||
}
|
||||
if jsonObject {
|
||||
reqBody.ResponseFormat = &chatResponseFormat{Type: "json_object"}
|
||||
}
|
||||
|
||||
body, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal chat request: %w", err)
|
||||
}
|
||||
|
||||
url := endpointURL(cfg.BaseURL, "chat/completions")
|
||||
|
||||
var content string
|
||||
err = c.doWithRetry(ctx, c.chatHTTP, "chat API",
|
||||
func() (*http.Request, error) {
|
||||
httpReq, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if reqErr != nil {
|
||||
return nil, fmt.Errorf("create request: %w", reqErr)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
if cfg.APIKey != "" {
|
||||
httpReq.Header.Set("Authorization", "Bearer "+cfg.APIKey)
|
||||
}
|
||||
return httpReq, nil
|
||||
},
|
||||
func(respBody []byte) error {
|
||||
var parsed chatCompletionResponse
|
||||
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
||||
return fmt.Errorf("decode chat response: %w", err)
|
||||
}
|
||||
if parsed.Error != nil && parsed.Error.Message != "" {
|
||||
// 200 with an upstream error object — transient on gateways.
|
||||
return fmt.Errorf("chat API error: %s", parsed.Error.Message)
|
||||
}
|
||||
if len(parsed.Choices) == 0 || parsed.Choices[0].Message.Content == "" {
|
||||
slog.Warn("AI chat returned no choices, retrying",
|
||||
"model", cfg.ChatModel, "response_bytes", len(respBody))
|
||||
return fmt.Errorf("chat API returned no choices")
|
||||
}
|
||||
content = parsed.Choices[0].Message.Content
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
// SystemUserChat performs one chat completion from a system + user prompt
|
||||
// pair, requesting a JSON object response. Its signature matches
|
||||
// aitranslate.ChatFn so a client method reference wires straight in.
|
||||
func (c *Client) SystemUserChat(ctx context.Context, system, user string) (string, error) {
|
||||
return c.Chat(ctx, []Message{
|
||||
{Role: "system", Content: system},
|
||||
{Role: "user", Content: user},
|
||||
}, true)
|
||||
}
|
||||
|
||||
// permanentError marks a parse failure that retrying cannot fix (e.g. an
|
||||
// endpoint that structurally does not support the requested response format).
|
||||
type permanentError struct{ err error }
|
||||
|
||||
func (e *permanentError) Error() string { return e.err.Error() }
|
||||
func (e *permanentError) Unwrap() error { return e.err }
|
||||
|
||||
// doWithRetry runs the shared request/retry loop: build constructs a fresh
|
||||
// request per attempt, parse consumes a 200 body. Transport errors, read
|
||||
// errors, 429 (honoring Retry-After), 5xx, and retryable parse errors back
|
||||
// off and retry; other 4xx and permanentError parse failures return at once.
|
||||
func (c *Client) doWithRetry(ctx context.Context, httpClient *http.Client, label string,
|
||||
build func() (*http.Request, error), parse func(body []byte) error) error {
|
||||
const maxAttempts = 6
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < maxAttempts; attempt++ {
|
||||
httpReq, err := build()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, doErr := httpClient.Do(httpReq)
|
||||
if doErr != nil {
|
||||
lastErr = fmt.Errorf("%s request failed: %w", label, doErr)
|
||||
if waitErr := sleepCtx(ctx, time.Duration(attempt+1)*time.Second); waitErr != nil {
|
||||
return waitErr
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
respBody, readErr := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if readErr != nil {
|
||||
// A truncated/failed read could otherwise be misparsed as a valid
|
||||
// (empty) response; treat it as a retryable transport error.
|
||||
lastErr = fmt.Errorf("read %s response: %w", label, readErr)
|
||||
if waitErr := sleepCtx(ctx, time.Duration(attempt+1)*time.Second); waitErr != nil {
|
||||
return waitErr
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusTooManyRequests:
|
||||
wait := rateLimitBackoff(resp, attempt)
|
||||
slog.Warn("rate limited by AI API, waiting", "api", label, "attempt", attempt+1, "wait", wait)
|
||||
lastErr = fmt.Errorf("%s returned 429: %s", label, Truncate(string(respBody), 300))
|
||||
if waitErr := sleepCtx(ctx, wait); waitErr != nil {
|
||||
return waitErr
|
||||
}
|
||||
continue
|
||||
case resp.StatusCode >= 500:
|
||||
lastErr = fmt.Errorf("%s returned %d: %s", label, resp.StatusCode, Truncate(string(respBody), 300))
|
||||
if waitErr := sleepCtx(ctx, time.Duration(attempt+1)*time.Second); waitErr != nil {
|
||||
return waitErr
|
||||
}
|
||||
continue
|
||||
case resp.StatusCode != http.StatusOK:
|
||||
// 4xx other than 429: not retryable.
|
||||
return fmt.Errorf("%s returned %d: %s", label, resp.StatusCode, Truncate(string(respBody), 300))
|
||||
}
|
||||
|
||||
parseErr := parse(respBody)
|
||||
if parseErr == nil {
|
||||
return nil
|
||||
}
|
||||
var perm *permanentError
|
||||
if errors.As(parseErr, &perm) {
|
||||
return perm.err
|
||||
}
|
||||
lastErr = parseErr
|
||||
if waitErr := sleepCtx(ctx, time.Duration(attempt+1)*time.Second); waitErr != nil {
|
||||
return waitErr
|
||||
}
|
||||
}
|
||||
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("%s: retries exhausted", label)
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// endpointURL joins a configured base URL with an OpenAI API path,
|
||||
// tolerating bases that already include the version segment (e.g.
|
||||
// DeepInfra's https://api.deepinfra.com/v1/openai) alongside bare hosts
|
||||
// (https://api.openai.com) and prefixed hosts (https://api.groq.com/openai).
|
||||
func endpointURL(base, path string) string {
|
||||
base = strings.TrimRight(base, "/")
|
||||
if strings.Contains(base, "/v1") {
|
||||
return base + "/" + path
|
||||
}
|
||||
return base + "/v1/" + path
|
||||
}
|
||||
|
||||
// Truncate caps a string for inclusion in an error or log line.
|
||||
func Truncate(s string, maxLen int) string {
|
||||
if len(s) > maxLen {
|
||||
return s[:maxLen] + "..."
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// sleepCtx waits for d or until ctx is cancelled.
|
||||
func sleepCtx(ctx context.Context, d time.Duration) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(d):
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// rateLimitBackoff returns how long to wait after a 429, honoring Retry-After
|
||||
// and otherwise backing off exponentially (capped at 60s).
|
||||
func rateLimitBackoff(resp *http.Response, attempt int) time.Duration {
|
||||
if ra := resp.Header.Get("Retry-After"); ra != "" {
|
||||
if secs, err := strconv.Atoi(ra); err == nil && secs > 0 {
|
||||
return time.Duration(secs) * time.Second
|
||||
}
|
||||
}
|
||||
wait := 10 * time.Second * (1 << attempt)
|
||||
if wait > 60*time.Second {
|
||||
wait = 60 * time.Second
|
||||
}
|
||||
return wait
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func chatConfig(baseURL string) Config {
|
||||
return Config{BaseURL: baseURL, APIKey: "test-key", ChatModel: "test-model"}
|
||||
}
|
||||
|
||||
const chatOK = `{"choices":[{"message":{"role":"assistant","content":"hello"}}]}`
|
||||
|
||||
func TestChatSuccessSendsAuthAndModel(t *testing.T) {
|
||||
var gotAuth, gotBody string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
buf := make([]byte, 4096)
|
||||
n, _ := r.Body.Read(buf)
|
||||
gotBody = string(buf[:n])
|
||||
w.Write([]byte(chatOK))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(chatConfig(srv.URL))
|
||||
out, err := c.Chat(context.Background(), []Message{{Role: "user", Content: "hi"}}, true)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat: %v", err)
|
||||
}
|
||||
if out != "hello" {
|
||||
t.Errorf("content = %q, want hello", out)
|
||||
}
|
||||
if gotAuth != "Bearer test-key" {
|
||||
t.Errorf("auth = %q", gotAuth)
|
||||
}
|
||||
if !strings.Contains(gotBody, `"model":"test-model"`) || !strings.Contains(gotBody, `"json_object"`) {
|
||||
t.Errorf("request body missing model/response_format: %s", gotBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatRetriesOn429HonoringRetryAfter(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if calls.Add(1) == 1 {
|
||||
w.Header().Set("Retry-After", "1")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
w.Write([]byte(chatOK))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
start := time.Now()
|
||||
c := NewClient(chatConfig(srv.URL))
|
||||
if _, err := c.Chat(context.Background(), []Message{{Role: "user", Content: "hi"}}, false); err != nil {
|
||||
t.Fatalf("Chat: %v", err)
|
||||
}
|
||||
if calls.Load() != 2 {
|
||||
t.Errorf("calls = %d, want 2", calls.Load())
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed < time.Second {
|
||||
t.Errorf("did not honor Retry-After: elapsed %v", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatRetriesOn5xxAndEmbeddedErrorAndEmptyChoices(t *testing.T) {
|
||||
responses := []func(w http.ResponseWriter){
|
||||
func(w http.ResponseWriter) { w.WriteHeader(http.StatusBadGateway) },
|
||||
func(w http.ResponseWriter) { w.Write([]byte(`{"error":{"message":"upstream sad"}}`)) },
|
||||
func(w http.ResponseWriter) { w.Write([]byte(`{"choices":[]}`)) },
|
||||
func(w http.ResponseWriter) { w.Write([]byte(chatOK)) },
|
||||
}
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
responses[calls.Add(1)-1](w)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(chatConfig(srv.URL))
|
||||
out, err := c.Chat(context.Background(), []Message{{Role: "user", Content: "hi"}}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat: %v", err)
|
||||
}
|
||||
if out != "hello" || calls.Load() != 4 {
|
||||
t.Errorf("out=%q calls=%d, want hello/4", out, calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatFailsFastOnNon429ClientError(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(chatConfig(srv.URL))
|
||||
if _, err := c.Chat(context.Background(), []Message{{Role: "user", Content: "hi"}}, false); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if calls.Load() != 1 {
|
||||
t.Errorf("calls = %d, want 1 (no retry on 401)", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
const verboseJSON = `{"language":"english","text":"hi there","segments":[{"start":0.0,"end":1.5,"text":" hi"},{"start":1.5,"end":3.0,"text":" there"}]}`
|
||||
|
||||
func TestTranscribeParsesSegmentsAndMultipart(t *testing.T) {
|
||||
var gotModel, gotFormat, gotLang, gotAuth, gotVAD string
|
||||
var gotGranularities []string
|
||||
var gotFile []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||
t.Errorf("parse multipart: %v", err)
|
||||
}
|
||||
gotModel = r.FormValue("model")
|
||||
gotFormat = r.FormValue("response_format")
|
||||
gotLang = r.FormValue("language")
|
||||
gotVAD = r.FormValue("vad_filter")
|
||||
gotGranularities = r.MultipartForm.Value["timestamp_granularities[]"]
|
||||
f, _, err := r.FormFile("file")
|
||||
if err == nil {
|
||||
buf := make([]byte, 64)
|
||||
n, _ := f.Read(buf)
|
||||
gotFile = buf[:n]
|
||||
f.Close()
|
||||
}
|
||||
w.Write([]byte(verboseJSON))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := chatConfig(srv.URL)
|
||||
cfg.ASRModel = "whisper-test"
|
||||
c := NewClient(cfg)
|
||||
tr, err := c.Transcribe(context.Background(), TranscribeRequest{
|
||||
Filename: "chunk.wav", Audio: []byte("RIFFfake"), Language: "ja",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Transcribe: %v", err)
|
||||
}
|
||||
if gotModel != "whisper-test" || gotFormat != "verbose_json" || gotLang != "ja" {
|
||||
t.Errorf("fields model=%q format=%q lang=%q", gotModel, gotFormat, gotLang)
|
||||
}
|
||||
// Local (non-hosted) endpoint: faster-whisper VAD must be requested or
|
||||
// segment timestamps stretch wall-to-wall across silence.
|
||||
if gotVAD != "true" {
|
||||
t.Errorf("vad_filter = %q, want true for a self-hosted endpoint", gotVAD)
|
||||
}
|
||||
if fmt.Sprint(gotGranularities) != "[segment word]" {
|
||||
t.Errorf("timestamp_granularities[] = %v, want [segment word]", gotGranularities)
|
||||
}
|
||||
if gotAuth != "Bearer test-key" {
|
||||
t.Errorf("auth = %q (should fall back to chat key)", gotAuth)
|
||||
}
|
||||
if string(gotFile) != "RIFFfake" {
|
||||
t.Errorf("file payload = %q", gotFile)
|
||||
}
|
||||
if tr.Language != "english" || len(tr.Segments) != 2 || tr.Segments[1].Text != " there" || tr.Segments[1].End != 3.0 {
|
||||
t.Errorf("unexpected transcription: %+v", tr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscribeOmitsVADFilterForStrictHostedEndpoints(t *testing.T) {
|
||||
for url, wantVAD := range map[string]bool{
|
||||
"https://api.openai.com": false,
|
||||
"https://myorg.openai.azure.com": false,
|
||||
"https://api.groq.com/openai": false,
|
||||
"http://192.168.1.10:8000": true,
|
||||
"https://whisper.example.com": true,
|
||||
"https://api.deepinfra.com/v1/openai": true,
|
||||
} {
|
||||
got := !hostMatchesAny(url, strictHostedASRHosts)
|
||||
if got != wantVAD {
|
||||
t.Errorf("vad_filter for %s = %v, want %v", url, got, wantVAD)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscribeParsesPerSegmentWords(t *testing.T) {
|
||||
// speaches/faster-whisper shape: words nested inside each segment.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{"language":"en","text":"hi there","segments":[
|
||||
{"start":0.0,"end":3.0,"text":" hi there","words":[
|
||||
{"start":0.2,"end":0.5,"word":" hi"},{"start":0.6,"end":1.0,"word":" there"}]}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := chatConfig(srv.URL)
|
||||
cfg.ASRModel = "whisper-test"
|
||||
tr, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")})
|
||||
if err != nil {
|
||||
t.Fatalf("Transcribe: %v", err)
|
||||
}
|
||||
words := tr.Segments[0].Words
|
||||
if len(words) != 2 || words[1].Text != " there" || words[1].Start != 0.6 || words[1].End != 1.0 {
|
||||
t.Errorf("segment words = %+v", words)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscribeAttachesTopLevelWordsBySegmentTime(t *testing.T) {
|
||||
// OpenAI shape: words in a top-level array, segments without words.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{"language":"en","text":"hi there friend","segments":[
|
||||
{"start":0.0,"end":1.5,"text":" hi there"},{"start":1.5,"end":3.0,"text":" friend"}],
|
||||
"words":[{"start":0.2,"end":0.5,"word":"hi"},{"start":0.6,"end":1.0,"word":"there"},
|
||||
{"start":1.8,"end":2.2,"word":"friend"}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := chatConfig(srv.URL)
|
||||
cfg.ASRModel = "whisper-test"
|
||||
tr, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")})
|
||||
if err != nil {
|
||||
t.Fatalf("Transcribe: %v", err)
|
||||
}
|
||||
if len(tr.Segments[0].Words) != 2 || len(tr.Segments[1].Words) != 1 {
|
||||
t.Errorf("word distribution = %d/%d, want 2/1", len(tr.Segments[0].Words), len(tr.Segments[1].Words))
|
||||
}
|
||||
if tr.Segments[1].Words[0].Text != "friend" {
|
||||
t.Errorf("segment 1 word = %+v", tr.Segments[1].Words[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscribeEmptySegmentsIsNotAnError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{"language":"english","text":"","segments":[]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := chatConfig(srv.URL)
|
||||
cfg.ASRModel = "whisper-test"
|
||||
tr, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")})
|
||||
if err != nil {
|
||||
t.Fatalf("Transcribe: %v", err)
|
||||
}
|
||||
if len(tr.Segments) != 0 {
|
||||
t.Errorf("segments = %v, want empty", tr.Segments)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscribeMissingSegmentsFieldFailsFast(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
w.Write([]byte(`{"text":"plain response without segments"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := chatConfig(srv.URL)
|
||||
cfg.ASRModel = "whisper-test"
|
||||
_, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")})
|
||||
if err == nil || !strings.Contains(err.Error(), "verbose_json") {
|
||||
t.Fatalf("err = %v, want verbose_json complaint", err)
|
||||
}
|
||||
if calls.Load() != 1 {
|
||||
t.Errorf("calls = %d, want 1 (permanent error must not retry)", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscribeUsesASROverrides(t *testing.T) {
|
||||
var gotAuth atomic.Value
|
||||
asrSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth.Store(r.Header.Get("Authorization"))
|
||||
w.Write([]byte(verboseJSON))
|
||||
}))
|
||||
defer asrSrv.Close()
|
||||
|
||||
cfg := Config{
|
||||
BaseURL: "http://chat.invalid", APIKey: "chat-key", ChatModel: "m",
|
||||
ASRBaseURL: asrSrv.URL, ASRAPIKey: "asr-key", ASRModel: "whisper-test",
|
||||
}
|
||||
if _, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")}); err != nil {
|
||||
t.Fatalf("Transcribe: %v", err)
|
||||
}
|
||||
if gotAuth.Load() != "Bearer asr-key" {
|
||||
t.Errorf("auth = %q, want asr-key", gotAuth.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscribeRequiresConfig(t *testing.T) {
|
||||
c := NewClient(Config{BaseURL: "http://x", ChatModel: "m"}) // no ASR model
|
||||
if _, err := c.Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")}); err == nil {
|
||||
t.Fatal("expected not-configured error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscribeChatOnlyGatewayGetsConfigHint(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(`{"error":{"message":"invalid content-type: multipart/form-data","code":400}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := chatConfig(srv.URL)
|
||||
cfg.ASRModel = "whisper-test"
|
||||
_, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")})
|
||||
if err == nil || !strings.Contains(err.Error(), "Whisper-compatible Transcription base URL") {
|
||||
t.Fatalf("err = %v, want configuration hint", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsChatOnlyGateway(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
"https://openrouter.ai/api": true,
|
||||
"https://openrouter.ai": true,
|
||||
"openrouter.ai/api/v1": true,
|
||||
"https://gateway.openrouter.ai": true,
|
||||
"https://api.groq.com/openai": false,
|
||||
"https://api.openai.com": false,
|
||||
"http://localhost:8000": false,
|
||||
"https://my-openrouter.ai.example.io": false,
|
||||
"": false,
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := IsChatOnlyGateway(in); got != want {
|
||||
t.Errorf("IsChatOnlyGateway(%q) = %v, want %v", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscribeRefusesChatOnlyGateway(t *testing.T) {
|
||||
cfg := Config{BaseURL: "https://openrouter.ai/api", ChatModel: "m", ASRModel: "whisper-1"}
|
||||
_, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")})
|
||||
if err == nil || !strings.Contains(err.Error(), "cannot produce timestamped transcriptions") {
|
||||
t.Fatalf("err = %v, want chat-only refusal", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpointURLToleratesVersionedBases(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"https://api.openai.com": "https://api.openai.com/v1/chat/completions",
|
||||
"https://api.groq.com/openai": "https://api.groq.com/openai/v1/chat/completions",
|
||||
"https://api.deepinfra.com/v1/openai": "https://api.deepinfra.com/v1/openai/chat/completions",
|
||||
"http://localhost:8969": "http://localhost:8969/v1/chat/completions",
|
||||
"http://localhost:8969/v1": "http://localhost:8969/v1/chat/completions",
|
||||
"https://api.openai.com/": "https://api.openai.com/v1/chat/completions",
|
||||
}
|
||||
for base, want := range cases {
|
||||
if got := endpointURL(base, "chat/completions"); got != want {
|
||||
t.Errorf("endpointURL(%q) = %q, want %q", base, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// defaultTranscribeTimeout bounds a single transcription request when the
|
||||
// caller does not size one to its chunk duration. Generous on purpose: local
|
||||
// Whisper servers on modest hardware can run well below realtime.
|
||||
const defaultTranscribeTimeout = 20 * time.Minute
|
||||
|
||||
// TranscribeRequest is one audio-transcription call. Audio is held in memory
|
||||
// so the request can be rebuilt across retries; callers chunk long files
|
||||
// (a 10-minute 16 kHz mono WAV is ~19 MB).
|
||||
type TranscribeRequest struct {
|
||||
Filename string // e.g. "chunk00001.wav"; the extension hints the container
|
||||
Audio []byte
|
||||
Language string // optional ISO-639-1 hint; empty lets the model detect
|
||||
Timeout time.Duration // per-request deadline; 0 uses defaultTranscribeTimeout
|
||||
}
|
||||
|
||||
// TranscriptionSegment is one timed segment of recognized speech, in seconds
|
||||
// relative to the start of the submitted audio.
|
||||
type TranscriptionSegment struct {
|
||||
Start float64
|
||||
End float64
|
||||
Text string
|
||||
// Words carries per-word timings when the endpoint honors
|
||||
// timestamp_granularities[]=word; empty otherwise. Word-level times let
|
||||
// cue building split paragraph-length segments and end cues when speech
|
||||
// actually stops instead of when the next segment begins.
|
||||
Words []TranscriptionWord
|
||||
}
|
||||
|
||||
// TranscriptionWord is one recognized word with timing, in seconds relative
|
||||
// to the start of the submitted audio.
|
||||
type TranscriptionWord struct {
|
||||
Start float64
|
||||
End float64
|
||||
Text string
|
||||
}
|
||||
|
||||
// Transcription is the parsed verbose_json transcription result.
|
||||
type Transcription struct {
|
||||
// Language is the detected (or hinted) language as reported by the
|
||||
// endpoint. OpenAI returns an English language name ("english"); other
|
||||
// servers return ISO codes. Callers must normalize.
|
||||
Language string
|
||||
// Segments may legitimately be empty for speech-free audio (silence,
|
||||
// music-only chunks).
|
||||
Segments []TranscriptionSegment
|
||||
}
|
||||
|
||||
type transcriptionWordJSON struct {
|
||||
Start float64 `json:"start"`
|
||||
End float64 `json:"end"`
|
||||
Word string `json:"word"`
|
||||
}
|
||||
|
||||
type transcriptionResponse struct {
|
||||
Language string `json:"language"`
|
||||
Text string `json:"text"`
|
||||
// Segments distinguishes "verbose_json honored, no speech" (empty array)
|
||||
// from "endpoint ignored verbose_json" (field absent) — the latter cannot
|
||||
// produce timed cues and must fail rather than silently emit nothing.
|
||||
Segments *[]struct {
|
||||
Start float64 `json:"start"`
|
||||
End float64 `json:"end"`
|
||||
Text string `json:"text"`
|
||||
Words []transcriptionWordJSON `json:"words"`
|
||||
} `json:"segments"`
|
||||
// Words is where OpenAI puts word timings; faster-whisper servers
|
||||
// (speaches) nest them inside each segment instead.
|
||||
Words []transcriptionWordJSON `json:"words"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
// chatOnlyGatewayHosts lists OpenAI-compatible gateways that cannot serve
|
||||
// Silo's transcription needs: they either lack /v1/audio/transcriptions
|
||||
// entirely or (OpenRouter) return plain text with no segment timestamps,
|
||||
// which subtitle cues require. Matched by host suffix.
|
||||
var chatOnlyGatewayHosts = []string{
|
||||
"openrouter.ai",
|
||||
}
|
||||
|
||||
// strictHostedASRHosts lists hosted transcription providers that reject
|
||||
// multipart fields outside the OpenAI spec, so the faster-whisper-only
|
||||
// vad_filter field must be omitted for them. Nothing is lost: hosted Whisper
|
||||
// runs voice-activity detection server-side. Self-hosted servers (speaches,
|
||||
// faster-whisper) need the explicit field — without it segment timestamps
|
||||
// stretch wall-to-wall across silence. Matched by host suffix.
|
||||
var strictHostedASRHosts = []string{
|
||||
"api.openai.com",
|
||||
"openai.azure.com",
|
||||
"api.groq.com",
|
||||
"api.mistral.ai",
|
||||
}
|
||||
|
||||
// IsChatOnlyGateway reports whether baseURL points at a known chat-only
|
||||
// gateway that cannot produce timestamped transcriptions. Used to validate
|
||||
// the transcription settings and to disable ASR rather than fail jobs at
|
||||
// runtime.
|
||||
func IsChatOnlyGateway(baseURL string) bool {
|
||||
return hostMatchesAny(baseURL, chatOnlyGatewayHosts)
|
||||
}
|
||||
|
||||
// hostMatchesAny reports whether baseURL's hostname equals or is a subdomain
|
||||
// of any of the given host suffixes.
|
||||
func hostMatchesAny(baseURL string, hosts []string) bool {
|
||||
baseURL = strings.TrimSpace(baseURL)
|
||||
if baseURL == "" {
|
||||
return false
|
||||
}
|
||||
if !strings.Contains(baseURL, "://") {
|
||||
baseURL = "https://" + baseURL
|
||||
}
|
||||
u, err := url.Parse(baseURL)
|
||||
if err != nil || u.Host == "" {
|
||||
return false
|
||||
}
|
||||
host := strings.ToLower(u.Hostname())
|
||||
for _, h := range hosts {
|
||||
if host == h || strings.HasSuffix(host, "."+h) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Transcribe performs one audio transcription against the ASR endpoint
|
||||
// (falling back to the chat endpoint's base URL/key when no ASR override is
|
||||
// configured), using the OpenAI-compatible /v1/audio/transcriptions API with
|
||||
// response_format=verbose_json for segment timestamps.
|
||||
func (c *Client) Transcribe(ctx context.Context, req TranscribeRequest) (*Transcription, error) {
|
||||
cfg := c.Config()
|
||||
if !cfg.ASRConfigured() {
|
||||
return nil, fmt.Errorf("transcription endpoint is not configured")
|
||||
}
|
||||
if IsChatOnlyGateway(cfg.asrBaseURL()) {
|
||||
return nil, fmt.Errorf("the configured transcription endpoint (%s) cannot produce timestamped transcriptions; "+
|
||||
"set a Whisper-compatible Transcription base URL under Admin Settings → AI Services", cfg.asrBaseURL())
|
||||
}
|
||||
if len(req.Audio) == 0 {
|
||||
return nil, fmt.Errorf("no audio data to transcribe")
|
||||
}
|
||||
|
||||
timeout := req.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultTranscribeTimeout
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
url := endpointURL(cfg.asrBaseURL(), "audio/transcriptions")
|
||||
|
||||
var result *Transcription
|
||||
doErr := c.doWithRetry(ctx, c.asrHTTP, "transcription API",
|
||||
func() (*http.Request, error) {
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
fw, err := w.CreateFormFile("file", req.Filename)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create multipart file: %w", err)
|
||||
}
|
||||
if _, err := fw.Write(req.Audio); err != nil {
|
||||
return nil, fmt.Errorf("write multipart audio: %w", err)
|
||||
}
|
||||
fields := [][2]string{
|
||||
{"model", cfg.ASRModel},
|
||||
{"response_format", "verbose_json"},
|
||||
{"temperature", "0"},
|
||||
// Word timings let cue building split paragraph-length
|
||||
// segments and end cues when speech actually stops; segment
|
||||
// granularity must be requested alongside or OpenAI omits it.
|
||||
{"timestamp_granularities[]", "segment"},
|
||||
{"timestamp_granularities[]", "word"},
|
||||
}
|
||||
if req.Language != "" {
|
||||
fields = append(fields, [2]string{"language", req.Language})
|
||||
}
|
||||
if !hostMatchesAny(cfg.asrBaseURL(), strictHostedASRHosts) {
|
||||
fields = append(fields, [2]string{"vad_filter", "true"})
|
||||
}
|
||||
for _, f := range fields {
|
||||
if err := w.WriteField(f[0], f[1]); err != nil {
|
||||
return nil, fmt.Errorf("write multipart field %s: %w", f[0], err)
|
||||
}
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return nil, fmt.Errorf("finalize multipart body: %w", err)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(buf.Bytes()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", w.FormDataContentType())
|
||||
if key := cfg.asrAPIKey(); key != "" {
|
||||
httpReq.Header.Set("Authorization", "Bearer "+key)
|
||||
}
|
||||
return httpReq, nil
|
||||
},
|
||||
func(respBody []byte) error {
|
||||
var parsed transcriptionResponse
|
||||
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
||||
return fmt.Errorf("decode transcription response: %w", err)
|
||||
}
|
||||
if parsed.Error != nil && parsed.Error.Message != "" {
|
||||
return fmt.Errorf("transcription API error: %s", parsed.Error.Message)
|
||||
}
|
||||
if parsed.Segments == nil {
|
||||
return &permanentError{err: fmt.Errorf(
|
||||
"transcription endpoint did not return verbose_json segments (model %q); a verbose_json-capable Whisper endpoint is required", cfg.ASRModel)}
|
||||
}
|
||||
out := &Transcription{Language: parsed.Language}
|
||||
for _, s := range *parsed.Segments {
|
||||
out.Segments = append(out.Segments, TranscriptionSegment{
|
||||
Start: s.Start, End: s.End, Text: s.Text, Words: wordsFromJSON(s.Words),
|
||||
})
|
||||
}
|
||||
attachTopLevelWords(out.Segments, wordsFromJSON(parsed.Words))
|
||||
result = out
|
||||
return nil
|
||||
})
|
||||
if doErr != nil {
|
||||
return nil, decorateTranscribeError(doErr)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func wordsFromJSON(words []transcriptionWordJSON) []TranscriptionWord {
|
||||
out := make([]TranscriptionWord, 0, len(words))
|
||||
for _, w := range words {
|
||||
out = append(out, TranscriptionWord{Start: w.Start, End: w.End, Text: w.Word})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// attachTopLevelWords distributes an OpenAI-style top-level word list onto
|
||||
// segments by word midpoint, for endpoints that report words separately from
|
||||
// segments. Segments that already carry their own words are left untouched.
|
||||
func attachTopLevelWords(segments []TranscriptionSegment, words []TranscriptionWord) {
|
||||
if len(segments) == 0 || len(words) == 0 {
|
||||
return
|
||||
}
|
||||
for _, s := range segments {
|
||||
if len(s.Words) > 0 {
|
||||
return
|
||||
}
|
||||
}
|
||||
si := 0
|
||||
for _, w := range words {
|
||||
mid := (w.Start + w.End) / 2
|
||||
for si < len(segments)-1 && mid >= segments[si].End {
|
||||
si++
|
||||
}
|
||||
segments[si].Words = append(segments[si].Words, w)
|
||||
}
|
||||
}
|
||||
|
||||
// decorateTranscribeError appends a configuration hint to the errors a
|
||||
// chat-only gateway produces when it receives a transcription upload (no
|
||||
// /v1/audio/transcriptions route, or multipart rejected). Operators routinely
|
||||
// point the shared base URL at a chat-only provider; without the hint the raw
|
||||
// 400/404 reads like a pipeline bug instead of "set a Whisper endpoint".
|
||||
func decorateTranscribeError(err error) error {
|
||||
msg := err.Error()
|
||||
likelyUnsupported := strings.Contains(msg, "returned 400") ||
|
||||
strings.Contains(msg, "returned 404") ||
|
||||
strings.Contains(msg, "returned 405")
|
||||
if !likelyUnsupported {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("%w — the configured endpoint likely does not support audio transcription; "+
|
||||
"set a Whisper-compatible Transcription base URL (and model) under Admin Settings → AI Services "+
|
||||
"(e.g. a self-hosted faster-whisper/speaches server, api.groq.com/openai with whisper-large-v3-turbo, or api.openai.com with whisper-1)", err)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package ai
|
||||
package aitranslate
|
||||
|
||||
import "strings"
|
||||
|
||||
@@ -18,9 +18,25 @@ var languageNames = map[string]string{
|
||||
"tr": "Turkish", "uk": "Ukrainian", "vi": "Vietnamese", "zh": "Chinese",
|
||||
}
|
||||
|
||||
// languageDisplayName returns a human-readable language name for a code, or the
|
||||
// trimmed code itself when unknown. An empty code yields an empty string.
|
||||
func languageDisplayName(code string) string {
|
||||
// LanguageCodeFromName maps an English language name back to its ISO 639-1
|
||||
// code — Whisper endpoints report detected languages as names ("english")
|
||||
// while local servers report codes. Returns "" when unknown.
|
||||
func LanguageCodeFromName(name string) string {
|
||||
name = strings.ToLower(strings.TrimSpace(name))
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
for code, display := range languageNames {
|
||||
if strings.ToLower(display) == name {
|
||||
return code
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// LanguageDisplayName returns a human-readable language name for a code, or
|
||||
// the trimmed code itself when unknown. An empty code yields an empty string.
|
||||
func LanguageDisplayName(code string) string {
|
||||
code = strings.TrimSpace(code)
|
||||
if code == "" {
|
||||
return ""
|
||||
@@ -0,0 +1,212 @@
|
||||
// Package aitranslate implements generic batched text translation over an
|
||||
// OpenAI-compatible chat model. Callers hand it ordered text segments and a
|
||||
// domain-specific system prompt; it owns the wire protocol (indexed-JSON
|
||||
// batches the model must echo back key-for-key), context carryover between
|
||||
// batches, and retries on malformed model output. Subtitle cues and metadata
|
||||
// descriptions both translate through this one implementation.
|
||||
package aitranslate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Segment is one unit of translatable text. ID is the caller's identifier and
|
||||
// is never sent to the model (batches are keyed by position), so any stable
|
||||
// string works.
|
||||
type Segment struct {
|
||||
ID string
|
||||
Text string
|
||||
}
|
||||
|
||||
// ChatFn performs one chat completion. Wiring this as a function keeps the
|
||||
// package independent of the client and trivially testable.
|
||||
type ChatFn func(ctx context.Context, system, user string) (string, error)
|
||||
|
||||
// Request describes one translation run.
|
||||
type Request struct {
|
||||
Segments []Segment
|
||||
SystemPrompt string // domain-specific; must demand same-keys JSON output
|
||||
// TargetName is the human-readable target language name used in the
|
||||
// per-batch user message.
|
||||
TargetName string
|
||||
// EntryNoun names the units in the user message ("cues", "descriptions");
|
||||
// empty defaults to "entries".
|
||||
EntryNoun string
|
||||
// BatchSize is the number of segments per chat request (required, > 0).
|
||||
BatchSize int
|
||||
// ContextNeighbors is how many preceding source segments are included,
|
||||
// untranslated, for continuity across batch boundaries.
|
||||
ContextNeighbors int
|
||||
}
|
||||
|
||||
// maxFormatRetries is how many times a batch is re-asked after the model
|
||||
// returns unparsable or incomplete JSON. Transport/API errors are retried
|
||||
// inside the chat client and are not part of this count.
|
||||
const maxFormatRetries = 2
|
||||
|
||||
// Translate translates req.Segments in order, preserving count and IDs and
|
||||
// only rewriting Text. onBatch, when non-nil, is called after each batch with
|
||||
// that batch's translated segments and overall progress (done/total
|
||||
// segments). Implementations must honor ctx cancellation between batches.
|
||||
func Translate(ctx context.Context, chat ChatFn, req Request, onBatch func(batch []Segment, done, total int)) ([]Segment, error) {
|
||||
if chat == nil {
|
||||
return nil, fmt.Errorf("chat function is nil")
|
||||
}
|
||||
if req.BatchSize <= 0 {
|
||||
return nil, fmt.Errorf("invalid batch size: %d", req.BatchSize)
|
||||
}
|
||||
total := len(req.Segments)
|
||||
if total == 0 {
|
||||
return nil, fmt.Errorf("no segments to translate")
|
||||
}
|
||||
noun := req.EntryNoun
|
||||
if noun == "" {
|
||||
noun = "entries"
|
||||
}
|
||||
|
||||
// Preserve IDs by copying input segments and only replacing Text.
|
||||
out := make([]Segment, total)
|
||||
copy(out, req.Segments)
|
||||
|
||||
for start := 0; start < total; start += req.BatchSize {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
end := min(start+req.BatchSize, total)
|
||||
|
||||
contextStart := max(0, start-req.ContextNeighbors)
|
||||
translated, err := translateBatch(ctx, chat, req, noun, req.Segments[contextStart:start], req.Segments[start:end])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("translate %s %d-%d: %w", noun, start+1, end, err)
|
||||
}
|
||||
for i, text := range translated {
|
||||
out[start+i].Text = text
|
||||
}
|
||||
if onBatch != nil {
|
||||
onBatch(out[start:end], end, total)
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func translateBatch(ctx context.Context, chat ChatFn, req Request, noun string, contextSegs, batch []Segment) ([]string, error) {
|
||||
texts := make([]string, len(batch))
|
||||
for i, s := range batch {
|
||||
texts[i] = s.Text
|
||||
}
|
||||
payload, err := buildIndexedJSON(texts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var user strings.Builder
|
||||
if len(contextSegs) > 0 {
|
||||
user.WriteString("Preceding lines for context only — do not translate or include them in your output:\n")
|
||||
for _, s := range contextSegs {
|
||||
user.WriteString(strings.ReplaceAll(s.Text, "\n", " "))
|
||||
user.WriteByte('\n')
|
||||
}
|
||||
user.WriteByte('\n')
|
||||
}
|
||||
target := req.TargetName
|
||||
if target == "" {
|
||||
target = "the target language"
|
||||
}
|
||||
fmt.Fprintf(&user, "Translate these %d %s into %s. Respond with only the JSON object:\n%s", len(batch), noun, target, payload)
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= maxFormatRetries; attempt++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
content, err := chat(ctx, req.SystemPrompt, user.String())
|
||||
if err != nil {
|
||||
return nil, err // transport/API errors are already retried inside the client
|
||||
}
|
||||
|
||||
obj, err := extractJSONObject(content)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
var m map[string]string
|
||||
if err := json.Unmarshal([]byte(obj), &m); err != nil {
|
||||
lastErr = fmt.Errorf("decode translation JSON: %w", err)
|
||||
continue
|
||||
}
|
||||
|
||||
out := make([]string, len(batch))
|
||||
complete := true
|
||||
for i := range batch {
|
||||
v, ok := m[strconv.Itoa(i+1)]
|
||||
if !ok {
|
||||
complete = false
|
||||
break
|
||||
}
|
||||
out[i] = v
|
||||
}
|
||||
if !complete {
|
||||
lastErr = fmt.Errorf("model omitted one or more %s", noun)
|
||||
continue
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("invalid model response after %d attempts: %w", maxFormatRetries+1, lastErr)
|
||||
}
|
||||
|
||||
// buildIndexedJSON renders texts as a JSON object {"1":..., "2":...} keyed by
|
||||
// 1-based position, escaping each value safely. It is built by hand rather
|
||||
// than json.Marshal'ing a map so the keys stay in numeric order — that reads
|
||||
// more naturally for the model than the lexicographic order Go emits for maps
|
||||
// ("1","10","11",...,"2"). Correctness doesn't depend on order (results are
|
||||
// mapped back by key), but ordered input gives the model better context.
|
||||
func buildIndexedJSON(texts []string) (string, error) {
|
||||
var b strings.Builder
|
||||
b.WriteByte('{')
|
||||
for i, text := range texts {
|
||||
if i > 0 {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
key, err := json.Marshal(strconv.Itoa(i + 1))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
val, err := json.Marshal(text)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b.Write(key)
|
||||
b.WriteByte(':')
|
||||
b.Write(val)
|
||||
}
|
||||
b.WriteByte('}')
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// extractJSONObject pulls the first balanced-looking JSON object out of a
|
||||
// model reply, tolerating ``` code fences and surrounding prose.
|
||||
func extractJSONObject(s string) (string, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if strings.HasPrefix(s, "```") {
|
||||
s = strings.TrimPrefix(s, "```")
|
||||
if nl := strings.IndexByte(s, '\n'); nl >= 0 {
|
||||
s = s[nl+1:]
|
||||
}
|
||||
if idx := strings.LastIndex(s, "```"); idx >= 0 {
|
||||
s = s[:idx]
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
}
|
||||
start := strings.IndexByte(s, '{')
|
||||
end := strings.LastIndexByte(s, '}')
|
||||
if start < 0 || end < 0 || end < start {
|
||||
return "", fmt.Errorf("no JSON object found in model response")
|
||||
}
|
||||
return s[start : end+1], nil
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package aitranslate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// echoChat translates by upper-casing every value of the indexed JSON payload
|
||||
// in the user message, mimicking a well-behaved model.
|
||||
func echoChat(t *testing.T) ChatFn {
|
||||
t.Helper()
|
||||
return func(_ context.Context, _ string, user string) (string, error) {
|
||||
obj, err := extractJSONObject(user)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("test chat: %w", err)
|
||||
}
|
||||
var m map[string]string
|
||||
if err := json.Unmarshal([]byte(obj), &m); err != nil {
|
||||
return "", fmt.Errorf("test chat decode: %w", err)
|
||||
}
|
||||
out := make(map[string]string, len(m))
|
||||
for k, v := range m {
|
||||
out[k] = strings.ToUpper(v)
|
||||
}
|
||||
b, _ := json.Marshal(out)
|
||||
return string(b), nil
|
||||
}
|
||||
}
|
||||
|
||||
func segments(n int) []Segment {
|
||||
out := make([]Segment, n)
|
||||
for i := range out {
|
||||
out[i] = Segment{ID: strconv.Itoa(i + 1), Text: fmt.Sprintf("line %d", i+1)}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestTranslateBatchesAndPreservesIDs(t *testing.T) {
|
||||
var batches []int
|
||||
out, err := Translate(context.Background(), echoChat(t), Request{
|
||||
Segments: segments(7),
|
||||
BatchSize: 3,
|
||||
}, func(batch []Segment, done, total int) {
|
||||
batches = append(batches, len(batch))
|
||||
if total != 7 {
|
||||
t.Errorf("total = %d, want 7", total)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Translate: %v", err)
|
||||
}
|
||||
if len(out) != 7 {
|
||||
t.Fatalf("len(out) = %d", len(out))
|
||||
}
|
||||
for i, s := range out {
|
||||
if s.ID != strconv.Itoa(i+1) {
|
||||
t.Errorf("ID[%d] = %q, want %d", i, s.ID, i+1)
|
||||
}
|
||||
if want := strings.ToUpper(fmt.Sprintf("line %d", i+1)); s.Text != want {
|
||||
t.Errorf("Text[%d] = %q, want %q", i, s.Text, want)
|
||||
}
|
||||
}
|
||||
if fmt.Sprint(batches) != "[3 3 1]" {
|
||||
t.Errorf("batch sizes = %v, want [3 3 1]", batches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateSendsContextNeighborsUntranslated(t *testing.T) {
|
||||
var secondBatchMsg string
|
||||
call := 0
|
||||
chat := func(ctx context.Context, system, user string) (string, error) {
|
||||
call++
|
||||
if call == 2 {
|
||||
secondBatchMsg = user
|
||||
}
|
||||
return echoChat(t)(ctx, system, user)
|
||||
}
|
||||
_, err := Translate(context.Background(), chat, Request{
|
||||
Segments: segments(4),
|
||||
BatchSize: 2,
|
||||
ContextNeighbors: 2,
|
||||
TargetName: "French",
|
||||
EntryNoun: "cues",
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Translate: %v", err)
|
||||
}
|
||||
if !strings.Contains(secondBatchMsg, "context only") ||
|
||||
!strings.Contains(secondBatchMsg, "line 1\nline 2") {
|
||||
t.Errorf("second batch missing context block:\n%s", secondBatchMsg)
|
||||
}
|
||||
if !strings.Contains(secondBatchMsg, "Translate these 2 cues into French.") {
|
||||
t.Errorf("second batch missing instruction:\n%s", secondBatchMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateToleratesCodeFences(t *testing.T) {
|
||||
chat := func(_ context.Context, _, _ string) (string, error) {
|
||||
return "Sure! Here you go:\n```json\n{\"1\":\"ok\"}\n```", nil
|
||||
}
|
||||
out, err := Translate(context.Background(), chat, Request{
|
||||
Segments: segments(1),
|
||||
BatchSize: 5,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Translate: %v", err)
|
||||
}
|
||||
if out[0].Text != "ok" {
|
||||
t.Errorf("Text = %q", out[0].Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateRetriesOmittedKeysThenFails(t *testing.T) {
|
||||
calls := 0
|
||||
chat := func(_ context.Context, _, _ string) (string, error) {
|
||||
calls++
|
||||
return `{"1":"only one"}`, nil // always omits key "2"
|
||||
}
|
||||
_, err := Translate(context.Background(), chat, Request{
|
||||
Segments: segments(2),
|
||||
BatchSize: 5,
|
||||
}, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "omitted") {
|
||||
t.Fatalf("err = %v, want omission failure", err)
|
||||
}
|
||||
if calls != maxFormatRetries+1 {
|
||||
t.Errorf("calls = %d, want %d", calls, maxFormatRetries+1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateStopsOnCancelledContext(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
calls := 0
|
||||
chat := func(_ context.Context, _, _ string) (string, error) {
|
||||
calls++
|
||||
cancel() // cancel after the first batch completes
|
||||
return `{"1":"a"}`, nil
|
||||
}
|
||||
_, err := Translate(ctx, chat, Request{
|
||||
Segments: segments(3),
|
||||
BatchSize: 1,
|
||||
}, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "context canceled") {
|
||||
t.Fatalf("err = %v, want context cancellation", err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("calls = %d, want 1", calls)
|
||||
}
|
||||
}
|
||||
@@ -20,12 +20,13 @@ type autoscanQueuer = autoscan.Queuer
|
||||
|
||||
// RequestIntegrationLookup adapts the Requests repository to the autoscan
|
||||
// connection resolver's RequestIntegrationLookup: it resolves a soft-linked
|
||||
// Requests integration to its base URL and (write-only) api-key ref.
|
||||
// Requests integration to its base URL and api key (the repo decrypts the key
|
||||
// on read, so this returns plaintext).
|
||||
type RequestIntegrationLookup struct {
|
||||
Repo *mediarequests.Repository
|
||||
}
|
||||
|
||||
func (l RequestIntegrationLookup) Get(ctx context.Context, integrationID string) (baseURL, apiKeyRef string, err error) {
|
||||
func (l RequestIntegrationLookup) Get(ctx context.Context, integrationID string) (baseURL, apiKey string, err error) {
|
||||
integration, err := l.Repo.GetIntegration(ctx, integrationID)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
@@ -64,8 +65,8 @@ type PluginScanSourceAdapter struct {
|
||||
Svc *plugins.Service
|
||||
}
|
||||
|
||||
func (a PluginScanSourceAdapter) ScanSourceClient(ctx context.Context, installationID int, capabilityID string) (autoscan.PollChangesClient, error) {
|
||||
return a.Svc.ScanSourceClient(ctx, installationID, capabilityID)
|
||||
func (a PluginScanSourceAdapter) ScanSourceClient(ctx context.Context, pluginID, capabilityID string) (autoscan.PollChangesClient, error) {
|
||||
return a.Svc.ScanSourceClientByPluginID(ctx, pluginID, capabilityID)
|
||||
}
|
||||
|
||||
// scanSourceCapabilityType is the plugin capability type autoscan discovery
|
||||
@@ -75,15 +76,6 @@ const scanSourceCapabilityType = "scan_source.v1"
|
||||
// PluginScanSourceLister adapts the plugin installation store to
|
||||
// autoscan.ScanSourceLister: it enumerates every installed scan_source.v1
|
||||
// capability across ALL installed plugins, regardless of enabled state.
|
||||
//
|
||||
// Using List (not ListEnabled) is deliberate for orphan detection: a temporarily
|
||||
// DISABLED-but-installed plugin must NOT be treated as orphaned. If we dropped
|
||||
// disabled plugins here, PollOnce would prune their sources and skip them with no
|
||||
// last_error — they'd vanish silently. Keeping them present means PollOnce still
|
||||
// attempts them; the plugin client fails to load (not running) and the source
|
||||
// gets a visible RecordError instead. Only a fully UNINSTALLED plugin (gone from
|
||||
// List entirely) is a true orphan. The Add-source picker shares this all-installed
|
||||
// set, which is fine — operators can bind sources against an installed capability.
|
||||
type PluginScanSourceLister struct {
|
||||
Store *plugins.InstallationStore
|
||||
}
|
||||
@@ -104,10 +96,9 @@ func (l PluginScanSourceLister) ListScanSources(ctx context.Context) ([]autoscan
|
||||
continue
|
||||
}
|
||||
out = append(out, autoscan.DiscoveredSource{
|
||||
InstallationID: c.InstallationID,
|
||||
CapabilityID: c.ID,
|
||||
PluginID: inst.PluginID,
|
||||
DisplayName: scanSourceDisplayName(inst.PluginID, c),
|
||||
PluginID: inst.PluginID,
|
||||
CapabilityID: c.ID,
|
||||
DisplayName: scanSourceDisplayName(inst.PluginID, c),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -135,27 +126,22 @@ func scanSourceDisplayName(pluginID string, c *plugins.Capability) string {
|
||||
}
|
||||
}
|
||||
|
||||
// AutoscanSecretResolver resolves an encrypted api-key reference to plaintext.
|
||||
// It is satisfied by the server settings repo (Get(ctx, key) (string, error)).
|
||||
type AutoscanSecretResolver interface {
|
||||
Get(ctx context.Context, ref string) (string, error)
|
||||
}
|
||||
|
||||
// BuildAutoscanService wires the v2 autoscan engine from its concrete
|
||||
// dependencies. Both the HTTP router (manual trigger) and the background poll
|
||||
// task share this constructor so the adapter wiring lives in exactly one place.
|
||||
// Credentials are now decrypted inline by the autoscan/requests repos, so there
|
||||
// is no separate secret resolver to thread.
|
||||
func BuildAutoscanService(
|
||||
repo *autoscan.Repository,
|
||||
pluginService *plugins.Service,
|
||||
installationStore *plugins.InstallationStore,
|
||||
requestsRepo *mediarequests.Repository,
|
||||
secrets AutoscanSecretResolver,
|
||||
folderRepo *catalog.FolderRepository,
|
||||
queue autoscanQueuer,
|
||||
redisClient *redis.Client,
|
||||
) *autoscan.Service {
|
||||
provider := autoscan.NewPluginProvider(PluginScanSourceAdapter{pluginService})
|
||||
connRes := autoscan.NewConnectionResolver(RequestIntegrationLookup{requestsRepo}, secrets)
|
||||
connRes := autoscan.NewConnectionResolver(RequestIntegrationLookup{requestsRepo})
|
||||
svc := autoscan.NewService(
|
||||
repo,
|
||||
provider,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package api
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
|
||||
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
|
||||
)
|
||||
|
||||
// TestCheckRequestIntegrationUsable verifies the Fix 4 gate: a reused Requests
|
||||
// connection is rejected when the integration is disabled or has no base_url, so
|
||||
@@ -22,3 +26,31 @@ func TestCheckRequestIntegrationUsable(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestCheckRequestIntegrationUsableGatesReuse is the Task 17 regression test. It
|
||||
// locks the autoscan→requests credential-reuse behavior after the
|
||||
// request_integrations table was generalized into the two-tier connection
|
||||
// registry (Task 9/10): autoscan must keep reusing the Sonarr/Radarr connection
|
||||
// rows via the same enabled + base_url gate, regardless of the added
|
||||
// capability/installation columns.
|
||||
func TestCheckRequestIntegrationUsableGatesReuse(t *testing.T) {
|
||||
// Enabled + non-blank base_url -> usable (autoscan can reuse the connection).
|
||||
if err := checkRequestIntegrationUsable("int-1", true, "http://sonarr:8989"); err != nil {
|
||||
t.Fatalf("enabled integration with base_url should be usable, got: %v", err)
|
||||
}
|
||||
// Disabled -> error (autoscan must not poll a disabled connection).
|
||||
if err := checkRequestIntegrationUsable("int-1", false, "http://sonarr:8989"); err == nil {
|
||||
t.Fatal("disabled integration must be rejected")
|
||||
}
|
||||
// Blank base_url -> error.
|
||||
if err := checkRequestIntegrationUsable("int-1", true, " "); err == nil {
|
||||
t.Fatal("blank base_url must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// Compile-time guard: autoscan's RequestIntegrationLookup reads these fields off
|
||||
// mediarequests.Integration. If any is renamed/removed, autoscan reuse breaks —
|
||||
// fail the build here rather than silently at runtime.
|
||||
var _ = func(in mediarequests.Integration) (string, string, bool) {
|
||||
return in.BaseURL, in.APIKeyRef, in.Enabled
|
||||
}
|
||||
|
||||
@@ -21,14 +21,18 @@ import (
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/access"
|
||||
"github.com/Silo-Server/silo-server/internal/adminjob"
|
||||
"github.com/Silo-Server/silo-server/internal/ai/llm"
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
"github.com/Silo-Server/silo-server/internal/cache"
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/clientip"
|
||||
"github.com/Silo-Server/silo-server/internal/config"
|
||||
evt "github.com/Silo-Server/silo-server/internal/events"
|
||||
"github.com/Silo-Server/silo-server/internal/markers"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/notifications"
|
||||
subtitleai "github.com/Silo-Server/silo-server/internal/subtitles/ai"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
|
||||
@@ -527,6 +531,15 @@ func (h *AdminHandler) HandleUpdateUser(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
}
|
||||
|
||||
// Publish a domain event rather than creating the notification inline; the
|
||||
// notifications materializer's system matcher turns it into the user's
|
||||
// "Password changed" notice, keeping all notification creation event-driven.
|
||||
if req.Password != nil && h.RealtimeHub != nil {
|
||||
_ = h.RealtimeHub.EventsHub().PublishJSON(r.Context(),
|
||||
evt.ChannelSessions, notifications.EventUserPasswordChanged, nil,
|
||||
evt.PublishOptions{UserID: id})
|
||||
}
|
||||
|
||||
user, err := h.userRepo.GetByID(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch updated user")
|
||||
@@ -1137,29 +1150,10 @@ func (h *AdminHandler) HandleUpdateItemMetadata(w http.ResponseWriter, r *http.R
|
||||
|
||||
// --- Server Settings endpoints ---
|
||||
|
||||
var sensitiveSettingKeys = map[string]bool{
|
||||
"auth.jwt_secret": true,
|
||||
"s3.public_access_key": true,
|
||||
"s3.public_secret_key": true,
|
||||
"s3.public_token_secret": true,
|
||||
"s3.private_access_key": true,
|
||||
"s3.private_secret_key": true,
|
||||
"s3.user_db_access_key": true,
|
||||
"s3.user_db_secret_key": true,
|
||||
"redis.url": true,
|
||||
"recommendations.openai_api_key": true,
|
||||
"recommendations.embedding_auth_token": true,
|
||||
"tmdb.api_key": true,
|
||||
"introdb.api_key": true,
|
||||
"mdblist.api_key": true,
|
||||
"requests.radarr.api_key": true,
|
||||
"requests.sonarr.api_key": true,
|
||||
"subtitle_ai.api_key": true,
|
||||
"watchsync.trakt.client_id": true,
|
||||
"watchsync.trakt.client_secret": true,
|
||||
"watchsync.simkl.client_id": true,
|
||||
"watchsync.simkl.client_secret": true,
|
||||
}
|
||||
// sensitiveSettingKeys is the audited allowlist of secret-bearing settings
|
||||
// keys, shared with the at-rest encryption decorator so redaction and
|
||||
// encryption can never drift apart. See catalog.SensitiveSettingKeys.
|
||||
var sensitiveSettingKeys = catalog.SensitiveSettingKeys
|
||||
|
||||
// HandleGetSettings handles GET /admin/settings.
|
||||
func (h *AdminHandler) HandleGetSettings(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -1234,6 +1228,9 @@ func (h *AdminHandler) HandleGetSensitiveStatus(w http.ResponseWriter, r *http.R
|
||||
type adminSettingResponse struct {
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
// RestartRequired reports whether the saved value only takes effect
|
||||
// after a server restart (set on update responses only).
|
||||
RestartRequired bool `json:"restart_required,omitempty"`
|
||||
}
|
||||
|
||||
type adminSettingsListResponse struct {
|
||||
@@ -2120,6 +2117,33 @@ func (h *AdminHandler) HandleUpdateSetting(w http.ResponseWriter, r *http.Reques
|
||||
} else {
|
||||
req.Value = normalized
|
||||
}
|
||||
case "ai.asr_base_url":
|
||||
if llm.IsChatOnlyGateway(req.Value) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request",
|
||||
"This endpoint cannot produce timestamped transcriptions (chat-only gateway). "+
|
||||
"Use a self-hosted Whisper server (faster-whisper/speaches), api.groq.com/openai, or api.openai.com.")
|
||||
return
|
||||
}
|
||||
case "metadata_ai.on_view":
|
||||
switch req.Value {
|
||||
case "off", "button", "auto":
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, "bad_request",
|
||||
"metadata_ai.on_view must be off, button, or auto")
|
||||
return
|
||||
}
|
||||
case "subtitle_ai.transcribe_quota_jobs":
|
||||
if n, err := strconv.Atoi(req.Value); err != nil || n < 0 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request",
|
||||
"subtitle_ai.transcribe_quota_jobs must be an integer >= 0 (0 = unlimited)")
|
||||
return
|
||||
}
|
||||
case "subtitle_ai.transcribe_quota_period":
|
||||
if !subtitleai.ValidQuotaPeriod(req.Value) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request",
|
||||
"subtitle_ai.transcribe_quota_period must be day, week, or month")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.SettingsRepo.Set(r.Context(), key, req.Value); err != nil {
|
||||
@@ -2135,9 +2159,10 @@ func (h *AdminHandler) HandleUpdateSetting(w http.ResponseWriter, r *http.Reques
|
||||
h.OnServerSettingUpdated(r.Context(), key, req.Value)
|
||||
}
|
||||
|
||||
restartRequired := config.RestartRequired(key)
|
||||
if sensitiveSettingKeys[key] {
|
||||
writeJSON(w, http.StatusOK, adminSettingResponse{Key: key})
|
||||
writeJSON(w, http.StatusOK, adminSettingResponse{Key: key, RestartRequired: restartRequired})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, adminSettingResponse{Key: key, Value: req.Value})
|
||||
writeJSON(w, http.StatusOK, adminSettingResponse{Key: key, Value: req.Value, RestartRequired: restartRequired})
|
||||
}
|
||||
|
||||
@@ -6,8 +6,6 @@ import (
|
||||
"net/http"
|
||||
"sort"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/cache"
|
||||
"github.com/Silo-Server/silo-server/internal/markers"
|
||||
)
|
||||
@@ -122,7 +120,11 @@ func (h *AdminMarkerProvidersHandler) HandleUpdateProvider(w http.ResponseWriter
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Marker providers are not configured")
|
||||
return
|
||||
}
|
||||
provider := chi.URLParam(r, "provider")
|
||||
provider, err := decodedURLParam(r, "provider")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid provider ID")
|
||||
return
|
||||
}
|
||||
existing, ok := h.Config.Get(provider)
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Unknown marker provider")
|
||||
@@ -179,7 +181,11 @@ func (h *AdminMarkerProvidersHandler) HandleValidateProvider(w http.ResponseWrit
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Marker providers are not configured")
|
||||
return
|
||||
}
|
||||
provider := chi.URLParam(r, "provider")
|
||||
provider, err := decodedURLParam(r, "provider")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid provider ID")
|
||||
return
|
||||
}
|
||||
var submitter markers.Submitter
|
||||
for _, p := range h.Registry.Providers() {
|
||||
if p.ID() == provider {
|
||||
|
||||
@@ -49,6 +49,34 @@ func TestValidateMarkerProviderUsesSnakeCaseStats(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type fakePluginMarkerSubmitter struct{ fakeMarkerStatsSubmitter }
|
||||
|
||||
func (fakePluginMarkerSubmitter) ID() string { return "plugin:6:introdb" }
|
||||
|
||||
// Browsers send encodeURIComponent-escaped provider IDs (plugin%3A6%3Aintrodb),
|
||||
// and chi matches the raw path, so the handler must decode the route param.
|
||||
func TestValidateMarkerProviderDecodesEncodedID(t *testing.T) {
|
||||
reg := markers.NewRegistry(nil)
|
||||
if err := reg.Register(fakePluginMarkerSubmitter{}); err != nil {
|
||||
t.Fatalf("register provider: %v", err)
|
||||
}
|
||||
h := NewAdminMarkerProvidersHandler(reg, nil, nil, nil)
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Post("/admin/markers/providers/{provider}/validate", h.HandleValidateProvider)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/markers/providers/plugin%3A6%3Aintrodb/validate", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), `"valid":true`) {
|
||||
t.Fatalf("unexpected response: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkerProviderResponseIncludesPluginMetadata(t *testing.T) {
|
||||
resp := toProviderConfigResponse(
|
||||
markers.ProviderConfig{Provider: "plugin:4:markers", FetchEnabled: true, FetchPriority: 25},
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
evt "github.com/Silo-Server/silo-server/internal/events"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/notifications"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal fake UserRepository for AdminHandler tests.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type fakeUserRepo struct {
|
||||
user *models.User
|
||||
getErr error
|
||||
}
|
||||
|
||||
func (f *fakeUserRepo) List(_ context.Context) ([]*models.User, error) { return nil, nil }
|
||||
|
||||
func (f *fakeUserRepo) Create(_ context.Context, _ models.CreateUserInput) (*models.User, error) {
|
||||
return f.user, nil
|
||||
}
|
||||
|
||||
func (f *fakeUserRepo) Update(_ context.Context, _ int, _ models.UpdateUserInput) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeUserRepo) Delete(_ context.Context, _ int) error { return nil }
|
||||
|
||||
func (f *fakeUserRepo) GetByID(_ context.Context, _ int) (*models.User, error) {
|
||||
return f.user, f.getErr
|
||||
}
|
||||
|
||||
// Compile-time check.
|
||||
var _ UserRepository = (*fakeUserRepo)(nil)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: build AdminHandler with a realtime hub whose events the test can
|
||||
// observe. Password changes publish a domain event; the notifications
|
||||
// materializer (tested separately) turns it into the user's notification.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func newAdminHandlerWithHub(userRepo UserRepository) (*AdminHandler, *evt.Hub) {
|
||||
rh := notifications.NewHub("test", nil)
|
||||
h := NewAdminHandler(userRepo, nil, nil)
|
||||
h.RealtimeHub = rh
|
||||
return h, rh.EventsHub()
|
||||
}
|
||||
|
||||
// adminUpdateRequest builds a PUT /admin/users/{id} HTTP request.
|
||||
func adminUpdateRequest(body []byte, userID string) *http.Request {
|
||||
req := httptest.NewRequest(http.MethodPut, "/admin/users/"+userID, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("id", userID)
|
||||
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: HandleUpdateUser password change notification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestHandleUpdateUser_WithPassword_PublishesEvent asserts that including a
|
||||
// Password in the update payload publishes one user.password_changed event on
|
||||
// ChannelSessions targeted at the updated user.
|
||||
func TestHandleUpdateUser_WithPassword_PublishesEvent(t *testing.T) {
|
||||
targetUser := &models.User{
|
||||
ID: 99,
|
||||
Username: "alice",
|
||||
Email: "alice@example.com",
|
||||
Role: "user",
|
||||
}
|
||||
repo := &fakeUserRepo{user: targetUser}
|
||||
h, eventsHub := newAdminHandlerWithHub(repo)
|
||||
ch, unsub := eventsHub.Subscribe()
|
||||
defer unsub()
|
||||
|
||||
body := []byte(`{"password":"newSecret123"}`)
|
||||
req := adminUpdateRequest(body, "99")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.HandleUpdateUser(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
select {
|
||||
case env := <-ch:
|
||||
if env.Channel != evt.ChannelSessions {
|
||||
t.Errorf("channel = %q, want %q", env.Channel, evt.ChannelSessions)
|
||||
}
|
||||
if env.Event != notifications.EventUserPasswordChanged {
|
||||
t.Errorf("event = %q, want %q", env.Event, notifications.EventUserPasswordChanged)
|
||||
}
|
||||
if env.UserID != 99 {
|
||||
t.Errorf("event user_id = %d, want 99", env.UserID)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("expected a user.password_changed event, got none")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleUpdateUser_WithoutPassword_NoEvent asserts that a non-password
|
||||
// update publishes no user.password_changed event.
|
||||
func TestHandleUpdateUser_WithoutPassword_NoEvent(t *testing.T) {
|
||||
targetUser := &models.User{
|
||||
ID: 99,
|
||||
Username: "alice",
|
||||
Email: "alice@example.com",
|
||||
Role: "user",
|
||||
}
|
||||
repo := &fakeUserRepo{user: targetUser}
|
||||
h, eventsHub := newAdminHandlerWithHub(repo)
|
||||
ch, unsub := eventsHub.Subscribe()
|
||||
defer unsub()
|
||||
|
||||
body := []byte(`{"username":"alice-renamed"}`)
|
||||
req := adminUpdateRequest(body, "99")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.HandleUpdateUser(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
select {
|
||||
case env := <-ch:
|
||||
t.Errorf("expected no event, got %q on %q", env.Event, env.Channel)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
// no event, as expected
|
||||
}
|
||||
}
|
||||
@@ -326,6 +326,47 @@ func TestAdminUpdateSettingRedactsSensitiveSetting(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminUpdateSettingReportsRestartRequired(t *testing.T) {
|
||||
cases := []struct {
|
||||
key string
|
||||
value string
|
||||
restartRequired bool
|
||||
}{
|
||||
// Infrastructure settings are captured at startup.
|
||||
{key: "database.max_connections", value: "40", restartRequired: true},
|
||||
{key: "s3.public_bucket", value: "assets", restartRequired: true},
|
||||
// Branding is read live from the settings repo per request.
|
||||
{key: "branding.server_name", value: "Casa", restartRequired: false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.key, func(t *testing.T) {
|
||||
settings := &fakeServerSettingsStore{}
|
||||
handler := &AdminHandler{SettingsRepo: settings}
|
||||
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPut,
|
||||
"/admin/settings/"+tc.key,
|
||||
strings.NewReader(`{"value":"`+tc.value+`"}`),
|
||||
)
|
||||
req = withChiParam(req, "key", tc.key)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleUpdateSetting(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp adminSettingResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if resp.RestartRequired != tc.restartRequired {
|
||||
t.Fatalf("restart_required = %v, want %v", resp.RestartRequired, tc.restartRequired)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func performSettingsCheckRequest(
|
||||
t *testing.T,
|
||||
handler *AdminHandler,
|
||||
|
||||
@@ -22,7 +22,9 @@ type AdminStats struct {
|
||||
TotalFiles int `json:"total_files"`
|
||||
TotalUsers int `json:"total_users"`
|
||||
TotalMovies int `json:"total_movies"`
|
||||
TotalMovieFiles int `json:"total_movie_files"`
|
||||
TotalShows int `json:"total_shows"`
|
||||
TotalShowFiles int `json:"total_show_files"`
|
||||
ActiveStreams int `json:"active_streams"`
|
||||
TotalStorageBytes int64 `json:"total_storage_bytes"`
|
||||
WatchProviderActivity WatchProviderActivity `json:"watch_provider_activity"`
|
||||
@@ -127,13 +129,15 @@ func queryAdminStats(ctx context.Context, pool *pgxpool.Pool) (AdminStats, error
|
||||
}
|
||||
|
||||
var (
|
||||
totalUsers int64
|
||||
totalItems int64
|
||||
totalFiles int64
|
||||
totalMovies int64
|
||||
totalShows int64
|
||||
activeStreams int64
|
||||
totalStorage int64
|
||||
totalUsers int64
|
||||
totalItems int64
|
||||
totalFiles int64
|
||||
totalMovies int64
|
||||
totalMovieFiles int64
|
||||
totalShows int64
|
||||
totalShowFiles int64
|
||||
activeStreams int64
|
||||
totalStorage int64
|
||||
)
|
||||
|
||||
row := pool.QueryRow(ctx, `
|
||||
@@ -151,8 +155,34 @@ func queryAdminStats(ctx context.Context, pool *pgxpool.Pool) (AdminStats, error
|
||||
file_stats AS (
|
||||
SELECT
|
||||
COUNT(*)::bigint AS total_files,
|
||||
COUNT(*) FILTER (WHERE file_kind = 'movie')::bigint AS total_movie_files,
|
||||
COUNT(*) FILTER (WHERE file_kind = 'series')::bigint AS total_show_files,
|
||||
COALESCE(SUM(file_size), 0)::bigint AS total_storage_bytes
|
||||
FROM media_files
|
||||
FROM (
|
||||
SELECT
|
||||
media_files.file_size,
|
||||
CASE
|
||||
WHEN lower(trim(COALESCE(NULLIF(media_items.type, ''), ''))) = 'movie'
|
||||
THEN 'movie'
|
||||
WHEN lower(trim(COALESCE(NULLIF(media_items.type, ''), ''))) = 'series'
|
||||
THEN 'series'
|
||||
WHEN episodes.content_id IS NOT NULL
|
||||
THEN 'series'
|
||||
WHEN lower(trim(COALESCE(NULLIF(media_files.base_type, ''), ''))) IN ('movie', 'movies')
|
||||
THEN 'movie'
|
||||
WHEN lower(trim(COALESCE(NULLIF(media_files.base_type, ''), ''))) IN ('series', 'tv', 'show', 'shows', 'tvshows')
|
||||
THEN 'series'
|
||||
WHEN lower(trim(media_folders.type)) IN ('movie', 'movies')
|
||||
THEN 'movie'
|
||||
WHEN lower(trim(media_folders.type)) IN ('series', 'tv', 'show', 'shows', 'tvshows')
|
||||
THEN 'series'
|
||||
ELSE ''
|
||||
END AS file_kind
|
||||
FROM media_files
|
||||
JOIN media_folders ON media_folders.id = media_files.media_folder_id
|
||||
LEFT JOIN media_items ON media_items.content_id = media_files.content_id
|
||||
LEFT JOIN episodes ON episodes.content_id = media_files.episode_id
|
||||
) classified_files
|
||||
),
|
||||
session_stats AS (
|
||||
SELECT COUNT(*)::bigint AS active_streams
|
||||
@@ -163,7 +193,9 @@ func queryAdminStats(ctx context.Context, pool *pgxpool.Pool) (AdminStats, error
|
||||
item_stats.total_items,
|
||||
file_stats.total_files,
|
||||
item_stats.total_movies,
|
||||
file_stats.total_movie_files,
|
||||
item_stats.total_shows,
|
||||
file_stats.total_show_files,
|
||||
session_stats.active_streams,
|
||||
file_stats.total_storage_bytes
|
||||
FROM user_stats
|
||||
@@ -176,7 +208,9 @@ func queryAdminStats(ctx context.Context, pool *pgxpool.Pool) (AdminStats, error
|
||||
&totalItems,
|
||||
&totalFiles,
|
||||
&totalMovies,
|
||||
&totalMovieFiles,
|
||||
&totalShows,
|
||||
&totalShowFiles,
|
||||
&activeStreams,
|
||||
&totalStorage,
|
||||
); err != nil {
|
||||
@@ -194,7 +228,9 @@ func queryAdminStats(ctx context.Context, pool *pgxpool.Pool) (AdminStats, error
|
||||
TotalItems: int(totalItems),
|
||||
TotalFiles: int(totalFiles),
|
||||
TotalMovies: int(totalMovies),
|
||||
TotalMovieFiles: int(totalMovieFiles),
|
||||
TotalShows: int(totalShows),
|
||||
TotalShowFiles: int(totalShowFiles),
|
||||
ActiveStreams: int(activeStreams),
|
||||
TotalStorageBytes: totalStorage,
|
||||
WatchProviderActivity: activity,
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/subtitles/opensubtitles"
|
||||
"github.com/Silo-Server/silo-server/internal/subtitles/subdl"
|
||||
"github.com/Silo-Server/silo-server/internal/subtitles/subsource"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
@@ -57,7 +56,11 @@ func (h *AdminSubtitleHandler) HandleListProviders(w http.ResponseWriter, r *htt
|
||||
|
||||
// HandleUpdateProvider handles PUT /api/v1/admin/subtitle-providers/{provider}
|
||||
func (h *AdminSubtitleHandler) HandleUpdateProvider(w http.ResponseWriter, r *http.Request) {
|
||||
providerName := chi.URLParam(r, "provider")
|
||||
providerName, err := decodedURLParam(r, "provider")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid provider name")
|
||||
return
|
||||
}
|
||||
|
||||
var req updateSubtitleProviderRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@@ -83,7 +86,11 @@ func (h *AdminSubtitleHandler) HandleUpdateProvider(w http.ResponseWriter, r *ht
|
||||
|
||||
// HandleTestProvider handles POST /api/v1/admin/subtitle-providers/{provider}/test
|
||||
func (h *AdminSubtitleHandler) HandleTestProvider(w http.ResponseWriter, r *http.Request) {
|
||||
providerName := chi.URLParam(r, "provider")
|
||||
providerName, err := decodedURLParam(r, "provider")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid provider name")
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := h.repo.GetProviderConfig(r.Context(), providerName)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
)
|
||||
|
||||
type audiobookGroupResponse struct {
|
||||
Name string `json:"name"`
|
||||
ItemCount int `json:"item_count"`
|
||||
TotalDurationSeconds int64 `json:"total_duration_seconds"`
|
||||
InProgressCount int `json:"in_progress_count"`
|
||||
FinishedCount int `json:"finished_count"`
|
||||
PosterURLs []string `json:"poster_urls"`
|
||||
}
|
||||
|
||||
type audiobookGroupsResponse struct {
|
||||
Total int `json:"total"`
|
||||
Groups []audiobookGroupResponse `json:"groups"`
|
||||
}
|
||||
|
||||
// HandleGetAudiobookGroups — GET /api/v1/catalog/audiobook-groups
|
||||
//
|
||||
// Grouped browse for audiobook libraries: authors, narrators, or series with
|
||||
// aggregate stats (book count, total duration, per-profile progress counts)
|
||||
// and up to four poster URLs for cover stacks. Query parameters:
|
||||
// library_id=<id> (required), group_by=author|narrator|series (required),
|
||||
// sort=name|count|duration (default name), limit, offset.
|
||||
//
|
||||
// Group names round-trip into the corresponding catalog filter fields
|
||||
// (author / narrator / series), which match case-insensitively.
|
||||
func (h *CatalogHandler) HandleGetAudiobookGroups(w http.ResponseWriter, r *http.Request) {
|
||||
if h == nil || h.itemsH == nil || h.itemsH.browseRepo == nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Catalog is not configured")
|
||||
return
|
||||
}
|
||||
|
||||
libraryID, err := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("library_id")))
|
||||
if err != nil || libraryID <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "library_id must be a positive integer")
|
||||
return
|
||||
}
|
||||
|
||||
groupBy, ok := catalog.ParseAudiobookGroupBy(r.URL.Query().Get("group_by"))
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "group_by must be one of author, narrator, series")
|
||||
return
|
||||
}
|
||||
|
||||
sort := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("sort")))
|
||||
switch sort {
|
||||
case "", "name", "count", "duration":
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "sort must be one of name, count, duration")
|
||||
return
|
||||
}
|
||||
|
||||
limit := 200
|
||||
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
|
||||
n, parseErr := strconv.Atoi(raw)
|
||||
if parseErr != nil || n <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "limit must be a positive integer")
|
||||
return
|
||||
}
|
||||
limit = n
|
||||
}
|
||||
offset := max(catalog.ParseIntParam(r.URL.Query().Get("offset")), 0)
|
||||
|
||||
groups, total, err := catalog.ListAudiobookGroups(
|
||||
r.Context(),
|
||||
h.itemsH.browseRepo.Pool(),
|
||||
catalog.AudiobookGroupsQuery{
|
||||
LibraryID: libraryID,
|
||||
GroupBy: groupBy,
|
||||
Sort: sort,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
},
|
||||
h.itemsH.accessFilter(r),
|
||||
)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list audiobook groups")
|
||||
return
|
||||
}
|
||||
|
||||
resp := audiobookGroupsResponse{Total: total, Groups: make([]audiobookGroupResponse, 0, len(groups))}
|
||||
for _, g := range groups {
|
||||
posterURLs := make([]string, 0, len(g.PosterPaths))
|
||||
for _, path := range g.PosterPaths {
|
||||
if url := h.itemsH.presignURL(r, cardThumbnailPath(path), "card"); url != "" {
|
||||
posterURLs = append(posterURLs, url)
|
||||
}
|
||||
}
|
||||
resp.Groups = append(resp.Groups, audiobookGroupResponse{
|
||||
Name: g.Name,
|
||||
ItemCount: g.ItemCount,
|
||||
TotalDurationSeconds: g.TotalDurationSeconds,
|
||||
InProgressCount: g.InProgressCount,
|
||||
FinishedCount: g.FinishedCount,
|
||||
PosterURLs: posterURLs,
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
// HandleGetAudiobook serves GET /api/v1/audiobooks/{id}. Returns the
|
||||
// audiobook media_item, its media_files (with embedded chapters),
|
||||
// author/narrator from item_people (kinds 7/8), and the caller's
|
||||
// per-profile listening progress.
|
||||
func (h *AudiobookHandler) HandleGetAudiobook(w http.ResponseWriter, r *http.Request) {
|
||||
contentID := chi.URLParam(r, "id")
|
||||
if contentID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "id is required")
|
||||
return
|
||||
}
|
||||
|
||||
items, err := h.Items.GetByIDsWithAccess(r.Context(), []string{contentID}, requestAccessFilter(r))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "load audiobook failed")
|
||||
return
|
||||
}
|
||||
if len(items) == 0 {
|
||||
writeError(w, http.StatusNotFound, "not_found", "audiobook not found")
|
||||
return
|
||||
}
|
||||
item := items[0]
|
||||
if item == nil || item.Type != "audiobook" {
|
||||
writeError(w, http.StatusNotFound, "not_found", "audiobook not found")
|
||||
return
|
||||
}
|
||||
|
||||
files, err := h.Files.GetByContentID(r.Context(), contentID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "load files failed")
|
||||
return
|
||||
}
|
||||
|
||||
author, narrator, err := h.fetchAuthorNarrator(r, contentID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "load people failed")
|
||||
return
|
||||
}
|
||||
|
||||
progress := h.fetchProgress(r, contentID)
|
||||
|
||||
resp := audiobookDetailResponse{
|
||||
Audiobook: audiobookDetailItem{
|
||||
ContentID: item.ContentID,
|
||||
Title: item.Title,
|
||||
Year: item.Year,
|
||||
Overview: item.Overview,
|
||||
PosterURL: h.presignAudiobookPoster(r.Context(), item.PosterPath),
|
||||
},
|
||||
Author: author,
|
||||
Narrator: narrator,
|
||||
Files: audiobookDetailFiles(files),
|
||||
Progress: progress,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// fetchAuthorNarrator retrieves the first author (kind=7) and narrator (kind=8)
|
||||
// name from item_people for the given content ID.
|
||||
func (h *AudiobookHandler) fetchAuthorNarrator(r *http.Request, contentID string) (author, narrator string, err error) {
|
||||
people, err := h.Items.GetPeople(r.Context(), contentID)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
for _, p := range people {
|
||||
switch p.Kind {
|
||||
case models.PersonKindAuthor:
|
||||
if author == "" {
|
||||
author = p.Name
|
||||
}
|
||||
case models.PersonKindNarrator:
|
||||
if narrator == "" {
|
||||
narrator = p.Name
|
||||
}
|
||||
}
|
||||
if author != "" && narrator != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
return author, narrator, nil
|
||||
}
|
||||
|
||||
// fetchProgress returns the caller's listening progress for this audiobook, or
|
||||
// nil when no progress exists or the caller is not authenticated with a profile.
|
||||
func (h *AudiobookHandler) fetchProgress(r *http.Request, contentID string) *audiobookDetailProgress {
|
||||
if h.StoreProvider == nil {
|
||||
return nil
|
||||
}
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
if userID == 0 {
|
||||
return nil
|
||||
}
|
||||
profileID := apimw.GetProfileID(r.Context())
|
||||
if profileID == "" {
|
||||
profileID = r.Header.Get("X-Profile-Id")
|
||||
}
|
||||
if profileID == "" {
|
||||
return nil
|
||||
}
|
||||
store, err := h.StoreProvider.ForUser(r.Context(), userID)
|
||||
if err != nil || store == nil {
|
||||
return nil
|
||||
}
|
||||
wp, err := store.GetProgress(r.Context(), profileID, contentID)
|
||||
if err != nil || wp == nil {
|
||||
return nil
|
||||
}
|
||||
return &audiobookDetailProgress{
|
||||
PositionSeconds: wp.PositionSeconds,
|
||||
Completed: wp.Completed,
|
||||
UpdatedAt: wp.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
type audiobookDetailResponse struct {
|
||||
Audiobook audiobookDetailItem `json:"audiobook"`
|
||||
Author string `json:"author,omitempty"`
|
||||
Narrator string `json:"narrator,omitempty"`
|
||||
Files []audiobookDetailFile `json:"files"`
|
||||
Progress *audiobookDetailProgress `json:"progress,omitempty"`
|
||||
}
|
||||
|
||||
type audiobookDetailItem struct {
|
||||
ContentID string `json:"content_id"`
|
||||
Title string `json:"title"`
|
||||
Year int `json:"year"`
|
||||
Overview string `json:"overview,omitempty"`
|
||||
PosterURL string `json:"poster_url,omitempty"`
|
||||
}
|
||||
|
||||
type audiobookDetailFile struct {
|
||||
ID int `json:"id"`
|
||||
Path string `json:"path"`
|
||||
DurationSeconds int `json:"duration_seconds"`
|
||||
Chapters []models.MediaChapter `json:"chapters,omitempty"`
|
||||
}
|
||||
|
||||
type audiobookDetailProgress struct {
|
||||
PositionSeconds float64 `json:"position_seconds"`
|
||||
Completed bool `json:"completed"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
func audiobookDetailFiles(files []*models.MediaFile) []audiobookDetailFile {
|
||||
out := make([]audiobookDetailFile, 0, len(files))
|
||||
for _, f := range files {
|
||||
out = append(out, audiobookDetailFile{
|
||||
ID: f.ID,
|
||||
Path: f.FilePath,
|
||||
DurationSeconds: f.Duration,
|
||||
Chapters: f.Chapters,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/scanner"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
|
||||
// AudiobookHandler serves silo-native audiobook endpoints under
|
||||
// /api/v1/audiobooks/*. Sub-plan 3 implements list/detail/progress;
|
||||
// sub-plan 4's ABS-compat layer is separate.
|
||||
type AudiobookHandler struct {
|
||||
Items *catalog.ItemRepository
|
||||
Files *scanner.FileRepository
|
||||
Detail *catalog.DetailService
|
||||
StoreProvider userstore.UserStoreProvider
|
||||
}
|
||||
|
||||
// HandleListAudiobooks serves GET /api/v1/audiobooks?limit=&offset=.
|
||||
// Paginated catalog of media_items with type='audiobook' scoped to the
|
||||
// caller's accessible libraries.
|
||||
func (h *AudiobookHandler) HandleListAudiobooks(w http.ResponseWriter, r *http.Request) {
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
filter := requestAccessFilter(r)
|
||||
items, total, err := h.Items.Search(r.Context(), "", []string{"audiobook"}, limit, offset, filter)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "list audiobooks failed")
|
||||
return
|
||||
}
|
||||
resp := struct {
|
||||
Items []audiobookListItem `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
}{
|
||||
Items: h.audiobookListItems(r.Context(), items),
|
||||
Total: total,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
type audiobookListItem struct {
|
||||
ContentID string `json:"content_id"`
|
||||
Title string `json:"title"`
|
||||
Year int `json:"year"`
|
||||
PosterURL string `json:"poster_url,omitempty"`
|
||||
}
|
||||
|
||||
func (h *AudiobookHandler) audiobookListItems(ctx context.Context, items []*models.MediaItem) []audiobookListItem {
|
||||
out := make([]audiobookListItem, 0, len(items))
|
||||
for _, it := range items {
|
||||
out = append(out, audiobookListItem{
|
||||
ContentID: it.ContentID,
|
||||
Title: it.Title,
|
||||
Year: it.Year,
|
||||
PosterURL: h.presignAudiobookPoster(ctx, it.PosterPath),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *AudiobookHandler) presignAudiobookPoster(ctx context.Context, path string) string {
|
||||
if h == nil || h.Detail == nil || path == "" {
|
||||
return ""
|
||||
}
|
||||
return h.Detail.PresignURL(ctx, cardThumbnailPath(path), "card")
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
|
||||
type audiobookProgressRequest struct {
|
||||
PositionSeconds float64 `json:"position_seconds"`
|
||||
MediaFileID int `json:"media_file_id,omitempty"`
|
||||
}
|
||||
|
||||
// HandleReportAudiobookProgress serves POST /api/v1/audiobooks/{id}/progress.
|
||||
// UPSERTs into user_watch_progress for the caller's (user_id, profile_id, content_id).
|
||||
func (h *AudiobookHandler) HandleReportAudiobookProgress(w http.ResponseWriter, r *http.Request) {
|
||||
contentID := chi.URLParam(r, "id")
|
||||
if contentID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "id is required")
|
||||
return
|
||||
}
|
||||
|
||||
var req audiobookProgressRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.PositionSeconds < 0 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "position_seconds must be >= 0")
|
||||
return
|
||||
}
|
||||
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
profileID := apimw.GetProfileID(r.Context())
|
||||
if profileID == "" {
|
||||
profileID = r.Header.Get("X-Profile-Id")
|
||||
}
|
||||
if userID == 0 || profileID == "" {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
if h.Items == nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "catalog unavailable")
|
||||
return
|
||||
}
|
||||
items, err := h.Items.GetByIDsWithAccess(r.Context(), []string{contentID}, requestAccessFilter(r))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "check access failed")
|
||||
return
|
||||
}
|
||||
if len(items) == 0 || items[0] == nil || items[0].Type != "audiobook" {
|
||||
writeError(w, http.StatusNotFound, "not_found", "audiobook not found")
|
||||
return
|
||||
}
|
||||
|
||||
durationSeconds, err := h.audiobookProgressDuration(r.Context(), contentID, req.MediaFileID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "load files failed")
|
||||
return
|
||||
}
|
||||
|
||||
if h.StoreProvider == nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "store unavailable")
|
||||
return
|
||||
}
|
||||
store, err := h.StoreProvider.ForUser(r.Context(), userID)
|
||||
if err != nil || store == nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "store unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
if err := store.SetProgress(r.Context(), profileID, contentID, req.PositionSeconds, durationSeconds, userstore.ProgressThresholds{}); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "save progress failed")
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *AudiobookHandler) audiobookProgressDuration(ctx context.Context, contentID string, mediaFileID int) (float64, error) {
|
||||
if h == nil || h.Files == nil {
|
||||
return 0, nil
|
||||
}
|
||||
files, err := h.Files.GetByContentID(ctx, contentID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return audiobookDurationForProgress(files, mediaFileID), nil
|
||||
}
|
||||
|
||||
func audiobookDurationForProgress(files []*models.MediaFile, mediaFileID int) float64 {
|
||||
var total int
|
||||
for _, f := range files {
|
||||
if f == nil || f.Duration <= 0 {
|
||||
continue
|
||||
}
|
||||
if mediaFileID > 0 && f.ID == mediaFileID {
|
||||
return float64(f.Duration)
|
||||
}
|
||||
total += f.Duration
|
||||
}
|
||||
if total <= 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(total)
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
func TestAudiobookListItemsDoNotExposeRawPosterKeysWithoutPresigner(t *testing.T) {
|
||||
h := &AudiobookHandler{}
|
||||
items := h.audiobookListItems(context.Background(), []*models.MediaItem{{
|
||||
ContentID: "book-1",
|
||||
Title: "Book",
|
||||
PosterPath: "metadata/audiobooks/book-1/poster/original.webp",
|
||||
}})
|
||||
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("len(items) = %d, want 1", len(items))
|
||||
}
|
||||
if items[0].PosterURL != "" {
|
||||
t.Fatalf("PosterURL = %q, want empty without presigner", items[0].PosterURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudiobookDurationForProgress(t *testing.T) {
|
||||
files := []*models.MediaFile{
|
||||
{ID: 10, Duration: 120},
|
||||
{ID: 11, Duration: 180},
|
||||
}
|
||||
|
||||
if got := audiobookDurationForProgress(files, 10); got != 120 {
|
||||
t.Fatalf("selected file duration = %v, want 120", got)
|
||||
}
|
||||
if got := audiobookDurationForProgress(files, 0); got != 300 {
|
||||
t.Fatalf("total duration = %v, want 300", got)
|
||||
}
|
||||
if got := audiobookDurationForProgress([]*models.MediaFile{{ID: 1}}, 1); got != 0 {
|
||||
t.Fatalf("unknown duration = %v, want 0", got)
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ type autoscanStore interface {
|
||||
CountAutoscanScans(ctx context.Context, filter autoscan.ScanListFilter) (int, error)
|
||||
ListEvents(ctx context.Context, filter autoscan.EventListFilter) ([]autoscan.EventWithRuns, error)
|
||||
CountEvents(ctx context.Context, filter autoscan.EventListFilter) (int, error)
|
||||
ListRunningEvents(ctx context.Context) ([]autoscan.Event, error)
|
||||
GetQueueSummary(ctx context.Context) (autoscan.QueueSummary, error)
|
||||
LatestEventAt(ctx context.Context) (*time.Time, error)
|
||||
}
|
||||
@@ -309,7 +310,7 @@ func (h *AutoscanHandler) HandleDeleteConnection(w http.ResponseWriter, r *http.
|
||||
// credentials: the connection link is by id only.
|
||||
type autoscanSourceResponse struct {
|
||||
ID string `json:"id"`
|
||||
InstallationID int `json:"installation_id"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
CapabilityID string `json:"capability_id"`
|
||||
ConnectionID *string `json:"connection_id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
@@ -331,7 +332,7 @@ func sourceResponse(s autoscan.Source) autoscanSourceResponse {
|
||||
config := normalizeSourceConfig(s.SourceConfig)
|
||||
return autoscanSourceResponse{
|
||||
ID: s.ID,
|
||||
InstallationID: s.InstallationID,
|
||||
PluginID: s.PluginID,
|
||||
CapabilityID: s.CapabilityID,
|
||||
ConnectionID: s.ConnectionID,
|
||||
Enabled: s.Enabled,
|
||||
@@ -364,10 +365,9 @@ func (h *AutoscanHandler) HandleListSources(w http.ResponseWriter, r *http.Reque
|
||||
// --- Available scan-source plugins (Add-source picker) ---
|
||||
|
||||
type autoscanScanSourcePluginResponse struct {
|
||||
InstallationID int `json:"installation_id"`
|
||||
CapabilityID string `json:"capability_id"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
CapabilityID string `json:"capability_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
|
||||
// HandleListAvailableScanSources returns every installed scan_source capability
|
||||
@@ -381,10 +381,9 @@ func (h *AutoscanHandler) HandleListAvailableScanSources(w http.ResponseWriter,
|
||||
out := make([]autoscanScanSourcePluginResponse, 0, len(available))
|
||||
for _, a := range available {
|
||||
out = append(out, autoscanScanSourcePluginResponse{
|
||||
InstallationID: a.InstallationID,
|
||||
CapabilityID: a.CapabilityID,
|
||||
PluginID: a.PluginID,
|
||||
DisplayName: a.DisplayName,
|
||||
PluginID: a.PluginID,
|
||||
CapabilityID: a.CapabilityID,
|
||||
DisplayName: a.DisplayName,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, struct {
|
||||
@@ -394,12 +393,12 @@ func (h *AutoscanHandler) HandleListAvailableScanSources(w http.ResponseWriter,
|
||||
|
||||
// --- Create source ---
|
||||
|
||||
// autoscanCreateSourceInput is the create payload: it names the installed
|
||||
// scan_source capability to bind (installation_id + capability_id) plus the
|
||||
// initial binding/scheduling fields. Unlike the update payload, identity is
|
||||
// supplied here rather than read from an existing row.
|
||||
// autoscanCreateSourceInput is the create payload: it names the scan_source
|
||||
// capability to bind (plugin_id + capability_id) plus the initial
|
||||
// binding/scheduling fields. Unlike the update payload, identity is supplied
|
||||
// here rather than read from an existing row.
|
||||
type autoscanCreateSourceInput struct {
|
||||
InstallationID int `json:"installation_id"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
CapabilityID string `json:"capability_id"`
|
||||
ConnectionID *string `json:"connection_id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
@@ -420,6 +419,11 @@ func (h *AutoscanHandler) HandleCreateSource(w http.ResponseWriter, r *http.Requ
|
||||
return
|
||||
}
|
||||
capID := strings.TrimSpace(in.CapabilityID)
|
||||
pluginID := strings.TrimSpace(in.PluginID)
|
||||
if pluginID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "plugin_id is required")
|
||||
return
|
||||
}
|
||||
if capID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "capability_id is required")
|
||||
return
|
||||
@@ -432,7 +436,7 @@ func (h *AutoscanHandler) HandleCreateSource(w http.ResponseWriter, r *http.Requ
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
// Validate the (installation_id, capability_id) is a currently-installed
|
||||
// Validate the (plugin_id, capability_id) is a currently-installed
|
||||
// scan_source capability — a source may only be created against an installed
|
||||
// plugin capability.
|
||||
available, err := h.svc.ListAvailableScanSources(r.Context())
|
||||
@@ -440,13 +444,13 @@ func (h *AutoscanHandler) HandleCreateSource(w http.ResponseWriter, r *http.Requ
|
||||
writeAutoscanError(w, err)
|
||||
return
|
||||
}
|
||||
if !scanSourceInstalled(available, in.InstallationID, capID) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "installation_id + capability_id is not a currently-installed scan_source capability")
|
||||
if !scanSourceInstalled(available, pluginID, capID) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "plugin_id + capability_id is not a currently-installed scan_source capability")
|
||||
return
|
||||
}
|
||||
connArg := normalizeConnectionID(in.ConnectionID)
|
||||
created, err := h.repo.CreateSource(r.Context(), autoscan.Source{
|
||||
InstallationID: in.InstallationID,
|
||||
PluginID: pluginID,
|
||||
CapabilityID: capID,
|
||||
ConnectionID: connArg,
|
||||
Enabled: in.Enabled,
|
||||
@@ -462,18 +466,18 @@ func (h *AutoscanHandler) HandleCreateSource(w http.ResponseWriter, r *http.Requ
|
||||
writeJSON(w, http.StatusCreated, sourceResponse(created))
|
||||
}
|
||||
|
||||
// scanSourceInstalled reports whether (installationID, capabilityID) is in the
|
||||
// scanSourceInstalled reports whether (pluginID, capabilityID) is in the
|
||||
// set of currently-installed scan_source capabilities.
|
||||
func scanSourceInstalled(available []autoscan.AvailableScanSource, installationID int, capabilityID string) bool {
|
||||
func scanSourceInstalled(available []autoscan.AvailableScanSource, pluginID, capabilityID string) bool {
|
||||
for _, a := range available {
|
||||
if a.InstallationID == installationID && a.CapabilityID == capabilityID {
|
||||
if a.PluginID == pluginID && a.CapabilityID == capabilityID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// autoscanSourceInput is the source write payload. The (installation_id,
|
||||
// autoscanSourceInput is the source write payload. The (plugin_id,
|
||||
// capability_id) identity is read from the existing source row, so only the
|
||||
// schedulable/binding fields are accepted.
|
||||
//
|
||||
@@ -548,7 +552,7 @@ func (h *AutoscanHandler) HandleUpdateSource(w http.ResponseWriter, r *http.Requ
|
||||
// connection_id is a full-state field: nil means unbind, a UUID string
|
||||
// means bind. A whitespace-only string is normalised to nil (unbound).
|
||||
connArg := normalizeConnectionID(in.ConnectionID)
|
||||
// Update by id; identity (installation_id, capability_id) is immutable and
|
||||
// Update by id; identity (plugin_id, capability_id) is immutable and
|
||||
// preserved by the repo. A missing source maps to 404.
|
||||
updated, err := h.repo.UpdateSource(r.Context(), autoscan.Source{
|
||||
ID: id,
|
||||
@@ -581,9 +585,8 @@ func normalizeConnectionID(in *string) *string {
|
||||
}
|
||||
|
||||
// HandleDeleteSource removes a source row. This is the operator's escape hatch
|
||||
// for an orphaned source (one whose scan_source plugin was uninstalled): the
|
||||
// poll loop skips such rows quietly, and this endpoint clears them. An unknown
|
||||
// id maps to 404 via autoscan.ErrNotFound.
|
||||
// for an orphaned source (one whose scan_source plugin was uninstalled or no
|
||||
// longer resolves cleanly). An unknown id maps to 404 via autoscan.ErrNotFound.
|
||||
func (h *AutoscanHandler) HandleDeleteSource(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimSpace(chi.URLParam(r, "id"))
|
||||
if err := h.repo.DeleteSource(r.Context(), id); err != nil {
|
||||
@@ -710,7 +713,7 @@ type autoscanEventScanRunResponse struct {
|
||||
type autoscanEventResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
SourceID *string `json:"source_id"`
|
||||
InstallationID int `json:"installation_id"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
CapabilityID string `json:"capability_id"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
CompletedAt time.Time `json:"completed_at"`
|
||||
@@ -753,7 +756,7 @@ func eventResponse(event autoscan.EventWithRuns) autoscanEventResponse {
|
||||
return autoscanEventResponse{
|
||||
ID: e.ID,
|
||||
SourceID: e.SourceID,
|
||||
InstallationID: e.InstallationID,
|
||||
PluginID: e.PluginID,
|
||||
CapabilityID: e.CapabilityID,
|
||||
StartedAt: e.StartedAt,
|
||||
CompletedAt: e.CompletedAt,
|
||||
@@ -794,9 +797,9 @@ func (h *AutoscanHandler) HandleListEvents(w http.ResponseWriter, r *http.Reques
|
||||
filter.Offset = offset
|
||||
}
|
||||
switch filter.Status {
|
||||
case "", autoscan.EventStatusSuccess, autoscan.EventStatusError, autoscan.EventStatusUnresolved:
|
||||
case "", autoscan.EventStatusRunning, autoscan.EventStatusSuccess, autoscan.EventStatusError, autoscan.EventStatusUnresolved:
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "status must be success, error, or unresolved")
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "status must be running, success, error, or unresolved")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -835,7 +838,7 @@ type autoscanScanResponse struct {
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
AutoscanEventID *int64 `json:"autoscan_event_id,omitempty"`
|
||||
SourceID *string `json:"source_id,omitempty"`
|
||||
InstallationID *int `json:"installation_id,omitempty"`
|
||||
PluginID string `json:"plugin_id,omitempty"`
|
||||
CapabilityID string `json:"capability_id,omitempty"`
|
||||
EventStatus string `json:"event_status,omitempty"`
|
||||
EventCompletedAt *time.Time `json:"event_completed_at,omitempty"`
|
||||
@@ -862,7 +865,7 @@ func autoscanScanRunResponse(scan autoscan.ScanWithEvent) autoscanScanResponse {
|
||||
CompletedAt: scan.CompletedAt,
|
||||
AutoscanEventID: scan.AutoscanEventID,
|
||||
SourceID: scan.SourceID,
|
||||
InstallationID: scan.InstallationID,
|
||||
PluginID: scan.PluginID,
|
||||
CapabilityID: scan.CapabilityID,
|
||||
EventStatus: string(scan.EventStatus),
|
||||
EventCompletedAt: scan.EventCompletedAt,
|
||||
@@ -923,24 +926,35 @@ func (h *AutoscanHandler) HandleListScans(w http.ResponseWriter, r *http.Request
|
||||
// --- Status ---
|
||||
|
||||
type autoscanStatusSource struct {
|
||||
ID string `json:"id"`
|
||||
InstallationID int `json:"installation_id"`
|
||||
CapabilityID string `json:"capability_id"`
|
||||
ConnectionID *string `json:"connection_id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Label string `json:"label"`
|
||||
PathRewrites []autoscan.PathRewrite `json:"path_rewrites"`
|
||||
LastRunAt *time.Time `json:"last_run_at,omitempty"`
|
||||
LastError *string `json:"last_error,omitempty"`
|
||||
ID string `json:"id"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
CapabilityID string `json:"capability_id"`
|
||||
ConnectionID *string `json:"connection_id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Label string `json:"label"`
|
||||
PathRewrites []autoscan.PathRewrite `json:"path_rewrites"`
|
||||
LastRunAt *time.Time `json:"last_run_at,omitempty"`
|
||||
LastError *string `json:"last_error,omitempty"`
|
||||
}
|
||||
|
||||
type autoscanRunningPollResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
SourceID *string `json:"source_id"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
CapabilityID string `json:"capability_id"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
ElapsedMS int64 `json:"elapsed_ms"`
|
||||
MarkerBefore *string `json:"marker_before,omitempty"`
|
||||
}
|
||||
|
||||
type autoscanStatusResponse struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Sources []autoscanStatusSource `json:"sources"`
|
||||
ActiveScans int `json:"active_scans"`
|
||||
AcceptedScans int `json:"accepted_scans"`
|
||||
RunningScans int `json:"running_scans"`
|
||||
LatestEventAt *time.Time `json:"latest_event_at,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Sources []autoscanStatusSource `json:"sources"`
|
||||
RunningPolls []autoscanRunningPollResponse `json:"running_polls"`
|
||||
ActiveScans int `json:"active_scans"`
|
||||
AcceptedScans int `json:"accepted_scans"`
|
||||
RunningScans int `json:"running_scans"`
|
||||
LatestEventAt *time.Time `json:"latest_event_at,omitempty"`
|
||||
}
|
||||
|
||||
func (h *AutoscanHandler) HandleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -960,6 +974,11 @@ func (h *AutoscanHandler) HandleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
writeAutoscanError(w, err)
|
||||
return
|
||||
}
|
||||
runningEvents, err := h.repo.ListRunningEvents(ctx)
|
||||
if err != nil {
|
||||
writeAutoscanError(w, err)
|
||||
return
|
||||
}
|
||||
latestEventAt, err := h.repo.LatestEventAt(ctx)
|
||||
if err != nil {
|
||||
writeAutoscanError(w, err)
|
||||
@@ -972,20 +991,38 @@ func (h *AutoscanHandler) HandleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
rewrites = []autoscan.PathRewrite{}
|
||||
}
|
||||
trimmed = append(trimmed, autoscanStatusSource{
|
||||
ID: src.ID,
|
||||
InstallationID: src.InstallationID,
|
||||
CapabilityID: src.CapabilityID,
|
||||
ConnectionID: src.ConnectionID,
|
||||
Enabled: src.Enabled,
|
||||
Label: src.Label,
|
||||
PathRewrites: rewrites,
|
||||
LastRunAt: src.LastRunAt,
|
||||
LastError: src.LastError,
|
||||
ID: src.ID,
|
||||
PluginID: src.PluginID,
|
||||
CapabilityID: src.CapabilityID,
|
||||
ConnectionID: src.ConnectionID,
|
||||
Enabled: src.Enabled,
|
||||
Label: src.Label,
|
||||
PathRewrites: rewrites,
|
||||
LastRunAt: src.LastRunAt,
|
||||
LastError: src.LastError,
|
||||
})
|
||||
}
|
||||
now := time.Now()
|
||||
runningPolls := make([]autoscanRunningPollResponse, 0, len(runningEvents))
|
||||
for _, event := range runningEvents {
|
||||
elapsed := now.Sub(event.StartedAt)
|
||||
if elapsed < 0 {
|
||||
elapsed = 0
|
||||
}
|
||||
runningPolls = append(runningPolls, autoscanRunningPollResponse{
|
||||
ID: event.ID,
|
||||
SourceID: event.SourceID,
|
||||
PluginID: event.PluginID,
|
||||
CapabilityID: event.CapabilityID,
|
||||
StartedAt: event.StartedAt,
|
||||
ElapsedMS: elapsed.Milliseconds(),
|
||||
MarkerBefore: event.MarkerBefore,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, autoscanStatusResponse{
|
||||
Enabled: s.Enabled,
|
||||
Sources: trimmed,
|
||||
RunningPolls: runningPolls,
|
||||
ActiveScans: queue.Active,
|
||||
AcceptedScans: queue.Accepted,
|
||||
RunningScans: queue.Running,
|
||||
|
||||
@@ -31,6 +31,7 @@ type fakeAutoscanStore struct {
|
||||
countScansFn func(autoscan.ScanListFilter) (int, error)
|
||||
listEventsFn func(autoscan.EventListFilter) ([]autoscan.EventWithRuns, error)
|
||||
countEventsFn func(autoscan.EventListFilter) (int, error)
|
||||
listRunningFn func() ([]autoscan.Event, error)
|
||||
queueSummaryFn func() (autoscan.QueueSummary, error)
|
||||
latestEventAtFn func() (*time.Time, error)
|
||||
}
|
||||
@@ -140,6 +141,13 @@ func (f *fakeAutoscanStore) CountEvents(_ context.Context, filter autoscan.Event
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (f *fakeAutoscanStore) ListRunningEvents(context.Context) ([]autoscan.Event, error) {
|
||||
if f.listRunningFn != nil {
|
||||
return f.listRunningFn()
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeAutoscanStore) GetQueueSummary(context.Context) (autoscan.QueueSummary, error) {
|
||||
if f.queueSummaryFn != nil {
|
||||
return f.queueSummaryFn()
|
||||
@@ -273,7 +281,7 @@ func TestAutoscanHandleListSourcesListsOnly(t *testing.T) {
|
||||
store := &fakeAutoscanStore{
|
||||
listSourcesFn: func() ([]autoscan.Source, error) {
|
||||
listed = true
|
||||
return []autoscan.Source{{ID: "src-1", InstallationID: 1, CapabilityID: "arr"}}, nil
|
||||
return []autoscan.Source{{ID: "src-1", PluginID: "silo.autoscan.arr", CapabilityID: "arr"}}, nil
|
||||
},
|
||||
}
|
||||
h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{})
|
||||
@@ -300,7 +308,7 @@ func TestAutoscanHandleListSourcesListsOnly(t *testing.T) {
|
||||
|
||||
func TestAutoscanHandleListAvailableScanSources(t *testing.T) {
|
||||
trig := &fakeAutoscanTriggerer{available: []autoscan.AvailableScanSource{
|
||||
{InstallationID: 1, CapabilityID: "arr", PluginID: "sonarr", DisplayName: "Sonarr"},
|
||||
{PluginID: "sonarr", CapabilityID: "arr", DisplayName: "Sonarr"},
|
||||
}}
|
||||
h := NewAutoscanHandler(&fakeAutoscanStore{}, trig)
|
||||
|
||||
@@ -331,19 +339,19 @@ func TestAutoscanHandleCreateSourceSucceeds(t *testing.T) {
|
||||
},
|
||||
}
|
||||
trig := &fakeAutoscanTriggerer{available: []autoscan.AvailableScanSource{
|
||||
{InstallationID: 1, CapabilityID: "arr"},
|
||||
{PluginID: "silo.autoscan.arr", CapabilityID: "arr"},
|
||||
}}
|
||||
h := NewAutoscanHandler(store, trig)
|
||||
|
||||
req := newAutoscanRequest("POST", "/api/v1/admin/autoscan/sources",
|
||||
`{"installation_id":1,"capability_id":"arr","connection_id":"conn-1","enabled":true}`, "")
|
||||
`{"plugin_id":"silo.autoscan.arr","capability_id":"arr","connection_id":"conn-1","enabled":true}`, "")
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleCreateSource(rec, req)
|
||||
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got.InstallationID != 1 || got.CapabilityID != "arr" || got.ConnectionID == nil || *got.ConnectionID != "conn-1" {
|
||||
if got.PluginID != "silo.autoscan.arr" || got.CapabilityID != "arr" || got.ConnectionID == nil || *got.ConnectionID != "conn-1" {
|
||||
t.Fatalf("unexpected created source: %+v", got)
|
||||
}
|
||||
var body autoscanSourceResponse
|
||||
@@ -363,14 +371,14 @@ func TestAutoscanHandleCreateSourceRejectsUnknownCapability(t *testing.T) {
|
||||
return s, nil
|
||||
},
|
||||
}
|
||||
// Installed set does NOT include (1, "arr").
|
||||
// Installed set does NOT include ("silo.autoscan.arr", "arr").
|
||||
trig := &fakeAutoscanTriggerer{available: []autoscan.AvailableScanSource{
|
||||
{InstallationID: 2, CapabilityID: "other"},
|
||||
{PluginID: "silo.autoscan.other", CapabilityID: "other"},
|
||||
}}
|
||||
h := NewAutoscanHandler(store, trig)
|
||||
|
||||
req := newAutoscanRequest("POST", "/api/v1/admin/autoscan/sources",
|
||||
`{"installation_id":1,"capability_id":"arr","enabled":false}`, "")
|
||||
`{"plugin_id":"silo.autoscan.arr","capability_id":"arr","enabled":false}`, "")
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleCreateSource(rec, req)
|
||||
|
||||
@@ -394,12 +402,12 @@ func TestAutoscanHandleCreateSourceEnableWithoutConnectionSucceeds(t *testing.T)
|
||||
},
|
||||
}
|
||||
trig := &fakeAutoscanTriggerer{available: []autoscan.AvailableScanSource{
|
||||
{InstallationID: 1, CapabilityID: "arr"},
|
||||
{PluginID: "silo.autoscan.arr", CapabilityID: "arr"},
|
||||
}}
|
||||
h := NewAutoscanHandler(store, trig)
|
||||
|
||||
req := newAutoscanRequest("POST", "/api/v1/admin/autoscan/sources",
|
||||
`{"installation_id":1,"capability_id":"arr","enabled":true}`, "")
|
||||
`{"plugin_id":"silo.autoscan.arr","capability_id":"arr","enabled":true}`, "")
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleCreateSource(rec, req)
|
||||
|
||||
@@ -615,7 +623,7 @@ func TestAutoscanHandleUpdateSourceEnableWithoutConnectionSucceeds(t *testing.T)
|
||||
var got autoscan.Source
|
||||
store := &fakeAutoscanStore{
|
||||
getSourceFn: func(id string) (autoscan.Source, error) {
|
||||
return autoscan.Source{ID: id, InstallationID: 1, CapabilityID: "arr", ConnectionID: nil}, nil
|
||||
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: nil}, nil
|
||||
},
|
||||
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
|
||||
got = s
|
||||
@@ -641,7 +649,7 @@ func TestAutoscanHandleUpdateSourceEnableWithConnectionSucceeds(t *testing.T) {
|
||||
var got autoscan.Source
|
||||
store := &fakeAutoscanStore{
|
||||
getSourceFn: func(id string) (autoscan.Source, error) {
|
||||
return autoscan.Source{ID: id, InstallationID: 1, CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
|
||||
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
|
||||
},
|
||||
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
|
||||
got = s
|
||||
@@ -669,7 +677,7 @@ func TestAutoscanHandleUpdateSourceBindConnectionSucceeds(t *testing.T) {
|
||||
store := &fakeAutoscanStore{
|
||||
getSourceFn: func(id string) (autoscan.Source, error) {
|
||||
// Source starts with no connection.
|
||||
return autoscan.Source{ID: id, InstallationID: 1, CapabilityID: "arr", ConnectionID: nil}, nil
|
||||
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: nil}, nil
|
||||
},
|
||||
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
|
||||
got = s
|
||||
@@ -697,7 +705,7 @@ func TestAutoscanHandleUpdateSourceUnbindConnectionSucceeds(t *testing.T) {
|
||||
store := &fakeAutoscanStore{
|
||||
getSourceFn: func(id string) (autoscan.Source, error) {
|
||||
// Source starts with a connection already bound.
|
||||
return autoscan.Source{ID: id, InstallationID: 1, CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
|
||||
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
|
||||
},
|
||||
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
|
||||
got = s
|
||||
@@ -727,7 +735,7 @@ func TestAutoscanHandleUpdateSourceUnbindWhileEnabledSucceeds(t *testing.T) {
|
||||
var got autoscan.Source
|
||||
store := &fakeAutoscanStore{
|
||||
getSourceFn: func(id string) (autoscan.Source, error) {
|
||||
return autoscan.Source{ID: id, InstallationID: 1, CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
|
||||
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
|
||||
},
|
||||
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
|
||||
got = s
|
||||
@@ -925,7 +933,7 @@ func TestAutoscanHandleUpdateSourceRoundTripsPathRewrites(t *testing.T) {
|
||||
var got autoscan.Source
|
||||
store := &fakeAutoscanStore{
|
||||
getSourceFn: func(id string) (autoscan.Source, error) {
|
||||
return autoscan.Source{ID: id, InstallationID: 1, CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
|
||||
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
|
||||
},
|
||||
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
|
||||
got = s
|
||||
@@ -990,7 +998,7 @@ func TestAutoscanHandleUpdateSourceRejectsBlankRewrite(t *testing.T) {
|
||||
upserted := false
|
||||
store := &fakeAutoscanStore{
|
||||
getSourceFn: func(id string) (autoscan.Source, error) {
|
||||
return autoscan.Source{ID: id, InstallationID: 1, CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
|
||||
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
|
||||
},
|
||||
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
|
||||
upserted = true
|
||||
@@ -1020,7 +1028,7 @@ func TestAutoscanHandleListScansSerializesFiltersAndEventContext(t *testing.T) {
|
||||
eventCompleted := completed.Add(-1 * time.Second)
|
||||
eventID := int64(42)
|
||||
sourceID := "src-1"
|
||||
installationID := 7
|
||||
pluginID := "silo.autoscan.cephfs"
|
||||
var gotFilter autoscan.ScanListFilter
|
||||
var gotCountFilter autoscan.ScanListFilter
|
||||
store := &fakeAutoscanStore{
|
||||
@@ -1040,7 +1048,7 @@ func TestAutoscanHandleListScansSerializesFiltersAndEventContext(t *testing.T) {
|
||||
},
|
||||
AutoscanEventID: &eventID,
|
||||
SourceID: &sourceID,
|
||||
InstallationID: &installationID,
|
||||
PluginID: pluginID,
|
||||
CapabilityID: "cephfs",
|
||||
EventStatus: autoscan.EventStatusSuccess,
|
||||
EventCompletedAt: &eventCompleted,
|
||||
@@ -1084,7 +1092,7 @@ func TestAutoscanHandleListScansSerializesFiltersAndEventContext(t *testing.T) {
|
||||
if scan.AutoscanEventID == nil || *scan.AutoscanEventID != eventID || scan.EventStatus != "success" {
|
||||
t.Fatalf("event context = %+v", scan)
|
||||
}
|
||||
if scan.SourceID == nil || *scan.SourceID != sourceID || scan.InstallationID == nil || *scan.InstallationID != installationID || scan.CapabilityID != "cephfs" {
|
||||
if scan.SourceID == nil || *scan.SourceID != sourceID || scan.PluginID != pluginID || scan.CapabilityID != "cephfs" {
|
||||
t.Fatalf("source context = %+v", scan)
|
||||
}
|
||||
}
|
||||
@@ -1128,7 +1136,7 @@ func TestAutoscanHandleListEventsSerializesFiltersAndRuns(t *testing.T) {
|
||||
Event: autoscan.Event{
|
||||
ID: 42,
|
||||
SourceID: &sourceID,
|
||||
InstallationID: 7,
|
||||
PluginID: "silo.autoscan.cephfs",
|
||||
CapabilityID: "cephfs",
|
||||
StartedAt: started,
|
||||
CompletedAt: completed,
|
||||
@@ -1258,18 +1266,30 @@ func TestAutoscanHandleUpdateSourceNormalizesLabel(t *testing.T) {
|
||||
|
||||
func TestAutoscanHandleStatusReturnsTrimmedSources(t *testing.T) {
|
||||
latestEventAt := time.Date(2026, 6, 4, 14, 30, 0, 0, time.UTC)
|
||||
runningStartedAt := time.Now().Add(-90 * time.Second)
|
||||
sourceID := "src-1"
|
||||
store := &fakeAutoscanStore{
|
||||
getSettingsFn: func() (autoscan.Settings, error) {
|
||||
return autoscan.Settings{Enabled: true}, nil
|
||||
},
|
||||
listSourcesFn: func() ([]autoscan.Source, error) {
|
||||
return []autoscan.Source{
|
||||
{ID: "src-1", InstallationID: 7, CapabilityID: "scan_source", ConnectionID: ptr("conn-1"), Enabled: true},
|
||||
{ID: "src-1", PluginID: "silo.autoscan.cephfs", CapabilityID: "scan_source", ConnectionID: ptr("conn-1"), Enabled: true},
|
||||
}, nil
|
||||
},
|
||||
queueSummaryFn: func() (autoscan.QueueSummary, error) {
|
||||
return autoscan.QueueSummary{Active: 3, Accepted: 2, Running: 1}, nil
|
||||
},
|
||||
listRunningFn: func() ([]autoscan.Event, error) {
|
||||
return []autoscan.Event{{
|
||||
ID: 44,
|
||||
SourceID: &sourceID,
|
||||
PluginID: "silo.autoscan.cephfs",
|
||||
CapabilityID: "cephfs",
|
||||
StartedAt: runningStartedAt,
|
||||
Status: autoscan.EventStatusRunning,
|
||||
}}, nil
|
||||
},
|
||||
latestEventAtFn: func() (*time.Time, error) {
|
||||
return &latestEventAt, nil
|
||||
},
|
||||
@@ -1296,6 +1316,16 @@ func TestAutoscanHandleStatusReturnsTrimmedSources(t *testing.T) {
|
||||
if body.ActiveScans != 3 || body.AcceptedScans != 2 || body.RunningScans != 1 {
|
||||
t.Errorf("queue summary = %+v", body)
|
||||
}
|
||||
if len(body.RunningPolls) != 1 {
|
||||
t.Fatalf("running_polls = %+v", body.RunningPolls)
|
||||
}
|
||||
poll := body.RunningPolls[0]
|
||||
if poll.ID != 44 || poll.SourceID == nil || *poll.SourceID != sourceID || poll.PluginID != "silo.autoscan.cephfs" || poll.CapabilityID != "cephfs" {
|
||||
t.Fatalf("running poll identity = %+v", poll)
|
||||
}
|
||||
if poll.ElapsedMS <= 0 {
|
||||
t.Fatalf("running poll elapsed_ms = %d, want > 0", poll.ElapsedMS)
|
||||
}
|
||||
if body.LatestEventAt == nil || !body.LatestEventAt.Equal(latestEventAt) {
|
||||
t.Errorf("latest_event_at = %v, want %v", body.LatestEventAt, latestEventAt)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/sections"
|
||||
@@ -42,6 +43,9 @@ type catalogFiltersResponse struct {
|
||||
Countries []string `json:"countries"`
|
||||
OriginalLanguages []string `json:"original_languages"`
|
||||
ContentRatings []string `json:"content_ratings"`
|
||||
Authors []string `json:"authors"`
|
||||
Narrators []string `json:"narrators"`
|
||||
Series []string `json:"series"`
|
||||
Resolutions *[]string `json:"resolutions,omitempty"`
|
||||
AudioLanguages *[]string `json:"audio_languages,omitempty"`
|
||||
SubtitleLanguages *[]string `json:"subtitle_languages,omitempty"`
|
||||
@@ -86,6 +90,7 @@ func (h *CatalogHandler) HandleGetCatalog(w http.ResponseWriter, r *http.Request
|
||||
h.itemsH.accessFilter(r),
|
||||
overlaySummaries,
|
||||
store,
|
||||
apimw.GetUserID(r.Context()),
|
||||
profileID,
|
||||
)
|
||||
items := make([]itemListResponse, 0, len(result.Items))
|
||||
@@ -163,12 +168,94 @@ func (h *CatalogHandler) HandleGetCatalogFilters(w http.ResponseWriter, r *http.
|
||||
Countries: filters.Countries,
|
||||
OriginalLanguages: filters.OriginalLanguages,
|
||||
ContentRatings: filters.ContentRatings,
|
||||
Authors: filters.Authors,
|
||||
Narrators: filters.Narrators,
|
||||
Series: filters.Series,
|
||||
Resolutions: resolutions,
|
||||
AudioLanguages: audioLanguages,
|
||||
SubtitleLanguages: subtitleLanguages,
|
||||
})
|
||||
}
|
||||
|
||||
// catalogFacetSearchResponse mirrors catalog.CatalogFacetSearchResult on
|
||||
// the wire. matches[] is always present (empty when no hits); has_more
|
||||
// is true when the underlying result set held more entries than the
|
||||
// requested limit.
|
||||
type catalogFacetSearchResponse struct {
|
||||
Matches []string `json:"matches"`
|
||||
HasMore bool `json:"has_more"`
|
||||
}
|
||||
|
||||
// HandleGetCatalogFacetSearch — GET /api/v1/catalog/filters/search
|
||||
//
|
||||
// Prefix-typeahead for the high-cardinality filter facets (authors /
|
||||
// narrators / series, plus genre / studio / network / country /
|
||||
// original_language / content_rating for consistency). Query
|
||||
// parameters: same as /api/v1/catalog/filters for scope (source,
|
||||
// library_id, etc.), plus facet=<name>, q=<prefix>, limit=<N>.
|
||||
//
|
||||
// The bulk /api/v1/catalog/filters endpoint stays as the source for
|
||||
// the initial dropdown render (top 1000 alphabetical); this endpoint
|
||||
// takes over once the user starts typing.
|
||||
func (h *CatalogHandler) HandleGetCatalogFacetSearch(w http.ResponseWriter, r *http.Request) {
|
||||
if h == nil || h.resolver == nil || h.itemsH == nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Catalog is not configured")
|
||||
return
|
||||
}
|
||||
|
||||
req, err := catalog.ParseCatalogRequest(r.URL.Query())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
facet := strings.TrimSpace(r.URL.Query().Get("facet"))
|
||||
if facet == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "facet parameter is required")
|
||||
return
|
||||
}
|
||||
prefix := r.URL.Query().Get("q")
|
||||
|
||||
limit := 20
|
||||
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
|
||||
n, parseErr := strconv.Atoi(raw)
|
||||
if parseErr != nil || n <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "limit must be a positive integer")
|
||||
return
|
||||
}
|
||||
if n > 100 {
|
||||
n = 100
|
||||
}
|
||||
limit = n
|
||||
}
|
||||
|
||||
result, err := h.resolver.SearchFacet(
|
||||
r.Context(),
|
||||
req,
|
||||
h.itemsH.accessFilter(r),
|
||||
facet,
|
||||
prefix,
|
||||
limit,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, catalog.ErrInvalidCatalogRequest) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to search catalog facet")
|
||||
return
|
||||
}
|
||||
|
||||
matches := result.Matches
|
||||
if matches == nil {
|
||||
matches = []string{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, catalogFacetSearchResponse{
|
||||
Matches: matches,
|
||||
HasMore: result.HasMore,
|
||||
})
|
||||
}
|
||||
|
||||
func parseIncludeTechnical(raw string) bool {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return true
|
||||
|
||||
@@ -161,35 +161,7 @@ func (h *CatalogResourceHandler) HandleGetItemEpisodes(w http.ResponseWriter, r
|
||||
return
|
||||
}
|
||||
h.items.maybeRequestStaleSeasonMetadataRefresh(r.Context(), season.ContentID, episodes)
|
||||
|
||||
resp := make([]episodeResponse, 0, len(episodes))
|
||||
fallbacks := h.items.episodeImageFallbacks(r.Context(), episodes)
|
||||
for _, ep := range episodes {
|
||||
epResp := h.items.toEpisodeResponseWithFallback(r, ep, fallbacks[ep.SeriesID])
|
||||
if h.items.fileRepo != nil {
|
||||
files, fileErr := h.items.fileRepo.GetByEpisodeID(r.Context(), ep.ContentID)
|
||||
if fileErr == nil {
|
||||
for _, f := range files {
|
||||
if !catalog.FileAllowedByAccess(f, filter) {
|
||||
continue
|
||||
}
|
||||
epResp.Files = append(epResp.Files, episodeFileResponse{
|
||||
FileID: f.ID,
|
||||
Resolution: f.Resolution,
|
||||
CodecVideo: f.CodecVideo,
|
||||
HDR: f.HDR,
|
||||
AudioChannels: f.AudioChannels,
|
||||
Container: f.Container,
|
||||
FileSize: f.FileSize,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
epResp.UserData = h.items.getLeafUserData(r, ep.ContentID)
|
||||
resp = append(resp, epResp)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, episodesListResponse{Episodes: resp})
|
||||
h.writeEpisodeResponses(w, r, episodes)
|
||||
}
|
||||
|
||||
func (h *CatalogResourceHandler) HandleGetSeasons(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -439,34 +411,7 @@ func (h *CatalogResourceHandler) syntheticSeasonDetail(r *http.Request, seasonID
|
||||
}
|
||||
|
||||
func (h *CatalogResourceHandler) writeEpisodeResponses(w http.ResponseWriter, r *http.Request, episodes []*models.Episode) {
|
||||
filter := h.items.accessFilter(r)
|
||||
resp := make([]episodeResponse, 0, len(episodes))
|
||||
fallbacks := h.items.episodeImageFallbacks(r.Context(), episodes)
|
||||
for _, ep := range episodes {
|
||||
epResp := h.items.toEpisodeResponseWithFallback(r, ep, fallbacks[ep.SeriesID])
|
||||
if h.items.fileRepo != nil {
|
||||
files, fileErr := h.items.fileRepo.GetByEpisodeID(r.Context(), ep.ContentID)
|
||||
if fileErr == nil {
|
||||
for _, f := range files {
|
||||
if !catalog.FileAllowedByAccess(f, filter) {
|
||||
continue
|
||||
}
|
||||
epResp.Files = append(epResp.Files, episodeFileResponse{
|
||||
FileID: f.ID,
|
||||
Resolution: f.Resolution,
|
||||
CodecVideo: f.CodecVideo,
|
||||
HDR: f.HDR,
|
||||
AudioChannels: f.AudioChannels,
|
||||
Container: f.Container,
|
||||
FileSize: f.FileSize,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
epResp.UserData = h.items.getLeafUserData(r, ep.ContentID)
|
||||
resp = append(resp, epResp)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, episodesListResponse{Episodes: resp})
|
||||
writeJSON(w, http.StatusOK, episodesListResponse{Episodes: h.items.buildEpisodeResponses(r, episodes)})
|
||||
}
|
||||
|
||||
func parseSyntheticSeasonID(contentID string) (string, int, bool) {
|
||||
@@ -497,8 +442,8 @@ func (h *CatalogResourceHandler) enrichItemDetail(r *http.Request, detail *catal
|
||||
detail.SeasonUserData = h.items.getAggregateUserData(r, episodes)
|
||||
}
|
||||
}
|
||||
case "movie", "episode":
|
||||
detail.SeasonUserData = h.items.getLeafUserData(r, detail.ContentID)
|
||||
case "movie", "episode", "audiobook", "ebook":
|
||||
detail.SeasonUserData = h.items.getLeafUserData(r, detail.ContentID, detail.Type)
|
||||
applyEffectiveEditionPreference(detail.SeasonUserData, &detail.EffectiveVersionEditionKey)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
// Ebook read state lives exclusively in ebook_reader_progress: every consumer
|
||||
// (user-state Played, catalog watched/in_progress filters, Continue Reading,
|
||||
// sort metrics, recommendations) keys off progress crossing
|
||||
// models.EbookFinishedProgressThreshold. The native watched endpoints therefore
|
||||
// write that table instead of user_watch_progress/user_watch_history. The
|
||||
// helpers live in this package — next to the ebook progress store and types —
|
||||
// because the handler layer is the only producer today: the webhook-import
|
||||
// matcher (internal/historyimport) only matches movie/series/episode kinds, so
|
||||
// imported mark-played events can never resolve to an ebook content ID.
|
||||
|
||||
// EbookReadStateStore is the ebook_reader_progress surface needed to set or
|
||||
// clear an ebook's read state from the watched endpoints.
|
||||
// *PGEbookReaderProgressStore implements it.
|
||||
type EbookReadStateStore interface {
|
||||
Get(ctx context.Context, userID int, profileID string, contentID string) (*EbookReaderProgress, error)
|
||||
Upsert(ctx context.Context, progress EbookReaderProgress) error
|
||||
Delete(ctx context.Context, userID int, profileID string, contentID string) error
|
||||
}
|
||||
|
||||
// EbookReaderProgressReadWriter is the full progress-store surface the items
|
||||
// handler needs: batch reads for list/user-state responses plus read-state
|
||||
// writes for the watched endpoints.
|
||||
type EbookReaderProgressReadWriter interface {
|
||||
EbookReaderProgressLister
|
||||
EbookReadStateStore
|
||||
}
|
||||
|
||||
// markEbookRead marks an ebook as finished by upserting its reader-progress
|
||||
// row with progress = 1.0 (>= models.EbookFinishedProgressThreshold). An
|
||||
// existing row keeps its file_id and location so the reader still reopens at
|
||||
// the user's last position even though the book is marked read; a missing row
|
||||
// references the item's preferred reader file (resolved lazily via
|
||||
// defaultFileID, since ebook_reader_progress.file_id is NOT NULL) with an
|
||||
// empty location meaning "start at the beginning". A fresh updated_at also
|
||||
// resurfaces a previously hidden book, mirroring how re-watching resurfaces
|
||||
// hidden video history.
|
||||
func markEbookRead(
|
||||
ctx context.Context,
|
||||
store EbookReadStateStore,
|
||||
userID int,
|
||||
profileID, contentID string,
|
||||
now time.Time,
|
||||
defaultFileID func(ctx context.Context) (int, error),
|
||||
) error {
|
||||
existing, err := store.Get(ctx, userID, profileID, contentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
progress := EbookReaderProgress{
|
||||
UserID: userID,
|
||||
ProfileID: profileID,
|
||||
ContentID: contentID,
|
||||
Progress: 1.0,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if existing != nil {
|
||||
progress.FileID = existing.FileID
|
||||
progress.Location = existing.Location
|
||||
} else {
|
||||
fileID, err := defaultFileID(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
progress.FileID = fileID
|
||||
}
|
||||
return store.Upsert(ctx, progress)
|
||||
}
|
||||
|
||||
// markEbookUnread clears an ebook's read state by deleting its reader-progress
|
||||
// row — the direct analog of the video unwatch path, where
|
||||
// userstore.ClearProgress DELETEs the user_watch_progress row (dropping the
|
||||
// resume position along with the watched flag).
|
||||
func markEbookUnread(
|
||||
ctx context.Context,
|
||||
store EbookReadStateStore,
|
||||
userID int,
|
||||
profileID, contentID string,
|
||||
) error {
|
||||
return store.Delete(ctx, userID, profileID, contentID)
|
||||
}
|
||||
|
||||
// setEbookReadState applies the watched toggle for an ebook target resolved by
|
||||
// resolveWatchedTargets, which has already enforced the access filter on the
|
||||
// item. Scoping matches the rest of the ebook progress code: user_id +
|
||||
// profile_id.
|
||||
func (h *ItemsHandler) setEbookReadState(
|
||||
ctx context.Context,
|
||||
userID int,
|
||||
profileID, contentID string,
|
||||
read bool,
|
||||
filter catalog.AccessFilter,
|
||||
) error {
|
||||
if h.ebookReadStateStore == nil {
|
||||
return fmt.Errorf("ebook reader progress store is not configured")
|
||||
}
|
||||
if !read {
|
||||
return markEbookUnread(ctx, h.ebookReadStateStore, userID, profileID, contentID)
|
||||
}
|
||||
return markEbookRead(ctx, h.ebookReadStateStore, userID, profileID, contentID, time.Now().UTC(), func(ctx context.Context) (int, error) {
|
||||
return h.defaultEbookFileID(ctx, contentID, filter)
|
||||
})
|
||||
}
|
||||
|
||||
// defaultEbookFileID resolves the file a fresh mark-read progress row should
|
||||
// reference, respecting the caller's access filter.
|
||||
func (h *ItemsHandler) defaultEbookFileID(ctx context.Context, contentID string, filter catalog.AccessFilter) (int, error) {
|
||||
if h.fileRepo == nil {
|
||||
return 0, catalog.ErrItemNotFound
|
||||
}
|
||||
files, err := h.fileRepo.GetByContentID(ctx, contentID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
file := preferredEbookReadFile(catalog.FilterMediaFilesByAccess(files, filter))
|
||||
if file == nil {
|
||||
return 0, catalog.ErrItemNotFound
|
||||
}
|
||||
return file.ID, nil
|
||||
}
|
||||
|
||||
// preferredEbookReadFile mirrors the web reader's default file choice
|
||||
// (web/src/pages/ItemDetail/EbookContent.tsx preferredReadVersion): prefer an
|
||||
// EPUB version, otherwise the first reader-supported ebook file. Callers must
|
||||
// pass access-filtered files.
|
||||
func preferredEbookReadFile(files []*models.MediaFile) *models.MediaFile {
|
||||
var first *models.MediaFile
|
||||
for _, file := range files {
|
||||
if !isEbookFile(file) {
|
||||
continue
|
||||
}
|
||||
if ebookReaderFormat(file.FilePath, file.Container) == "epub" {
|
||||
return file
|
||||
}
|
||||
if first == nil {
|
||||
first = file
|
||||
}
|
||||
}
|
||||
return first
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
// fakeEbookReadStateStore is an in-memory EbookReaderProgressReadWriter keyed
|
||||
// by content ID for a single (user, profile) scope.
|
||||
type fakeEbookReadStateStore struct {
|
||||
rows map[string]EbookReaderProgress
|
||||
}
|
||||
|
||||
func newFakeEbookReadStateStore() *fakeEbookReadStateStore {
|
||||
return &fakeEbookReadStateStore{rows: map[string]EbookReaderProgress{}}
|
||||
}
|
||||
|
||||
func (f *fakeEbookReadStateStore) Get(_ context.Context, _ int, _ string, contentID string) (*EbookReaderProgress, error) {
|
||||
row, ok := f.rows[contentID]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
func (f *fakeEbookReadStateStore) Upsert(_ context.Context, progress EbookReaderProgress) error {
|
||||
f.rows[progress.ContentID] = progress
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeEbookReadStateStore) Delete(_ context.Context, _ int, _ string, contentID string) error {
|
||||
delete(f.rows, contentID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeEbookReadStateStore) ListByContentIDs(_ context.Context, _ int, _ string, contentIDs []string) (map[string]EbookReaderProgress, error) {
|
||||
result := make(map[string]EbookReaderProgress, len(contentIDs))
|
||||
for _, contentID := range contentIDs {
|
||||
if row, ok := f.rows[contentID]; ok {
|
||||
result[contentID] = row
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// fakeEbookFileProvider implements EpisodeFileProvider for ebook file lookups.
|
||||
type fakeEbookFileProvider struct {
|
||||
files map[string][]*models.MediaFile
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeEbookFileProvider) GetByContentID(_ context.Context, contentID string) ([]*models.MediaFile, error) {
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.files[contentID], nil
|
||||
}
|
||||
|
||||
func (f *fakeEbookFileProvider) GetByEpisodeID(context.Context, string) ([]*models.MediaFile, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeEbookFileProvider) ListByContentIDs(context.Context, []string) (map[string][]*models.MediaFile, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestMarkEbookReadPreservesExistingFileAndLocation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newFakeEbookReadStateStore()
|
||||
store.rows["ebook-1"] = EbookReaderProgress{
|
||||
UserID: 42,
|
||||
ProfileID: "profile-1",
|
||||
ContentID: "ebook-1",
|
||||
FileID: 7,
|
||||
Location: "epubcfi(/6/14!/4/2/14)",
|
||||
Progress: 0.42,
|
||||
UpdatedAt: time.Date(2026, 6, 1, 8, 0, 0, 0, time.UTC),
|
||||
}
|
||||
now := time.Date(2026, 6, 9, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
err := markEbookRead(ctx, store, 42, "profile-1", "ebook-1", now, func(context.Context) (int, error) {
|
||||
t.Fatal("defaultFileID must not be called when a progress row exists")
|
||||
return 0, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("markEbookRead: %v", err)
|
||||
}
|
||||
|
||||
row := store.rows["ebook-1"]
|
||||
if row.Progress != 1.0 {
|
||||
t.Fatalf("progress = %v, want 1.0", row.Progress)
|
||||
}
|
||||
if row.FileID != 7 || row.Location != "epubcfi(/6/14!/4/2/14)" {
|
||||
t.Fatalf("file/location not preserved: %+v", row)
|
||||
}
|
||||
if !row.UpdatedAt.Equal(now) {
|
||||
t.Fatalf("updated_at = %v, want %v", row.UpdatedAt, now)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkEbookReadUsesDefaultFileWhenNoProgressExists(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newFakeEbookReadStateStore()
|
||||
now := time.Date(2026, 6, 9, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
err := markEbookRead(ctx, store, 42, "profile-1", "ebook-1", now, func(context.Context) (int, error) {
|
||||
return 12, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("markEbookRead: %v", err)
|
||||
}
|
||||
|
||||
row, ok := store.rows["ebook-1"]
|
||||
if !ok {
|
||||
t.Fatal("expected a progress row to be created")
|
||||
}
|
||||
if row.Progress != 1.0 || row.FileID != 12 || row.Location != "" {
|
||||
t.Fatalf("row = %+v, want progress 1.0 with file 12 and empty location", row)
|
||||
}
|
||||
if row.UserID != 42 || row.ProfileID != "profile-1" {
|
||||
t.Fatalf("row scope = user %d profile %q, want user 42 profile-1", row.UserID, row.ProfileID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkEbookReadPropagatesDefaultFileError(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newFakeEbookReadStateStore()
|
||||
|
||||
err := markEbookRead(ctx, store, 42, "profile-1", "ebook-1", time.Now().UTC(), func(context.Context) (int, error) {
|
||||
return 0, catalog.ErrItemNotFound
|
||||
})
|
||||
if !errors.Is(err, catalog.ErrItemNotFound) {
|
||||
t.Fatalf("err = %v, want catalog.ErrItemNotFound", err)
|
||||
}
|
||||
if len(store.rows) != 0 {
|
||||
t.Fatalf("no row must be written on error, got %+v", store.rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkEbookUnreadDeletesProgressRow(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newFakeEbookReadStateStore()
|
||||
store.rows["ebook-1"] = EbookReaderProgress{ContentID: "ebook-1", FileID: 7, Progress: 1.0}
|
||||
|
||||
if err := markEbookUnread(ctx, store, 42, "profile-1", "ebook-1"); err != nil {
|
||||
t.Fatalf("markEbookUnread: %v", err)
|
||||
}
|
||||
if _, ok := store.rows["ebook-1"]; ok {
|
||||
t.Fatal("progress row must be deleted on mark unread")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreferredEbookReadFilePrefersEpub(t *testing.T) {
|
||||
pdf := &models.MediaFile{ID: 1, BaseType: "ebook", FilePath: "/books/a.pdf", Container: "pdf"}
|
||||
epub := &models.MediaFile{ID: 2, BaseType: "ebook", FilePath: "/books/a.epub", Container: "epub"}
|
||||
video := &models.MediaFile{ID: 3, BaseType: "movie", FilePath: "/movies/a.mkv", Container: "mkv"}
|
||||
|
||||
if got := preferredEbookReadFile([]*models.MediaFile{video, pdf, epub}); got == nil || got.ID != 2 {
|
||||
t.Fatalf("preferredEbookReadFile = %+v, want epub file 2", got)
|
||||
}
|
||||
if got := preferredEbookReadFile([]*models.MediaFile{video, pdf}); got == nil || got.ID != 1 {
|
||||
t.Fatalf("preferredEbookReadFile = %+v, want first reader-supported file 1", got)
|
||||
}
|
||||
if got := preferredEbookReadFile([]*models.MediaFile{video}); got != nil {
|
||||
t.Fatalf("preferredEbookReadFile = %+v, want nil without ebook files", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetEbookReadStateMarkReadShowsPlayedUserState(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newFakeEbookReadStateStore()
|
||||
handler := &ItemsHandler{
|
||||
ebookProgressStore: store,
|
||||
ebookReadStateStore: store,
|
||||
fileRepo: &fakeEbookFileProvider{files: map[string][]*models.MediaFile{
|
||||
"ebook-1": {{ID: 5, BaseType: "ebook", FilePath: "/books/b.epub", Container: "epub"}},
|
||||
}},
|
||||
}
|
||||
|
||||
if err := handler.setEbookReadState(ctx, 42, "profile-1", "ebook-1", true, catalog.AccessFilter{}); err != nil {
|
||||
t.Fatalf("setEbookReadState(read): %v", err)
|
||||
}
|
||||
|
||||
userStore := newProfileTestStore(t)
|
||||
items := []*models.MediaItem{{ContentID: "ebook-1", Type: "ebook", Title: "Book"}}
|
||||
states, err := resolveItemUserStatesWithOptions(ctx, userStore, "profile-1", nil, items, itemUserStateOptions{
|
||||
UserID: 42,
|
||||
EbookProgressStore: store,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolveItemUserStatesWithOptions: %v", err)
|
||||
}
|
||||
if states["ebook-1"] == nil || !states["ebook-1"].Played {
|
||||
t.Fatalf("user state = %+v, want played after mark read", states["ebook-1"])
|
||||
}
|
||||
|
||||
// A marked-read book is finished, not in progress: the reader-progress
|
||||
// response derived from the row must exclude it from Continue Reading.
|
||||
row := store.rows["ebook-1"]
|
||||
if row.Progress < models.EbookFinishedProgressThreshold {
|
||||
t.Fatalf("progress %v must cross the finished threshold", row.Progress)
|
||||
}
|
||||
|
||||
if err := handler.setEbookReadState(ctx, 42, "profile-1", "ebook-1", false, catalog.AccessFilter{}); err != nil {
|
||||
t.Fatalf("setEbookReadState(unread): %v", err)
|
||||
}
|
||||
if _, ok := store.rows["ebook-1"]; ok {
|
||||
t.Fatal("mark unread must delete the reader progress row")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetEbookReadStateFailsWithoutAccessibleFile(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newFakeEbookReadStateStore()
|
||||
handler := &ItemsHandler{
|
||||
ebookProgressStore: store,
|
||||
ebookReadStateStore: store,
|
||||
fileRepo: &fakeEbookFileProvider{},
|
||||
}
|
||||
|
||||
err := handler.setEbookReadState(ctx, 42, "profile-1", "ebook-1", true, catalog.AccessFilter{})
|
||||
if !errors.Is(err, catalog.ErrItemNotFound) {
|
||||
t.Fatalf("err = %v, want catalog.ErrItemNotFound", err)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,901 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
func newEbookReaderAuthRequest(method, path string) *http.Request {
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
|
||||
UserID: 1,
|
||||
Role: "user",
|
||||
TokenType: auth.TokenTypeAccess,
|
||||
})
|
||||
ctx = apimw.SetProfileID(ctx, "profile-1")
|
||||
return req.WithContext(ctx)
|
||||
}
|
||||
|
||||
func withEbookReaderRouteParams(req *http.Request, contentID, fileID string) *http.Request {
|
||||
routeCtx := chi.NewRouteContext()
|
||||
routeCtx.URLParams.Add("content_id", contentID)
|
||||
routeCtx.URLParams.Add("file_id", fileID)
|
||||
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
|
||||
}
|
||||
|
||||
func withEbookReaderContentRouteParam(req *http.Request, contentID string) *http.Request {
|
||||
routeCtx := chi.NewRouteContext()
|
||||
routeCtx.URLParams.Add("content_id", contentID)
|
||||
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
|
||||
}
|
||||
|
||||
func withEbookReaderAnnotationRouteParams(req *http.Request, contentID, annotationID string) *http.Request {
|
||||
routeCtx := chi.NewRouteContext()
|
||||
routeCtx.URLParams.Add("content_id", contentID)
|
||||
routeCtx.URLParams.Add("annotation_id", annotationID)
|
||||
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
|
||||
}
|
||||
|
||||
type fakeEbookProgressStore struct {
|
||||
progress *EbookReaderProgress
|
||||
upserted *EbookReaderProgress
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *fakeEbookProgressStore) Get(
|
||||
context.Context,
|
||||
int,
|
||||
string,
|
||||
string,
|
||||
) (*EbookReaderProgress, error) {
|
||||
return s.progress, s.err
|
||||
}
|
||||
|
||||
func (s *fakeEbookProgressStore) Upsert(_ context.Context, progress EbookReaderProgress) error {
|
||||
if s.err != nil {
|
||||
return s.err
|
||||
}
|
||||
s.upserted = &progress
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeEbookReaderConfigStore struct {
|
||||
config *EbookReaderConfig
|
||||
saved *EbookReaderConfig
|
||||
getErr error
|
||||
saveErr error
|
||||
}
|
||||
|
||||
func (s *fakeEbookReaderConfigStore) Get(
|
||||
context.Context,
|
||||
int,
|
||||
string,
|
||||
string,
|
||||
) (*EbookReaderConfig, error) {
|
||||
return s.config, s.getErr
|
||||
}
|
||||
|
||||
func (s *fakeEbookReaderConfigStore) Upsert(_ context.Context, config EbookReaderConfig) error {
|
||||
if s.saveErr != nil {
|
||||
return s.saveErr
|
||||
}
|
||||
s.saved = &config
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeEbookReaderAnnotationStore struct {
|
||||
items []EbookReaderAnnotation
|
||||
existing *EbookReaderAnnotation
|
||||
created *EbookReaderAnnotation
|
||||
updated *EbookReaderAnnotation
|
||||
mergedFrom *EbookReaderAnnotation
|
||||
mergeCalls int
|
||||
deleted string
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *fakeEbookReaderAnnotationStore) List(context.Context, int, string, string) ([]EbookReaderAnnotation, error) {
|
||||
return s.items, s.err
|
||||
}
|
||||
|
||||
func (s *fakeEbookReaderAnnotationStore) Create(_ context.Context, annotation EbookReaderAnnotation) error {
|
||||
if s.err != nil {
|
||||
return s.err
|
||||
}
|
||||
s.created = &annotation
|
||||
s.items = append(s.items, annotation)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update models the PG store contract: read the stored row, invoke merge with
|
||||
// it, and persist the merged result, all as one atomic operation.
|
||||
func (s *fakeEbookReaderAnnotationStore) Update(
|
||||
_ context.Context,
|
||||
_ int,
|
||||
_ string,
|
||||
_ string,
|
||||
annotationID string,
|
||||
merge func(existing EbookReaderAnnotation) (EbookReaderAnnotation, error),
|
||||
) (*EbookReaderAnnotation, error) {
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
if s.existing == nil || s.existing.ID != annotationID {
|
||||
return nil, nil
|
||||
}
|
||||
existing := *s.existing
|
||||
s.mergedFrom = &existing
|
||||
s.mergeCalls++
|
||||
merged, err := merge(existing)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.updated = &merged
|
||||
stored := merged
|
||||
s.existing = &stored
|
||||
return &merged, nil
|
||||
}
|
||||
|
||||
func (s *fakeEbookReaderAnnotationStore) Delete(_ context.Context, userID int, profileID, contentID, annotationID string) error {
|
||||
if s.err != nil {
|
||||
return s.err
|
||||
}
|
||||
s.deleted = annotationID
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestEbookReaderServesEbookInlineWithRangeSupport(t *testing.T) {
|
||||
filePath := writePlaybackTestMediaFile(t, "book.epub")
|
||||
if err := os.WriteFile(filePath, []byte("0123456789"), 0o644); err != nil {
|
||||
t.Fatalf("write ebook: %v", err)
|
||||
}
|
||||
|
||||
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{
|
||||
file: &models.MediaFile{
|
||||
ID: 42,
|
||||
ContentID: "ebook-1",
|
||||
FilePath: filePath,
|
||||
Container: "epub",
|
||||
BaseType: "ebook",
|
||||
},
|
||||
},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
})
|
||||
|
||||
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/ebook-1/files/42/read")
|
||||
req.Header.Set("Range", "bytes=2-5")
|
||||
req = withEbookReaderRouteParams(req, "ebook-1", "42")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleReadFile(rr, req)
|
||||
|
||||
if rr.Code != http.StatusPartialContent {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if got := rr.Header().Get("Content-Type"); got != "application/epub+zip" {
|
||||
t.Fatalf("content-type = %q", got)
|
||||
}
|
||||
if got := rr.Header().Get("Content-Disposition"); got != "inline; filename=book.epub" {
|
||||
t.Fatalf("content-disposition = %q", got)
|
||||
}
|
||||
if got := rr.Header().Get("X-Content-Type-Options"); got != "nosniff" {
|
||||
t.Fatalf("x-content-type-options = %q, want nosniff", got)
|
||||
}
|
||||
if rr.Body.String() != "2345" {
|
||||
t.Fatalf("body = %q", rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderServesContainerMimeForMismatchedExtension(t *testing.T) {
|
||||
filePath := writePlaybackTestMediaFile(t, "book.txt")
|
||||
if err := os.WriteFile(filePath, []byte("<html>not really</html>"), 0o644); err != nil {
|
||||
t.Fatalf("write ebook: %v", err)
|
||||
}
|
||||
|
||||
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{
|
||||
file: &models.MediaFile{
|
||||
ID: 42,
|
||||
ContentID: "ebook-1",
|
||||
FilePath: filePath,
|
||||
Container: "epub",
|
||||
BaseType: "ebook",
|
||||
},
|
||||
},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
})
|
||||
|
||||
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/ebook-1/files/42/read")
|
||||
req = withEbookReaderRouteParams(req, "ebook-1", "42")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleReadFile(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
// The file is admitted via its validated container, so the response must
|
||||
// carry that container's MIME type, never application/octet-stream.
|
||||
if got := rr.Header().Get("Content-Type"); got != "application/epub+zip" {
|
||||
t.Fatalf("content-type = %q, want application/epub+zip", got)
|
||||
}
|
||||
if got := rr.Header().Get("X-Content-Type-Options"); got != "nosniff" {
|
||||
t.Fatalf("x-content-type-options = %q, want nosniff", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookMimeTypeNeverReturnsOctetStreamForAdmittedFiles(t *testing.T) {
|
||||
cases := []struct {
|
||||
path string
|
||||
container string
|
||||
wantMime string
|
||||
}{
|
||||
{"/library/book.txt", "epub", "application/epub+zip"},
|
||||
{"/library/book", "pdf", "application/pdf"},
|
||||
{"/library/book.epub", "pdf", "application/epub+zip"}, // known extension wins
|
||||
}
|
||||
for _, tc := range cases {
|
||||
file := &models.MediaFile{FilePath: tc.path, Container: tc.container, BaseType: "ebook"}
|
||||
if !isEbookFile(file) {
|
||||
t.Fatalf("isEbookFile(%q, %q) = false, want admitted", tc.path, tc.container)
|
||||
}
|
||||
if got := ebookMimeType(tc.path, tc.container); got != tc.wantMime {
|
||||
t.Fatalf("ebookMimeType(%q, %q) = %q, want %q", tc.path, tc.container, got, tc.wantMime)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderRejectsNonEbookFile(t *testing.T) {
|
||||
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{
|
||||
file: &models.MediaFile{
|
||||
ID: 42,
|
||||
ContentID: "movie-1",
|
||||
FilePath: "/tmp/movie.mkv",
|
||||
Container: "mkv",
|
||||
BaseType: "movie",
|
||||
},
|
||||
},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
})
|
||||
|
||||
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/movie-1/files/42/read")
|
||||
req = withEbookReaderRouteParams(req, "movie-1", "42")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleReadFile(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderRecognizesReadestFormats(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"book.epub": "application/epub+zip",
|
||||
"book.pdf": "application/pdf",
|
||||
"book.mobi": "application/x-mobipocket-ebook",
|
||||
"book.azw": "application/vnd.amazon.ebook",
|
||||
"book.azw3": "application/vnd.amazon.mobi8-ebook",
|
||||
"book.cbz": "application/vnd.comicbook+zip",
|
||||
"book.cbr": "application/vnd.comicbook-rar",
|
||||
"book.fb2": "application/x-fictionbook+xml",
|
||||
"book.fbz": "application/x-zip-compressed-fb2",
|
||||
"book.fb2.zip": "application/x-zip-compressed-fb2",
|
||||
"book.md": "application/octet-stream",
|
||||
"book.unknown": "application/octet-stream",
|
||||
}
|
||||
rejected := map[string]bool{"book.md": true, "book.unknown": true}
|
||||
|
||||
for name, wantMime := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
container := name
|
||||
if name == "book.fb2.zip" {
|
||||
container = "fbz"
|
||||
}
|
||||
file := &models.MediaFile{FilePath: "/library/" + name, Container: container}
|
||||
if rejected[name] {
|
||||
file.BaseType = "ebook"
|
||||
if isEbookFile(file) {
|
||||
t.Fatalf("%s should not be treated as an ebook reader format", name)
|
||||
}
|
||||
if got := ebookMimeType(file.FilePath, file.Container); got != wantMime {
|
||||
t.Fatalf("ebookMimeType() = %q, want %q", got, wantMime)
|
||||
}
|
||||
return
|
||||
}
|
||||
file.BaseType = "ebook"
|
||||
if !isEbookFile(file) {
|
||||
t.Fatal("expected ebook reader format")
|
||||
}
|
||||
if got := ebookMimeType(file.FilePath, file.Container); got != wantMime {
|
||||
t.Fatalf("ebookMimeType() = %q, want %q", got, wantMime)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderRejectsPlainText(t *testing.T) {
|
||||
file := &models.MediaFile{
|
||||
FilePath: "/library/book.txt",
|
||||
BaseType: "ebook",
|
||||
}
|
||||
|
||||
if isEbookFile(file) {
|
||||
t.Fatal("plain text should not be treated as an ebook reader format")
|
||||
}
|
||||
if got := ebookMimeType(file.FilePath, file.Container); got == "text/plain; charset=utf-8" {
|
||||
t.Fatal("plain text should not have an ebook reader MIME type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderRecognizesFBZFromCompoundFilenameWithoutContainer(t *testing.T) {
|
||||
file := &models.MediaFile{
|
||||
FilePath: "/library/book.fb2.zip",
|
||||
BaseType: "ebook",
|
||||
}
|
||||
|
||||
if !isEbookFile(file) {
|
||||
t.Fatal("expected .fb2.zip path to be treated as an ebook reader format")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderMapsAccessFailureToNotFound(t *testing.T) {
|
||||
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{
|
||||
file: &models.MediaFile{
|
||||
ID: 42,
|
||||
ContentID: "ebook-1",
|
||||
FilePath: "/tmp/book.epub",
|
||||
Container: "epub",
|
||||
BaseType: "ebook",
|
||||
},
|
||||
},
|
||||
ItemAccess: stubItemAccessChecker{err: catalog.ErrItemNotFound},
|
||||
})
|
||||
|
||||
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/ebook-1/files/42/read")
|
||||
req = withEbookReaderRouteParams(req, "ebook-1", "42")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleReadFile(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderReturnsInternalErrorForUnexpectedAuthorizeFailure(t *testing.T) {
|
||||
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{err: errors.New("db down")},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
})
|
||||
|
||||
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/ebook-1/files/42/read")
|
||||
req = withEbookReaderRouteParams(req, "ebook-1", "42")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleReadFile(rr, req)
|
||||
|
||||
if rr.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderSavesProgressForAuthorizedEbookFile(t *testing.T) {
|
||||
store := &fakeEbookProgressStore{}
|
||||
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{
|
||||
file: &models.MediaFile{
|
||||
ID: 42,
|
||||
ContentID: "ebook-1",
|
||||
FilePath: "/tmp/book.epub",
|
||||
Container: "epub",
|
||||
BaseType: "ebook",
|
||||
},
|
||||
},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
})
|
||||
handler.ProgressStore = store
|
||||
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPut,
|
||||
"/ebooks/ebook-1/progress",
|
||||
strings.NewReader(`{"file_id":42,"location":"epubcfi(/6/4)","progress":0.42}`),
|
||||
)
|
||||
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
|
||||
UserID: 1,
|
||||
Role: "user",
|
||||
TokenType: auth.TokenTypeAccess,
|
||||
})
|
||||
req = req.WithContext(apimw.SetProfileID(ctx, "profile-9"))
|
||||
req = withEbookReaderContentRouteParam(req, "ebook-1")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleSaveProgress(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if store.upserted == nil {
|
||||
t.Fatal("expected progress to be saved")
|
||||
}
|
||||
if store.upserted.UserID != 1 || store.upserted.ProfileID != "profile-9" {
|
||||
t.Fatalf("scope = user %d profile %q", store.upserted.UserID, store.upserted.ProfileID)
|
||||
}
|
||||
if store.upserted.ContentID != "ebook-1" || store.upserted.FileID != 42 {
|
||||
t.Fatalf("target = content %q file %d", store.upserted.ContentID, store.upserted.FileID)
|
||||
}
|
||||
if store.upserted.Location != "epubcfi(/6/4)" || store.upserted.Progress != 0.42 {
|
||||
t.Fatalf("progress = %+v", store.upserted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderReturnsSavedProgress(t *testing.T) {
|
||||
updatedAt := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)
|
||||
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
})
|
||||
handler.ProgressStore = &fakeEbookProgressStore{
|
||||
progress: &EbookReaderProgress{
|
||||
UserID: 1,
|
||||
ProfileID: "profile-1",
|
||||
ContentID: "ebook-1",
|
||||
FileID: 42,
|
||||
Location: "epubcfi(/6/8)",
|
||||
Progress: 0.75,
|
||||
UpdatedAt: updatedAt,
|
||||
},
|
||||
}
|
||||
|
||||
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/ebook-1/progress")
|
||||
req = withEbookReaderContentRouteParam(req, "ebook-1")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleGetProgress(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{
|
||||
`"content_id":"ebook-1"`,
|
||||
`"file_id":42`,
|
||||
`"location":"epubcfi(/6/8)"`,
|
||||
`"progress":0.75`,
|
||||
`"updated_at":"2026-06-08T12:00:00Z"`,
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("response %s missing %s", body, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderReturnsEmptyConfigWhenNoneSaved(t *testing.T) {
|
||||
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
})
|
||||
handler.ConfigStore = &fakeEbookReaderConfigStore{}
|
||||
|
||||
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/ebook-1/reader-config")
|
||||
req = withEbookReaderContentRouteParam(req, "ebook-1")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleGetConfig(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if got := strings.TrimSpace(rr.Body.String()); got != `{"config":{}}` {
|
||||
t.Fatalf("body = %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderSavesConfigForAccessibleEbook(t *testing.T) {
|
||||
store := &fakeEbookReaderConfigStore{}
|
||||
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
})
|
||||
handler.ConfigStore = store
|
||||
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPut,
|
||||
"/ebooks/ebook-1/reader-config",
|
||||
strings.NewReader(`{"config":{"theme":"dark","flow":"scrolled","fontSize":120}}`),
|
||||
)
|
||||
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
|
||||
UserID: 1,
|
||||
Role: "user",
|
||||
TokenType: auth.TokenTypeAccess,
|
||||
})
|
||||
req = req.WithContext(apimw.SetProfileID(ctx, "profile-9"))
|
||||
req = withEbookReaderContentRouteParam(req, "ebook-1")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleSaveConfig(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if store.saved == nil {
|
||||
t.Fatal("expected config to be saved")
|
||||
}
|
||||
if store.saved.UserID != 1 || store.saved.ProfileID != "profile-9" || store.saved.ContentID != "ebook-1" {
|
||||
t.Fatalf("scope = %+v", store.saved)
|
||||
}
|
||||
var config map[string]any
|
||||
if err := json.Unmarshal(store.saved.Config, &config); err != nil {
|
||||
t.Fatalf("config json: %v", err)
|
||||
}
|
||||
if config["theme"] != "dark" || config["flow"] != "scrolled" || config["fontSize"].(float64) != 120 {
|
||||
t.Fatalf("config = %#v", config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderRejectsNonObjectConfig(t *testing.T) {
|
||||
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
})
|
||||
handler.ConfigStore = &fakeEbookReaderConfigStore{}
|
||||
|
||||
req := newEbookReaderAuthRequest(http.MethodPut, "/ebooks/ebook-1/reader-config")
|
||||
req.Body = http.NoBody
|
||||
req = httptest.NewRequest(
|
||||
http.MethodPut,
|
||||
"/ebooks/ebook-1/reader-config",
|
||||
strings.NewReader(`{"config":["bad"]}`),
|
||||
).WithContext(req.Context())
|
||||
req = withEbookReaderContentRouteParam(req, "ebook-1")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleSaveConfig(rr, req)
|
||||
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderConfigRequiresAccessibleContent(t *testing.T) {
|
||||
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{},
|
||||
ItemAccess: stubItemAccessChecker{err: catalog.ErrItemNotFound},
|
||||
})
|
||||
handler.ConfigStore = &fakeEbookReaderConfigStore{}
|
||||
|
||||
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/ebook-1/reader-config")
|
||||
req = withEbookReaderContentRouteParam(req, "ebook-1")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleGetConfig(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderCreatesAnnotationForAccessibleEbook(t *testing.T) {
|
||||
store := &fakeEbookReaderAnnotationStore{}
|
||||
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
})
|
||||
handler.AnnotationStore = store
|
||||
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/ebooks/ebook-1/annotations",
|
||||
strings.NewReader(`{"kind":"highlight","cfi_range":"epubcfi(/6/4,/1:0,/1:12)","selected_text":"sample text","note":"note text","style":"highlight","color":"#facc15"}`),
|
||||
)
|
||||
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
|
||||
UserID: 1,
|
||||
Role: "user",
|
||||
TokenType: auth.TokenTypeAccess,
|
||||
})
|
||||
req = req.WithContext(apimw.SetProfileID(ctx, "profile-9"))
|
||||
req = withEbookReaderContentRouteParam(req, "ebook-1")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleCreateAnnotation(rr, req)
|
||||
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if store.created == nil {
|
||||
t.Fatal("expected annotation to be created")
|
||||
}
|
||||
if store.created.UserID != 1 || store.created.ProfileID != "profile-9" || store.created.ContentID != "ebook-1" {
|
||||
t.Fatalf("scope = %+v", store.created)
|
||||
}
|
||||
if store.created.Kind != "highlight" || store.created.CFIRange != "epubcfi(/6/4,/1:0,/1:12)" {
|
||||
t.Fatalf("annotation = %+v", store.created)
|
||||
}
|
||||
if store.created.SelectedText != "sample text" || store.created.Note != "note text" {
|
||||
t.Fatalf("annotation text = %+v", store.created)
|
||||
}
|
||||
if store.created.ID == "" {
|
||||
t.Fatal("expected generated annotation id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderReturnsAnnotations(t *testing.T) {
|
||||
updatedAt := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)
|
||||
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
})
|
||||
handler.AnnotationStore = &fakeEbookReaderAnnotationStore{
|
||||
items: []EbookReaderAnnotation{
|
||||
{
|
||||
ID: "ann-1",
|
||||
UserID: 1,
|
||||
ProfileID: "profile-1",
|
||||
ContentID: "ebook-1",
|
||||
Kind: "bookmark",
|
||||
Location: "epubcfi(/6/8)",
|
||||
SelectedText: "",
|
||||
Note: "Saved spot",
|
||||
Style: "highlight",
|
||||
Color: "#facc15",
|
||||
Metadata: json.RawMessage(`{}`),
|
||||
CreatedAt: updatedAt,
|
||||
UpdatedAt: updatedAt,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/ebook-1/annotations")
|
||||
req = withEbookReaderContentRouteParam(req, "ebook-1")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleListAnnotations(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{`"items":[`, `"id":"ann-1"`, `"kind":"bookmark"`, `"location":"epubcfi(/6/8)"`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("response %s missing %s", body, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func existingEbookReaderAnnotation() *EbookReaderAnnotation {
|
||||
createdAt := time.Date(2026, 6, 1, 9, 0, 0, 0, time.UTC)
|
||||
return &EbookReaderAnnotation{
|
||||
ID: "ann-1",
|
||||
UserID: 1,
|
||||
ProfileID: "profile-1",
|
||||
ContentID: "ebook-1",
|
||||
Kind: "highlight",
|
||||
CFIRange: "epubcfi(/6/4,/1:0,/1:12)",
|
||||
SelectedText: "sample text",
|
||||
Note: "original note",
|
||||
Style: "highlight",
|
||||
Color: "#facc15",
|
||||
Metadata: json.RawMessage(`{"chapter":"one"}`),
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
func patchEbookReaderAnnotation(t *testing.T, store *fakeEbookReaderAnnotationStore, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
})
|
||||
handler.AnnotationStore = store
|
||||
|
||||
req := newEbookReaderAuthRequest(http.MethodPatch, "/ebooks/ebook-1/annotations/ann-1")
|
||||
req.Body = ioNopCloser{strings.NewReader(body)}
|
||||
req = withEbookReaderAnnotationRouteParams(req, "ebook-1", "ann-1")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleUpdateAnnotation(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
func TestEbookReaderPatchKeepsAbsentFields(t *testing.T) {
|
||||
store := &fakeEbookReaderAnnotationStore{existing: existingEbookReaderAnnotation()}
|
||||
|
||||
rr := patchEbookReaderAnnotation(t, store, `{"note":"updated","color":"#bfdbfe"}`)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if store.updated == nil || store.updated.ID != "ann-1" || store.updated.Note != "updated" || store.updated.Color != "#bfdbfe" {
|
||||
t.Fatalf("updated = %+v", store.updated)
|
||||
}
|
||||
if string(store.updated.Metadata) != `{"chapter":"one"}` {
|
||||
t.Fatalf("metadata should be preserved when absent, got %s", store.updated.Metadata)
|
||||
}
|
||||
if store.updated.Kind != "highlight" || store.updated.CFIRange != "epubcfi(/6/4,/1:0,/1:12)" {
|
||||
t.Fatalf("absent fields should keep stored values, got %+v", store.updated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderPatchClearsExplicitlyEmptyFields(t *testing.T) {
|
||||
store := &fakeEbookReaderAnnotationStore{existing: existingEbookReaderAnnotation()}
|
||||
|
||||
rr := patchEbookReaderAnnotation(t, store, `{"note":"","metadata":{}}`)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if store.updated == nil || store.updated.Note != "" {
|
||||
t.Fatalf("note should be cleared, got %+v", store.updated)
|
||||
}
|
||||
if string(store.updated.Metadata) != `{}` {
|
||||
t.Fatalf("metadata should be cleared to {}, got %s", store.updated.Metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderPatchValidatesResultingState(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"kind change without location": `{"kind":"bookmark"}`,
|
||||
"clearing required cfi_range": `{"cfi_range":""}`,
|
||||
"unknown kind": `{"kind":"doodle"}`,
|
||||
"non-object metadata": `{"metadata":["bad"]}`,
|
||||
}
|
||||
for name, body := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
store := &fakeEbookReaderAnnotationStore{existing: existingEbookReaderAnnotation()}
|
||||
rr := patchEbookReaderAnnotation(t, store, body)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if store.updated != nil {
|
||||
t.Fatalf("invalid patch must not reach the store, got %+v", store.updated)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderPatchAllowsKindChangeWithRequiredFields(t *testing.T) {
|
||||
store := &fakeEbookReaderAnnotationStore{existing: existingEbookReaderAnnotation()}
|
||||
|
||||
rr := patchEbookReaderAnnotation(t, store, `{"kind":"bookmark","location":"epubcfi(/6/8)"}`)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if store.updated == nil || store.updated.Kind != "bookmark" || store.updated.Location != "epubcfi(/6/8)" {
|
||||
t.Fatalf("updated = %+v", store.updated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderPatchMergesOntoRowReadInsideStoreUpdate(t *testing.T) {
|
||||
store := &fakeEbookReaderAnnotationStore{existing: existingEbookReaderAnnotation()}
|
||||
|
||||
rr := patchEbookReaderAnnotation(t, store, `{"note":"merged note"}`)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
// The handler must not pre-read and merge outside the store: the merge
|
||||
// runs exactly once, on the row handed over by the (locked) store read.
|
||||
if store.mergeCalls != 1 {
|
||||
t.Fatalf("merge calls = %d, want 1", store.mergeCalls)
|
||||
}
|
||||
if store.mergedFrom == nil || store.mergedFrom.Note != "original note" {
|
||||
t.Fatalf("merge input = %+v, want the stored row", store.mergedFrom)
|
||||
}
|
||||
if store.updated == nil || store.updated.Note != "merged note" {
|
||||
t.Fatalf("written row = %+v, want merged note", store.updated)
|
||||
}
|
||||
if store.updated.CFIRange != store.mergedFrom.CFIRange || store.updated.Kind != store.mergedFrom.Kind {
|
||||
t.Fatalf("written row %+v must keep unpatched fields from merge input %+v", store.updated, store.mergedFrom)
|
||||
}
|
||||
if !store.updated.UpdatedAt.After(store.mergedFrom.UpdatedAt) {
|
||||
t.Fatalf("updated_at %v must advance past %v", store.updated.UpdatedAt, store.mergedFrom.UpdatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderPatchReturnsNotFoundForMissingAnnotation(t *testing.T) {
|
||||
store := &fakeEbookReaderAnnotationStore{}
|
||||
|
||||
rr := patchEbookReaderAnnotation(t, store, `{"note":"updated"}`)
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderRejectsOversizedProgressBody(t *testing.T) {
|
||||
store := &fakeEbookProgressStore{}
|
||||
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
})
|
||||
handler.ProgressStore = store
|
||||
|
||||
body := `{"file_id":42,"location":"` + strings.Repeat("x", int(ebookReaderProgressMaxBodySize)) + `","progress":0.5}`
|
||||
req := newEbookReaderAuthRequest(http.MethodPut, "/ebooks/ebook-1/progress")
|
||||
req.Body = ioNopCloser{strings.NewReader(body)}
|
||||
req = withEbookReaderContentRouteParam(req, "ebook-1")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleSaveProgress(rr, req)
|
||||
|
||||
if rr.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if store.upserted != nil {
|
||||
t.Fatalf("oversized body must not be saved, got %+v", store.upserted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInlineContentDispositionProducesParseableHeaders(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"book.epub": "book.epub",
|
||||
`bo"ok\.epub`: `bo"ok\.epub`,
|
||||
`trailing-slash\`: `trailing-slash\`,
|
||||
"börk \U0001F4DA.epub": "börk \U0001F4DA.epub",
|
||||
"": "ebook",
|
||||
".": "ebook",
|
||||
}
|
||||
for name, want := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
got := inlineContentDisposition(name)
|
||||
mediaType, params, err := mime.ParseMediaType(got)
|
||||
if err != nil {
|
||||
t.Fatalf("header %q is malformed: %v", got, err)
|
||||
}
|
||||
if mediaType != "inline" {
|
||||
t.Fatalf("media type = %q", mediaType)
|
||||
}
|
||||
if params["filename"] != want {
|
||||
t.Fatalf("filename = %q, want %q", params["filename"], want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderDeletesAnnotation(t *testing.T) {
|
||||
store := &fakeEbookReaderAnnotationStore{}
|
||||
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
})
|
||||
handler.AnnotationStore = store
|
||||
|
||||
req := newEbookReaderAuthRequest(http.MethodDelete, "/ebooks/ebook-1/annotations/ann-1")
|
||||
req = withEbookReaderAnnotationRouteParams(req, "ebook-1", "ann-1")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleDeleteAnnotation(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if store.deleted != "ann-1" {
|
||||
t.Fatalf("deleted = %q", store.deleted)
|
||||
}
|
||||
}
|
||||
|
||||
type ioNopCloser struct {
|
||||
*strings.Reader
|
||||
}
|
||||
|
||||
func (c ioNopCloser) Close() error { return nil }
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
evt "github.com/Silo-Server/silo-server/internal/events"
|
||||
"github.com/Silo-Server/silo-server/internal/historyimport"
|
||||
"github.com/Silo-Server/silo-server/internal/notifications"
|
||||
"github.com/Silo-Server/silo-server/internal/presence"
|
||||
"github.com/Silo-Server/silo-server/internal/scanqueue"
|
||||
"github.com/Silo-Server/silo-server/internal/taskmanager"
|
||||
"github.com/gorilla/websocket"
|
||||
@@ -31,6 +33,11 @@ type activeScanLister interface {
|
||||
ListActive(ctx context.Context) ([]evt.ScanRun, error)
|
||||
}
|
||||
|
||||
// notificationUnreadCounter is the subset of notifications.Service used by EventsHandler.
|
||||
type notificationUnreadCounter interface {
|
||||
UnreadCount(ctx context.Context, userID int, profileID string, childSafe bool) (int, error)
|
||||
}
|
||||
|
||||
type EventsHandler struct {
|
||||
hub *evt.Hub
|
||||
jobs *AdminJobsHandler
|
||||
@@ -39,6 +46,8 @@ type EventsHandler struct {
|
||||
scans *evt.ScanRegistry
|
||||
persistedScans activeScanLister
|
||||
historyImports historyImportActiveLister
|
||||
notifications notificationUnreadCounter
|
||||
presence presence.Registry
|
||||
}
|
||||
|
||||
func NewEventsHandler(
|
||||
@@ -49,6 +58,8 @@ func NewEventsHandler(
|
||||
scans *evt.ScanRegistry,
|
||||
persistedScans *scanqueue.Service,
|
||||
historyImports historyImportActiveLister,
|
||||
notificationsSvc *notifications.Service,
|
||||
presenceReg presence.Registry,
|
||||
) *EventsHandler {
|
||||
return &EventsHandler{
|
||||
hub: hub,
|
||||
@@ -58,6 +69,8 @@ func NewEventsHandler(
|
||||
scans: scans,
|
||||
persistedScans: persistedScans,
|
||||
historyImports: historyImports,
|
||||
notifications: notificationsSvc,
|
||||
presence: presenceReg,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +98,16 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
eventsCh, unsubscribe := h.hub.Subscribe()
|
||||
defer unsubscribe()
|
||||
|
||||
if h.presence != nil {
|
||||
release := h.presence.Add(ctx, claims.UserID)
|
||||
defer release()
|
||||
}
|
||||
|
||||
startWebSocketPingLoop(ctx, func() error {
|
||||
if h.presence != nil {
|
||||
h.presence.Refresh(ctx, claims.UserID)
|
||||
}
|
||||
return writeWebSocketControl(conn, websocket.PingMessage, nil)
|
||||
})
|
||||
|
||||
@@ -274,6 +296,8 @@ func allowedChannelsForRole(role string) []evt.EventChannel {
|
||||
channels := []evt.EventChannel{
|
||||
evt.ChannelCatalog,
|
||||
evt.ChannelHistoryImport,
|
||||
evt.ChannelNotifications,
|
||||
evt.ChannelRequests,
|
||||
evt.ChannelUserState,
|
||||
}
|
||||
if role == "admin" {
|
||||
@@ -294,12 +318,24 @@ func allowsEventForClaims(claims *auth.Claims, env evt.Envelope) bool {
|
||||
if env.AdminOnly && claims.Role != "admin" {
|
||||
return false
|
||||
}
|
||||
if env.UserID > 0 && claims.Role != "admin" && env.UserID != claims.UserID {
|
||||
return false
|
||||
if env.UserID > 0 && env.UserID != claims.UserID {
|
||||
// The notifications and requests channels carry per-user private content
|
||||
// (inbox titles/bodies, requested media names). Nobody — including
|
||||
// admins — may receive another user's events on them. Other channels
|
||||
// keep the admin cross-user monitoring bypass.
|
||||
if claims.Role != "admin" || isPersonalChannel(env.Channel) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// isPersonalChannel reports whether a channel carries per-user private content
|
||||
// that must never be delivered across user boundaries, even to admins.
|
||||
func isPersonalChannel(c evt.EventChannel) bool {
|
||||
return c == evt.ChannelNotifications || c == evt.ChannelRequests
|
||||
}
|
||||
|
||||
func marshalJSON(value any) json.RawMessage {
|
||||
if value == nil {
|
||||
return json.RawMessage("null")
|
||||
@@ -319,6 +355,28 @@ func (h *EventsHandler) snapshotForChannel(
|
||||
switch channel {
|
||||
case evt.ChannelCatalog, evt.ChannelUserState:
|
||||
return json.RawMessage("null"), nil
|
||||
case evt.ChannelNotifications:
|
||||
if h == nil || h.notifications == nil {
|
||||
return json.RawMessage(`{"unread_count":0}`), nil
|
||||
}
|
||||
// Profile ID is read directly from the WS upgrade request header because
|
||||
// the events/ws route does not go through the RequireProfile middleware.
|
||||
// childSafe is intentionally false here: the EventsHandler has no access
|
||||
// to a profile store, so the WS snapshot is a conservative approximation.
|
||||
// The REST GET /notifications/unread-count endpoint is authoritative for
|
||||
// child-safe profile filtering.
|
||||
profileID := r.Header.Get("X-Profile-Id")
|
||||
count, err := h.notifications.UnreadCount(r.Context(), claims.UserID, profileID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalJSON(struct {
|
||||
UnreadCount int `json:"unread_count"`
|
||||
}{UnreadCount: count}), nil
|
||||
case evt.ChannelRequests:
|
||||
// Request state is fetched via REST; the WS channel only carries
|
||||
// lifecycle events. Return an empty object as the initial snapshot.
|
||||
return json.RawMessage(`{}`), nil
|
||||
case evt.ChannelJobs:
|
||||
if h == nil || h.jobs == nil || h.jobs.repo == nil {
|
||||
return json.RawMessage("[]"), nil
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
evt "github.com/Silo-Server/silo-server/internal/events"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fake notificationUnreadCounter for snapshot tests.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type fakeUnreadCounter struct {
|
||||
count int
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeUnreadCounter) UnreadCount(_ context.Context, _ int, _ string, _ bool) (int, error) {
|
||||
return f.count, f.err
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// allowedChannelsForRole tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestAllowedChannelsForRole_UserContainsNotificationsAndRequests(t *testing.T) {
|
||||
channels := allowedChannelsForRole("user")
|
||||
if !slices.Contains(channels, evt.ChannelNotifications) {
|
||||
t.Errorf("allowedChannelsForRole(user) missing ChannelNotifications; got %v", channels)
|
||||
}
|
||||
if !slices.Contains(channels, evt.ChannelRequests) {
|
||||
t.Errorf("allowedChannelsForRole(user) missing ChannelRequests; got %v", channels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowedChannelsForRole_AdminContainsAllUserChannelsPlusExtras(t *testing.T) {
|
||||
userChannels := allowedChannelsForRole("user")
|
||||
adminChannels := allowedChannelsForRole("admin")
|
||||
|
||||
for _, ch := range userChannels {
|
||||
if !slices.Contains(adminChannels, ch) {
|
||||
t.Errorf("allowedChannelsForRole(admin) missing user channel %q", ch)
|
||||
}
|
||||
}
|
||||
|
||||
adminExtras := []evt.EventChannel{
|
||||
evt.ChannelJobs,
|
||||
evt.ChannelSessions,
|
||||
evt.ChannelTasks,
|
||||
evt.ChannelScans,
|
||||
}
|
||||
for _, ch := range adminExtras {
|
||||
if !slices.Contains(adminChannels, ch) {
|
||||
t.Errorf("allowedChannelsForRole(admin) missing admin-only channel %q", ch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// snapshotForChannel tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSnapshotForChannel_NotificationsNilService(t *testing.T) {
|
||||
h := &EventsHandler{notifications: nil}
|
||||
r := httptest.NewRequest("GET", "/events/ws", nil)
|
||||
claims := &auth.Claims{UserID: 42, Role: "user"}
|
||||
|
||||
data, err := h.snapshotForChannel(r, claims, evt.ChannelNotifications)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
UnreadCount int `json:"unread_count"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal: %v, raw=%s", err, data)
|
||||
}
|
||||
if got.UnreadCount != 0 {
|
||||
t.Errorf("nil service: want unread_count=0, got %d", got.UnreadCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotForChannel_NotificationsWithService(t *testing.T) {
|
||||
h := &EventsHandler{notifications: &fakeUnreadCounter{count: 7}}
|
||||
r := httptest.NewRequest("GET", "/events/ws", nil)
|
||||
r.Header.Set("X-Profile-Id", "profile-abc")
|
||||
claims := &auth.Claims{UserID: 5, Role: "user"}
|
||||
|
||||
data, err := h.snapshotForChannel(r, claims, evt.ChannelNotifications)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
UnreadCount int `json:"unread_count"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal: %v, raw=%s", err, data)
|
||||
}
|
||||
if got.UnreadCount != 7 {
|
||||
t.Errorf("want unread_count=7, got %d", got.UnreadCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotForChannel_RequestsReturnsEmptyObject(t *testing.T) {
|
||||
h := &EventsHandler{}
|
||||
r := httptest.NewRequest("GET", "/events/ws", nil)
|
||||
claims := &auth.Claims{UserID: 1, Role: "user"}
|
||||
|
||||
data, err := h.snapshotForChannel(r, claims, evt.ChannelRequests)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal: %v, raw=%s", err, data)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("ChannelRequests snapshot should be empty object, got %v", got)
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ type PersonalDataHandler struct {
|
||||
localFavoriteDispatcher LocalFavoriteEventDispatcher
|
||||
profileStaler ProfileStaler
|
||||
profileRefreshRequester ProfileRefreshRequester
|
||||
ebookProgressStore EbookReaderProgressLister
|
||||
}
|
||||
|
||||
// NewPersonalDataHandler creates a new PersonalDataHandler.
|
||||
@@ -55,6 +56,10 @@ func (h *PersonalDataHandler) SetDetailService(svc *catalog.DetailService) {
|
||||
h.detailSvc = svc
|
||||
}
|
||||
|
||||
func (h *PersonalDataHandler) SetEbookReaderProgressStore(store EbookReaderProgressLister) {
|
||||
h.ebookProgressStore = store
|
||||
}
|
||||
|
||||
// SetProfileStaler configures an optional staleness trigger for taste profiles.
|
||||
func (h *PersonalDataHandler) SetProfileStaler(ps ProfileStaler) {
|
||||
h.profileStaler = ps
|
||||
@@ -552,7 +557,10 @@ func resolveItemsByIDs(h *PersonalDataHandler, r *http.Request, ids []string) ([
|
||||
|
||||
userStates := map[string]*itemUserStateResponse{}
|
||||
if store, profileID, ok := h.userStoreForRequest(r); ok {
|
||||
if resolvedStates, err := resolveItemUserStates(r.Context(), store, profileID, h.episodeRepo, accessibleItems); err == nil {
|
||||
if resolvedStates, err := resolveItemUserStatesWithOptions(r.Context(), store, profileID, h.episodeRepo, accessibleItems, itemUserStateOptions{
|
||||
UserID: apimw.GetUserID(r.Context()),
|
||||
EbookProgressStore: h.ebookProgressStore,
|
||||
}); err == nil {
|
||||
userStates = resolvedStates
|
||||
}
|
||||
}
|
||||
@@ -669,7 +677,11 @@ func (h *PersonalDataHandler) resolveHistoryRemovalMediaItemIDs(
|
||||
return nil, err
|
||||
}
|
||||
switch item.Type {
|
||||
case "movie":
|
||||
case "movie", "ebook":
|
||||
// Hiding an ebook gates ebook_reader_progress reads via
|
||||
// user_history_hidden_items (updated_at <= hidden_before)
|
||||
// without deleting the progress row, so the reader position
|
||||
// survives: hidden is not the same as unread.
|
||||
return []string{item.ContentID}, nil
|
||||
case "series":
|
||||
return h.seriesEpisodeIDs(ctx, item.ContentID)
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
|
||||
type fakeHistoryItemRepo struct {
|
||||
items map[string]*models.MediaItem
|
||||
inaccessible map[string]bool
|
||||
}
|
||||
|
||||
func (r *fakeHistoryItemRepo) GetByID(_ context.Context, contentID string) (*models.MediaItem, error) {
|
||||
item, ok := r.items[contentID]
|
||||
if !ok {
|
||||
return nil, catalog.ErrItemNotFound
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (r *fakeHistoryItemRepo) GetByIDs(_ context.Context, contentIDs []string) ([]*models.MediaItem, error) {
|
||||
result := make([]*models.MediaItem, 0, len(contentIDs))
|
||||
for _, contentID := range contentIDs {
|
||||
if item, ok := r.items[contentID]; ok {
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *fakeHistoryItemRepo) EnsureAccessible(_ context.Context, contentID string, _ catalog.AccessFilter) error {
|
||||
if r.inaccessible[contentID] {
|
||||
return catalog.ErrItemNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestHandleRemoveHistoryAcceptsEbookTargets(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newProfileTestStore(t)
|
||||
handler := NewPersonalDataHandler(testUserStoreProvider{store: store}, &fakeHistoryItemRepo{
|
||||
items: map[string]*models.MediaItem{
|
||||
"ebook-1": {ContentID: "ebook-1", Type: "ebook", Title: "Book"},
|
||||
},
|
||||
})
|
||||
|
||||
// Seed a history entry keyed by the ebook content ID so the removal has
|
||||
// something to hide. RemoveHistoryItems only touches watch history,
|
||||
// watch progress, and the hidden-items gate — PersonalDataHandler has no
|
||||
// write access to ebook_reader_progress (its store interface is read-only),
|
||||
// so the reader position survives: hidden is not the same as unread.
|
||||
if err := store.AddHistory(ctx, userstore.WatchHistoryEntry{
|
||||
ProfileID: "profile-1",
|
||||
MediaItemID: "ebook-1",
|
||||
WatchedAt: "2026-06-01T10:00:00Z",
|
||||
Completed: true,
|
||||
Source: userstore.WatchHistorySourceManual,
|
||||
}); err != nil {
|
||||
t.Fatalf("AddHistory: %v", err)
|
||||
}
|
||||
|
||||
req := newAuthorizedProfileRequestWithRole(
|
||||
http.MethodPost,
|
||||
"/history/remove",
|
||||
`{"targets":[{"content_id":"ebook-1"}]}`,
|
||||
"user",
|
||||
"profile-1",
|
||||
)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleRemoveHistory(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
entries, err := store.ListHistory(ctx, "profile-1", 10, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("ListHistory: %v", err)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Fatalf("history entries = %+v, want hidden after removal", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRemoveHistoryRejectsInaccessibleEbook(t *testing.T) {
|
||||
store := newProfileTestStore(t)
|
||||
handler := NewPersonalDataHandler(testUserStoreProvider{store: store}, &fakeHistoryItemRepo{
|
||||
items: map[string]*models.MediaItem{
|
||||
"ebook-1": {ContentID: "ebook-1", Type: "ebook", Title: "Book"},
|
||||
},
|
||||
inaccessible: map[string]bool{"ebook-1": true},
|
||||
})
|
||||
|
||||
req := newAuthorizedProfileRequestWithRole(
|
||||
http.MethodPost,
|
||||
"/history/remove",
|
||||
`{"targets":[{"content_id":"ebook-1"}]}`,
|
||||
"user",
|
||||
"profile-1",
|
||||
)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleRemoveHistory(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
+332
-27
@@ -46,6 +46,10 @@ type LocalWatchEventDispatcher interface {
|
||||
HandleLocalWatchEvent(ctx context.Context, event watchsync.LocalWatchEvent) error
|
||||
}
|
||||
|
||||
type EbookReaderProgressLister interface {
|
||||
ListByContentIDs(ctx context.Context, userID int, profileID string, contentIDs []string) (map[string]EbookReaderProgress, error)
|
||||
}
|
||||
|
||||
// ItemsHandler handles browse, search, item detail, and series endpoints.
|
||||
type ItemsHandler struct {
|
||||
browseRepo *catalog.BrowseRepository
|
||||
@@ -62,6 +66,8 @@ type ItemsHandler struct {
|
||||
profileRefreshRequester ProfileRefreshRequester
|
||||
metadataRefreshRequester MetadataRefreshRequester
|
||||
localWatchDispatcher LocalWatchEventDispatcher
|
||||
ebookProgressStore EbookReaderProgressLister
|
||||
ebookReadStateStore EbookReadStateStore
|
||||
EventsHub *evt.Hub
|
||||
UserRepo *auth.UserRepository
|
||||
}
|
||||
@@ -114,6 +120,11 @@ func (h *ItemsHandler) SetLocalWatchEventDispatcher(dispatcher LocalWatchEventDi
|
||||
h.localWatchDispatcher = dispatcher
|
||||
}
|
||||
|
||||
func (h *ItemsHandler) SetEbookReaderProgressStore(store EbookReaderProgressReadWriter) {
|
||||
h.ebookProgressStore = store
|
||||
h.ebookReadStateStore = store
|
||||
}
|
||||
|
||||
func (h *ItemsHandler) maybeRequestStaleDetailMetadataRefresh(ctx context.Context, detail *catalog.ItemDetail) {
|
||||
if h == nil || detail == nil || h.metadataRefreshRequester == nil {
|
||||
return
|
||||
@@ -225,6 +236,9 @@ type sortMetricsResponse struct {
|
||||
ProgressRatio *float64 `json:"progress_ratio,omitempty"`
|
||||
ViewedAt string `json:"viewed_at,omitempty"`
|
||||
PlayCount *int `json:"play_count,omitempty"`
|
||||
Author string `json:"author,omitempty"`
|
||||
Narrator string `json:"narrator,omitempty"`
|
||||
SeriesName string `json:"series_name,omitempty"`
|
||||
}
|
||||
|
||||
// browseResponse is the paginated response for the /items endpoint.
|
||||
@@ -373,8 +387,8 @@ func (h *ItemsHandler) HandleGetWatchDetail(w http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
}
|
||||
|
||||
if detail.Type == "movie" || detail.Type == "episode" {
|
||||
detail.UserData = h.getLeafUserData(r, detail.ContentID)
|
||||
if detail.Type == "movie" || detail.Type == "episode" || detail.Type == "ebook" {
|
||||
detail.UserData = h.getLeafUserData(r, detail.ContentID, detail.Type)
|
||||
applyEffectiveEditionPreference(detail.UserData, &detail.EffectiveVersionEditionKey)
|
||||
}
|
||||
|
||||
@@ -429,7 +443,8 @@ func (h *ItemsHandler) handleSetWatchedState(w http.ResponseWriter, r *http.Requ
|
||||
return
|
||||
}
|
||||
|
||||
targetType, targets, err := h.resolveWatchedTargets(r.Context(), id, h.accessFilter(r))
|
||||
filter := h.accessFilter(r)
|
||||
targetType, targets, err := h.resolveWatchedTargets(r.Context(), id, filter)
|
||||
if err != nil {
|
||||
switch {
|
||||
case isNotFound(err):
|
||||
@@ -440,12 +455,16 @@ func (h *ItemsHandler) handleSetWatchedState(w http.ResponseWriter, r *http.Requ
|
||||
return
|
||||
}
|
||||
|
||||
if h.watchState == nil {
|
||||
switch {
|
||||
case targetType == "ebook":
|
||||
// Ebook read state lives in ebook_reader_progress, not in
|
||||
// user_watch_progress/user_watch_history; watch providers do not sync
|
||||
// books, so no local watch event is dispatched.
|
||||
err = h.setEbookReadState(r.Context(), userID, profileID, id, played, filter)
|
||||
case h.watchState == nil:
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
|
||||
return
|
||||
}
|
||||
|
||||
if played {
|
||||
case played:
|
||||
leafTargets := make([]watchstate.LeafWatchTarget, 0, len(targets))
|
||||
for _, target := range targets {
|
||||
leafTargets = append(leafTargets, watchstate.LeafWatchTarget{
|
||||
@@ -459,7 +478,7 @@ func (h *ItemsHandler) handleSetWatchedState(w http.ResponseWriter, r *http.Requ
|
||||
if err == nil {
|
||||
h.dispatchLocalWatchEvent(r.Context(), watchsync.LocalWatchEventMarkedWatched, userID, profileID, result)
|
||||
}
|
||||
} else {
|
||||
default:
|
||||
targetIDs := make([]string, 0, len(targets))
|
||||
for _, target := range targets {
|
||||
targetIDs = append(targetIDs, target.ContentID)
|
||||
@@ -471,6 +490,10 @@ func (h *ItemsHandler) handleSetWatchedState(w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Item not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update watched state")
|
||||
return
|
||||
}
|
||||
@@ -739,7 +762,10 @@ func (h *ItemsHandler) listItemUserStates(r *http.Request, items []*models.Media
|
||||
if !ok {
|
||||
return map[string]*itemUserStateResponse{}
|
||||
}
|
||||
states, err := resolveItemUserStates(r.Context(), store, profileID, h.episodeRepo, items)
|
||||
states, err := resolveItemUserStatesWithOptions(r.Context(), store, profileID, h.episodeRepo, items, itemUserStateOptions{
|
||||
UserID: apimw.GetUserID(r.Context()),
|
||||
EbookProgressStore: h.ebookProgressStore,
|
||||
})
|
||||
if err != nil {
|
||||
return map[string]*itemUserStateResponse{}
|
||||
}
|
||||
@@ -757,6 +783,14 @@ func (h *ItemsHandler) toEpisodeResponseWithFallback(r *http.Request, ep *models
|
||||
ep = localized
|
||||
}
|
||||
}
|
||||
resp, stillPath := episodeResponseShell(ep, fallback)
|
||||
resp.StillURL = h.presignURL(r, stillPath, "card")
|
||||
return resp
|
||||
}
|
||||
|
||||
// episodeResponseShell maps an already-localized episode onto the response
|
||||
// shape, returning the card-variant still path for the caller to presign.
|
||||
func episodeResponseShell(ep *models.Episode, fallback episodeImageFallback) (episodeResponse, string) {
|
||||
stillPath := ep.StillPath
|
||||
stillThumbhash := ep.StillThumbhash
|
||||
if strings.TrimSpace(stillPath) == "" && strings.TrimSpace(fallback.Path) != "" {
|
||||
@@ -780,8 +814,94 @@ func (h *ItemsHandler) toEpisodeResponseWithFallback(r *http.Request, ep *models
|
||||
resp.AirDate = ep.AirDate.Format("2006-01-02")
|
||||
}
|
||||
|
||||
resp.StillURL = h.presignURL(r, cardThumbnailPath(stillPath), "card")
|
||||
return resp, cardThumbnailPath(stillPath)
|
||||
}
|
||||
|
||||
// buildEpisodeResponses converts episodes to API responses using batched
|
||||
// lookups — localization, media files, watch progress, and image presigning
|
||||
// each resolve in one round-trip for the whole list instead of per episode.
|
||||
func (h *ItemsHandler) buildEpisodeResponses(r *http.Request, episodes []*models.Episode) []episodeResponse {
|
||||
ctx := r.Context()
|
||||
filter := h.accessFilter(r)
|
||||
|
||||
if h.detailSvc != nil {
|
||||
if localized, err := h.detailSvc.LocalizeEpisodeModels(ctx, episodes, filter); err == nil && len(localized) == len(episodes) {
|
||||
episodes = localized
|
||||
}
|
||||
}
|
||||
|
||||
fallbacks := h.episodeImageFallbacks(ctx, episodes)
|
||||
|
||||
episodeIDs := make([]string, 0, len(episodes))
|
||||
for _, ep := range episodes {
|
||||
if ep != nil && ep.ContentID != "" {
|
||||
episodeIDs = append(episodeIDs, ep.ContentID)
|
||||
}
|
||||
}
|
||||
filesByEpisode := h.listEpisodeFiles(ctx, episodeIDs)
|
||||
userData := h.listLeafUserData(r, episodeIDs)
|
||||
|
||||
resp := make([]episodeResponse, 0, len(episodes))
|
||||
stillPaths := make([]string, 0, len(episodes))
|
||||
for _, ep := range episodes {
|
||||
if ep == nil {
|
||||
continue
|
||||
}
|
||||
shell, stillPath := episodeResponseShell(ep, fallbacks[ep.SeriesID])
|
||||
resp = append(resp, shell)
|
||||
stillPaths = append(stillPaths, stillPath)
|
||||
}
|
||||
|
||||
stillURLs := map[string]catalog.ResolvedImageURL{}
|
||||
if h.detailSvc != nil {
|
||||
stillURLs = h.detailSvc.PresignURLsWithExpiry(ctx, stillPaths, "card")
|
||||
}
|
||||
for i := range resp {
|
||||
resp[i].StillURL = stillURLs[stillPaths[i]].URL
|
||||
resp[i].Files = episodeFileResponses(filesByEpisode[resp[i].ContentID], filter)
|
||||
resp[i].UserData = userData[resp[i].ContentID]
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// listEpisodeFiles batch-fetches media files keyed by episode ID, falling back
|
||||
// to per-episode lookups when the repository lacks batch support.
|
||||
func (h *ItemsHandler) listEpisodeFiles(ctx context.Context, episodeIDs []string) map[string][]*models.MediaFile {
|
||||
if h.fileRepo == nil || len(episodeIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
if batchProvider, ok := h.fileRepo.(batchEpisodeFileProvider); ok {
|
||||
files, err := batchProvider.ListByEpisodeIDs(ctx, episodeIDs)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return files
|
||||
}
|
||||
files := make(map[string][]*models.MediaFile, len(episodeIDs))
|
||||
for _, id := range episodeIDs {
|
||||
if list, err := h.fileRepo.GetByEpisodeID(ctx, id); err == nil {
|
||||
files[id] = list
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
func episodeFileResponses(files []*models.MediaFile, filter catalog.AccessFilter) []episodeFileResponse {
|
||||
var resp []episodeFileResponse
|
||||
for _, f := range files {
|
||||
if !catalog.FileAllowedByAccess(f, filter) {
|
||||
continue
|
||||
}
|
||||
resp = append(resp, episodeFileResponse{
|
||||
FileID: f.ID,
|
||||
Resolution: f.Resolution,
|
||||
CodecVideo: f.CodecVideo,
|
||||
HDR: f.HDR,
|
||||
AudioChannels: f.AudioChannels,
|
||||
Container: f.Container,
|
||||
FileSize: f.FileSize,
|
||||
})
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
@@ -850,6 +970,7 @@ func (h *ItemsHandler) listSortMetrics(
|
||||
filter catalog.AccessFilter,
|
||||
overlaySummaries map[string]*models.OverlaySummary,
|
||||
store userstore.UserStore,
|
||||
userID int,
|
||||
profileID string,
|
||||
) map[string]*sortMetricsResponse {
|
||||
metrics := make(map[string]*sortMetricsResponse, len(items))
|
||||
@@ -890,16 +1011,97 @@ func (h *ItemsHandler) listSortMetrics(
|
||||
}
|
||||
}
|
||||
case "progress", "date_viewed", "plays":
|
||||
h.listUserSortMetrics(ctx, items, sortField, store, profileID, metrics)
|
||||
h.listUserSortMetrics(ctx, items, sortField, store, userID, profileID, metrics)
|
||||
case "author", "narrator", "series":
|
||||
h.listAudiobookSortMetrics(ctx, items, sortField, metrics)
|
||||
}
|
||||
return metrics
|
||||
}
|
||||
|
||||
func (h *ItemsHandler) listAudiobookSortMetrics(
|
||||
ctx context.Context,
|
||||
items []*models.MediaItem,
|
||||
sortField string,
|
||||
metrics map[string]*sortMetricsResponse,
|
||||
) {
|
||||
if h == nil || h.browseRepo == nil || h.browseRepo.Pool() == nil || len(items) == 0 {
|
||||
return
|
||||
}
|
||||
contentIDs := uniqueItemContentIDs(items)
|
||||
if len(contentIDs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var query string
|
||||
switch sortField {
|
||||
case "author":
|
||||
query = audiobookPersonSortMetricQuery(int(models.PersonKindAuthor))
|
||||
case "narrator":
|
||||
query = audiobookPersonSortMetricQuery(int(models.PersonKindNarrator))
|
||||
case "series":
|
||||
query = `
|
||||
SELECT target.content_id, BTRIM(s.series_name) AS value
|
||||
FROM unnest($1::text[]) WITH ORDINALITY AS target(content_id, ord)
|
||||
JOIN audiobook_series s ON s.content_id = target.content_id
|
||||
WHERE BTRIM(s.series_name) <> ''`
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := h.browseRepo.Pool().Query(ctx, query, contentIDs)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var contentID, value string
|
||||
if err := rows.Scan(&contentID, &value); err != nil {
|
||||
return
|
||||
}
|
||||
value = strings.TrimSpace(value)
|
||||
if contentID == "" || value == "" {
|
||||
continue
|
||||
}
|
||||
resp := metrics[contentID]
|
||||
if resp == nil {
|
||||
resp = &sortMetricsResponse{}
|
||||
metrics[contentID] = resp
|
||||
}
|
||||
switch sortField {
|
||||
case "author":
|
||||
resp.Author = value
|
||||
case "narrator":
|
||||
resp.Narrator = value
|
||||
case "series":
|
||||
resp.SeriesName = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func audiobookPersonSortMetricQuery(kind int) string {
|
||||
return fmt.Sprintf(`
|
||||
SELECT target.content_id, person.name AS value
|
||||
FROM unnest($1::text[]) WITH ORDINALITY AS target(content_id, ord)
|
||||
JOIN LATERAL (
|
||||
SELECT BTRIM(p.name) AS name
|
||||
FROM item_people ip
|
||||
JOIN people p ON p.id = ip.person_id
|
||||
WHERE ip.content_id = target.content_id
|
||||
AND ip.kind = %d
|
||||
AND p.name IS NOT NULL
|
||||
AND BTRIM(p.name) <> ''
|
||||
ORDER BY p.name ASC
|
||||
LIMIT 1
|
||||
) person ON TRUE`, kind)
|
||||
}
|
||||
|
||||
func (h *ItemsHandler) listUserSortMetrics(
|
||||
ctx context.Context,
|
||||
items []*models.MediaItem,
|
||||
sortField string,
|
||||
store userstore.UserStore,
|
||||
userID int,
|
||||
profileID string,
|
||||
metrics map[string]*sortMetricsResponse,
|
||||
) {
|
||||
@@ -915,6 +1117,10 @@ func (h *ItemsHandler) listUserSortMetrics(
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ebookProgressMap, err := h.listEbookReaderProgressByContentIDs(ctx, userID, profileID, contentIDs)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch sortField {
|
||||
case "progress":
|
||||
@@ -923,10 +1129,16 @@ func (h *ItemsHandler) listUserSortMetrics(
|
||||
continue
|
||||
}
|
||||
progress, ok := progressMap[item.ContentID]
|
||||
if !ok || progress.Completed || progress.PositionSeconds <= 0 || progress.DurationSeconds <= 0 {
|
||||
if ok && !progress.Completed && progress.PositionSeconds > 0 && progress.DurationSeconds > 0 {
|
||||
ratio := progress.PositionSeconds / progress.DurationSeconds
|
||||
metrics[item.ContentID] = &sortMetricsResponse{ProgressRatio: &ratio}
|
||||
continue
|
||||
}
|
||||
ratio := progress.PositionSeconds / progress.DurationSeconds
|
||||
ebookProgress, ok := ebookProgressMap[item.ContentID]
|
||||
if !ok || ebookProgress.Progress <= 0 || ebookProgress.Progress >= models.EbookFinishedProgressThreshold {
|
||||
continue
|
||||
}
|
||||
ratio := ebookProgress.Progress
|
||||
metrics[item.ContentID] = &sortMetricsResponse{ProgressRatio: &ratio}
|
||||
}
|
||||
case "date_viewed", "plays":
|
||||
@@ -955,6 +1167,12 @@ func (h *ItemsHandler) listUserSortMetrics(
|
||||
if progress, ok := progressMap[item.ContentID]; ok && progress.Completed && progress.UpdatedAt > viewedAt {
|
||||
viewedAt = progress.UpdatedAt
|
||||
}
|
||||
if progress, ok := ebookProgressMap[item.ContentID]; ok && progress.Progress >= models.EbookFinishedProgressThreshold {
|
||||
ebookViewedAt := progress.UpdatedAt.UTC().Format(time.RFC3339)
|
||||
if ebookViewedAt > viewedAt {
|
||||
viewedAt = ebookViewedAt
|
||||
}
|
||||
}
|
||||
if viewedAt == "" {
|
||||
continue
|
||||
}
|
||||
@@ -964,6 +1182,9 @@ func (h *ItemsHandler) listUserSortMetrics(
|
||||
if progress, ok := progressMap[item.ContentID]; ok && progress.Completed && playCount < 1 {
|
||||
playCount = 1
|
||||
}
|
||||
if progress, ok := ebookProgressMap[item.ContentID]; ok && progress.Progress >= models.EbookFinishedProgressThreshold && playCount < 1 {
|
||||
playCount = 1
|
||||
}
|
||||
if playCount <= 0 {
|
||||
continue
|
||||
}
|
||||
@@ -974,6 +1195,18 @@ func (h *ItemsHandler) listUserSortMetrics(
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ItemsHandler) listEbookReaderProgressByContentIDs(
|
||||
ctx context.Context,
|
||||
userID int,
|
||||
profileID string,
|
||||
contentIDs []string,
|
||||
) (map[string]EbookReaderProgress, error) {
|
||||
if h == nil || h.ebookProgressStore == nil || userID <= 0 || profileID == "" || len(contentIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return h.ebookProgressStore.ListByContentIDs(ctx, userID, profileID, contentIDs)
|
||||
}
|
||||
|
||||
func uniqueItemContentIDs(items []*models.MediaItem) []string {
|
||||
contentIDs := make([]string, 0, len(items))
|
||||
seen := make(map[string]struct{}, len(items))
|
||||
@@ -1110,7 +1343,11 @@ func (h *ItemsHandler) toSeasonResponse(r *http.Request, seriesID string, s *mod
|
||||
return resp
|
||||
}
|
||||
|
||||
func (h *ItemsHandler) getLeafUserData(r *http.Request, contentID string) *catalog.SeasonUserData {
|
||||
func (h *ItemsHandler) getLeafUserData(r *http.Request, contentID string, itemType ...string) *catalog.SeasonUserData {
|
||||
if len(itemType) > 0 && itemType[0] == "ebook" {
|
||||
return h.getEbookLeafUserData(r, contentID)
|
||||
}
|
||||
|
||||
store, profileID, ok := h.userStoreForRequest(r)
|
||||
if !ok {
|
||||
return nil
|
||||
@@ -1121,10 +1358,34 @@ func (h *ItemsHandler) getLeafUserData(r *http.Request, contentID string) *catal
|
||||
return nil
|
||||
}
|
||||
|
||||
return leafUserDataFromProgress(*progress)
|
||||
}
|
||||
|
||||
// listLeafUserData batch-fetches watch progress for the given content IDs in a
|
||||
// single query; the result only holds entries for items with progress rows.
|
||||
func (h *ItemsHandler) listLeafUserData(r *http.Request, contentIDs []string) map[string]*catalog.SeasonUserData {
|
||||
store, profileID, ok := h.userStoreForRequest(r)
|
||||
if !ok || len(contentIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
progressMap, err := store.ListProgressByMediaItems(r.Context(), profileID, contentIDs)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make(map[string]*catalog.SeasonUserData, len(progressMap))
|
||||
for contentID, progress := range progressMap {
|
||||
result[contentID] = leafUserDataFromProgress(progress)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func leafUserDataFromProgress(progress userstore.WatchProgress) *catalog.SeasonUserData {
|
||||
return &catalog.SeasonUserData{
|
||||
PositionSeconds: progress.PositionSeconds,
|
||||
DurationSeconds: progress.DurationSeconds,
|
||||
IsInProgress: !progress.Completed && progress.PositionSeconds > 0,
|
||||
IsInProgress: progress.PositionSeconds > 0,
|
||||
Played: progress.Completed,
|
||||
LastFileID: progress.LastFileID,
|
||||
LastResolution: progress.LastResolution,
|
||||
@@ -1134,6 +1395,38 @@ func (h *ItemsHandler) getLeafUserData(r *http.Request, contentID string) *catal
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ItemsHandler) getEbookLeafUserData(r *http.Request, contentID string) *catalog.SeasonUserData {
|
||||
if h == nil || h.ebookProgressStore == nil {
|
||||
return nil
|
||||
}
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
profileID := requestProfileID(r)
|
||||
if userID <= 0 || profileID == "" || contentID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
progress, err := h.ebookProgressStore.ListByContentIDs(r.Context(), userID, profileID, []string{contentID})
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
row, ok := progress[contentID]
|
||||
if !ok || row.Progress <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ebooks have no playback duration, so PositionSeconds/DurationSeconds
|
||||
// encode the 0..1 reading ratio (position=ratio, duration=1). Clients can
|
||||
// derive percent-complete from the pair as usual, but rendering them as
|
||||
// absolute times is meaningless for ebooks. Changing the wire shape needs
|
||||
// coordinated Android/Apple client updates.
|
||||
return &catalog.SeasonUserData{
|
||||
PositionSeconds: row.Progress,
|
||||
DurationSeconds: 1,
|
||||
IsInProgress: row.Progress < models.EbookFinishedProgressThreshold,
|
||||
Played: row.Progress >= models.EbookFinishedProgressThreshold,
|
||||
}
|
||||
}
|
||||
|
||||
func applyEffectiveEditionPreference(userData *catalog.SeasonUserData, target **string) {
|
||||
if userData == nil || userData.LastEditionKey == nil || *userData.LastEditionKey == "" {
|
||||
return
|
||||
@@ -1178,6 +1471,16 @@ func (h *ItemsHandler) getAggregateUserData(r *http.Request, episodes []*models.
|
||||
}
|
||||
}
|
||||
|
||||
// requestProfileID resolves the active profile for a request, falling back to
|
||||
// the X-Profile-Id header when the auth context does not carry one.
|
||||
func requestProfileID(r *http.Request) string {
|
||||
profileID := apimw.GetProfileID(r.Context())
|
||||
if profileID == "" {
|
||||
profileID = r.Header.Get("X-Profile-Id")
|
||||
}
|
||||
return profileID
|
||||
}
|
||||
|
||||
func (h *ItemsHandler) userStoreForRequest(r *http.Request) (userstore.UserStore, string, bool) {
|
||||
if h.storeProvider == nil {
|
||||
return nil, "", false
|
||||
@@ -1188,10 +1491,7 @@ func (h *ItemsHandler) userStoreForRequest(r *http.Request) (userstore.UserStore
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
profileID := apimw.GetProfileID(r.Context())
|
||||
if profileID == "" {
|
||||
profileID = r.Header.Get("X-Profile-Id")
|
||||
}
|
||||
profileID := requestProfileID(r)
|
||||
if profileID == "" {
|
||||
return nil, "", false
|
||||
}
|
||||
@@ -1315,14 +1615,15 @@ func (h *ItemsHandler) accessFilter(r *http.Request) catalog.AccessFilter {
|
||||
|
||||
if scope, ok := access.GetScope(r.Context()); ok {
|
||||
return catalog.AccessFilter{
|
||||
AllowedLibraryIDs: scope.AllowedLibraryIDs,
|
||||
DisabledLibraryIDs: scope.DisabledLibraryIDs,
|
||||
MaxContentRating: scope.MaxContentRating,
|
||||
MaxPlaybackQuality: scope.MaxPlaybackQuality,
|
||||
PresentationLibraryID: presentationLibraryID,
|
||||
SelectedFileID: selectedFileID,
|
||||
UserID: apimw.GetUserID(r.Context()),
|
||||
ProfileID: apimw.GetProfileID(r.Context()),
|
||||
AllowedLibraryIDs: scope.AllowedLibraryIDs,
|
||||
DisabledLibraryIDs: scope.DisabledLibraryIDs,
|
||||
MaxContentRating: scope.MaxContentRating,
|
||||
MaxPlaybackQuality: scope.MaxPlaybackQuality,
|
||||
PresentationLibraryID: presentationLibraryID,
|
||||
ProfilePreferredLanguage: scope.PreferredMetadataLanguage,
|
||||
SelectedFileID: selectedFileID,
|
||||
UserID: apimw.GetUserID(r.Context()),
|
||||
ProfileID: apimw.GetProfileID(r.Context()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1385,6 +1686,10 @@ func (h *ItemsHandler) resolveWatchedTargets(ctx context.Context, contentID stri
|
||||
ContentID: item.ContentID,
|
||||
DurationSeconds: h.contentDurationSeconds(ctx, item.ContentID, "", item.Runtime),
|
||||
}}, nil
|
||||
case "ebook":
|
||||
// Ebooks have no playback duration; read state is keyed off
|
||||
// ebook_reader_progress, so the leaf target only carries the ID.
|
||||
return "ebook", []watchedLeafTarget{{ContentID: item.ContentID}}, nil
|
||||
case "series":
|
||||
if h.episodeRepo == nil {
|
||||
return "", nil, catalog.ErrItemNotFound
|
||||
|
||||
@@ -40,20 +40,145 @@ func TestListSortMetricsIncludesUserStateSorts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
progressMetrics := handler.listSortMetrics(ctx, items, "progress", catalog.AccessFilter{}, nil, store, "profile-1")
|
||||
progressMetrics := handler.listSortMetrics(ctx, items, "progress", catalog.AccessFilter{}, nil, store, 0, "profile-1")
|
||||
progress := progressMetrics["movie-progress"]
|
||||
if progress == nil || progress.ProgressRatio == nil || *progress.ProgressRatio != 0.25 {
|
||||
t.Fatalf("progress metrics = %#v", progress)
|
||||
}
|
||||
|
||||
viewedMetrics := handler.listSortMetrics(ctx, items, "date_viewed", catalog.AccessFilter{}, nil, store, "profile-1")
|
||||
viewedMetrics := handler.listSortMetrics(ctx, items, "date_viewed", catalog.AccessFilter{}, nil, store, 0, "profile-1")
|
||||
if got := viewedMetrics["movie-viewed"]; got == nil || got.ViewedAt != "2026-05-29T18:30:00Z" {
|
||||
t.Fatalf("viewed metrics = %#v", got)
|
||||
}
|
||||
|
||||
playsMetrics := handler.listSortMetrics(ctx, items, "plays", catalog.AccessFilter{}, nil, store, "profile-1")
|
||||
playsMetrics := handler.listSortMetrics(ctx, items, "plays", catalog.AccessFilter{}, nil, store, 0, "profile-1")
|
||||
plays := playsMetrics["movie-plays"]
|
||||
if plays == nil || plays.PlayCount == nil || *plays.PlayCount != 2 {
|
||||
t.Fatalf("plays metrics = %#v", plays)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSortMetricsIncludesEbookReaderProgress(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newProfileTestStore(t)
|
||||
updatedAt := time.Date(2026, 6, 3, 9, 15, 0, 0, time.UTC)
|
||||
handler := &ItemsHandler{
|
||||
ebookProgressStore: &fakeEbookReaderProgressLister{
|
||||
progress: map[string]EbookReaderProgress{
|
||||
"ebook-progress": {
|
||||
UserID: 42,
|
||||
ProfileID: "profile-1",
|
||||
ContentID: "ebook-progress",
|
||||
Progress: 0.37,
|
||||
UpdatedAt: updatedAt,
|
||||
},
|
||||
"ebook-complete": {
|
||||
UserID: 42,
|
||||
ProfileID: "profile-1",
|
||||
ContentID: "ebook-complete",
|
||||
Progress: 0.95,
|
||||
UpdatedAt: updatedAt.Add(time.Hour),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
items := []*models.MediaItem{
|
||||
{ContentID: "ebook-progress", Type: "ebook", Title: "Progress"},
|
||||
{ContentID: "ebook-complete", Type: "ebook", Title: "Complete"},
|
||||
}
|
||||
|
||||
progressMetrics := handler.listSortMetrics(ctx, items, "progress", catalog.AccessFilter{}, nil, store, 42, "profile-1")
|
||||
progress := progressMetrics["ebook-progress"]
|
||||
if progress == nil || progress.ProgressRatio == nil || *progress.ProgressRatio != 0.37 {
|
||||
t.Fatalf("progress metrics = %#v", progress)
|
||||
}
|
||||
|
||||
viewedMetrics := handler.listSortMetrics(ctx, items, "date_viewed", catalog.AccessFilter{}, nil, store, 42, "profile-1")
|
||||
if got := viewedMetrics["ebook-complete"]; got == nil || got.ViewedAt != "2026-06-03T10:15:00Z" {
|
||||
t.Fatalf("viewed metrics = %#v", got)
|
||||
}
|
||||
|
||||
playsMetrics := handler.listSortMetrics(ctx, items, "plays", catalog.AccessFilter{}, nil, store, 42, "profile-1")
|
||||
plays := playsMetrics["ebook-complete"]
|
||||
if plays == nil || plays.PlayCount == nil || *plays.PlayCount != 1 {
|
||||
t.Fatalf("plays metrics = %#v", plays)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSortMetricsExcludesHiddenEbookReaderProgress(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newProfileTestStore(t)
|
||||
updatedAt := time.Date(2026, 6, 3, 9, 15, 0, 0, time.UTC)
|
||||
handler := &ItemsHandler{
|
||||
ebookProgressStore: &fakeEbookReaderProgressLister{
|
||||
progress: map[string]EbookReaderProgress{
|
||||
"ebook-hidden": {
|
||||
UserID: 42,
|
||||
ProfileID: "profile-1",
|
||||
ContentID: "ebook-hidden",
|
||||
Progress: 0.95,
|
||||
UpdatedAt: updatedAt,
|
||||
},
|
||||
"ebook-reread": {
|
||||
UserID: 42,
|
||||
ProfileID: "profile-1",
|
||||
ContentID: "ebook-reread",
|
||||
Progress: 0.95,
|
||||
UpdatedAt: updatedAt.Add(2 * time.Hour),
|
||||
},
|
||||
},
|
||||
hiddenBefore: map[string]time.Time{
|
||||
"ebook-hidden": updatedAt.Add(time.Hour),
|
||||
"ebook-reread": updatedAt.Add(time.Hour),
|
||||
},
|
||||
},
|
||||
}
|
||||
items := []*models.MediaItem{
|
||||
{ContentID: "ebook-hidden", Type: "ebook", Title: "Hidden"},
|
||||
{ContentID: "ebook-reread", Type: "ebook", Title: "Reread"},
|
||||
}
|
||||
|
||||
viewedMetrics := handler.listSortMetrics(ctx, items, "date_viewed", catalog.AccessFilter{}, nil, store, 42, "profile-1")
|
||||
if got := viewedMetrics["ebook-hidden"]; got != nil {
|
||||
t.Fatalf("hidden ebook must have no viewed metrics, got %#v", got)
|
||||
}
|
||||
if got := viewedMetrics["ebook-reread"]; got == nil || got.ViewedAt != "2026-06-03T11:15:00Z" {
|
||||
t.Fatalf("progress updated after hidden_before must count again, got %#v", got)
|
||||
}
|
||||
|
||||
playsMetrics := handler.listSortMetrics(ctx, items, "plays", catalog.AccessFilter{}, nil, store, 42, "profile-1")
|
||||
if got := playsMetrics["ebook-hidden"]; got != nil {
|
||||
t.Fatalf("hidden ebook must have no play count, got %#v", got)
|
||||
}
|
||||
if got := playsMetrics["ebook-reread"]; got == nil || got.PlayCount == nil || *got.PlayCount != 1 {
|
||||
t.Fatalf("progress updated after hidden_before must count again, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeEbookReaderProgressLister struct {
|
||||
progress map[string]EbookReaderProgress
|
||||
// hiddenBefore models user_history_hidden_items: like the PG store, a
|
||||
// row is excluded while progress.UpdatedAt <= hidden_before and counts
|
||||
// again once the progress row is updated after the hide.
|
||||
hiddenBefore map[string]time.Time
|
||||
}
|
||||
|
||||
func (f *fakeEbookReaderProgressLister) ListByContentIDs(
|
||||
_ context.Context,
|
||||
_ int,
|
||||
_ string,
|
||||
contentIDs []string,
|
||||
) (map[string]EbookReaderProgress, error) {
|
||||
result := make(map[string]EbookReaderProgress, len(contentIDs))
|
||||
for _, contentID := range contentIDs {
|
||||
progress, ok := f.progress[contentID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if hiddenBefore, hidden := f.hiddenBefore[contentID]; hidden && !progress.UpdatedAt.After(hiddenBefore) {
|
||||
continue
|
||||
}
|
||||
result[contentID] = progress
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
)
|
||||
|
||||
func TestGetLeafUserDataUsesEbookReaderProgress(t *testing.T) {
|
||||
handler := &ItemsHandler{
|
||||
ebookProgressStore: &fakeEbookReaderProgressLister{
|
||||
progress: map[string]EbookReaderProgress{
|
||||
"ebook-progress": {
|
||||
UserID: 7,
|
||||
ProfileID: "profile-1",
|
||||
ContentID: "ebook-progress",
|
||||
Progress: 0.42,
|
||||
UpdatedAt: time.Date(2026, 6, 5, 10, 0, 0, 0, time.UTC),
|
||||
},
|
||||
"ebook-complete": {
|
||||
UserID: 7,
|
||||
ProfileID: "profile-1",
|
||||
ContentID: "ebook-complete",
|
||||
Progress: 0.93,
|
||||
UpdatedAt: time.Date(2026, 6, 5, 11, 0, 0, 0, time.UTC),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
req := httptest.NewRequest("GET", "/items/ebook-progress", nil)
|
||||
ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7})
|
||||
ctx = apimw.SetProfileID(ctx, "profile-1")
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
progress := handler.getLeafUserData(req, "ebook-progress", "ebook")
|
||||
if progress == nil || progress.Played || !progress.IsInProgress || progress.PositionSeconds != 0.42 || progress.DurationSeconds != 1 {
|
||||
t.Fatalf("partial ebook user data = %#v", progress)
|
||||
}
|
||||
|
||||
complete := handler.getLeafUserData(req, "ebook-complete", "ebook")
|
||||
if complete == nil || !complete.Played || complete.IsInProgress {
|
||||
t.Fatalf("completed ebook user data = %#v", complete)
|
||||
}
|
||||
}
|
||||
@@ -192,6 +192,7 @@ type updateLibraryRequest struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
MetadataLanguage *string `json:"metadata_language,omitempty"`
|
||||
AutoTranslateMetadata *bool `json:"auto_translate_metadata,omitempty"`
|
||||
ChapterThumbnailsEnabled *bool `json:"chapter_thumbnails_enabled,omitempty"`
|
||||
IntroDetectionEnabled *bool `json:"intro_detection_enabled,omitempty"`
|
||||
}
|
||||
@@ -226,6 +227,7 @@ type libraryResponse struct {
|
||||
Name string `json:"name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
MetadataLanguage string `json:"metadata_language"`
|
||||
AutoTranslateMetadata bool `json:"auto_translate_metadata"`
|
||||
ChapterThumbnailsEnabled bool `json:"chapter_thumbnails_enabled"`
|
||||
ChapterThumbnailsSupported bool `json:"chapter_thumbnails_supported"`
|
||||
IntroDetectionEnabled bool `json:"intro_detection_enabled"`
|
||||
@@ -348,6 +350,7 @@ func toLibraryResponse(f *models.MediaFolder) libraryResponse {
|
||||
Name: f.Name,
|
||||
Enabled: f.Enabled,
|
||||
MetadataLanguage: f.MetadataLanguage,
|
||||
AutoTranslateMetadata: f.AutoTranslateMetadata,
|
||||
ChapterThumbnailsEnabled: f.ChapterThumbnailsEnabled,
|
||||
ChapterThumbnailsSupported: false,
|
||||
IntroDetectionEnabled: f.IntroDetectionEnabled,
|
||||
@@ -574,6 +577,11 @@ func (h *LibraryHandler) HandleCreateLibrary(w http.ResponseWriter, r *http.Requ
|
||||
if seedErr := h.SectionRepo.SeedDefaults(r.Context(), "library", &folder.ID, sections.DefaultLibrarySectionsForType(&folder.ID, folder.Type)); seedErr != nil {
|
||||
slog.Warn("seed default sections for new library", "library_id", folder.ID, "error", seedErr)
|
||||
}
|
||||
if sections.IsAudiobookLibraryType(folder.Type) {
|
||||
if _, seedErr := h.SectionRepo.EnsureHomeContinueListeningSection(r.Context()); seedErr != nil {
|
||||
slog.Warn("ensure home continue listening section", "library_id", folder.ID, "error", seedErr)
|
||||
}
|
||||
}
|
||||
if _, seedErr := h.SectionRepo.CreateGeneratedHomeLibraryRecentSections(r.Context(), folder.ID, folder.Name, folder.Type); seedErr != nil {
|
||||
slog.Warn("seed generated home sections for new library", "library_id", folder.ID, "error", seedErr)
|
||||
}
|
||||
@@ -647,6 +655,7 @@ func (h *LibraryHandler) HandleUpdateLibrary(w http.ResponseWriter, r *http.Requ
|
||||
Name: req.Name,
|
||||
Enabled: req.Enabled,
|
||||
MetadataLanguage: req.MetadataLanguage,
|
||||
AutoTranslateMetadata: req.AutoTranslateMetadata,
|
||||
ChapterThumbnailsEnabled: req.ChapterThumbnailsEnabled,
|
||||
IntroDetectionEnabled: req.IntroDetectionEnabled,
|
||||
})
|
||||
@@ -2029,16 +2038,8 @@ func (h *LibraryHandler) seedDefaultChain(ctx context.Context, libraryType strin
|
||||
return nil
|
||||
}
|
||||
|
||||
// Determine which content levels apply.
|
||||
var levels []string
|
||||
switch libraryType {
|
||||
case "series":
|
||||
levels = []string{"series", "season", "episode"}
|
||||
case "movies", "movie":
|
||||
levels = []string{"movie"}
|
||||
case "mixed":
|
||||
levels = []string{"movie", "series", "season", "episode"}
|
||||
default:
|
||||
levels := metadataContentLevelsForLibraryType(libraryType)
|
||||
if len(levels) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2053,7 +2054,7 @@ func (h *LibraryHandler) seedDefaultChain(ctx context.Context, libraryType strin
|
||||
var candidates []candidate
|
||||
|
||||
for _, c := range caps {
|
||||
defaultPriority := metadata.LookupDefaultPriority(ctx, h.ChainRepo.Pool(), c.PluginInstallationID, level)
|
||||
defaultPriority := metadata.LookupDefaultPriority(ctx, h.ChainRepo.Pool(), c.PluginInstallationID, c.CapabilityID, level)
|
||||
if defaultPriority > 0 {
|
||||
candidates = append(candidates, candidate{
|
||||
installationID: c.PluginInstallationID,
|
||||
@@ -2091,6 +2092,23 @@ func (h *LibraryHandler) seedDefaultChain(ctx context.Context, libraryType strin
|
||||
return entries
|
||||
}
|
||||
|
||||
func metadataContentLevelsForLibraryType(libraryType string) []string {
|
||||
switch libraryType {
|
||||
case "series":
|
||||
return []string{"series", "season", "episode"}
|
||||
case "movies", "movie":
|
||||
return []string{"movie"}
|
||||
case "audiobooks", "audiobook":
|
||||
return []string{"audiobook"}
|
||||
case "ebooks", "ebook":
|
||||
return []string{"ebook"}
|
||||
case "mixed":
|
||||
return []string{"movie", "series", "season", "episode", "audiobook", "ebook"}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// HandleListStaleIDs handles GET /libraries/stale-ids.
|
||||
func (h *LibraryHandler) HandleListStaleIDs(w http.ResponseWriter, r *http.Request) {
|
||||
if h.StaleIDRepo == nil {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMetadataContentLevelsForLibraryTypeIncludesEbooks(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
libraryType string
|
||||
want []string
|
||||
}{
|
||||
{name: "plural ebooks", libraryType: "ebooks", want: []string{"ebook"}},
|
||||
{name: "singular ebook", libraryType: "ebook", want: []string{"ebook"}},
|
||||
{name: "mixed includes ebook", libraryType: "mixed", want: []string{"movie", "series", "season", "episode", "audiobook", "ebook"}},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := metadataContentLevelsForLibraryType(tc.libraryType); !reflect.DeepEqual(got, tc.want) {
|
||||
t.Fatalf("metadataContentLevelsForLibraryType(%q) = %#v, want %#v", tc.libraryType, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -724,6 +724,13 @@ func templateEligibleForLibrary(tmpl templates.Template, library *models.MediaFo
|
||||
return tmpl.MediaKind == templates.MediaMovie || tmpl.MediaKind == templates.MediaMixed
|
||||
case "series", "tv", "show", "shows", "tvshows":
|
||||
return tmpl.MediaKind == templates.MediaTV || tmpl.MediaKind == templates.MediaMixed
|
||||
case "audiobook", "audiobooks":
|
||||
// Audiobook libraries only accept audiobook-targeted templates.
|
||||
// Today the catalog has none (built-ins are TMDB/Trakt/MDBList
|
||||
// movie/TV imports), so this evaluates to an empty gallery —
|
||||
// the correct UX. Previously this fell through to default:true
|
||||
// and offered admins broken movie/TV templates.
|
||||
return tmpl.MediaKind == templates.MediaAudiobook || tmpl.MediaKind == templates.MediaMixed
|
||||
default:
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/access"
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/metadata/translation"
|
||||
)
|
||||
|
||||
// MetadataAIHandler exposes AI translation of catalog descriptions into the
|
||||
// localization tables. The admin routes are mounted under the per-item
|
||||
// metadata curation guard; the on-view route is viewer-facing and enforces
|
||||
// item access itself.
|
||||
type MetadataAIHandler struct {
|
||||
service *translation.Service
|
||||
// ItemAccess authorizes the viewer-facing on-view route; nil disables it.
|
||||
ItemAccess *catalog.ItemRepository
|
||||
}
|
||||
|
||||
// NewMetadataAIHandler creates a handler backed by the given service.
|
||||
func NewMetadataAIHandler(service *translation.Service) *MetadataAIHandler {
|
||||
return &MetadataAIHandler{service: service}
|
||||
}
|
||||
|
||||
// HandleStatus reports whether metadata AI translation is available and the
|
||||
// viewer-facing on-view mode, so the metadata editor and detail pages can
|
||||
// show or hide their entry points.
|
||||
// GET /api/v1/metadata/ai/status
|
||||
func (h *MetadataAIHandler) HandleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"enabled": h.service.Enabled(),
|
||||
"on_view": h.service.OnViewMode(),
|
||||
})
|
||||
}
|
||||
|
||||
// WriteMetadataAIDisabledStatus answers the status probe with a clean negative
|
||||
// when no metadata AI handler is wired.
|
||||
func WriteMetadataAIDisabledStatus(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"enabled": false, "on_view": "off"})
|
||||
}
|
||||
|
||||
type translateDescriptionRequest struct {
|
||||
// TargetLanguage echoes the detail response's pending_translation_language.
|
||||
TargetLanguage string `json:"target_language"`
|
||||
}
|
||||
|
||||
// HandleTranslateOnView is the viewer-facing on-demand description
|
||||
// translation: any profile that can access the item may request its
|
||||
// descriptions in the language the detail response reported missing. Gated by
|
||||
// metadata_ai.on_view; duplicate viewers collapse onto one job and recently
|
||||
// failed targets are not retried (cooldown in the service).
|
||||
// POST /api/v1/items/{id}/translate-description
|
||||
func (h *MetadataAIHandler) HandleTranslateOnView(w http.ResponseWriter, r *http.Request) {
|
||||
contentID := chi.URLParam(r, "id")
|
||||
var req translateDescriptionRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
if req.TargetLanguage == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "target_language is required")
|
||||
return
|
||||
}
|
||||
|
||||
scope, ok := access.GetScope(r.Context())
|
||||
if !ok || h.ItemAccess == nil {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "Viewer access is required")
|
||||
return
|
||||
}
|
||||
filter := catalog.AccessFilter{
|
||||
AllowedLibraryIDs: scope.AllowedLibraryIDs,
|
||||
DisabledLibraryIDs: scope.DisabledLibraryIDs,
|
||||
MaxContentRating: scope.MaxContentRating,
|
||||
UserID: scope.UserID,
|
||||
ProfileID: scope.ProfileID,
|
||||
}
|
||||
if err := h.ItemAccess.EnsureAccessible(r.Context(), contentID, filter); err != nil {
|
||||
if errors.Is(err, catalog.ErrItemNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Item not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to authorize item")
|
||||
return
|
||||
}
|
||||
|
||||
var requestedBy *int
|
||||
if userID := apimw.GetUserID(r.Context()); userID != 0 {
|
||||
requestedBy = &userID
|
||||
}
|
||||
|
||||
job, err := h.service.RequestOnView(r.Context(), contentID, req.TargetLanguage, requestedBy)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, translation.ErrNotConfigured):
|
||||
writeError(w, http.StatusServiceUnavailable, "not_configured",
|
||||
"On-view translation is not enabled on this server")
|
||||
case errors.Is(err, translation.ErrInvalidRequest):
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
default:
|
||||
slog.Error("failed to request on-view translation",
|
||||
"content_id", contentID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to start translation")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"job": job})
|
||||
}
|
||||
|
||||
type translateMetadataRequest struct {
|
||||
TargetLanguage string `json:"target_language"`
|
||||
IncludeChildren *bool `json:"include_children"` // default true
|
||||
Force bool `json:"force"`
|
||||
}
|
||||
|
||||
// HandleTranslate enqueues a translation job for an item.
|
||||
// POST /api/v1/admin/items/{id}/metadata-translation
|
||||
func (h *MetadataAIHandler) HandleTranslate(w http.ResponseWriter, r *http.Request) {
|
||||
contentID := chi.URLParam(r, "id")
|
||||
var req translateMetadataRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
if req.TargetLanguage == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "target_language is required")
|
||||
return
|
||||
}
|
||||
includeChildren := true
|
||||
if req.IncludeChildren != nil {
|
||||
includeChildren = *req.IncludeChildren
|
||||
}
|
||||
|
||||
var requestedBy *int
|
||||
if userID := apimw.GetUserID(r.Context()); userID != 0 {
|
||||
requestedBy = &userID
|
||||
}
|
||||
|
||||
job, err := h.service.Enqueue(r.Context(), translation.JobRequest{
|
||||
TargetKind: translation.TargetItem,
|
||||
ContentID: contentID,
|
||||
TargetLanguage: req.TargetLanguage,
|
||||
IncludeChildren: includeChildren,
|
||||
Force: req.Force,
|
||||
RequestedBy: requestedBy,
|
||||
})
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, translation.ErrNotConfigured):
|
||||
writeError(w, http.StatusServiceUnavailable, "not_configured",
|
||||
"Metadata AI translation is not configured on this server")
|
||||
case errors.Is(err, translation.ErrInvalidRequest):
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
default:
|
||||
slog.Error("failed to enqueue metadata translation",
|
||||
"content_id", contentID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to start translation")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"job": job})
|
||||
}
|
||||
|
||||
// HandleListJobs lists recent translation jobs for an item; the metadata
|
||||
// editor polls this for progress.
|
||||
// GET /api/v1/admin/items/{id}/metadata-translation/jobs
|
||||
func (h *MetadataAIHandler) HandleListJobs(w http.ResponseWriter, r *http.Request) {
|
||||
jobs, err := h.service.ListJobs(r.Context(), chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "list_error", "Failed to list jobs")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"jobs": jobs})
|
||||
}
|
||||
|
||||
// HandleCancelJob cancels a job belonging to the item in the URL.
|
||||
// POST /api/v1/admin/items/{id}/metadata-translation/jobs/{job_id}/cancel
|
||||
func (h *MetadataAIHandler) HandleCancelJob(w http.ResponseWriter, r *http.Request) {
|
||||
jobID, err := strconv.ParseInt(chi.URLParam(r, "job_id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_id", "Invalid job ID")
|
||||
return
|
||||
}
|
||||
job, err := h.service.GetJob(r.Context(), jobID)
|
||||
if err != nil {
|
||||
if errors.Is(err, translation.ErrJobNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Job not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load job")
|
||||
return
|
||||
}
|
||||
// The curation guard authorized {id}; the job must belong to it.
|
||||
if job.ContentID != chi.URLParam(r, "id") {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Job not found")
|
||||
return
|
||||
}
|
||||
if err := h.service.Cancel(r.Context(), jobID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to cancel job")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -26,7 +26,7 @@ type NodeRepository interface {
|
||||
Create(ctx context.Context, input nodepool.CreateNodeInput) (*nodepool.Node, error)
|
||||
Update(ctx context.Context, id int, input nodepool.UpdateNodeInput) (*nodepool.Node, error)
|
||||
Delete(ctx context.Context, id int) error
|
||||
UpdateHealth(ctx context.Context, id int, healthy bool, activeJobs int) error
|
||||
UpdateHealth(ctx context.Context, id int, healthy bool, activeJobs, egressKbps int) error
|
||||
}
|
||||
|
||||
// NodeListEnabled queries enabled nodes by type for pool reload.
|
||||
@@ -70,6 +70,7 @@ type ForceReloadResult struct {
|
||||
type checkNodeResult struct {
|
||||
Healthy bool `json:"healthy"`
|
||||
ActiveJobs int `json:"active_jobs"`
|
||||
EgressKbps int `json:"egress_kbps"`
|
||||
}
|
||||
|
||||
// HandleListNodes handles GET /admin/nodes.
|
||||
@@ -181,15 +182,16 @@ func (h *NodeHandler) HandleCheckNode(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
healthy, activeJobs := nodepool.CheckNode(r.Context(), node)
|
||||
healthy, activeJobs, egressKbps := nodepool.CheckNode(r.Context(), node)
|
||||
|
||||
if err := h.repo.UpdateHealth(r.Context(), id, healthy, activeJobs); err != nil {
|
||||
if err := h.repo.UpdateHealth(r.Context(), id, healthy, activeJobs, egressKbps); err != nil {
|
||||
slog.Error("persisting health check result", "node_id", id, "error", err)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, checkNodeResult{
|
||||
Healthy: healthy,
|
||||
ActiveJobs: activeJobs,
|
||||
EgressKbps: egressKbps,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/notifications"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
|
||||
// profileResolver is the minimal interface NotificationsHandler needs from the
|
||||
// userstore provider. Defined on the handler side so tests can stub it without
|
||||
// importing the full provider.
|
||||
type profileResolver interface {
|
||||
ForUser(ctx context.Context, userID int) (userstore.UserStore, error)
|
||||
}
|
||||
|
||||
// NotificationsHandler handles inbox, preferences, and announcement endpoints.
|
||||
type NotificationsHandler struct {
|
||||
svc *notifications.Service
|
||||
profiles profileResolver // nil-tolerant: ChildSafe=false when absent
|
||||
}
|
||||
|
||||
// NewNotificationsHandler creates a new NotificationsHandler.
|
||||
// provider may be nil; when nil, ChildSafe defaults to false on all requests.
|
||||
func NewNotificationsHandler(svc *notifications.Service, provider ...profileResolver) *NotificationsHandler {
|
||||
h := &NotificationsHandler{svc: svc}
|
||||
if len(provider) > 0 {
|
||||
h.profiles = provider[0]
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// childSafe resolves the active profile and returns Profile.IsChild.
|
||||
// Fails closed: an unresolvable profile is treated as a child so restricted
|
||||
// categories are never leaked on errors. Logs at debug level only — never fails
|
||||
// the request. Nil resolver / missing ids keep returning false (unauthenticated
|
||||
// paths never reach here; that's the wiring-absent case).
|
||||
func (h *NotificationsHandler) childSafe(r *http.Request, userID int, profileID string) bool {
|
||||
if h.profiles == nil || userID == 0 || profileID == "" {
|
||||
return false
|
||||
}
|
||||
store, err := h.profiles.ForUser(r.Context(), userID)
|
||||
if err != nil {
|
||||
slog.Debug("notifications: childSafe: ForUser failed", "user_id", userID, "error", err)
|
||||
return true // fail closed: treat unknown as child
|
||||
}
|
||||
p, err := store.GetProfile(r.Context(), profileID)
|
||||
if err != nil || p == nil {
|
||||
slog.Debug("notifications: childSafe: GetProfile failed", "profile_id", profileID, "error", err)
|
||||
return true // fail closed: treat unknown as child
|
||||
}
|
||||
return p.IsChild
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// User-scoped inbox endpoints
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// HandleList handles GET /notifications.
|
||||
// Query params: unread=1, category, cursor (int64), limit (int, default 50, max 100).
|
||||
// Response: {"items":[...],"next_cursor":<int64|null>}
|
||||
func (h *NotificationsHandler) HandleList(w http.ResponseWriter, r *http.Request) {
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
profileID := apimw.GetProfileID(r.Context())
|
||||
|
||||
q := r.URL.Query()
|
||||
|
||||
limit := 50
|
||||
if v := q.Get("limit"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
// keep in sync with the clamp in internal/notifications/store.go List
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
f := notifications.ListFilter{
|
||||
UserID: userID,
|
||||
ProfileID: profileID,
|
||||
Limit: limit,
|
||||
ChildSafe: h.childSafe(r, userID, profileID),
|
||||
}
|
||||
if q.Get("unread") == "1" {
|
||||
f.UnreadOnly = true
|
||||
}
|
||||
if cat := q.Get("category"); cat != "" {
|
||||
f.Category = notifications.Category(cat)
|
||||
}
|
||||
if v := q.Get("cursor"); v != "" {
|
||||
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||
f.Cursor = n
|
||||
}
|
||||
}
|
||||
|
||||
items, err := h.svc.List(r.Context(), f)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list notifications")
|
||||
return
|
||||
}
|
||||
|
||||
var nextCursor *int64
|
||||
if len(items) == limit && limit > 0 {
|
||||
last := items[len(items)-1].ID
|
||||
nextCursor = &last
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, struct {
|
||||
Items []*notifications.Notification `json:"items"`
|
||||
NextCursor *int64 `json:"next_cursor"`
|
||||
}{
|
||||
Items: items,
|
||||
NextCursor: nextCursor,
|
||||
})
|
||||
}
|
||||
|
||||
// HandleUnreadCount handles GET /notifications/unread-count.
|
||||
// Response: {"count": n}
|
||||
func (h *NotificationsHandler) HandleUnreadCount(w http.ResponseWriter, r *http.Request) {
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
profileID := apimw.GetProfileID(r.Context())
|
||||
|
||||
count, err := h.svc.UnreadCount(r.Context(), userID, profileID, h.childSafe(r, userID, profileID))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get unread count")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, struct {
|
||||
Count int `json:"count"`
|
||||
}{Count: count})
|
||||
}
|
||||
|
||||
// markReadRequest is the request body for POST /notifications/read.
|
||||
type markReadRequest struct {
|
||||
IDs []int64 `json:"ids"`
|
||||
All bool `json:"all"`
|
||||
}
|
||||
|
||||
// HandleMarkRead handles POST /notifications/read.
|
||||
// Body: {"ids":[...]} OR {"all":true}. 400 when neither present.
|
||||
func (h *NotificationsHandler) HandleMarkRead(w http.ResponseWriter, r *http.Request) {
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
profileID := apimw.GetProfileID(r.Context())
|
||||
|
||||
var req markReadRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if !req.All && len(req.IDs) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Provide ids or all:true")
|
||||
return
|
||||
}
|
||||
|
||||
if req.All {
|
||||
if err := h.svc.MarkAllRead(r.Context(), userID, profileID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to mark all read")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if err := h.svc.MarkRead(r.Context(), userID, profileID, req.IDs); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to mark notifications read")
|
||||
return
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// HandleDismiss handles POST /notifications/{id}/dismiss.
|
||||
// 400 for invalid id; 404 on ErrNotFound; 204 on success.
|
||||
func (h *NotificationsHandler) HandleDismiss(w http.ResponseWriter, r *http.Request) {
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
profileID := apimw.GetProfileID(r.Context())
|
||||
|
||||
rawID := chi.URLParam(r, "id")
|
||||
id, err := strconv.ParseInt(rawID, 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid notification id")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.Dismiss(r.Context(), userID, profileID, id); err != nil {
|
||||
if errors.Is(err, notifications.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Notification not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to dismiss notification")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// HandleGetPreferences handles GET /notifications/preferences.
|
||||
// Response: {"preferences":[{category,enabled},...]}
|
||||
func (h *NotificationsHandler) HandleGetPreferences(w http.ResponseWriter, r *http.Request) {
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
|
||||
prefs, err := h.svc.GetPreferences(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get preferences")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, struct {
|
||||
Preferences []notifications.Preference `json:"preferences"`
|
||||
}{Preferences: prefs})
|
||||
}
|
||||
|
||||
// putPreferencesRequest is the request body for PUT /notifications/preferences.
|
||||
type putPreferencesRequest struct {
|
||||
Preferences []notifications.Preference `json:"preferences"`
|
||||
}
|
||||
|
||||
// HandlePutPreferences handles PUT /notifications/preferences.
|
||||
// 400 on empty or invalid category; 204 on success.
|
||||
func (h *NotificationsHandler) HandlePutPreferences(w http.ResponseWriter, r *http.Request) {
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
|
||||
var req putPreferencesRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
if len(req.Preferences) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "preferences must not be empty")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.SetPreferences(r.Context(), userID, req.Preferences); err != nil {
|
||||
if errors.Is(err, notifications.ErrInvalidCategory) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save preferences")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Admin announcement endpoints
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// HandleListAnnouncements handles GET /admin/announcements.
|
||||
// Response: {"items":[...]}
|
||||
func (h *NotificationsHandler) HandleListAnnouncements(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := h.svc.ListAnnouncements(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list announcements")
|
||||
return
|
||||
}
|
||||
if items == nil {
|
||||
items = []*notifications.Announcement{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, struct {
|
||||
Items []*notifications.Announcement `json:"items"`
|
||||
}{Items: items})
|
||||
}
|
||||
|
||||
// HandleCreateAnnouncement handles POST /admin/announcements.
|
||||
// Decodes an Announcement, sets CreatedBy from claims, publishes; 400 on
|
||||
// validation error; 201 with the announcement on success.
|
||||
func (h *NotificationsHandler) HandleCreateAnnouncement(w http.ResponseWriter, r *http.Request) {
|
||||
claims := apimw.GetClaims(r.Context())
|
||||
|
||||
// Announcements fan out one row per (user, profile); cap the request body so
|
||||
// a large title/body cannot be amplified into the whole server's inboxes.
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 64*1024)
|
||||
|
||||
var a notifications.Announcement
|
||||
if err := json.NewDecoder(r.Body).Decode(&a); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if claims != nil {
|
||||
userID := claims.UserID
|
||||
a.CreatedBy = &userID
|
||||
}
|
||||
|
||||
if err := h.svc.PublishAnnouncement(r.Context(), &a); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, &a)
|
||||
}
|
||||
|
||||
// HandleDeleteAnnouncement handles DELETE /admin/announcements/{id}.
|
||||
// 404 on ErrNotFound; 204 on success.
|
||||
func (h *NotificationsHandler) HandleDeleteAnnouncement(w http.ResponseWriter, r *http.Request) {
|
||||
rawID := chi.URLParam(r, "id")
|
||||
id, err := strconv.ParseInt(rawID, 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid announcement id")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.DeleteAnnouncement(r.Context(), id); err != nil {
|
||||
if errors.Is(err, notifications.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Announcement not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete announcement")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,673 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
evt "github.com/Silo-Server/silo-server/internal/events"
|
||||
"github.com/Silo-Server/silo-server/internal/notifications"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fake profile resolver for child-safe tests.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// fakeProfileStore satisfies userstore.UserStore minimally.
|
||||
// Only GetProfile is exercised by childSafe.
|
||||
type fakeProfileStore struct {
|
||||
userstore.UserStore // embed for unimplemented methods
|
||||
isChild bool
|
||||
}
|
||||
|
||||
func (s *fakeProfileStore) GetProfile(_ context.Context, id string) (*userstore.Profile, error) {
|
||||
return &userstore.Profile{ID: id, IsChild: s.isChild}, nil
|
||||
}
|
||||
|
||||
// fakeResolverAsProfileResolver implements the profileResolver interface used
|
||||
// by NotificationsHandler.
|
||||
type fakeResolverAsProfileResolver struct {
|
||||
isChild bool
|
||||
}
|
||||
|
||||
func (f *fakeResolverAsProfileResolver) ForUser(_ context.Context, _ int) (userstore.UserStore, error) {
|
||||
return &fakeProfileStore{isChild: f.isChild}, nil
|
||||
}
|
||||
|
||||
// fakeResolverWithError returns an error from ForUser.
|
||||
type fakeResolverWithError struct{}
|
||||
|
||||
func (f *fakeResolverWithError) ForUser(_ context.Context, _ int) (userstore.UserStore, error) {
|
||||
return nil, errors.New("resolver failed")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fake store implementing notifications.Store for handler tests.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type fakeNotificationsStore struct {
|
||||
notifications.Store // embed for unimplemented panics
|
||||
|
||||
list []*notifications.Notification
|
||||
announcements []*notifications.Announcement
|
||||
unreadCount int
|
||||
prefs map[notifications.Category]bool
|
||||
markReadCalled bool
|
||||
markAllReadCalled bool
|
||||
dismissErr error
|
||||
setPrefErr error
|
||||
deleteAnnouncementErr error
|
||||
insertCalled bool
|
||||
|
||||
// capturedFilter records the most recent ListFilter passed to List.
|
||||
capturedFilter *notifications.ListFilter
|
||||
}
|
||||
|
||||
func (f *fakeNotificationsStore) Insert(_ context.Context, n *notifications.Notification) (bool, error) {
|
||||
f.insertCalled = true
|
||||
f.list = append(f.list, n)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (f *fakeNotificationsStore) List(_ context.Context, filter notifications.ListFilter) ([]*notifications.Notification, error) {
|
||||
f.capturedFilter = &filter
|
||||
return f.list, nil
|
||||
}
|
||||
|
||||
func (f *fakeNotificationsStore) UnreadCount(_ context.Context, _ int, _ string, _ bool) (int, error) {
|
||||
return f.unreadCount, nil
|
||||
}
|
||||
|
||||
func (f *fakeNotificationsStore) MarkRead(_ context.Context, _ int, _ string, _ []int64) error {
|
||||
f.markReadCalled = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeNotificationsStore) MarkAllRead(_ context.Context, _ int, _ string) error {
|
||||
f.markAllReadCalled = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeNotificationsStore) Dismiss(_ context.Context, _ int, _ string, _ int64) error {
|
||||
return f.dismissErr
|
||||
}
|
||||
|
||||
func (f *fakeNotificationsStore) Preferences(_ context.Context, _ int) (map[notifications.Category]bool, error) {
|
||||
if f.prefs != nil {
|
||||
return f.prefs, nil
|
||||
}
|
||||
return map[notifications.Category]bool{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeNotificationsStore) SetPreferences(_ context.Context, _ int, _ []notifications.Preference) error {
|
||||
return f.setPrefErr
|
||||
}
|
||||
|
||||
func (f *fakeNotificationsStore) InsertAnnouncement(_ context.Context, a *notifications.Announcement) error {
|
||||
a.ID = int64(len(f.announcements) + 1)
|
||||
a.CreatedAt = time.Now().UTC()
|
||||
f.announcements = append(f.announcements, a)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeNotificationsStore) ListAnnouncements(_ context.Context) ([]*notifications.Announcement, error) {
|
||||
return f.announcements, nil
|
||||
}
|
||||
|
||||
func (f *fakeNotificationsStore) DeleteAnnouncement(_ context.Context, id int64) error {
|
||||
if f.deleteAnnouncementErr != nil {
|
||||
return f.deleteAnnouncementErr
|
||||
}
|
||||
for i, a := range f.announcements {
|
||||
if a.ID == id {
|
||||
f.announcements = append(f.announcements[:i], f.announcements[i+1:]...)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return notifications.ErrNotFound
|
||||
}
|
||||
|
||||
func (f *fakeNotificationsStore) DismissAnnouncementNotifications(_ context.Context, _ int64) error {
|
||||
return nil
|
||||
}
|
||||
func (f *fakeNotificationsStore) ProfileIDsForUsers(_ context.Context, _ []int) (map[int][]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeNotificationsStore) PurgeOld(_ context.Context, _, _ time.Time) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
func (f *fakeNotificationsStore) AdminUserIDs(_ context.Context) ([]int, error) { return nil, nil }
|
||||
func (f *fakeNotificationsStore) UserIDsWithLibraryAccess(_ context.Context, _ int) ([]int, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeNotificationsStore) AllEnabledUserIDs(_ context.Context) ([]int, error) { return nil, nil }
|
||||
|
||||
// Compile-time check.
|
||||
var _ notifications.Store = (*fakeNotificationsStore)(nil)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: build a handler over the fake store.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func newTestNotificationsHandler(store *fakeNotificationsStore) *NotificationsHandler {
|
||||
hub := evt.NewHub("test", nil)
|
||||
svc := notifications.NewService(store, hub)
|
||||
return NewNotificationsHandler(svc)
|
||||
}
|
||||
|
||||
func newTestNotificationsHandlerWithResolver(store *fakeNotificationsStore, resolver profileResolver) *NotificationsHandler {
|
||||
hub := evt.NewHub("test", nil)
|
||||
svc := notifications.NewService(store, hub)
|
||||
return NewNotificationsHandler(svc, resolver)
|
||||
}
|
||||
|
||||
// notifUserRequest creates an authenticated request with a profile ID.
|
||||
func notifUserRequest(method, target string, body []byte, userID int, profileID string) *http.Request {
|
||||
var req *http.Request
|
||||
if len(body) > 0 {
|
||||
req = httptest.NewRequest(method, target, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
} else {
|
||||
req = httptest.NewRequest(method, target, nil)
|
||||
}
|
||||
ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: userID, Role: "user"})
|
||||
ctx = apimw.SetProfileID(ctx, profileID)
|
||||
return req.WithContext(ctx)
|
||||
}
|
||||
|
||||
// notifAdminRequest creates an admin-authenticated request.
|
||||
func notifAdminRequest(method, target string, body []byte, userID int) *http.Request {
|
||||
var req *http.Request
|
||||
if len(body) > 0 {
|
||||
req = httptest.NewRequest(method, target, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
} else {
|
||||
req = httptest.NewRequest(method, target, nil)
|
||||
}
|
||||
ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: userID, Role: "admin"})
|
||||
return req.WithContext(ctx)
|
||||
}
|
||||
|
||||
// notifChiRequest adds a chi URL param to a request context.
|
||||
func notifChiRequest(r *http.Request, key, value string) *http.Request {
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add(key, value)
|
||||
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: HandleList
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestNotificationsHandleList_HappyPath_TwoRowsNoCursor(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
store := &fakeNotificationsStore{
|
||||
list: []*notifications.Notification{
|
||||
{ID: 2, UserID: 1, Category: notifications.CategoryContent, Type: "content.added", Title: "New Movie", CreatedAt: now},
|
||||
{ID: 1, UserID: 1, Category: notifications.CategoryRequest, Type: "request.approved", Title: "Approved", CreatedAt: now.Add(-time.Minute)},
|
||||
},
|
||||
}
|
||||
h := newTestNotificationsHandler(store)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := notifUserRequest(http.MethodGet, "/notifications", nil, 1, "prof-1")
|
||||
h.HandleList(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Items []*notifications.Notification `json:"items"`
|
||||
NextCursor *int64 `json:"next_cursor"`
|
||||
}
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if len(resp.Items) != 2 {
|
||||
t.Fatalf("items count = %d, want 2", len(resp.Items))
|
||||
}
|
||||
// len < limit (default 50), so next_cursor must be null.
|
||||
if resp.NextCursor != nil {
|
||||
t.Fatalf("next_cursor = %v, want nil (items < limit)", resp.NextCursor)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: HandleUnreadCount
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestNotificationsHandleUnreadCount_ReturnsCount(t *testing.T) {
|
||||
store := &fakeNotificationsStore{unreadCount: 7}
|
||||
h := newTestNotificationsHandler(store)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := notifUserRequest(http.MethodGet, "/notifications/unread-count", nil, 1, "prof-1")
|
||||
h.HandleUnreadCount(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
var resp struct {
|
||||
Count int `json:"count"`
|
||||
}
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.Count != 7 {
|
||||
t.Fatalf("count = %d, want 7", resp.Count)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: HandleMarkRead
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestNotificationsHandleMarkRead_EmptyBody_Returns400(t *testing.T) {
|
||||
store := &fakeNotificationsStore{}
|
||||
h := newTestNotificationsHandler(store)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := notifUserRequest(http.MethodPost, "/notifications/read", []byte(`{}`), 1, "prof-1")
|
||||
h.HandleMarkRead(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationsHandleMarkRead_AllTrue_Returns204(t *testing.T) {
|
||||
store := &fakeNotificationsStore{}
|
||||
h := newTestNotificationsHandler(store)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := notifUserRequest(http.MethodPost, "/notifications/read", []byte(`{"all":true}`), 1, "prof-1")
|
||||
h.HandleMarkRead(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !store.markAllReadCalled {
|
||||
t.Fatal("MarkAllRead was not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationsHandleMarkRead_IDs_Returns204(t *testing.T) {
|
||||
store := &fakeNotificationsStore{}
|
||||
h := newTestNotificationsHandler(store)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := notifUserRequest(http.MethodPost, "/notifications/read", []byte(`{"ids":[1,2,3]}`), 1, "prof-1")
|
||||
h.HandleMarkRead(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !store.markReadCalled {
|
||||
t.Fatal("MarkRead was not called")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: HandleDismiss
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestNotificationsHandleDismiss_NotFound_Returns404(t *testing.T) {
|
||||
store := &fakeNotificationsStore{dismissErr: notifications.ErrNotFound}
|
||||
h := newTestNotificationsHandler(store)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := notifUserRequest(http.MethodPost, "/notifications/999/dismiss", nil, 1, "prof-1")
|
||||
req = notifChiRequest(req, "id", "999")
|
||||
h.HandleDismiss(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationsHandleDismiss_BadID_Returns400(t *testing.T) {
|
||||
store := &fakeNotificationsStore{}
|
||||
h := newTestNotificationsHandler(store)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := notifUserRequest(http.MethodPost, "/notifications/abc/dismiss", nil, 1, "prof-1")
|
||||
req = notifChiRequest(req, "id", "abc")
|
||||
h.HandleDismiss(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: HandleGetPreferences
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestNotificationsHandleGetPreferences_DefaultsReturned(t *testing.T) {
|
||||
// No stored prefs → service returns all MutableCategories with defaults.
|
||||
store := &fakeNotificationsStore{}
|
||||
h := newTestNotificationsHandler(store)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := notifUserRequest(http.MethodGet, "/notifications/preferences", nil, 1, "prof-1")
|
||||
h.HandleGetPreferences(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Preferences []notifications.Preference `json:"preferences"`
|
||||
}
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(resp.Preferences) != len(notifications.MutableCategories) {
|
||||
t.Fatalf("prefs count = %d, want %d", len(resp.Preferences), len(notifications.MutableCategories))
|
||||
}
|
||||
|
||||
prefMap := make(map[notifications.Category]bool)
|
||||
for _, p := range resp.Preferences {
|
||||
prefMap[p.Category] = p.Enabled
|
||||
}
|
||||
|
||||
// content_digest defaults to false (opt-in); all others default to true.
|
||||
for _, cat := range notifications.MutableCategories {
|
||||
enabled, ok := prefMap[cat]
|
||||
if !ok {
|
||||
t.Errorf("category %q missing from preferences", cat)
|
||||
continue
|
||||
}
|
||||
if cat == notifications.CategoryContentDigest {
|
||||
if enabled {
|
||||
t.Errorf("content_digest default should be false (opt-in), got true")
|
||||
}
|
||||
} else {
|
||||
if !enabled {
|
||||
t.Errorf("category %q default should be true, got false", cat)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: HandlePutPreferences
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestNotificationsHandlePutPreferences_RejectsAnnouncementCategory_400(t *testing.T) {
|
||||
store := &fakeNotificationsStore{}
|
||||
h := newTestNotificationsHandler(store)
|
||||
|
||||
// "announcement" is not in MutableCategories.
|
||||
body := `{"preferences":[{"category":"announcement","enabled":false}]}`
|
||||
rec := httptest.NewRecorder()
|
||||
req := notifUserRequest(http.MethodPut, "/notifications/preferences", []byte(body), 1, "prof-1")
|
||||
h.HandlePutPreferences(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationsHandlePutPreferences_ValidPrefs_204(t *testing.T) {
|
||||
store := &fakeNotificationsStore{}
|
||||
h := newTestNotificationsHandler(store)
|
||||
|
||||
body := `{"preferences":[{"category":"request","enabled":false},{"category":"content","enabled":true}]}`
|
||||
rec := httptest.NewRecorder()
|
||||
req := notifUserRequest(http.MethodPut, "/notifications/preferences", []byte(body), 1, "prof-1")
|
||||
h.HandlePutPreferences(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: Admin announcements
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestNotificationsHandleCreateAnnouncement_201(t *testing.T) {
|
||||
store := &fakeNotificationsStore{}
|
||||
h := newTestNotificationsHandler(store)
|
||||
|
||||
body := `{"title":"Test Announcement","body":"Hello","audience":{"all":true}}`
|
||||
rec := httptest.NewRecorder()
|
||||
req := notifAdminRequest(http.MethodPost, "/admin/announcements", []byte(body), 1)
|
||||
h.HandleCreateAnnouncement(rec, req)
|
||||
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp notifications.Announcement
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.Title != "Test Announcement" {
|
||||
t.Fatalf("title = %q, want %q", resp.Title, "Test Announcement")
|
||||
}
|
||||
if resp.CreatedBy == nil || *resp.CreatedBy != 1 {
|
||||
t.Fatalf("created_by = %v, want 1", resp.CreatedBy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationsHandleCreateAnnouncement_ValidationError_400(t *testing.T) {
|
||||
store := &fakeNotificationsStore{}
|
||||
h := newTestNotificationsHandler(store)
|
||||
|
||||
// Empty title triggers service validation error.
|
||||
body := `{"title":"","body":"Hello","audience":{"all":true}}`
|
||||
rec := httptest.NewRecorder()
|
||||
req := notifAdminRequest(http.MethodPost, "/admin/announcements", []byte(body), 1)
|
||||
h.HandleCreateAnnouncement(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationsHandleDeleteAnnouncement_204(t *testing.T) {
|
||||
store := &fakeNotificationsStore{
|
||||
announcements: []*notifications.Announcement{
|
||||
{ID: 5, Title: "To Delete", Audience: notifications.Audience{All: true}},
|
||||
},
|
||||
}
|
||||
h := newTestNotificationsHandler(store)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := notifAdminRequest(http.MethodDelete, "/admin/announcements/5", nil, 1)
|
||||
req = notifChiRequest(req, "id", "5")
|
||||
h.HandleDeleteAnnouncement(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationsHandleDeleteAnnouncement_NotFound_404(t *testing.T) {
|
||||
store := &fakeNotificationsStore{}
|
||||
h := newTestNotificationsHandler(store)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := notifAdminRequest(http.MethodDelete, "/admin/announcements/999", nil, 1)
|
||||
req = notifChiRequest(req, "id", "999")
|
||||
h.HandleDeleteAnnouncement(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationsHandleListAnnouncements_ReturnsItems(t *testing.T) {
|
||||
store := &fakeNotificationsStore{
|
||||
announcements: []*notifications.Announcement{
|
||||
{ID: 1, Title: "Hello", Audience: notifications.Audience{All: true}},
|
||||
},
|
||||
}
|
||||
h := newTestNotificationsHandler(store)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := notifAdminRequest(http.MethodGet, "/admin/announcements", nil, 1)
|
||||
h.HandleListAnnouncements(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Items []*notifications.Announcement `json:"items"`
|
||||
}
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(resp.Items) != 1 {
|
||||
t.Fatalf("items count = %d, want 1", len(resp.Items))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: ChildSafe profile filtering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestNotificationsList_ChildProfileFiltered asserts that when the profile
|
||||
// resolver reports IsChild=true, the ListFilter passed to the store has
|
||||
// ChildSafe=true.
|
||||
func TestNotificationsList_ChildProfileFiltered(t *testing.T) {
|
||||
store := &fakeNotificationsStore{}
|
||||
resolver := &fakeResolverAsProfileResolver{isChild: true}
|
||||
h := newTestNotificationsHandlerWithResolver(store, resolver)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := notifUserRequest(http.MethodGet, "/notifications", nil, 1, "prof-child")
|
||||
h.HandleList(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if store.capturedFilter == nil {
|
||||
t.Fatal("List was not called; capturedFilter is nil")
|
||||
}
|
||||
if !store.capturedFilter.ChildSafe {
|
||||
t.Fatalf("capturedFilter.ChildSafe = false, want true for child profile")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotificationsList_AdultProfileNotFiltered asserts that a non-child
|
||||
// profile leaves ChildSafe=false in the ListFilter.
|
||||
func TestNotificationsList_AdultProfileNotFiltered(t *testing.T) {
|
||||
store := &fakeNotificationsStore{}
|
||||
resolver := &fakeResolverAsProfileResolver{isChild: false}
|
||||
h := newTestNotificationsHandlerWithResolver(store, resolver)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := notifUserRequest(http.MethodGet, "/notifications", nil, 1, "prof-adult")
|
||||
h.HandleList(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if store.capturedFilter == nil {
|
||||
t.Fatal("List was not called; capturedFilter is nil")
|
||||
}
|
||||
if store.capturedFilter.ChildSafe {
|
||||
t.Fatalf("capturedFilter.ChildSafe = true, want false for non-child profile")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotificationsList_NilResolverDefaultsToFalse asserts that when no
|
||||
// resolver is wired (nil), ChildSafe remains false.
|
||||
func TestNotificationsList_NilResolverDefaultsToFalse(t *testing.T) {
|
||||
store := &fakeNotificationsStore{}
|
||||
h := newTestNotificationsHandler(store) // no resolver
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := notifUserRequest(http.MethodGet, "/notifications", nil, 1, "prof-1")
|
||||
h.HandleList(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if store.capturedFilter == nil {
|
||||
t.Fatal("List was not called; capturedFilter is nil")
|
||||
}
|
||||
if store.capturedFilter.ChildSafe {
|
||||
t.Fatalf("capturedFilter.ChildSafe = true, want false when no resolver is wired")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotificationsList_ResolverErrorFailsClosed asserts that when the resolver
|
||||
// returns an error, ChildSafe=true (fail closed).
|
||||
func TestNotificationsList_ResolverErrorFailsClosed(t *testing.T) {
|
||||
store := &fakeNotificationsStore{}
|
||||
resolver := &fakeResolverWithError{}
|
||||
h := newTestNotificationsHandlerWithResolver(store, resolver)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := notifUserRequest(http.MethodGet, "/notifications", nil, 1, "prof-1")
|
||||
h.HandleList(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if store.capturedFilter == nil {
|
||||
t.Fatal("List was not called; capturedFilter is nil")
|
||||
}
|
||||
if !store.capturedFilter.ChildSafe {
|
||||
t.Fatalf("capturedFilter.ChildSafe = false, want true (fail closed on resolver error)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotificationsList_NextCursorSet asserts that when limit=2 and the store
|
||||
// returns exactly 2 notifications, next_cursor is set to the last item's ID.
|
||||
func TestNotificationsList_NextCursorSet(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
store := &fakeNotificationsStore{
|
||||
list: []*notifications.Notification{
|
||||
{ID: 12, UserID: 1, Category: notifications.CategoryContent, Type: "content.added", Title: "New Movie", CreatedAt: now},
|
||||
{ID: 11, UserID: 1, Category: notifications.CategoryRequest, Type: "request.approved", Title: "Approved", CreatedAt: now.Add(-time.Minute)},
|
||||
},
|
||||
}
|
||||
h := newTestNotificationsHandler(store)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := notifUserRequest(http.MethodGet, "/notifications?limit=2", nil, 1, "prof-1")
|
||||
h.HandleList(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Items []*notifications.Notification `json:"items"`
|
||||
NextCursor *int64 `json:"next_cursor"`
|
||||
}
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if len(resp.Items) != 2 {
|
||||
t.Fatalf("items count = %d, want 2", len(resp.Items))
|
||||
}
|
||||
// With limit=2 and 2 items returned, next_cursor should be set to the last item's ID (11).
|
||||
if resp.NextCursor == nil {
|
||||
t.Fatal("next_cursor is nil, want 11")
|
||||
}
|
||||
if *resp.NextCursor != 11 {
|
||||
t.Fatalf("next_cursor = %d, want 11", *resp.NextCursor)
|
||||
}
|
||||
}
|
||||
|
||||
// Suppress unused import.
|
||||
var _ = strings.Contains
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/clientip"
|
||||
"github.com/Silo-Server/silo-server/internal/config"
|
||||
evt "github.com/Silo-Server/silo-server/internal/events"
|
||||
"github.com/Silo-Server/silo-server/internal/markers"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
@@ -49,6 +50,8 @@ type SessionManagerInterface interface {
|
||||
SetEffectiveMediaFileID(sessionID string, fileID int) error
|
||||
SetTranscodeNodeURL(sessionID, url string) error
|
||||
SetWebSocket(sessionID string, connected bool) error
|
||||
SetRealtimeConnection(sessionID string, connected bool) error
|
||||
SetProgressPersistenceDisabled(sessionID string, disabled bool) error
|
||||
StopSession(sessionID string) error
|
||||
GetSession(sessionID string) (*playback.Session, error)
|
||||
}
|
||||
@@ -57,17 +60,6 @@ type sessionStarterWithFilesContext interface {
|
||||
StartSessionWithFilesContext(ctx context.Context, userID int, profileID string, effectiveFileID int, requestedFileID int, method playback.PlayMethod, transcodeAudio bool) (*playback.Session, error)
|
||||
}
|
||||
|
||||
// ProxyPicker selects a proxy node for stream routing.
|
||||
type ProxyPicker interface {
|
||||
Pick() *nodepool.Node
|
||||
}
|
||||
|
||||
// TranscodeAcquirer selects a transcode node for transcoding jobs.
|
||||
type TranscodeAcquirer interface {
|
||||
Acquire() *nodepool.Node
|
||||
FindByURL(url string) *nodepool.Node
|
||||
}
|
||||
|
||||
type PlaybackItemAccessChecker interface {
|
||||
EnsureAccessible(ctx context.Context, contentID string, filter catalog.AccessFilter) error
|
||||
}
|
||||
@@ -117,8 +109,7 @@ type PlaybackHandler struct {
|
||||
SessionSyncer PlaybackSessionSyncer // optional; enables immediate session sync to shared admin view
|
||||
EventsHub *evt.Hub
|
||||
MissingMarker MissingFileMarker
|
||||
ProxyPool ProxyPicker // optional; enables proxy-based stream URLs
|
||||
TranscodePool TranscodeAcquirer // optional; enables transcode node selection
|
||||
NodePlanner nodepool.SessionPlanner // optional; enables proxy/transcode node selection
|
||||
JWTSecret string // needed for signing stream tokens
|
||||
ItemAccess PlaybackItemAccessChecker // optional; enables file authorization checks
|
||||
EpisodeLookup PlaybackEpisodeLookup // optional; resolves episode files to their series
|
||||
@@ -139,11 +130,12 @@ type PlaybackHandler struct {
|
||||
RealtimeHub *playback.RealtimeHub
|
||||
CommandTracker *playback.CommandTracker
|
||||
CommandDispatcher *playback.CommandDispatcher
|
||||
FFmpegPath string
|
||||
HWAccel string
|
||||
HWDevice string
|
||||
TranscodeDir string
|
||||
FFmpegLogSink playback.FFmpegLogSink
|
||||
// PlaybackConfig returns the current playback config (ffmpeg path,
|
||||
// hwaccel, transcode dir). Wired to the live config in integrated mode
|
||||
// so admin changes apply to newly started transcodes. Read it through
|
||||
// playbackConfig(), which falls back to defaults when unset.
|
||||
PlaybackConfig func() config.PlaybackConfig
|
||||
FFmpegLogSink playback.FFmpegLogSink
|
||||
transcodeMu sync.RWMutex
|
||||
transcodes map[string]*playback.TranscodeSession
|
||||
realtimeCommandMu sync.Mutex
|
||||
@@ -166,7 +158,6 @@ type sessionExpirationHookSetter interface {
|
||||
func NewPlaybackHandler(sessionMgr SessionManagerInterface, opts ...FilePathResolver) *PlaybackHandler {
|
||||
h := &PlaybackHandler{
|
||||
sessionMgr: sessionMgr,
|
||||
TranscodeDir: filepath.Join(os.TempDir(), "silo-transcode"),
|
||||
transcodes: make(map[string]*playback.TranscodeSession),
|
||||
realtimeCommands: make(map[string]playbackCommandRecord),
|
||||
}
|
||||
@@ -189,6 +180,19 @@ func (h *PlaybackHandler) SetProfileRefreshRequester(requester ProfileRefreshReq
|
||||
h.profileRefreshRequester = requester
|
||||
}
|
||||
|
||||
// playbackConfig returns the current playback config, falling back to the
|
||||
// same defaults as config loading (transcode enabled, temp transcode dir)
|
||||
// when no provider is wired (tests, minimal setups).
|
||||
func (h *PlaybackHandler) playbackConfig() config.PlaybackConfig {
|
||||
if h.PlaybackConfig != nil {
|
||||
return h.PlaybackConfig()
|
||||
}
|
||||
return config.PlaybackConfig{
|
||||
TranscodeEnabled: true,
|
||||
TranscodeDir: filepath.Join(os.TempDir(), "silo-transcode"),
|
||||
}
|
||||
}
|
||||
|
||||
// CleanupOrphanedTranscodes removes stale per-session temp directories for
|
||||
// transcodes that are no longer tracked in memory.
|
||||
func (h *PlaybackHandler) CleanupOrphanedTranscodes() (int, error) {
|
||||
@@ -199,7 +203,7 @@ func (h *PlaybackHandler) CleanupOrphanedTranscodes() (int, error) {
|
||||
}
|
||||
h.transcodeMu.RUnlock()
|
||||
|
||||
return playback.CleanupOrphanedTranscodeDirs(h.TranscodeDir, active)
|
||||
return playback.CleanupOrphanedTranscodeDirs(h.playbackConfig().TranscodeDir, active)
|
||||
}
|
||||
|
||||
// playbackThresholds reads the playback.watched_threshold and
|
||||
@@ -257,6 +261,7 @@ type startPlaybackRequest struct {
|
||||
StartPosition *float64 `json:"start_position,omitempty"`
|
||||
AudioTrackIndex *int `json:"audio_track_index,omitempty"`
|
||||
PreserveDirectAudioSelection bool `json:"preserve_direct_audio_selection,omitempty"`
|
||||
DisableProgressPersistence bool `json:"disable_progress_persistence,omitempty"`
|
||||
CodecsVideo []string `json:"codecs_video"`
|
||||
CodecsAudio []string `json:"codecs_audio"`
|
||||
Containers []string `json:"containers"`
|
||||
@@ -442,6 +447,13 @@ func playbackStreamURL(s *playback.Session) string {
|
||||
return fmt.Sprintf("/stream/%s", s.ID)
|
||||
}
|
||||
|
||||
func fileBitrateKbps(file *models.MediaFile) int {
|
||||
if file == nil || file.Bitrate <= 0 {
|
||||
return 0
|
||||
}
|
||||
return file.Bitrate
|
||||
}
|
||||
|
||||
func buildPlaybackInfo(session *playback.Session, file *models.MediaFile) *playbackInfoResult {
|
||||
if session == nil {
|
||||
return nil
|
||||
@@ -616,9 +628,9 @@ func normalizeAudioTrackIndex(file *models.MediaFile, audioTrackIndex int) int {
|
||||
return directPlayAudioTrackIndex(file)
|
||||
}
|
||||
|
||||
func playbackAdminSettingsFromRequest(ctx context.Context, repo PlaybackSettingsReader) playback.AdminSettings {
|
||||
func playbackAdminSettingsFromRequest(ctx context.Context, repo PlaybackSettingsReader, transcodeEnabled bool) playback.AdminSettings {
|
||||
settings := playback.AdminSettings{
|
||||
TranscodeEnabled: true,
|
||||
TranscodeEnabled: transcodeEnabled,
|
||||
}
|
||||
if repo != nil {
|
||||
if v, _ := repo.Get(ctx, "allow_4k_transcode"); v == "true" {
|
||||
@@ -663,7 +675,7 @@ func (h *PlaybackHandler) resolveCapabilityPlaybackSelection(
|
||||
}
|
||||
|
||||
audioTrackIndex = normalizeAudioTrackIndex(requestedFile, audioTrackIndex)
|
||||
adminSettings := playbackAdminSettingsFromRequest(ctx, h.SettingsRepo)
|
||||
adminSettings := playbackAdminSettingsFromRequest(ctx, h.SettingsRepo, h.playbackConfig().TranscodeEnabled)
|
||||
method, transcodeAudio := resolvePlaybackMethodForFile(requestedFile, req, audioTrackIndex, adminSettings)
|
||||
|
||||
if requestedFile.Resolution == "2160p" &&
|
||||
@@ -761,6 +773,9 @@ func (h *PlaybackHandler) persistProgress(ctx context.Context, session *playback
|
||||
if h.StoreProvider == nil || h.fileResolver == nil {
|
||||
return
|
||||
}
|
||||
if session == nil || session.DisableProgressPersistence {
|
||||
return
|
||||
}
|
||||
|
||||
file, err := h.loadFileByPreferredID(ctx, requestedMediaFileID(session), session.MediaFileID)
|
||||
targetID := playbackProgressTarget(file)
|
||||
@@ -799,7 +814,7 @@ func (h *PlaybackHandler) persistStopAndHistory(ctx context.Context, session *pl
|
||||
if h.StoreProvider == nil || h.fileResolver == nil {
|
||||
return watchstate.PlaybackStopResult{}
|
||||
}
|
||||
if session == nil || session.Position <= 0 {
|
||||
if session == nil || session.DisableProgressPersistence || session.Position <= 0 {
|
||||
return watchstate.PlaybackStopResult{}
|
||||
}
|
||||
|
||||
@@ -1281,6 +1296,13 @@ func (h *PlaybackHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Req
|
||||
return
|
||||
}
|
||||
setPlaybackSessionLogContext(r, session.ID)
|
||||
if req.DisableProgressPersistence {
|
||||
if err := h.sessionMgr.SetProgressPersistenceDisabled(session.ID, true); err != nil {
|
||||
slog.Error("failed to disable progress persistence", "session", session.ID, "error", err)
|
||||
} else {
|
||||
session.DisableProgressPersistence = true
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.sessionMgr.UpdateAudioTrack(session.ID, audioTrackIndex, session.PlayMethod); err != nil {
|
||||
slog.Error("failed to set audio track", "session", session.ID, "error", err)
|
||||
@@ -1320,7 +1342,7 @@ func (h *PlaybackHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Req
|
||||
} else {
|
||||
h.restoreSessionProgress(r.Context(), session, file)
|
||||
}
|
||||
if h.WatchScrobbler != nil && effectiveFile != nil {
|
||||
if !session.DisableProgressPersistence && h.WatchScrobbler != nil && effectiveFile != nil {
|
||||
targetID := playbackProgressTarget(effectiveFile)
|
||||
if targetID != "" {
|
||||
event := h.scrobbleEventForSession(r.Context(), session, targetID, float64(effectiveFile.Duration), session.Position)
|
||||
@@ -1359,10 +1381,14 @@ func (h *PlaybackHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
resp.SubtitleURLs = buildSubtitleURLs(session.ID, effectiveFile, downloadedSubs)
|
||||
|
||||
// If proxy nodes are available, generate proxy-based stream URLs.
|
||||
if h.ProxyPool != nil && h.JWTSecret != "" {
|
||||
proxyNode := h.ProxyPool.Pick()
|
||||
if proxyNode != nil {
|
||||
// If stream nodes are available, generate proxy-based stream URLs.
|
||||
// Remux and transcode both use HLS via a transcode node, so the planner
|
||||
// picks the transcode node and its group's proxy together.
|
||||
if h.NodePlanner != nil && h.JWTSecret != "" {
|
||||
needsTranscode := session.PlayMethod == playback.PlayTranscode || session.PlayMethod == playback.PlayRemux
|
||||
plan := h.NodePlanner.PlanSession(session.ID, "", needsTranscode, fileBitrateKbps(effectiveFile))
|
||||
proxyNode := plan.ProxyNode
|
||||
if proxyNode != nil && (!needsTranscode || plan.TranscodeNode != nil) {
|
||||
tokenClaims := streamtoken.Claims{
|
||||
SessionID: session.ID,
|
||||
PlayMethod: string(session.PlayMethod),
|
||||
@@ -1376,12 +1402,9 @@ func (h *PlaybackHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Req
|
||||
tokenClaims.TranscodeAudio = session.TranscodeAudio
|
||||
tokenClaims.AudioTrackIndex = session.AudioTrackIndex
|
||||
|
||||
// Remux and transcode both use HLS via a transcode node.
|
||||
if (session.PlayMethod == playback.PlayTranscode || session.PlayMethod == playback.PlayRemux) && h.TranscodePool != nil {
|
||||
if tcNode := h.TranscodePool.Acquire(); tcNode != nil {
|
||||
tokenClaims.TranscodeNode = tcNode.URL
|
||||
_ = h.sessionMgr.SetTranscodeNodeURL(session.ID, tcNode.URL)
|
||||
}
|
||||
if plan.TranscodeNode != nil {
|
||||
tokenClaims.TranscodeNode = plan.TranscodeNode.URL
|
||||
_ = h.sessionMgr.SetTranscodeNodeURL(session.ID, plan.TranscodeNode.URL)
|
||||
}
|
||||
|
||||
token, signErr := streamtoken.Sign(tokenClaims, h.JWTSecret, 24*time.Hour)
|
||||
@@ -1421,10 +1444,14 @@ func (h *PlaybackHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Req
|
||||
|
||||
// subtitleURLExt returns the URL file extension for a subtitle codec.
|
||||
// ASS/SSA tracks get ".ass" so the frontend can request raw ASS data for
|
||||
// client-side rendering (JASSUB); all other text formats get ".vtt".
|
||||
// client-side rendering (JASSUB); PGS tracks get ".sup" for client-side
|
||||
// bitmap rendering (libpgs); all other text formats get ".vtt".
|
||||
func subtitleURLExt(codec string) string {
|
||||
if playback.IsASS(codec) {
|
||||
switch {
|
||||
case playback.IsASS(codec):
|
||||
return ".ass"
|
||||
case playback.IsPGS(codec):
|
||||
return ".sup"
|
||||
}
|
||||
return ".vtt"
|
||||
}
|
||||
@@ -1451,7 +1478,10 @@ func buildSubtitleURLs(sessionID string, file *models.MediaFile, downloaded []su
|
||||
|
||||
embeddedOffset := len(file.ExternalSubtitles)
|
||||
for i, track := range file.SubtitleTracks {
|
||||
if playback.NeedsBurnIn(track.Codec) {
|
||||
// PGS bitmap tracks are deliverable as .sup streams for
|
||||
// client-side rendering; DVD/DVB bitmap tracks still have no
|
||||
// non-burn-in delivery path, so they stay hidden.
|
||||
if playback.NeedsBurnIn(track.Codec) && !playback.IsPGS(track.Codec) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1549,7 +1579,7 @@ func (h *PlaybackHandler) HandleUpdateProgress(w http.ResponseWriter, r *http.Re
|
||||
// Persist progress to UserStore (best-effort).
|
||||
if sess, getErr := h.sessionMgr.GetSession(sessionID); getErr == nil {
|
||||
h.persistProgress(r.Context(), sess)
|
||||
if h.WatchScrobbler != nil && wasPaused != sess.IsPaused {
|
||||
if !sess.DisableProgressPersistence && h.WatchScrobbler != nil && wasPaused != sess.IsPaused {
|
||||
if file, loadErr := h.loadFileByPreferredID(r.Context(), requestedMediaFileID(sess), sess.MediaFileID); loadErr == nil && file != nil {
|
||||
targetID := playbackProgressTarget(file)
|
||||
if targetID != "" {
|
||||
@@ -1758,8 +1788,14 @@ func (h *PlaybackHandler) HandleChangeAudioTrack(w http.ResponseWriter, r *http.
|
||||
PlaybackInfo: buildPlaybackInfo(&updatedSession, file),
|
||||
}
|
||||
|
||||
if h.ProxyPool != nil && h.JWTSecret != "" {
|
||||
if proxyNode := h.ProxyPool.Pick(); proxyNode != nil {
|
||||
if h.NodePlanner != nil && h.JWTSecret != "" {
|
||||
needsTranscode := updatedSession.PlayMethod == playback.PlayTranscode
|
||||
estKbps := updatedSession.TargetBitrateKbps
|
||||
if estKbps <= 0 {
|
||||
estKbps = fileBitrateKbps(file)
|
||||
}
|
||||
plan := h.NodePlanner.PlanSession(sessionID, session.TranscodeNodeURL, needsTranscode, estKbps)
|
||||
if proxyNode := plan.ProxyNode; proxyNode != nil && (!needsTranscode || plan.TranscodeNode != nil) {
|
||||
tokenClaims := streamtoken.Claims{
|
||||
SessionID: sessionID,
|
||||
PlayMethod: string(updatedSession.PlayMethod),
|
||||
@@ -1767,11 +1803,9 @@ func (h *PlaybackHandler) HandleChangeAudioTrack(w http.ResponseWriter, r *http.
|
||||
TranscodeAudio: updatedSession.TranscodeAudio,
|
||||
AudioTrackIndex: req.AudioTrackIndex,
|
||||
}
|
||||
if updatedSession.PlayMethod == playback.PlayTranscode && h.TranscodePool != nil {
|
||||
if tcNode := h.TranscodePool.Acquire(); tcNode != nil {
|
||||
tokenClaims.TranscodeNode = tcNode.URL
|
||||
_ = h.sessionMgr.SetTranscodeNodeURL(sessionID, tcNode.URL)
|
||||
}
|
||||
if plan.TranscodeNode != nil {
|
||||
tokenClaims.TranscodeNode = plan.TranscodeNode.URL
|
||||
_ = h.sessionMgr.SetTranscodeNodeURL(sessionID, plan.TranscodeNode.URL)
|
||||
}
|
||||
if token, signErr := streamtoken.Sign(tokenClaims, h.JWTSecret, 24*time.Hour); signErr == nil {
|
||||
switch updatedSession.PlayMethod {
|
||||
@@ -2024,7 +2058,15 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re
|
||||
}
|
||||
|
||||
// Determine whether to run locally or forward to a remote transcode node.
|
||||
tcNode := h.PickTranscodeNode(session.TranscodeNodeURL)
|
||||
var plan nodepool.Plan
|
||||
if h.NodePlanner != nil {
|
||||
estKbps := req.TargetBitrateKbps
|
||||
if estKbps <= 0 {
|
||||
estKbps = fileBitrateKbps(file)
|
||||
}
|
||||
plan = h.NodePlanner.PlanSession(req.SessionID, session.TranscodeNodeURL, true, estKbps)
|
||||
}
|
||||
tcNode := plan.TranscodeNode
|
||||
|
||||
if tcNode != nil {
|
||||
// Remote transcode: forward to the assigned node.
|
||||
@@ -2043,7 +2085,7 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re
|
||||
TargetCodecAudio: req.TargetCodecAudio,
|
||||
TargetBitrateKbps: req.TargetBitrateKbps,
|
||||
SegmentDuration: req.SegmentDuration,
|
||||
HWAccel: h.HWAccel,
|
||||
HWAccel: h.playbackConfig().HWAccel,
|
||||
AudioTrackIndex: session.AudioTrackIndex,
|
||||
SubtitleTrackIndex: req.SubtitleTrackIndex,
|
||||
SubtitleBurnIn: req.SubtitleBurnIn,
|
||||
@@ -2089,7 +2131,7 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re
|
||||
effectiveHWAccel = strings.TrimSpace(nodeReq.HWAccel)
|
||||
}
|
||||
|
||||
manifestURL := h.buildProxyManifestURL(req.SessionID, session, tcNode.URL)
|
||||
manifestURL := h.buildProxyManifestURL(req.SessionID, session, tcNode.URL, plan.ProxyNode)
|
||||
h.finalizeTranscodeStart(r, transcodeStartState{
|
||||
req: req,
|
||||
file: file,
|
||||
@@ -2102,14 +2144,24 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re
|
||||
}
|
||||
|
||||
// Local transcode (integrated mode — no transcode nodes available).
|
||||
if err := os.MkdirAll(h.TranscodeDir, 0o755); err != nil {
|
||||
// In distributed mode admins can disable this fallback so the API server
|
||||
// never transcodes when no eligible node exists.
|
||||
if h.NodePlanner != nil && !nodepool.LocalTranscodeFallbackAllowed(r.Context(), h.SettingsRepo) {
|
||||
writeError(w, http.StatusServiceUnavailable, "no_transcode_node",
|
||||
"No transcode node is available and local transcode fallback is disabled")
|
||||
return
|
||||
}
|
||||
// Snapshot once so the directory, ffmpeg path, and hwaccel of this
|
||||
// session stay consistent even if the config reloads mid-start.
|
||||
playbackCfg := h.playbackConfig()
|
||||
if err := os.MkdirAll(playbackCfg.TranscodeDir, 0o755); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to prepare transcode directory")
|
||||
return
|
||||
}
|
||||
|
||||
transcodeSession, err := playback.StartTranscode(context.WithoutCancel(r.Context()), playback.TranscodeOpts{
|
||||
InputPath: file.FilePath,
|
||||
OutputDir: filepath.Join(h.TranscodeDir, req.SessionID),
|
||||
OutputDir: filepath.Join(playbackCfg.TranscodeDir, req.SessionID),
|
||||
SessionID: req.SessionID,
|
||||
SourceVideoCodec: file.CodecVideo,
|
||||
SeekSeconds: req.SeekSeconds,
|
||||
@@ -2119,9 +2171,9 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re
|
||||
TargetCodecAudio: req.TargetCodecAudio,
|
||||
TargetBitrateKbps: req.TargetBitrateKbps,
|
||||
SegmentDuration: req.SegmentDuration,
|
||||
FFmpegPath: h.FFmpegPath,
|
||||
HWAccel: h.HWAccel,
|
||||
HWDevice: h.HWDevice,
|
||||
FFmpegPath: playbackCfg.FFmpegPath,
|
||||
HWAccel: playbackCfg.HWAccel,
|
||||
HWDevice: playbackCfg.HWDevice,
|
||||
AudioTrackIndex: session.AudioTrackIndex,
|
||||
SubtitleTrackIndex: req.SubtitleTrackIndex,
|
||||
SubtitleBurnIn: req.SubtitleBurnIn,
|
||||
@@ -2443,40 +2495,11 @@ func (h *PlaybackHandler) closeTranscodeSession(sessionID, transcodeNodeURL stri
|
||||
}
|
||||
}
|
||||
|
||||
// PickTranscodeNode selects a transcode node with soft affinity.
|
||||
// If currentURL is set and that node is healthy, reuse it unless another node
|
||||
// has significantly fewer active jobs (2+ difference).
|
||||
func (h *PlaybackHandler) PickTranscodeNode(currentURL string) *nodepool.Node {
|
||||
if h.TranscodePool == nil {
|
||||
return nil
|
||||
}
|
||||
best := h.TranscodePool.Acquire()
|
||||
if best == nil {
|
||||
return nil
|
||||
}
|
||||
if currentURL == "" || best.URL == currentURL {
|
||||
return best
|
||||
}
|
||||
// Reuse current node unless the best alternative has significantly fewer jobs.
|
||||
current := h.TranscodePool.FindByURL(currentURL)
|
||||
if current != nil && current.Healthy && current.Enabled &&
|
||||
best.ActiveJobs+2 <= current.ActiveJobs {
|
||||
return best // switch to less-loaded node
|
||||
}
|
||||
if current != nil && current.Healthy && current.Enabled {
|
||||
return current // soft affinity — stay on current
|
||||
}
|
||||
return best // current node is gone or unhealthy
|
||||
}
|
||||
|
||||
// buildProxyManifestURL signs a stream token and builds the proxy-based manifest URL.
|
||||
func (h *PlaybackHandler) buildProxyManifestURL(sessionID string, session *playback.Session, transcodeNodeURL string) string {
|
||||
if h.ProxyPool == nil {
|
||||
return fmt.Sprintf("/playback/transcode/%s/master.m3u8", sessionID)
|
||||
}
|
||||
proxyNode := h.ProxyPool.Pick()
|
||||
// buildProxyManifestURL signs a stream token and builds the proxy-based
|
||||
// manifest URL. proxyNode is the planner's pick for this session; when nil
|
||||
// the URL falls back to the API-local path.
|
||||
func (h *PlaybackHandler) buildProxyManifestURL(sessionID string, session *playback.Session, transcodeNodeURL string, proxyNode *nodepool.Node) string {
|
||||
if proxyNode == nil {
|
||||
// No proxy — fall back to API-local path.
|
||||
return fmt.Sprintf("/playback/transcode/%s/master.m3u8", sessionID)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
func TestSubtitleURLExt(t *testing.T) {
|
||||
cases := []struct {
|
||||
codec string
|
||||
want string
|
||||
}{
|
||||
{"ass", ".ass"},
|
||||
{"ssa", ".ass"},
|
||||
{"pgs", ".sup"},
|
||||
{"hdmv_pgs_subtitle", ".sup"},
|
||||
{"subrip", ".vtt"},
|
||||
{"srt", ".vtt"},
|
||||
{"", ".vtt"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := subtitleURLExt(tc.codec); got != tc.want {
|
||||
t.Errorf("subtitleURLExt(%q) = %q, want %q", tc.codec, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSubtitleURLs_IncludesPGSButNotOtherBitmaps(t *testing.T) {
|
||||
file := &models.MediaFile{
|
||||
SubtitleTracks: []models.SubtitleTrack{
|
||||
{Index: 0, Language: "en", Codec: "subrip"},
|
||||
{Index: 1, Language: "en", Codec: "hdmv_pgs_subtitle"},
|
||||
{Index: 2, Language: "fr", Codec: "dvd_subtitle"},
|
||||
{Index: 3, Language: "de", Codec: "dvb_subtitle"},
|
||||
},
|
||||
}
|
||||
|
||||
urls := buildSubtitleURLs("sess-1", file, nil)
|
||||
|
||||
if len(urls) != 2 {
|
||||
t.Fatalf("expected 2 subtitle URLs (text + PGS), got %d: %+v", len(urls), urls)
|
||||
}
|
||||
|
||||
srt := urls[0]
|
||||
if srt.Codec != "subrip" || srt.URL != "/stream/sess-1/subtitles/0.vtt" {
|
||||
t.Errorf("unexpected text track entry: %+v", srt)
|
||||
}
|
||||
|
||||
pgs := urls[1]
|
||||
if pgs.Codec != "hdmv_pgs_subtitle" {
|
||||
t.Errorf("expected PGS track to be included, got %+v", pgs)
|
||||
}
|
||||
if pgs.URL != "/stream/sess-1/subtitles/1.sup" {
|
||||
t.Errorf("PGS track should get a .sup URL, got %q", pgs.URL)
|
||||
}
|
||||
if pgs.FontBundleURL != "" {
|
||||
t.Errorf("PGS track must not advertise a font bundle, got %q", pgs.FontBundleURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSubtitleURLs_PGSIndexAccountsForExternalOffset(t *testing.T) {
|
||||
file := &models.MediaFile{
|
||||
ExternalSubtitles: []models.ExternalSubtitle{
|
||||
{Path: "/media/movie.en.srt", Language: "en", Format: "srt"},
|
||||
},
|
||||
SubtitleTracks: []models.SubtitleTrack{
|
||||
{Index: 0, Language: "en", Codec: "pgs"},
|
||||
},
|
||||
}
|
||||
|
||||
urls := buildSubtitleURLs("sess-2", file, nil)
|
||||
|
||||
if len(urls) != 2 {
|
||||
t.Fatalf("expected 2 subtitle URLs, got %d: %+v", len(urls), urls)
|
||||
}
|
||||
pgs := urls[1]
|
||||
if pgs.Index != 1 || pgs.URL != "/stream/sess-2/subtitles/1.sup" {
|
||||
t.Errorf("PGS track index should include the external offset, got %+v", pgs)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user