Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbdd4ab9f9 | ||
|
|
ef827a3a05 | ||
|
|
7b7ce39826 | ||
|
|
9cf9e1d305 | ||
|
|
daffe499eb | ||
|
|
0d613ba0e3 | ||
|
|
a4dc03984c | ||
|
|
1ed1602e70 | ||
|
|
2344f5686a | ||
|
|
5fb80f82ed | ||
|
|
57bc0e9dd4 | ||
|
|
96a40f7d88 | ||
|
|
a20741ccc9 | ||
|
|
d56031f334 | ||
|
|
0e29eff7b7 | ||
|
|
65f257f377 | ||
|
|
c448fa438f | ||
|
|
81e54f89b2 | ||
|
|
178b1f30c5 | ||
|
|
cce488b76f | ||
|
|
09f8f0875f | ||
|
|
ed42111df1 | ||
|
|
c52b514036 | ||
|
|
28efaf3fe9 | ||
|
|
7c7e282e79 | ||
|
|
a86bdac87e | ||
|
|
ab98015190 | ||
|
|
aaf57a66a2 | ||
|
|
744d33e13f | ||
|
|
d6619cd0e1 | ||
|
|
8aa66a30fc | ||
|
|
4410a994ef | ||
|
|
aa9bd950f9 | ||
|
|
79b47c9680 | ||
|
|
21142cae5f | ||
|
|
fd716a7dab | ||
|
|
536fb4794c | ||
|
|
cc0e9d7f91 | ||
|
|
d5dbaa48ea | ||
|
|
8aec80cbb7 | ||
|
|
94985adb5e | ||
|
|
7bf5697c71 | ||
|
|
c54430c254 | ||
|
|
94bd69d22d | ||
|
|
4f71eda6be | ||
|
|
eb68672390 | ||
|
|
6b2125a45a | ||
|
|
56871b65ed | ||
|
|
bc91ce68d4 | ||
|
|
962f9089e0 | ||
|
|
69773ec307 | ||
|
|
cc56b9ef2a | ||
|
|
abb7c84833 | ||
|
|
f68fb19afb | ||
|
|
adb93cba9e | ||
|
|
a90e7debfd | ||
|
|
7bf0b1257d | ||
|
|
f7ba4d24d6 | ||
|
|
f47734dd7d | ||
|
|
5fee8bfb4a | ||
|
|
7d5bbd1177 | ||
|
|
0741e98ea5 | ||
|
|
b293741eb1 | ||
|
|
ace9e57d27 | ||
|
|
2fd0828c06 | ||
|
|
15f41e6fd4 | ||
|
|
0da8e0cdf9 | ||
|
|
b574df346a | ||
|
|
f0f1eb6e8f | ||
|
|
5c545534c2 | ||
|
|
0f3c4a8cec | ||
|
|
e3675536aa | ||
|
|
7c7c08748e | ||
|
|
cdf81f6cf4 | ||
|
|
7ea84043fa | ||
|
|
253d8d0e8e | ||
|
|
acbcc61ae4 | ||
|
|
30766020c8 | ||
|
|
ffc80f7ba7 | ||
|
|
c753780817 | ||
|
|
8d9fb0046c | ||
|
|
5c10bbb90e | ||
|
|
49b0b6eeb9 | ||
|
|
28420d56ed | ||
|
|
2235b00c59 | ||
|
|
54d7d6e7a1 | ||
|
|
45b1152a15 | ||
|
|
a6723e6ac8 | ||
|
|
4288a86b2d | ||
|
|
85a9873a75 | ||
|
|
9a148c2344 | ||
|
|
c0e6993273 | ||
|
|
0b7ad0f376 | ||
|
|
4ab4f9fa90 | ||
|
|
304ea9599f | ||
|
|
cf73eec2e4 | ||
|
|
ab4bddc784 | ||
|
|
de26544d9d | ||
|
|
efbe312f16 | ||
|
|
d49a9e6ca2 | ||
|
|
313268a7ae | ||
|
|
1457dc7289 | ||
|
|
f579696633 | ||
|
|
4d697f5fcf | ||
|
|
bb2cc26e9d | ||
|
|
385a5d9dba | ||
|
|
922b92a8b6 | ||
|
|
8c3d919aa0 | ||
|
|
004d4197a4 | ||
|
|
f4b581872f | ||
|
|
a19d7a4cbf | ||
|
|
cb258b6d20 | ||
|
|
3dcb794032 | ||
|
|
2b122f2481 | ||
|
|
f1e4b2a603 | ||
|
|
795225f097 | ||
|
|
9f39e2efd8 | ||
|
|
7b9e8e4fc2 | ||
|
|
4c43caa639 | ||
|
|
fd9292e6f2 | ||
|
|
ffbcc53083 | ||
|
|
fb1645c90c | ||
|
|
96c1228805 | ||
|
|
14285ff314 | ||
|
|
e5d4744977 | ||
|
|
b462c7fb44 | ||
|
|
59eddc1386 | ||
|
|
eaf1b2c8dd | ||
|
|
61e5f47ec0 | ||
|
|
d1f9ad3b25 | ||
|
|
79f4d668f1 | ||
|
|
c71ef4af85 | ||
|
|
7c9e561e68 | ||
|
|
b7595c3011 | ||
|
|
6156ff6154 | ||
|
|
69d53e92a7 | ||
|
|
248bb7816d | ||
|
|
0f1d0abbb0 | ||
|
|
6f37be59fc | ||
|
|
fb117635f8 | ||
|
|
885d614092 | ||
|
|
d42c78f58c |
No files matched your search
+9
-1
@@ -6,10 +6,18 @@
|
|||||||
# Generate with: openssl rand -hex 32
|
# Generate with: openssl rand -hex 32
|
||||||
JWT_SECRET=your-secure-jwt-secret-key-here
|
JWT_SECRET=your-secure-jwt-secret-key-here
|
||||||
|
|
||||||
# PostgreSQL database password
|
# PostgreSQL database password (the bootstrap superuser account; kept
|
||||||
|
# for container initialization — the app itself connects as bookhoard_srv)
|
||||||
# Generate with: openssl rand -hex 16
|
# Generate with: openssl rand -hex 16
|
||||||
DBPASS=your-secure-database-password-here
|
DBPASS=your-secure-database-password-here
|
||||||
|
|
||||||
|
# Runtime database role password (bookhoard_srv — the non-superuser
|
||||||
|
# account the app connects as; subject to RLS). Required for the app to
|
||||||
|
# start. Consumed by the one-shot db-init service on every startup
|
||||||
|
# (idempotent — creates/refreshes the role and its privileges).
|
||||||
|
# Generate with: openssl rand -hex 16
|
||||||
|
DBSRVPASS=your-secure-runtime-role-password-here
|
||||||
|
|
||||||
# Networking: change a port if it conflicts on your host
|
# Networking: change a port if it conflicts on your host
|
||||||
# Postgres port, host + container (e.g. another local DB already uses 5432)
|
# Postgres port, host + container (e.g. another local DB already uses 5432)
|
||||||
# DB_PORT=15432
|
# DB_PORT=15432
|
||||||
|
|||||||
@@ -87,3 +87,4 @@ session-*.md
|
|||||||
# see docs/contributing/development.md → Media fixtures)
|
# see docs/contributing/development.md → Media fixtures)
|
||||||
internal/services/testdata/
|
internal/services/testdata/
|
||||||
test-results
|
test-results
|
||||||
|
.openchamber/
|
||||||
@@ -4,6 +4,32 @@ Standing rules for any agent or developer working in this repo.
|
|||||||
Project-wide context lives in the sibling repos' AGENTS.md files
|
Project-wide context lives in the sibling repos' AGENTS.md files
|
||||||
(../bookhoard-app/AGENTS.md is the Android client's).
|
(../bookhoard-app/AGENTS.md is the Android client's).
|
||||||
|
|
||||||
|
## Commit as each unit of work completes — ALWAYS apply
|
||||||
|
|
||||||
|
Commit when a logically complete piece of the project is done — a fix,
|
||||||
|
a feature slice, a schema change, a docs/test batch — before moving on
|
||||||
|
to the next piece. Do not hold a whole session's work for one giant
|
||||||
|
end-of-session commit, and never push uncommitted work aside until the
|
||||||
|
tree is clean.
|
||||||
|
|
||||||
|
- **One commit per related edit.** If the changed files serve more than
|
||||||
|
one purpose, they are separate commits; only share a commit when you
|
||||||
|
are sure every file in it is the same related edit. Generated
|
||||||
|
artifacts (templ codegen, sqlc output, vite/tailwind build output,
|
||||||
|
bruno/openapi mirrors) commit with the source change that produced
|
||||||
|
them.
|
||||||
|
- **Detailed messages**, in the repo's conventional-commit style
|
||||||
|
(`fix(scanner): …`, `feat(issues): …`) with a body that says WHY the
|
||||||
|
change exists, what behavior changed, and any precedent it follows —
|
||||||
|
not just what the diff shows. The body is how the next agent (and
|
||||||
|
git history) arbitrates "test stale vs code changed".
|
||||||
|
- **Ordering**: stack commits so each tree compiles where practical
|
||||||
|
(a service before the handler that calls it, a guard before the
|
||||||
|
feature that relies on it).
|
||||||
|
- **Committing ≠ pushing.** Commits are local bookkeeping and land as
|
||||||
|
work completes; pushing (once, after the last commit) is the author's
|
||||||
|
call. Never push half-finished units just because they were committed.
|
||||||
|
|
||||||
## Rendering ownership (templ vs TypeScript) — ALWAYS apply
|
## Rendering ownership (templ vs TypeScript) — ALWAYS apply
|
||||||
|
|
||||||
The point of templ + htmx + Alpine is: the server renders the UI, and
|
The point of templ + htmx + Alpine is: the server renders the UI, and
|
||||||
@@ -54,7 +80,11 @@ refuses, **silently at build/test time**, two authoring patterns:
|
|||||||
guard means recomputing the hash (the failing test prints it); adding
|
guard means recomputing the hash (the failing test prints it); adding
|
||||||
a *second* inline script means hashing it too and extending
|
a *second* inline script means hashing it too and extending
|
||||||
`cspSanctionedInlineScripts` in `templates/csp_source_test.go`.
|
`cspSanctionedInlineScripts` in `templates/csp_source_test.go`.
|
||||||
Everything else goes in a bundled `web/src` module.
|
Everything else goes in a bundled `web/src` module. This includes
|
||||||
|
partials emitted from Go handler strings — those use data attributes
|
||||||
|
+ `web/src/auth_forms.ts` (post-swap redirect/stash) or htmx's
|
||||||
|
`HX-Redirect`/`HX-Refresh` headers; `internal/middleware`'s
|
||||||
|
Go-source scan is the tripwire for that vector.
|
||||||
- **No native `on*=` handler attributes** (`onclick=`, `onerror=`,
|
- **No native `on*=` handler attributes** (`onclick=`, `onerror=`,
|
||||||
`onsubmit=`, `onchange=`, … — quote AND templ brace forms). Inline
|
`onsubmit=`, `onchange=`, … — quote AND templ brace forms). Inline
|
||||||
handlers never go through the hash path; hash-mode CSP refuses them
|
handlers never go through the hash path; hash-mode CSP refuses them
|
||||||
@@ -76,30 +106,61 @@ inside quoted attribute values (gotcha #1 below), so dynamic
|
|||||||
## Library visibility (family-lens rule) — ALWAYS apply
|
## Library visibility (family-lens rule) — ALWAYS apply
|
||||||
|
|
||||||
Per-user library visibility (`library_visibility` table, default-ALLOW:
|
Per-user library visibility (`library_visibility` table, default-ALLOW:
|
||||||
every user sees every library unless an admin hides one) must be
|
every user sees every library unless an admin hides one) is enforced by
|
||||||
enforced on **every endpoint that returns media metadata or content**.
|
**Postgres RLS** — one source of truth: the labeled policy block in
|
||||||
It is NOT automatic — enforcement is per-handler.
|
`database/schema/schema.sql` + the runtime role provisioned by the
|
||||||
|
`db-init` compose service.
|
||||||
|
|
||||||
- **New endpoints that list/filter media or series** MUST filter by the
|
- **The engine is the enforcement.** Every `media_items` read is
|
||||||
requesting user's visible-library set, and MUST reject an explicit
|
filtered by the policy, which joins `library_visibility` on the
|
||||||
`library_id` outside that set with 403 (never silently pass).
|
per-transaction GUC `app.user_id`. New media-listing queries need
|
||||||
- Helpers (do not hand-roll):
|
NO visibility clause; new endpoints need NO visibility plumbing.
|
||||||
- `handlers`: `(*MediaHandler).visibleLibrarySet` /
|
- **The app declares identity; it never computes visibility.** All
|
||||||
`(*SeriesHandler).visibleLibrarySet` — membership map + id slice.
|
sqlc statements run through `ScopedDB`
|
||||||
- `router`: `visibleLibraryIDsFor` — same for SSR routes.
|
(`internal/database/scoped.go`), which declares `app.user_id` from
|
||||||
- `resolveLibrary` (helpers.go) only honors requested library ids
|
the stdlib request context. Three bridge sites put it there:
|
||||||
inside the visible set.
|
`router.go` JWT, `router.go` cookie/device, and
|
||||||
- SQL pattern: `AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR
|
`middleware/device_auth.go` (KOReader/Kobo sync devices). A media
|
||||||
library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))` — see
|
read on a detached `context.Background()` RAISES — thread the
|
||||||
`ListMediaItems`, `GetDistinctSeries`, `SearchMediaItems`.
|
request context or the acting identity (see conflicts.go and
|
||||||
- Content endpoints (file bytes, page images) additionally gate per item
|
analytics.go for the pattern).
|
||||||
like `ReaderHandler.ensureItemVisible` / the download endpoint.
|
- **Background jobs declare the acting identity** before detached
|
||||||
- Tests must pin visibility: hidden library → 403 on the endpoint, and
|
work: scan jobs carry the library owner / first-admin fallback
|
||||||
absent from unscoped aggregates.
|
(the job Context carries it — `ScanFolders` self-declares the
|
||||||
|
configured admin), the hash backfill runs as first admin. The sync
|
||||||
|
queue processor needs nothing (`sync_queue` is not RLS-governed).
|
||||||
|
- **NEVER**: session-scoped `app.user_id` (pooled-connection identity
|
||||||
|
bleed); a bypass flag; connecting the app as the bootstrap
|
||||||
|
superuser (superusers bypass RLS unconditionally — `DATABASE_USER`
|
||||||
|
stays `bookhoard_srv`; the `db-init` compose service provisions the
|
||||||
|
role on every startup).
|
||||||
|
- **schema.sql re-runs on EVERY boot** (executeSchema) — everything
|
||||||
|
added to it must be idempotent (`DROP .. IF EXISTS` + `CREATE`).
|
||||||
|
The migration tx declares a synthetic identity
|
||||||
|
(`schemaMigrationIdentity`) because the schema's own dedup block
|
||||||
|
reads `media_items`.
|
||||||
|
- **Auditors**: `TestRlsInvariant` (API black-box — add new
|
||||||
|
media-returning surfaces to it; loosening requires a
|
||||||
|
docs/contract_pins.md entry) and the Playwright sweep (`tests/ui`,
|
||||||
|
new pages/controls go into `tests/ui/COVERAGE.md`). Item-scoped
|
||||||
|
gates may answer 404 as well as 403 (row invisible vs Go-level
|
||||||
|
check — contract_pins 2026-10-29).
|
||||||
|
- **Collections RLS (2026-10-31)**: `collections` and
|
||||||
|
`collection_items` are governed too — predicates
|
||||||
|
`app_can_read_collection` / `app_can_write_collection`, PURE over
|
||||||
|
their arguments (never query `collections`, so no policy recursion
|
||||||
|
under FORCE RLS). Reads: owner-or-admin; writes: owner, admin escape
|
||||||
|
for SYSTEM collections only. Future sharing = one EXISTS branch
|
||||||
|
inside the predicate functions, no policy migration. Registration
|
||||||
|
seeding declares the new user's identity (`rls.WithUser` in
|
||||||
|
`CreateDefaultCollectionsForUser` — the JWT does not exist yet);
|
||||||
|
test-harness writes to non-system collections must run as the row's
|
||||||
|
OWNER (`scopeExecAs`/`ownerSeedCtx`, not the admin handle).
|
||||||
|
|
||||||
History: 2026-10-13 (`431fec0`) the series/media listing surfaces
|
History: 2026-10-13 (`431fec0`) the series/media listing surfaces
|
||||||
leaked hidden libraries to any authenticated account — series endpoints
|
leaked hidden libraries; **2026-10-29 enforcement moved to RLS** — the
|
||||||
were name-only across ALL libraries. Fixed; do not regress.
|
dashboard section queries had silently never carried the clauses,
|
||||||
|
and the invariant test went red → green and stays as the auditor.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -41,12 +41,14 @@ RUN npm run build:ts
|
|||||||
# Declared after `COPY . .` so a version bump only invalidates this layer.
|
# Declared after `COPY . .` so a version bump only invalidates this layer.
|
||||||
# The release workflow passes the git tag, e.g. APP_VERSION=v1.5.
|
# The release workflow passes the git tag, e.g. APP_VERSION=v1.5.
|
||||||
ARG APP_VERSION=dev
|
ARG APP_VERSION=dev
|
||||||
|
# Short commit hash of the built source (stale-container drift guard).
|
||||||
|
ARG GIT_COMMIT=none
|
||||||
|
|
||||||
# Build Go binary (cached unless Go files or generated code changes)
|
# Build Go binary (cached unless Go files or generated code changes)
|
||||||
RUN --mount=type=cache,target=/root/go/pkg/mod \
|
RUN --mount=type=cache,target=/root/go/pkg/mod \
|
||||||
--mount=type=cache,target=/root/.cache/go-build \
|
--mount=type=cache,target=/root/.cache/go-build \
|
||||||
CGO_ENABLED=0 GOOS=linux go build -installsuffix cgo \
|
CGO_ENABLED=0 GOOS=linux go build -installsuffix cgo \
|
||||||
-ldflags "-X bookhoard/internal/version.Version=${APP_VERSION}" \
|
-ldflags "-X bookhoard/internal/version.Version=${APP_VERSION} -X bookhoard/internal/version.Commit=${GIT_COMMIT}" \
|
||||||
-o main ./cmd/server
|
-o main ./cmd/server
|
||||||
|
|
||||||
# Materialize the Go module cache into an image layer so the test-runner
|
# Materialize the Go module cache into an image layer so the test-runner
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ endif
|
|||||||
# Override with: CONTAINER_RUNTIME=podman make rebuild-app
|
# Override with: CONTAINER_RUNTIME=podman make rebuild-app
|
||||||
CONTAINER_RUNTIME ?= $(shell command -v docker 2>/dev/null || command -v podman 2>/dev/null)
|
CONTAINER_RUNTIME ?= $(shell command -v docker 2>/dev/null || command -v podman 2>/dev/null)
|
||||||
|
|
||||||
|
# Build stamp (stale-container drift guard): the rebuilt image carries
|
||||||
|
# the source commit; the app logs it at boot and /api/health reports it.
|
||||||
|
GIT_COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo none)
|
||||||
|
|
||||||
# Dev compose stack: base prod file merged with the dev override (local build + tests).
|
# Dev compose stack: base prod file merged with the dev override (local build + tests).
|
||||||
# Prod deploy does NOT use this — it runs plain `docker compose` against the base file only.
|
# Prod deploy does NOT use this — it runs plain `docker compose` against the base file only.
|
||||||
COMPOSE := $(CONTAINER_RUNTIME) compose -f docker-compose.yml -f docker-compose.dev.yml
|
COMPOSE := $(CONTAINER_RUNTIME) compose -f docker-compose.yml -f docker-compose.dev.yml
|
||||||
|
|||||||
@@ -21,160 +21,165 @@ A modern self-hosted media library system built with Go, PostgreSQL, HTMX, and T
|
|||||||
- **Podman** (recommended) or Docker
|
- **Podman** (recommended) or Docker
|
||||||
- **5 minutes** of your time
|
- **5 minutes** of your time
|
||||||
|
|
||||||
### Installation
|
### Installation (no source checkout needed)
|
||||||
|
|
||||||
|
Create a directory on the Docker host. You only need the compose file,
|
||||||
|
an `.env`, and an `uploads` folder — everything else rides the published
|
||||||
|
image.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Clone the repository
|
mkdir bookhoard && cd bookhoard
|
||||||
git clone https://git.linuxhg.com/Bookhoard/bookhoard.git
|
mkdir uploads
|
||||||
cd bookhoard
|
|
||||||
|
|
||||||
# 2. Set up environment
|
|
||||||
cp .env.example .env
|
|
||||||
# Generate secure passwords (no special characters):
|
|
||||||
# JWT_SECRET: openssl rand -hex 32
|
|
||||||
# DBPASS: openssl rand -hex 16
|
|
||||||
# Edit .env with your generated values
|
|
||||||
|
|
||||||
# 3. Pull images and start the server
|
|
||||||
docker compose pull
|
|
||||||
docker compose up -d
|
|
||||||
# Optionally pin a specific version: set IMAGE_TAG in .env (defaults to "latest")
|
|
||||||
|
|
||||||
# 4. Open your browser
|
|
||||||
open http://localhost:8765
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The first user to register automatically becomes an admin.
|
**`docker-compose.yml`** — copy this wholesale:
|
||||||
|
|
||||||
---
|
```yaml
|
||||||
|
services:
|
||||||
|
# PostgreSQL Database
|
||||||
|
db:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
container_name: bookhoard_db
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: bookhoard
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: ${DBPASS}
|
||||||
|
# PGPORT makes Postgres listen on DB_PORT (kept in sync with the host mapping + app's DATABASE_PORT)
|
||||||
|
PGPORT: ${DB_PORT:-5432}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${DB_PORT:-5432}:${DB_PORT:-5432}"
|
||||||
|
- "[::1]:${DB_PORT:-5432}:${DB_PORT:-5432}"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
|
||||||
## 📖 Key Features
|
# One-shot bootstrap: creates the bookhoard_srv runtime role and
|
||||||
|
# transfers object ownership to it (RLS prerequisite). Idempotent —
|
||||||
|
# safe on every startup; the app waits for its success. Passwords
|
||||||
|
# accept inline values or .env, like the rest of this file.
|
||||||
|
# NOTE: plpgsql $$ tags are written $$$$ — compose eats one dollar
|
||||||
|
# per pair before the container sees the SQL.
|
||||||
|
db-init:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
container_name: bookhoard_db_init
|
||||||
|
environment:
|
||||||
|
PGHOST: db
|
||||||
|
PGPORT: ${DB_PORT:-5432}
|
||||||
|
PGUSER: postgres
|
||||||
|
PGDATABASE: bookhoard
|
||||||
|
PGPASSWORD: ${DBPASS}
|
||||||
|
DBSRVPASS: ${DBSRVPASS}
|
||||||
|
entrypoint: ["bash", "-c"]
|
||||||
|
command:
|
||||||
|
- |
|
||||||
|
set -e
|
||||||
|
: "${DBSRVPASS:?DBSRVPASS required in .env}"
|
||||||
|
psql -v ON_ERROR_STOP=1 -v role_pass="$DBSRVPASS" <<'EOSQL'
|
||||||
|
DO $$$$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'bookhoard_srv') THEN
|
||||||
|
CREATE ROLE bookhoard_srv LOGIN;
|
||||||
|
END IF;
|
||||||
|
END $$$$;
|
||||||
|
ALTER ROLE bookhoard_srv LOGIN PASSWORD :'role_pass';
|
||||||
|
DO $$$$
|
||||||
|
DECLARE r RECORD;
|
||||||
|
BEGIN
|
||||||
|
FOR r IN SELECT tablename FROM pg_tables WHERE schemaname = 'public' LOOP
|
||||||
|
EXECUTE format('ALTER TABLE public.%I OWNER TO bookhoard_srv', r.tablename);
|
||||||
|
END LOOP;
|
||||||
|
FOR r IN SELECT sequencename FROM pg_sequences WHERE schemaname = 'public' LOOP
|
||||||
|
EXECUTE format('ALTER SEQUENCE public.%I OWNER TO bookhoard_srv', r.sequencename);
|
||||||
|
END LOOP;
|
||||||
|
FOR r IN SELECT p.oid::regprocedure AS fn FROM pg_proc p
|
||||||
|
JOIN pg_namespace n ON p.pronamespace = n.oid
|
||||||
|
WHERE n.nspname = 'public' AND p.prokind = 'f' LOOP
|
||||||
|
EXECUTE format('ALTER FUNCTION %s OWNER TO bookhoard_srv', r.fn);
|
||||||
|
END LOOP;
|
||||||
|
END $$$$;
|
||||||
|
ALTER SCHEMA public OWNER TO bookhoard_srv;
|
||||||
|
ALTER DATABASE bookhoard OWNER TO bookhoard_srv;
|
||||||
|
EOSQL
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
### Universal Cross-Platform Sync
|
# Bookhoard Application
|
||||||
|
# In production this image is pulled from the Gitea container registry.
|
||||||
|
# Override IMAGE_TAG in .env to pin or rollback a specific version (defaults to "latest").
|
||||||
|
app:
|
||||||
|
image: git.linuxhg.com/bookhoard/bookhoard:${IMAGE_TAG:-latest}
|
||||||
|
container_name: bookhoard
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
# Database Configuration
|
||||||
|
# Runtime role = bookhoard_srv (non-superuser OWNER, subject to RLS
|
||||||
|
# via FORCE). POSTGRES_USER above is the bootstrap account ONLY —
|
||||||
|
# the app must NEVER connect as it (superusers bypass RLS).
|
||||||
|
DATABASE_HOST: db
|
||||||
|
DATABASE_PORT: ${DB_PORT:-5432}
|
||||||
|
DATABASE_USER: bookhoard_srv
|
||||||
|
DATABASE_PASSWORD: ${DBSRVPASS}
|
||||||
|
DATABASE_NAME: bookhoard
|
||||||
|
|
||||||
- **Real-Time Progress**: Turn a page on your e-reader, see it in your browser
|
# Application Configuration
|
||||||
- **Format-Aware**: EPUB CFI, page numbers, percentages - all handled correctly
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
- **Offline Queue**: Changes sync when you reconnect, priority-processed
|
SERVER_PORT: ${SERVER_PORT:-8765}
|
||||||
- **Conflict Resolution**: Smart handling when same book read on multiple devices
|
# IMPORTANT: Device sync requires full URL with protocol
|
||||||
- **Book Matching**: Automatic matching using SHA-256, ISBN, UUID
|
# Local: http://localhost:8765
|
||||||
- **OPDS Catalog**: Wireless book delivery to e-readers over Wi-Fi
|
# Local network: http://192.168.1.X:8765
|
||||||
- **Format Conversion**: On-the-fly EPUB→KEPUB conversion (for upcoming native Kobo support)
|
# Domain: https://bookhoard.example.com
|
||||||
|
BASE_URL: ${BASE_URL:-http://localhost:8765}
|
||||||
|
# Mark session cookies Secure; set true behind a TLS-terminating reverse proxy (Caddy/nginx/traefik)
|
||||||
|
COOKIE_SECURE: ${COOKIE_SECURE:-false}
|
||||||
|
|
||||||
### Media Management
|
# Rate Limiting Configuration
|
||||||
|
TEST_MODE: ${TEST_MODE:-false}
|
||||||
|
RATE_LIMIT_ENABLED: ${RATE_LIMIT_ENABLED:-true}
|
||||||
|
REQUESTS_PER_MINUTE: ${REQUESTS_PER_MINUTE:-10}
|
||||||
|
|
||||||
- **Calibre Integration**: Automatic metadata import from Calibre `metadata.opf` sidecar files
|
# Conversion Service Configuration
|
||||||
- **Smart Search**: Partial matching with fuzzy search fallback for typos
|
BOOKHOARD_CONVERSION_CACHE_DIR: ${BOOKHOARD_CONVERSION_CACHE_DIR:-/app/cache/kepub}
|
||||||
- **Advanced Filtering**: Filter by author, series, genre, language, year, cover images
|
BOOKHOARD_CONVERSION_TOOL: ${BOOKHOARD_CONVERSION_TOOL:-/usr/bin/kepubify}
|
||||||
- **Dynamic Sorting**: By title, author, date added, published date, page count, series
|
BOOKHOARD_CONVERSION_CACHE_TTL: ${BOOKHOARD_CONVERSION_CACHE_TTL:-24h}
|
||||||
- **Rich Metadata**: Title, author, series, publisher, ISBN, language, edition, tags
|
|
||||||
- **5-Star Ratings**: Half-star precision (1-10 scale)
|
|
||||||
- **Notes & Highlights**: Color-coded annotations with linked notes
|
|
||||||
- **Usage Analytics**: Reading statistics, device usage, popular books
|
|
||||||
|
|
||||||
### Smart Collections
|
# Library Maintenance
|
||||||
|
# Days an archived item (file missing from disk for 2+ scans) is kept,
|
||||||
|
# with its reading history, before library scans purge it for good.
|
||||||
|
# Set 0 to keep archived items until purged manually on the library admin page.
|
||||||
|
ARCHIVE_RETENTION_DAYS: ${ARCHIVE_RETENTION_DAYS:-90}
|
||||||
|
|
||||||
- **Auto-Assign Rules**: Automatically add books based on genre, author, series, tags, language, publisher, year
|
# System timezone (fallback for server-side time operations)
|
||||||
- **Device Shelf Mappings**: Map collections to device shelves (used by native Kobo sync, coming soon)
|
TZ: ${TZ:-UTC}
|
||||||
- **Test Before Creating**: Preview which books match your rules
|
ports:
|
||||||
|
- "${SERVER_PORT:-8765}:${SERVER_PORT:-8765}"
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
db-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
volumes:
|
||||||
|
- ./uploads:/app/uploads
|
||||||
|
- bookhoard_conversion_cache:/app/cache/kepub
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "curl -f http://localhost:${SERVER_PORT:-8765}/health || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
### Library Organization
|
# Named Volumes
|
||||||
|
volumes:
|
||||||
- **Multi-Library Support**: Ebooks, Comics, and Manga with type-specific file formats
|
postgres_data:
|
||||||
- **Multiple Folders**: Add multiple scanning folders per library
|
bookhoard_conversion_cache:
|
||||||
- **Visibility Control**: Admins control which libraries each user can see
|
|
||||||
- **Background Scanning**: Auto-scan with per-user frequency settings
|
|
||||||
- **Watch Mode**: Real-time file system monitoring for instant updates
|
|
||||||
|
|
||||||
### Security
|
|
||||||
|
|
||||||
- **JWT Authentication**: Short-lived access tokens (1 hour) with refresh tokens (7 days)
|
|
||||||
- **Strong Passwords**: Complexity requirements enforced (8+ chars, uppercase, lowercase, number, special)
|
|
||||||
- **Account Lockout**: 5 failed attempts = 15-minute lockout
|
|
||||||
- **Rate Limiting**: 10 requests/minute on auth endpoints
|
|
||||||
- **Input Validation**: Comprehensive validation on all inputs
|
|
||||||
- **No Passwords on Devices**: Web-based device approval with QR codes
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📚 Documentation
|
|
||||||
|
|
||||||
### For Users & Self-Hosters
|
|
||||||
|
|
||||||
- **[docs/user/calibre-integration.md](docs/user/calibre-integration.md)** - Calibre library integration
|
|
||||||
- **[docs/user/sync-guide.md](docs/user/sync-guide.md)** - Understanding and using universal sync
|
|
||||||
- **[docs/user/devices/koreader-setup.md](docs/user/devices/koreader-setup.md)** - KOReader configuration
|
|
||||||
- **[docs/user/devices/kobo-setup.md](docs/user/devices/kobo-setup.md)** - Kobo e-reader configuration (coming soon)
|
|
||||||
- **[docs/user/user-guide.md](docs/user/user-guide.md)** - General user guide
|
|
||||||
- **[docs/user/admin-guide.md](docs/user/admin-guide.md)** - Admin features and configuration
|
|
||||||
- **[docs/user/settings-guide.md](docs/user/settings-guide.md)** - Settings and preferences
|
|
||||||
|
|
||||||
### For Developers
|
|
||||||
|
|
||||||
- **[docs/developer/api/api-reference.md](docs/developer/api/api-reference.md)** - Complete API documentation
|
|
||||||
- **[docs/developer/android-app.md](docs/developer/android-app.md)** - Android app design & roadmap
|
|
||||||
- **[docs/contributing/development.md](docs/contributing/development.md)** - Development workflow
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 Supported Devices
|
|
||||||
|
|
||||||
| Platform | Sync | OPDS | Status |
|
|
||||||
| ---------------- | ---- | ---- | ------------------------------------------------------------- |
|
|
||||||
| **Web Browser** | ✅ | ✅ | Full support |
|
|
||||||
| **KOReader** | ✅ | ✅ | Runs on Kindle, Kobo, PocketBook hardware |
|
|
||||||
| **Kobo Devices** | 🚧 | 🚧 | Native Kobo sync coming soon (use KOReader on Kobo today) |
|
|
||||||
| **Mobile Apps** | 🚧 | 🚧 | Native Android app in design ([docs](docs/developer/android-app.md)); iOS later |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 💾 Network Storage (NAS / NFS / SMB)
|
|
||||||
|
|
||||||
Libraries on network mounts are supported for everyday use — serving,
|
|
||||||
reading, progress, and annotation sync all work over the mount — with a
|
|
||||||
few behaviors worth knowing:
|
|
||||||
|
|
||||||
- **Outage hysteresis.** When the mount drops, files miss one scan
|
|
||||||
(marked missing, hidden) then a second (archived, still recoverable).
|
|
||||||
When the mount returns — by path or by identical content elsewhere —
|
|
||||||
everything is restored automatically: annotations, progress, and
|
|
||||||
covers included.
|
|
||||||
- **Retention purge.** Archived items older than `ARCHIVE_RETENTION_DAYS`
|
|
||||||
(default **90**, `0` = never auto-purge) are deleted for good by
|
|
||||||
library scans, annotations included. A NAS offline longer than the
|
|
||||||
retention window loses those books' metadata on return.
|
|
||||||
- **Deleting while the storage is down is refused.** Deleting a book
|
|
||||||
cascades its annotations, so deletes are rejected with a clear error
|
|
||||||
while the item's library storage is unreachable. Archive the item
|
|
||||||
instead, or retry when the mount is back.
|
|
||||||
- **Watch mode does not fire on network mounts** (inotify doesn't work
|
|
||||||
over NFS/SMB). The periodic scan is the change detector.
|
|
||||||
- **Latency** — folder reachability checks are cached ~30s; large
|
|
||||||
libraries on slow mounts will still feel slower than local disk.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🛠 Tech Stack
|
|
||||||
|
|
||||||
- **Backend**: Go 1.25+ with Echo framework
|
|
||||||
- **Database**: PostgreSQL 15+ with pgx v5
|
|
||||||
- **Frontend**: HTMX + Tailwind CSS + Templ
|
|
||||||
- **Auth**: JWT tokens with bcrypt password hashing
|
|
||||||
- **Container**: Podman (Docker compatible)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧪 Testing
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run all tests
|
|
||||||
make test-all
|
|
||||||
|
|
||||||
# Run integration tests (with test mode)
|
|
||||||
make test-integration
|
|
||||||
|
|
||||||
# Run Bruno OpenCollection YAML API tests
|
|
||||||
npm install -g @usebruno/cli
|
|
||||||
bruno run
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
info:
|
|
||||||
name: Add Auto-Assign Rule
|
|
||||||
type: http
|
|
||||||
seq: 1
|
|
||||||
|
|
||||||
http:
|
|
||||||
method: POST
|
|
||||||
url: '{{base_url}}/api/collections/{{collection_id}}/rules'
|
|
||||||
auth: inherit
|
|
||||||
body:
|
|
||||||
type: json
|
|
||||||
data: |-
|
|
||||||
{
|
|
||||||
"field": "genre",
|
|
||||||
"operator": "equals",
|
|
||||||
"value": "Science Fiction",
|
|
||||||
"priority": 1,
|
|
||||||
"enabled": true
|
|
||||||
}
|
|
||||||
settings:
|
|
||||||
encodeUrl: true
|
|
||||||
timeout: 0
|
|
||||||
followRedirects: true
|
|
||||||
maxRedirects: 5
|
|
||||||
|
|
||||||
runtime:
|
|
||||||
scripts:
|
|
||||||
- type: tests
|
|
||||||
code: |-
|
|
||||||
test("status is 201", function() {
|
|
||||||
expect(res.status).to.eql(201);
|
|
||||||
});
|
|
||||||
|
|
||||||
docs:
|
|
||||||
content: |-
|
|
||||||
# Add Auto-Assign Rule
|
|
||||||
|
|
||||||
`POST /api/collections/:id/rules` — auth: jwt.
|
|
||||||
|
|
||||||
Canonical reference: docs/developer/api/collections/add_auto_assign_rule.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
|
|
||||||
type: text/markdown
|
|
||||||
@@ -16,7 +16,6 @@ http:
|
|||||||
"color": "#00FF00",
|
"color": "#00FF00",
|
||||||
"icon": "📖",
|
"icon": "📖",
|
||||||
"collection_type": "static",
|
"collection_type": "static",
|
||||||
"auto_assign_rules": [],
|
|
||||||
"view_settings": { "kobo": { "view_mode": "grid" } }
|
"view_settings": { "kobo": { "view_mode": "grid" } }
|
||||||
}
|
}
|
||||||
settings:
|
settings:
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
info:
|
|
||||||
name: Remove Auto-Assign Rule
|
|
||||||
type: http
|
|
||||||
seq: 13
|
|
||||||
|
|
||||||
http:
|
|
||||||
method: DELETE
|
|
||||||
url: '{{base_url}}/api/collections/{{collection_id}}/rules/{{rule_id}}'
|
|
||||||
auth: inherit
|
|
||||||
settings:
|
|
||||||
encodeUrl: true
|
|
||||||
timeout: 0
|
|
||||||
followRedirects: true
|
|
||||||
maxRedirects: 5
|
|
||||||
|
|
||||||
runtime:
|
|
||||||
scripts:
|
|
||||||
- type: tests
|
|
||||||
code: |-
|
|
||||||
test("status is 204", function() {
|
|
||||||
expect(res.status).to.eql(204);
|
|
||||||
});
|
|
||||||
|
|
||||||
docs:
|
|
||||||
content: |-
|
|
||||||
# Remove Auto-Assign Rule
|
|
||||||
|
|
||||||
`DELETE /api/collections/:id/rules/:ruleId` — auth: jwt.
|
|
||||||
|
|
||||||
Canonical reference: docs/developer/api/collections/remove_auto_assign_rule.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
|
|
||||||
type: text/markdown
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
info:
|
||||||
|
name: Set Collection Chain Order
|
||||||
|
type: http
|
||||||
|
seq: 13
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: PUT
|
||||||
|
url: '{{base_url}}/api/collections/{{collection_id}}/view-settings'
|
||||||
|
auth: inherit
|
||||||
|
body:
|
||||||
|
type: json
|
||||||
|
data: |-
|
||||||
|
{
|
||||||
|
"chain_order": "newest"
|
||||||
|
}
|
||||||
|
settings:
|
||||||
|
encodeUrl: true
|
||||||
|
timeout: 0
|
||||||
|
followRedirects: true
|
||||||
|
maxRedirects: 5
|
||||||
|
|
||||||
|
docs:
|
||||||
|
content: |-
|
||||||
|
# Set Collection Chain Order
|
||||||
|
|
||||||
|
`PUT /api/collections/:id/view-settings` — auth: jwt.
|
||||||
|
|
||||||
|
Canonical reference: docs/developer/api/collections/set_chain_order.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
|
||||||
|
type: text/markdown
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
info:
|
|
||||||
name: Test Rule
|
|
||||||
type: http
|
|
||||||
seq: 15
|
|
||||||
|
|
||||||
http:
|
|
||||||
method: POST
|
|
||||||
url: '{{base_url}}/api/collections/test-rules'
|
|
||||||
auth: inherit
|
|
||||||
body:
|
|
||||||
type: json
|
|
||||||
data: |-
|
|
||||||
{
|
|
||||||
"rules": [
|
|
||||||
{
|
|
||||||
"field": "genre",
|
|
||||||
"operator": "equals",
|
|
||||||
"value": "Science Fiction"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"field": "author",
|
|
||||||
"operator": "contains",
|
|
||||||
"value": "Asimov"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
settings:
|
|
||||||
encodeUrl: true
|
|
||||||
timeout: 0
|
|
||||||
followRedirects: true
|
|
||||||
maxRedirects: 5
|
|
||||||
|
|
||||||
runtime:
|
|
||||||
scripts:
|
|
||||||
- type: tests
|
|
||||||
code: |-
|
|
||||||
test("status is 200", function() {
|
|
||||||
expect(res.status).to.eql(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
docs:
|
|
||||||
content: |-
|
|
||||||
# Test Rule
|
|
||||||
|
|
||||||
`POST /api/collections/test-rules` — auth: jwt.
|
|
||||||
|
|
||||||
Canonical reference: docs/developer/api/collections/test_rule.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
|
|
||||||
type: text/markdown
|
|
||||||
@@ -15,7 +15,6 @@ http:
|
|||||||
"description": "Updated description",
|
"description": "Updated description",
|
||||||
"color": "#33CCFF",
|
"color": "#33CCFF",
|
||||||
"icon": "📚",
|
"icon": "📚",
|
||||||
"auto_assign_rules": [],
|
|
||||||
"view_settings": { "hide_finished": true },
|
"view_settings": { "hide_finished": true },
|
||||||
"hide_finished": ["true"],
|
"hide_finished": ["true"],
|
||||||
"show_up_next": ["true"]
|
"show_up_next": ["true"]
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
info:
|
|
||||||
name: Update Collection Rule
|
|
||||||
type: http
|
|
||||||
seq: 17
|
|
||||||
|
|
||||||
http:
|
|
||||||
method: PUT
|
|
||||||
url: '{{base_url}}/api/collections/{{collection_id}}/rules/{{rule_id}}'
|
|
||||||
auth: inherit
|
|
||||||
body:
|
|
||||||
type: json
|
|
||||||
data: |-
|
|
||||||
{
|
|
||||||
"enabled": false
|
|
||||||
}
|
|
||||||
settings:
|
|
||||||
encodeUrl: true
|
|
||||||
timeout: 0
|
|
||||||
followRedirects: true
|
|
||||||
maxRedirects: 5
|
|
||||||
|
|
||||||
docs:
|
|
||||||
content: |-
|
|
||||||
# Update Collection Rule
|
|
||||||
|
|
||||||
`PUT /api/collections/:id/rules/:ruleId` — auth: jwt.
|
|
||||||
|
|
||||||
Canonical reference: docs/developer/api/collections/update_rule.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
|
|
||||||
type: text/markdown
|
|
||||||
@@ -60,3 +60,13 @@ variables:
|
|||||||
name: book_sha256
|
name: book_sha256
|
||||||
- secret: true
|
- secret: true
|
||||||
name: book_uuid
|
name: book_uuid
|
||||||
|
- secret: true
|
||||||
|
name: podcasts_library_id
|
||||||
|
- name: podcast_feed_url
|
||||||
|
value: https://example.com/feed.xml
|
||||||
|
- secret: true
|
||||||
|
name: podcast_feed_id
|
||||||
|
- secret: true
|
||||||
|
name: podcast_episode_id
|
||||||
|
- name: podcast_search_query
|
||||||
|
value: episode
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
info:
|
||||||
|
name: Retry Processing Issue
|
||||||
|
type: http
|
||||||
|
seq: 18
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: POST
|
||||||
|
url: '{{base_url}}/api/libraries/{{ebook_library_id}}/issues/{{issueid}}/retry'
|
||||||
|
auth: inherit
|
||||||
|
settings:
|
||||||
|
encodeUrl: true
|
||||||
|
timeout: 0
|
||||||
|
followRedirects: true
|
||||||
|
maxRedirects: 5
|
||||||
|
|
||||||
|
docs:
|
||||||
|
content: |-
|
||||||
|
# Retry Processing Issue
|
||||||
|
|
||||||
|
`POST /api/libraries/:id/issues/:issueId/retry` — auth: jwt+admin.
|
||||||
|
|
||||||
|
Canonical reference: docs/developer/api/libraries/retry_processing_issue.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
|
||||||
|
type: text/markdown
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Scan Library
|
name: Scan Library
|
||||||
type: http
|
type: http
|
||||||
seq: 18
|
seq: 19
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: POST
|
method: POST
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Set Library Visibility
|
name: Set Library Visibility
|
||||||
type: http
|
type: http
|
||||||
seq: 19
|
seq: 20
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: POST
|
method: POST
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Update Library
|
name: Update Library
|
||||||
type: http
|
type: http
|
||||||
seq: 20
|
seq: 21
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: PUT
|
method: PUT
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Update Scan Settings
|
name: Update Scan Settings
|
||||||
type: http
|
type: http
|
||||||
seq: 21
|
seq: 22
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: PUT
|
method: PUT
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
info:
|
||||||
|
name: Add Podcast Feed
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: POST
|
||||||
|
url: '{{base_url}}/api/podcasts/feeds'
|
||||||
|
auth: inherit
|
||||||
|
body:
|
||||||
|
type: json
|
||||||
|
data: |-
|
||||||
|
{
|
||||||
|
"library_id": "{{podcasts_library_id}}",
|
||||||
|
"url": "{{podcast_feed_url}}",
|
||||||
|
"download_all": false
|
||||||
|
}
|
||||||
|
settings:
|
||||||
|
encodeUrl: true
|
||||||
|
timeout: 30000
|
||||||
|
followRedirects: true
|
||||||
|
maxRedirects: 5
|
||||||
|
|
||||||
|
runtime:
|
||||||
|
scripts:
|
||||||
|
- type: tests
|
||||||
|
code: |-
|
||||||
|
test("status is 201", function() {
|
||||||
|
expect(res.status).to.eql(201);
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
info:
|
||||||
|
name: Delete Feed (admin; ?files=false keeps files)
|
||||||
|
type: http
|
||||||
|
seq: 12
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: DELETE
|
||||||
|
url: '{{base_url}}/api/podcasts/feeds/{{podcast_feed_id}}'
|
||||||
|
auth: inherit
|
||||||
|
|
||||||
|
runtime:
|
||||||
|
scripts:
|
||||||
|
- type: tests
|
||||||
|
code: |-
|
||||||
|
test("status is 200", function() {
|
||||||
|
expect(res.status).to.eql(200);
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
info:
|
||||||
|
name: Download All (every non-downloaded episode)
|
||||||
|
type: http
|
||||||
|
seq: 8
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: POST
|
||||||
|
url: '{{base_url}}/api/podcasts/feeds/{{podcast_feed_id}}/episodes/all/download'
|
||||||
|
auth: inherit
|
||||||
|
|
||||||
|
runtime:
|
||||||
|
scripts:
|
||||||
|
- type: tests
|
||||||
|
code: |-
|
||||||
|
test("status is 202", function() {
|
||||||
|
expect(res.status).to.eql(202);
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
info:
|
||||||
|
name: Download / Retry Episode (idempotent state machine)
|
||||||
|
type: http
|
||||||
|
seq: 7
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: POST
|
||||||
|
url: '{{base_url}}/api/podcasts/feeds/{{podcast_feed_id}}/episodes/{{podcast_episode_id}}/download'
|
||||||
|
auth: inherit
|
||||||
|
|
||||||
|
runtime:
|
||||||
|
scripts:
|
||||||
|
- type: tests
|
||||||
|
code: |-
|
||||||
|
test("status is 202", function() {
|
||||||
|
expect(res.status).to.eql(202);
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
info:
|
||||||
|
name: Feed Detail (episodes + download states)
|
||||||
|
type: http
|
||||||
|
seq: 3
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: GET
|
||||||
|
url: '{{base_url}}/api/podcasts/feeds/{{podcast_feed_id}}'
|
||||||
|
auth: inherit
|
||||||
|
|
||||||
|
runtime:
|
||||||
|
scripts:
|
||||||
|
- type: tests
|
||||||
|
code: |-
|
||||||
|
test("status is 200 (404 would be scoping/RLS)", function() {
|
||||||
|
expect(res.status).to.eql(200);
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
info:
|
||||||
|
name: List My Podcast Feeds
|
||||||
|
type: http
|
||||||
|
seq: 2
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: GET
|
||||||
|
url: '{{base_url}}/api/podcasts/feeds'
|
||||||
|
auth: inherit
|
||||||
|
|
||||||
|
runtime:
|
||||||
|
scripts:
|
||||||
|
- type: tests
|
||||||
|
code: |-
|
||||||
|
test("status is 200", function() {
|
||||||
|
expect(res.status).to.eql(200);
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
info:
|
||||||
|
name: Manual Refresh (poll one feed)
|
||||||
|
type: http
|
||||||
|
seq: 6
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: POST
|
||||||
|
url: '{{base_url}}/api/podcasts/feeds/{{podcast_feed_id}}/refresh'
|
||||||
|
auth: inherit
|
||||||
|
|
||||||
|
runtime:
|
||||||
|
scripts:
|
||||||
|
- type: tests
|
||||||
|
code: |-
|
||||||
|
test("status is 200", function() {
|
||||||
|
expect(res.status).to.eql(200);
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
info:
|
||||||
|
name: Effective Sequence (override applied, downloaded-only, gaps)
|
||||||
|
type: http
|
||||||
|
seq: 9
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: GET
|
||||||
|
url: '{{base_url}}/api/podcasts/feeds/{{podcast_feed_id}}/sequence'
|
||||||
|
auth: inherit
|
||||||
|
|
||||||
|
runtime:
|
||||||
|
scripts:
|
||||||
|
- type: tests
|
||||||
|
code: |-
|
||||||
|
test("status is 200", function() {
|
||||||
|
expect(res.status).to.eql(200);
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
info:
|
||||||
|
name: Set Order Override (native | oldest | newest)
|
||||||
|
type: http
|
||||||
|
seq: 10
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: PUT
|
||||||
|
url: '{{base_url}}/api/podcasts/feeds/{{podcast_feed_id}}/order'
|
||||||
|
auth: inherit
|
||||||
|
body:
|
||||||
|
type: json
|
||||||
|
data: |-
|
||||||
|
{
|
||||||
|
"order": "oldest"
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime:
|
||||||
|
scripts:
|
||||||
|
- type: tests
|
||||||
|
code: |-
|
||||||
|
test("status is 200", function() {
|
||||||
|
expect(res.status).to.eql(200);
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
info:
|
||||||
|
name: Subscription-scoped Search (covers un-downloaded + historical)
|
||||||
|
type: http
|
||||||
|
seq: 11
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: GET
|
||||||
|
url: '{{base_url}}/api/podcasts/search?q={{podcast_search_query}}'
|
||||||
|
auth: inherit
|
||||||
|
|
||||||
|
runtime:
|
||||||
|
scripts:
|
||||||
|
- type: tests
|
||||||
|
code: |-
|
||||||
|
test("status is 200", function() {
|
||||||
|
expect(res.status).to.eql(200);
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
info:
|
||||||
|
name: Unsubscribe (files retained)
|
||||||
|
type: http
|
||||||
|
seq: 5
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: DELETE
|
||||||
|
url: '{{base_url}}/api/podcasts/feeds/{{podcast_feed_id}}/subscription'
|
||||||
|
auth: inherit
|
||||||
|
|
||||||
|
runtime:
|
||||||
|
scripts:
|
||||||
|
- type: tests
|
||||||
|
code: |-
|
||||||
|
test("status is 200", function() {
|
||||||
|
expect(res.status).to.eql(200);
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
info:
|
||||||
|
name: Update Subscription (display name + auto-download)
|
||||||
|
type: http
|
||||||
|
seq: 4
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: PUT
|
||||||
|
url: '{{base_url}}/api/podcasts/feeds/{{podcast_feed_id}}/subscription'
|
||||||
|
auth: inherit
|
||||||
|
body:
|
||||||
|
type: json
|
||||||
|
data: |-
|
||||||
|
{
|
||||||
|
"display_name": "My Serialized Feed",
|
||||||
|
"auto_download": "newest",
|
||||||
|
"auto_download_count": 3
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime:
|
||||||
|
scripts:
|
||||||
|
- type: tests
|
||||||
|
code: |-
|
||||||
|
test("status is 200", function() {
|
||||||
|
expect(res.status).to.eql(200);
|
||||||
|
});
|
||||||
+30
-5
@@ -10,6 +10,7 @@ import (
|
|||||||
"bookhoard/internal/router"
|
"bookhoard/internal/router"
|
||||||
"bookhoard/internal/services"
|
"bookhoard/internal/services"
|
||||||
"bookhoard/internal/sync"
|
"bookhoard/internal/sync"
|
||||||
|
"bookhoard/internal/version"
|
||||||
"bookhoard/templates"
|
"bookhoard/templates"
|
||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
@@ -33,6 +34,7 @@ func (cv *CustomValidator) Validate(i interface{}) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
log.Printf("Bookhoard %s (commit %s) starting", version.Version, version.Commit)
|
||||||
cfg := config.LoadConfig()
|
cfg := config.LoadConfig()
|
||||||
|
|
||||||
dbPool, err := pgxpool.New(context.Background(), cfg.DatabaseURL())
|
dbPool, err := pgxpool.New(context.Background(), cfg.DatabaseURL())
|
||||||
@@ -41,7 +43,12 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer dbPool.Close()
|
defer dbPool.Close()
|
||||||
|
|
||||||
queries := database.New(dbPool)
|
// The search service's raw DBTX path must ALSO carry the RLS
|
||||||
|
// identity — hand it the ScopedDB itself (a DBTX), never the bare
|
||||||
|
// pool, or search queries bypass the per-user GUC and raise under
|
||||||
|
// the policies.
|
||||||
|
scopedDB := database.NewScopedDB(dbPool)
|
||||||
|
queries := database.New(scopedDB)
|
||||||
|
|
||||||
// Initialize database schema
|
// Initialize database schema
|
||||||
log.Println("🔧 Ensuring database schema is initialized...")
|
log.Println("🔧 Ensuring database schema is initialized...")
|
||||||
@@ -53,6 +60,9 @@ func main() {
|
|||||||
|
|
||||||
// Load tunable settings from the DB into the registry. All values fall back
|
// Load tunable settings from the DB into the registry. All values fall back
|
||||||
// to compiled defaults if a row is missing, so this never blocks startup.
|
// to compiled defaults if a row is missing, so this never blocks startup.
|
||||||
|
// (Startup has no end-user identity; system_settings is not RLS-
|
||||||
|
// governed, and ScopedDB declares nothing when no identity exists —
|
||||||
|
// the engine only raises on governed tables.)
|
||||||
registry := database.NewSettingsRegistry(queries)
|
registry := database.NewSettingsRegistry(queries)
|
||||||
if err := registry.Load(ctx); err != nil {
|
if err := registry.Load(ctx); err != nil {
|
||||||
log.Printf("⚠️ Could not load system settings (using defaults): %v", err)
|
log.Printf("⚠️ Could not load system settings (using defaults): %v", err)
|
||||||
@@ -160,15 +170,27 @@ func main() {
|
|||||||
opdsHandler := handlers.NewOPDSHandler(queries, libraryService, conversionService)
|
opdsHandler := handlers.NewOPDSHandler(queries, libraryService, conversionService)
|
||||||
opdsHandler.SetSettings(registry)
|
opdsHandler.SetSettings(registry)
|
||||||
|
|
||||||
collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager)
|
// One composed-engine service shared by search AND the collection
|
||||||
dashboardService := services.NewDashboardService(queries)
|
// subsystem (item 3.2): tree-driven smart collections evaluate
|
||||||
dashboardHandler := handlers.NewDashboardHandler(queries)
|
// through the unified engine.
|
||||||
|
collectionSearchService := services.NewSearchService(queries, scopedDB)
|
||||||
|
collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager, collectionSearchService)
|
||||||
|
dashboardService := services.NewDashboardService(queries, collectionSearchService)
|
||||||
|
dashboardHandler := handlers.NewDashboardHandler(queries, collectionSearchService)
|
||||||
seriesHandler := handlers.NewSeriesHandler(queries)
|
seriesHandler := handlers.NewSeriesHandler(queries)
|
||||||
filtersHandler := handlers.NewFiltersHandler(queries)
|
filtersHandler := handlers.NewFiltersHandler(queries)
|
||||||
mediaHandler := handlers.NewMediaHandler(queries, dbPool, libraryService, progressService, annotationService)
|
mediaHandler := handlers.NewMediaHandler(queries, scopedDB, libraryService, progressService, annotationService)
|
||||||
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
|
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
|
||||||
jobsHandler := handlers.NewJobsHandler(queries, worker)
|
jobsHandler := handlers.NewJobsHandler(queries, worker)
|
||||||
|
|
||||||
|
// Podcast subsystem: fetcher service + concurrent download worker
|
||||||
|
// (§0 item 4: pool, fair share, admin tunables) + poller. One
|
||||||
|
// instance trio per server (per-feed locks dedupe).
|
||||||
|
podcastService := services.NewPodcastService(queries)
|
||||||
|
podcastWorker := services.NewPodcastDownloadWorker(queries, podcastService)
|
||||||
|
podcastWorker.SetSettings(registry)
|
||||||
|
podcastHandler := handlers.NewPodcastHandler(queries, podcastService, podcastWorker)
|
||||||
|
|
||||||
e := echo.New()
|
e := echo.New()
|
||||||
|
|
||||||
// Set up validator
|
// Set up validator
|
||||||
@@ -229,6 +251,9 @@ func main() {
|
|||||||
CollectionHandler: collectionHandler,
|
CollectionHandler: collectionHandler,
|
||||||
FiltersHandler: filtersHandler,
|
FiltersHandler: filtersHandler,
|
||||||
DashboardHandler: dashboardHandler,
|
DashboardHandler: dashboardHandler,
|
||||||
|
PodcastHandler: podcastHandler,
|
||||||
|
PodcastService: podcastService,
|
||||||
|
PodcastWorker: podcastWorker,
|
||||||
DashboardService: dashboardService,
|
DashboardService: dashboardService,
|
||||||
SeriesHandler: seriesHandler,
|
SeriesHandler: seriesHandler,
|
||||||
OPDSHandler: opdsHandler,
|
OPDSHandler: opdsHandler,
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Regression tests for the archive reconciliation pass: a multi-folder
|
||||||
|
// library must be reconciled against the UNION of its folders' walks, not
|
||||||
|
// once per folder. The old per-folder loop listed every library item
|
||||||
|
// against each single folder's walk, so with two registered folders every
|
||||||
|
// item living under the other folder was flagged "missing (1/2)" on every
|
||||||
|
// scan (prod: 743/743 audiobooks stuck at count 1 — unarchivable only
|
||||||
|
// because each scan's walk kept clearing them), and with three or more
|
||||||
|
// folders the crossed marks would have reached the archive threshold
|
||||||
|
// mid-pass and archived real content. Folder-book roots (directory
|
||||||
|
// file_path) must also count as present across folder boundaries — the
|
||||||
|
// file walk never emits a directory.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"bookhoard/internal/database"
|
||||||
|
"bookhoard/internal/services"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// scanTestFolders runs a synchronous scan over several folders — the
|
||||||
|
// multi-folder shape the archive pass must get right (scanTestFolder
|
||||||
|
// covers the single-folder case).
|
||||||
|
func scanTestFolders(t *testing.T, setup *TestServerSetup, adminID pgtype.UUID, folders []string) {
|
||||||
|
t.Helper()
|
||||||
|
scanner := services.NewMediaScanner(setup.DB)
|
||||||
|
defer func() { _ = scanner.Close() }()
|
||||||
|
scanner.SetAdminID(adminID)
|
||||||
|
require.NoError(t, scanner.SetFolders(folders, false))
|
||||||
|
require.NoError(t, scanner.ScanFolders(context.Background()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArchivePassMultiFolderLibraryKeepsEverythingPresent(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
t.Cleanup(func() { _ = setup.Close() })
|
||||||
|
|
||||||
|
libID, folderA := createTypedTestLibrary(t, setup, fmt.Sprintf("Test Archive MultiFolder %d", time.Now().UnixNano()), "audiobooks")
|
||||||
|
t.Cleanup(func() { deleteLibraries(t, setup, libID) }) // after setup.Close defer (LIFO): pool still open
|
||||||
|
|
||||||
|
// Second registered folder in the SAME library — the shape the
|
||||||
|
// per-folder reconciliation got wrong.
|
||||||
|
folderB := t.TempDir()
|
||||||
|
_, err := setup.DB.AddLibraryFolder(context.Background(), database.AddLibraryFolderParams{
|
||||||
|
LibraryID: libID,
|
||||||
|
FolderPath: folderB,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
admin, _ := moveTestAdminID(t, setup)
|
||||||
|
|
||||||
|
// Folder A holds a lone single-file book; folder B holds a
|
||||||
|
// folder-of-tracks book whose file_path is a DIRECTORY.
|
||||||
|
writeTestTrack(t, filepath.Join(folderA, "Author A", "Lone Book", "lonely.mp3"))
|
||||||
|
writeTestTrack(t, filepath.Join(folderB, "Author B", "Folder Book", "01 First.mp3"))
|
||||||
|
writeTestTrack(t, filepath.Join(folderB, "Author B", "Folder Book", "02 Second.mp3"))
|
||||||
|
|
||||||
|
// Two consecutive scans: the first proves no flags are raised, the
|
||||||
|
// second proves the flag/walk bounce (clear → re-flag) is gone too.
|
||||||
|
for scanNo := 1; scanNo <= 2; scanNo++ {
|
||||||
|
scanTestFolders(t, setup, admin, []string{folderA, folderB})
|
||||||
|
|
||||||
|
items := listLibraryAudioItems(t, setup, libID)
|
||||||
|
var singleFile, folderBook bool
|
||||||
|
for _, item := range items {
|
||||||
|
assert.False(t, item.ArchivedAt.Valid, "scan %d: item %q must not be archived", scanNo, item.FilePath)
|
||||||
|
assert.Equal(t, int32(0), item.MissingScanCount, "scan %d: item %q must not be flagged missing", scanNo, item.FilePath)
|
||||||
|
switch item.FilePath {
|
||||||
|
case "Author A/Lone Book/lonely.mp3":
|
||||||
|
singleFile = true
|
||||||
|
case "Author B/Folder Book":
|
||||||
|
folderBook = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.True(t, singleFile, "scan %d: single-file book must exist", scanNo)
|
||||||
|
require.True(t, folderBook, "scan %d: folder book must exist at its directory path", scanNo)
|
||||||
|
require.Len(t, items, 2, "scan %d: exactly the two books, no per-track strays", scanNo)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ import (
|
|||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -288,7 +289,12 @@ func TestAudiobookTrackEndpoints(t *testing.T) {
|
|||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
_ = resp.Body.Close()
|
_ = resp.Body.Close()
|
||||||
require.Equal(t, http.StatusForbidden, resp.StatusCode, "%s must be gated", path)
|
// PIN WIDENED for RLS (docs/contract_pins.md 2026-10-29): the
|
||||||
|
// handler's own Go-level gate still answers 403, but under
|
||||||
|
// engine-level visibility the hidden row is simply ABSENT —
|
||||||
|
// 404 is equally gated (no existence leak). Both accepted.
|
||||||
|
require.True(t, resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusNotFound,
|
||||||
|
"%s must be gated (403 or 404), got %d", path, resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore visibility so harness cleanup ordering never sees a
|
// Restore visibility so harness cleanup ordering never sees a
|
||||||
@@ -411,7 +417,12 @@ func TestAudiobookTagFallbacks(t *testing.T) {
|
|||||||
require.Len(t, item.Narrators, 1, "composer tag fills narrators (Audible convention)")
|
require.Len(t, item.Narrators, 1, "composer tag fills narrators (Audible convention)")
|
||||||
require.Equal(t, "Tag Narrator", item.Narrators[0])
|
require.Equal(t, "Tag Narrator", item.Narrators[0])
|
||||||
require.Equal(t, "Tag Series", item.Series.String, "grouping tag fills series")
|
require.Equal(t, "Tag Series", item.Series.String, "grouping tag fills series")
|
||||||
require.Equal(t, "Audiobook", item.Genre.String, "genre tag fills genre")
|
// 885d6140 demoted format words ("Audiobook") to tags-only: the
|
||||||
|
// genre column stays empty and the raw candidate lands in tags.
|
||||||
|
// (The commit message declares this test's update; the edit itself
|
||||||
|
// never landed — pinned to the demoted contract now.)
|
||||||
|
require.Empty(t, item.Genre.String, "demoted format words never win the genre column")
|
||||||
|
assert.Contains(t, item.Tags, "Audiobook", "the demoted candidate lands as a tag")
|
||||||
require.False(t, item.DatePublished.Time.IsZero(), "date tag fills publish date")
|
require.False(t, item.DatePublished.Time.IsZero(), "date tag fills publish date")
|
||||||
require.Equal(t, 2020, item.DatePublished.Time.Year())
|
require.Equal(t, 2020, item.DatePublished.Time.Year())
|
||||||
// ASIN/abridged/subtitle arrive only through Audible freeform atoms,
|
// ASIN/abridged/subtitle arrive only through Audible freeform atoms,
|
||||||
@@ -648,12 +659,14 @@ func TestAudiobookFolderSidecarEditDetected(t *testing.T) {
|
|||||||
require.NoError(t, os.WriteFile(sidecar, []byte(`{"title":"Sidecar Real Titlz","series":["Sidecar Saga (Reading Order) #1"]}`), 0o644))
|
require.NoError(t, os.WriteFile(sidecar, []byte(`{"title":"Sidecar Real Titlz","series":["Sidecar Saga (Reading Order) #1"]}`), 0o644))
|
||||||
scanTestFolder(t, setup, admin, folder)
|
scanTestFolder(t, setup, admin, folder)
|
||||||
var collCount int
|
var collCount int
|
||||||
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
|
// Owner-scoped read: collections RLS (2026-10-31) hides rows from
|
||||||
|
// the pool session's SeedIdentity.
|
||||||
|
require.NoError(t, database.NewScopedDB(setup.DBPool).QueryRow(ownerSeedCtx(admin),
|
||||||
`SELECT COUNT(*) FROM collections WHERE name = 'Sidecar Saga (Reading Order)' AND user_id = $1`, admin,
|
`SELECT COUNT(*) FROM collections WHERE name = 'Sidecar Saga (Reading Order)' AND user_id = $1`, admin,
|
||||||
).Scan(&collCount))
|
).Scan(&collCount))
|
||||||
require.Equal(t, 1, collCount, "reading-order sidecar edit creates the collection for an existing book")
|
require.Equal(t, 1, collCount, "reading-order sidecar edit creates the collection for an existing book")
|
||||||
scanTestFolder(t, setup, admin, folder)
|
scanTestFolder(t, setup, admin, folder)
|
||||||
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
|
require.NoError(t, database.NewScopedDB(setup.DBPool).QueryRow(ownerSeedCtx(admin),
|
||||||
`SELECT COUNT(*) FROM collections WHERE name = 'Sidecar Saga (Reading Order)' AND user_id = $1`, admin,
|
`SELECT COUNT(*) FROM collections WHERE name = 'Sidecar Saga (Reading Order)' AND user_id = $1`, admin,
|
||||||
).Scan(&collCount))
|
).Scan(&collCount))
|
||||||
require.Equal(t, 1, collCount, "rescan is idempotent — no duplicate collection")
|
require.Equal(t, 1, collCount, "rescan is idempotent — no duplicate collection")
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -367,8 +366,6 @@ func TestPreviewCollection(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func createPreviewTestMediaItems(t *testing.T, db *database.Queries, libraryID string, count int) []string {
|
func createPreviewTestMediaItems(t *testing.T, db *database.Queries, libraryID string, count int) []string {
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
libUUID, err := uuid.Parse(libraryID)
|
libUUID, err := uuid.Parse(libraryID)
|
||||||
require.NoError(t, err, "Should parse library ID")
|
require.NoError(t, err, "Should parse library ID")
|
||||||
|
|
||||||
@@ -379,7 +376,7 @@ func createPreviewTestMediaItems(t *testing.T, db *database.Queries, libraryID s
|
|||||||
for i := 0; i < count; i++ {
|
for i := 0; i < count; i++ {
|
||||||
genre := genres[i%len(genres)]
|
genre := genres[i%len(genres)]
|
||||||
|
|
||||||
media, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
media, err := db.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||||
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
||||||
Title: "Preview Test Book " + string(rune('A'+i)),
|
Title: "Preview Test Book " + string(rune('A'+i)),
|
||||||
Authors: []string{"Test Author " + string(rune('A'+i))},
|
Authors: []string{"Test Author " + string(rune('A'+i))},
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Collections RLS (2026-10-31): ownership enforcement moved to the DB
|
||||||
|
// layer (p_collections_* + parent-checked collection_items policies in
|
||||||
|
// schema.sql, predicates app_can_read/write_collection). The API surface
|
||||||
|
// answers invisibility with 404 — including the write endpoints that
|
||||||
|
// had NO app-level gate (Update/AddBooks, the flagged gap). Admin keeps
|
||||||
|
// the escape; system collections stay per-user; registration still
|
||||||
|
// seeds them (the seeding identity fix in
|
||||||
|
// CreateDefaultCollectionsForUser is regression-pinned here).
|
||||||
|
func TestCollectionsRLS(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
ownerToken := setup.RegularToken // user A: the harness regular user
|
||||||
|
adminToken := setup.Token
|
||||||
|
marker := fmt.Sprintf("rls%d", time.Now().UnixNano()%100000)
|
||||||
|
|
||||||
|
// User B: a second regular user via registration. The register
|
||||||
|
// endpoint is rate-limited — exactly one call. The response's
|
||||||
|
// access_token is B's identity for every probe below.
|
||||||
|
regEmail := fmt.Sprintf("%s@tests.bookhoard.internal", marker)
|
||||||
|
regBody := fmt.Sprintf(
|
||||||
|
`{"email":%q,"username":%q,"password":"Test@Pass123!"}`,
|
||||||
|
regEmail, "rlsb"+marker)
|
||||||
|
regResp, err := http.Post(setup.Server.URL+"/api/auth/register",
|
||||||
|
"application/json", bytes.NewReader([]byte(regBody)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, http.StatusCreated, regResp.StatusCode,
|
||||||
|
"registration must succeed — including the system-collection seeding that now runs under the new user's RLS identity")
|
||||||
|
var reg struct {
|
||||||
|
Token string `json:"access_token"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.NewDecoder(regResp.Body).Decode(®))
|
||||||
|
require.NotEmpty(t, reg.Token)
|
||||||
|
otherToken := reg.Token
|
||||||
|
_ = regResp.Body.Close()
|
||||||
|
|
||||||
|
// B's registration seeded B's OWN system collections — visible in
|
||||||
|
// B's list (this is the registration-seeding identity fix, live).
|
||||||
|
code, body := podcastAuthed(t, setup, otherToken, "GET", "/api/collections", "")
|
||||||
|
require.Equal(t, http.StatusOK, code)
|
||||||
|
assert.Contains(t, string(body), "Continue Reading",
|
||||||
|
"registration seeded the new user's system collections under RLS")
|
||||||
|
|
||||||
|
// A creates the target collection.
|
||||||
|
code, body = podcastAuthed(t, setup, ownerToken, "POST", "/api/collections",
|
||||||
|
`{"name":"test RLS Owner ` + marker + `"}`)
|
||||||
|
require.Equal(t, http.StatusCreated, code, "%s", string(body))
|
||||||
|
var coll struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal(body, &coll))
|
||||||
|
t.Cleanup(func() {
|
||||||
|
podcastAuthed(t, setup, ownerToken, "DELETE", "/api/collections/"+coll.ID, "")
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── B (non-owner) probes A's collection: invisible = 404 ──
|
||||||
|
code, _ = podcastAuthed(t, setup, otherToken, "GET", "/api/collections/"+coll.ID, "")
|
||||||
|
require.Equal(t, http.StatusNotFound, code, "detail-by-id is RLS-invisible cross-user")
|
||||||
|
|
||||||
|
code, body = podcastAuthed(t, setup, otherToken, "GET", "/api/collections", "")
|
||||||
|
require.Equal(t, http.StatusOK, code)
|
||||||
|
assert.NotContains(t, string(body), "test RLS Owner "+marker,
|
||||||
|
"the list query stays user-scoped")
|
||||||
|
|
||||||
|
code, body = podcastAuthed(t, setup, otherToken, "PUT", "/api/collections/"+coll.ID,
|
||||||
|
`{"name":"renamed by B"}`)
|
||||||
|
require.Equal(t, http.StatusNotFound, code,
|
||||||
|
"update of an RLS-invisible row answers 404 (was: ungated success): %s", string(body))
|
||||||
|
|
||||||
|
code, body = podcastAuthed(t, setup, otherToken, "POST", "/api/collections/"+coll.ID+"/books",
|
||||||
|
`{"book_ids":[]}`)
|
||||||
|
require.Equal(t, http.StatusNotFound, code,
|
||||||
|
"AddBooks on an RLS-invisible collection answers 404 (the flagged ungated-write gap, closed at the DB)")
|
||||||
|
|
||||||
|
code, body = podcastAuthed(t, setup, otherToken, "PUT",
|
||||||
|
"/api/collections/"+coll.ID+"/view-settings", `{"chain_order":"newest"}`)
|
||||||
|
require.Equal(t, http.StatusNotFound, code,
|
||||||
|
"chain-order write on an invisible collection answers 404")
|
||||||
|
|
||||||
|
code, _ = podcastAuthed(t, setup, otherToken, "DELETE", "/api/collections/"+coll.ID, "")
|
||||||
|
require.Equal(t, http.StatusNoContent, code,
|
||||||
|
"DELETE touches no rows under RLS (silent no-op — no destructive action)")
|
||||||
|
|
||||||
|
// The no-op delete lied about nothing: A still owns the collection.
|
||||||
|
code, body = podcastAuthed(t, setup, ownerToken, "GET", "/api/collections/"+coll.ID, "")
|
||||||
|
require.Equal(t, http.StatusOK, code, "owner retains the collection after B's failed delete")
|
||||||
|
|
||||||
|
// ── admin escape: sees everything, as with media ──
|
||||||
|
code, _ = podcastAuthed(t, setup, adminToken, "GET", "/api/collections/"+coll.ID, "")
|
||||||
|
require.Equal(t, http.StatusOK, code, "admin persona sees collections (policy escape)")
|
||||||
|
|
||||||
|
// ── B's ownership works: B renames B's OWN system collection ──
|
||||||
|
// (positive control — B is not locked out of what B owns)
|
||||||
|
code, body = podcastAuthed(t, setup, otherToken, "GET", "/api/collections", "")
|
||||||
|
require.Equal(t, http.StatusOK, code)
|
||||||
|
var list struct {
|
||||||
|
Collections []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"collections"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal(body, &list))
|
||||||
|
var bSystem string
|
||||||
|
for _, c := range list.Collections {
|
||||||
|
if c.Name == "Continue Reading" {
|
||||||
|
bSystem = c.ID
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NotEmpty(t, bSystem, "B owns the seeded system collections")
|
||||||
|
code, body = podcastAuthed(t, setup, otherToken, "PUT", "/api/collections/"+bSystem,
|
||||||
|
`{"name":"Continue Reading"}`)
|
||||||
|
require.Contains(t, []int{http.StatusOK, http.StatusCreated}, code,
|
||||||
|
"owner mutation of an OWN collection still works: %s", string(body))
|
||||||
|
}
|
||||||
@@ -56,36 +56,6 @@ func smartGetCollection(t *testing.T, setup *TestServerSetup, token, collectionI
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func smartPostRule(t *testing.T, setup *TestServerSetup, token, collectionID, field, operator, value string, enabled *bool) *http.Response {
|
|
||||||
t.Helper()
|
|
||||||
payload := map[string]interface{}{
|
|
||||||
"field": field,
|
|
||||||
"operator": operator,
|
|
||||||
"value": value,
|
|
||||||
}
|
|
||||||
if enabled != nil {
|
|
||||||
payload["enabled"] = *enabled
|
|
||||||
}
|
|
||||||
body, _ := json.Marshal(payload)
|
|
||||||
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collectionID+"/rules", bytes.NewBuffer(body))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
require.NoError(t, err)
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
|
|
||||||
func smartDecodeRuleID(t *testing.T, resp *http.Response) string {
|
|
||||||
t.Helper()
|
|
||||||
defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body)
|
|
||||||
var result map[string]interface{}
|
|
||||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
|
|
||||||
rule := result["rule"].(map[string]interface{})
|
|
||||||
return rule["id"].(string)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Data helpers -----------------------------------------------------------
|
|
||||||
|
|
||||||
func smartCreateLibraryAndBooks(t *testing.T, setup *TestServerSetup, userID uuid.UUID, titlesByGenre map[string]string) (libraryID string, bookIDs []string) {
|
func smartCreateLibraryAndBooks(t *testing.T, setup *TestServerSetup, userID uuid.UUID, titlesByGenre map[string]string) (libraryID string, bookIDs []string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -108,7 +78,7 @@ func smartCreateLibraryAndBooks(t *testing.T, setup *TestServerSetup, userID uui
|
|||||||
})
|
})
|
||||||
|
|
||||||
for title, genre := range titlesByGenre {
|
for title, genre := range titlesByGenre {
|
||||||
media, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
media, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||||
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
||||||
Title: title,
|
Title: title,
|
||||||
Authors: []string{"Smart Rule Author"},
|
Authors: []string{"Smart Rule Author"},
|
||||||
@@ -145,81 +115,83 @@ func TestSmartCollections(t *testing.T) {
|
|||||||
setup := setupTestServer(t)
|
setup := setupTestServer(t)
|
||||||
adminID := getTestUserID(t, setup.DB)
|
adminID := getTestUserID(t, setup.DB)
|
||||||
|
|
||||||
t.Run("FirstRuleConvertsCollectionToSmart_LiveMembership", func(t *testing.T) {
|
t.Run("TreeCollectionLiveMembership", func(t *testing.T) {
|
||||||
|
// The unified engine (item 3.2): a collection created WITH a
|
||||||
|
// books-scope tree evaluates LIVE — stored links are ignored,
|
||||||
|
// only tree matches render.
|
||||||
_, bookIDs := smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{
|
_, bookIDs := smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{
|
||||||
"Smart SF Book One": "Science Fiction",
|
"Smart SF Book One": "Science Fiction",
|
||||||
"Smart SF Book Two": "Science Fiction",
|
"Smart SF Book Two": "Science Fiction",
|
||||||
"Smart FANT Book": "Fantasy",
|
"Smart FANT Book": "Fantasy",
|
||||||
})
|
})
|
||||||
|
_ = bookIDs
|
||||||
|
|
||||||
// Rule evaluation ignores stored links: a static collection's own
|
tree := map[string]interface{}{
|
||||||
// added item must NOT show up once the collection is smart.
|
"scope": "books", "mode": "all",
|
||||||
collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Live "+uuid.NewString()[:8], "static")
|
"children": []map[string]interface{}{
|
||||||
|
{"field": "genre", "op": "is", "values": []string{"Science Fiction"}},
|
||||||
// Add one hand-picked book (a Fantasy one that the smart rule
|
},
|
||||||
// will not match — so it must vanish from the render).
|
}
|
||||||
addBody, _ := json.Marshal(map[string]interface{}{"book_ids": []string{bookIDs[2]}})
|
body, _ := json.Marshal(map[string]interface{}{
|
||||||
addReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collID+"/books", bytes.NewBuffer(addBody))
|
"name": "Test Smart Tree " + uuid.NewString()[:8],
|
||||||
addReq.Header.Set("Content-Type", "application/json")
|
"filter_tree": tree,
|
||||||
addReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
})
|
||||||
addResp, err := http.DefaultClient.Do(addReq)
|
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections", bytes.NewBuffer(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, http.StatusNoContent, addResp.StatusCode)
|
|
||||||
_ = addResp.Body.Close()
|
|
||||||
|
|
||||||
staticDetail := smartGetCollection(t, setup, setup.Token, collID)
|
|
||||||
assert.Equal(t, false, staticDetail["is_smart"])
|
|
||||||
assert.Equal(t, float64(1), staticDetail["book_count"], "static collection shows its stored link")
|
|
||||||
|
|
||||||
resp := smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil)
|
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
var created struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&created))
|
||||||
_ = resp.Body.Close()
|
_ = resp.Body.Close()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
del, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/collections/"+created.ID, nil)
|
||||||
|
del.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
_, _ = http.DefaultClient.Do(del)
|
||||||
|
})
|
||||||
|
|
||||||
smartDetail := smartGetCollection(t, setup, setup.Token, collID)
|
detail := smartGetCollection(t, setup, setup.Token, created.ID)
|
||||||
assert.Equal(t, true, smartDetail["is_smart"], "rules presence derives the type")
|
assert.Equal(t, true, detail["is_smart"], "tree presence derives the type")
|
||||||
assert.Equal(t, float64(2), smartDetail["book_count"], "live rule matches only")
|
memberTitles := smartLibraryTitleSet(t, detail)
|
||||||
|
|
||||||
memberTitles := smartLibraryTitleSet(t, smartDetail)
|
|
||||||
assert.True(t, memberTitles["Smart SF Book One"])
|
assert.True(t, memberTitles["Smart SF Book One"])
|
||||||
assert.True(t, memberTitles["Smart SF Book Two"])
|
assert.True(t, memberTitles["Smart SF Book Two"])
|
||||||
assert.False(t, memberTitles["Smart FANT Book"], "hand-picked link is ignored under smart evaluation (v1 no hybrid)")
|
assert.False(t, memberTitles["Smart FANT Book"], "the tree's AND governs membership live")
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("DisabledRuleExcludedFromLiveSet", func(t *testing.T) {
|
|
||||||
smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{
|
|
||||||
"Smart Diss Book": "Science Fiction",
|
|
||||||
})
|
|
||||||
collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Disabled "+uuid.NewString()[:8], "smart")
|
|
||||||
|
|
||||||
resp := smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil)
|
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
|
||||||
ruleID := smartDecodeRuleID(t, resp)
|
|
||||||
|
|
||||||
assert.Equal(t, float64(1), smartGetCollection(t, setup, setup.Token, collID)["book_count"])
|
|
||||||
|
|
||||||
// Disable: membership drops to zero at once.
|
|
||||||
falseFlag := false
|
|
||||||
dBody, _ := json.Marshal(map[string]interface{}{"enabled": falseFlag})
|
|
||||||
dReq, _ := http.NewRequest("PUT", setup.Server.URL+"/api/collections/"+collID+"/rules/"+ruleID, bytes.NewBuffer(dBody))
|
|
||||||
dReq.Header.Set("Content-Type", "application/json")
|
|
||||||
dReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
|
||||||
dResp, err := http.DefaultClient.Do(dReq)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, http.StatusOK, dResp.StatusCode)
|
|
||||||
_ = dResp.Body.Close()
|
|
||||||
|
|
||||||
assert.Equal(t, float64(0), smartGetCollection(t, setup, setup.Token, collID)["book_count"], "disabled rules never match")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("SmartCollectionsRejectMembershipMutations", func(t *testing.T) {
|
t.Run("SmartCollectionsRejectMembershipMutations", func(t *testing.T) {
|
||||||
smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{
|
smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{
|
||||||
"Smart Guard Book": "Science Fiction",
|
"Smart Guard Book": "Science Fiction",
|
||||||
})
|
})
|
||||||
collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Guard "+uuid.NewString()[:8], "smart")
|
tree := map[string]interface{}{
|
||||||
|
"scope": "books", "mode": "all",
|
||||||
resp := smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil)
|
"children": []map[string]interface{}{
|
||||||
|
{"field": "genre", "op": "is", "values": []string{"Science Fiction"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"name": "Test Smart Guard " + uuid.NewString()[:8],
|
||||||
|
"filter_tree": tree,
|
||||||
|
})
|
||||||
|
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections", bytes.NewBuffer(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
var created struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&created))
|
||||||
_ = resp.Body.Close()
|
_ = resp.Body.Close()
|
||||||
|
collID := created.ID
|
||||||
|
t.Cleanup(func() {
|
||||||
|
del, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/collections/"+collID, nil)
|
||||||
|
del.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
_, _ = http.DefaultClient.Do(del)
|
||||||
|
})
|
||||||
|
|
||||||
addBody, _ := json.Marshal(map[string]interface{}{"book_ids": []string{uuid.NewString()}})
|
addBody, _ := json.Marshal(map[string]interface{}{"book_ids": []string{uuid.NewString()}})
|
||||||
addReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collID+"/books", bytes.NewBuffer(addBody))
|
addReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collID+"/books", bytes.NewBuffer(addBody))
|
||||||
@@ -238,53 +210,10 @@ func TestSmartCollections(t *testing.T) {
|
|||||||
_ = removeResp.Body.Close()
|
_ = removeResp.Body.Close()
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("RemoveLastRuleRevertsToStatic", func(t *testing.T) {
|
t.Run("SystemCollectionsBlockedFromTheEditor", func(t *testing.T) {
|
||||||
smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{
|
// System collections may never carry rules/trees — the editor
|
||||||
"Smart Back Book": "Science Fiction",
|
// route refuses them (item 3.4: the page IS the editor).
|
||||||
})
|
colls, err := setup.DB.GetCollectionsByUser(ownerSeedCtx(pgtype.UUID{Bytes: [16]byte(adminID), Valid: true}), pgtype.UUID{Bytes: [16]byte(adminID), Valid: true})
|
||||||
collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Revert "+uuid.NewString()[:8], "smart")
|
|
||||||
|
|
||||||
resp := smartPostRule(t, setup, setup.Token, collID, "tags", "contains", "no-such-tag", nil)
|
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
|
||||||
ruleID := smartDecodeRuleID(t, resp)
|
|
||||||
|
|
||||||
smartDetail := smartGetCollection(t, setup, setup.Token, collID)
|
|
||||||
assert.Equal(t, true, smartDetail["is_smart"])
|
|
||||||
|
|
||||||
delReq, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/collections/"+collID+"/rules/"+ruleID, nil)
|
|
||||||
delReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
|
||||||
delResp, err := http.DefaultClient.Do(delReq)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, http.StatusOK, delResp.StatusCode)
|
|
||||||
_ = delResp.Body.Close()
|
|
||||||
|
|
||||||
staticDetail := smartGetCollection(t, setup, setup.Token, collID)
|
|
||||||
assert.Equal(t, false, staticDetail["is_smart"], "removing the last rule reverts to static")
|
|
||||||
|
|
||||||
// Membership mutations work again on the reverted static.
|
|
||||||
addBody, _ := json.Marshal(map[string]interface{}{"book_ids": []string{uuid.NewString()}})
|
|
||||||
addReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collID+"/books", bytes.NewBuffer(addBody))
|
|
||||||
addReq.Header.Set("Content-Type", "application/json")
|
|
||||||
addReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
|
||||||
addResp, err := http.DefaultClient.Do(addReq)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, http.StatusNoContent, addResp.StatusCode)
|
|
||||||
_ = addResp.Body.Close()
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("RuleValidationAndSystemCollections", func(t *testing.T) {
|
|
||||||
collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Valid "+uuid.NewString()[:8], "static")
|
|
||||||
|
|
||||||
resp := smartPostRule(t, setup, setup.Token, collID, "not_a_field", "equals", "x", nil)
|
|
||||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
|
|
||||||
resp = smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil)
|
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
|
|
||||||
// System collections may never carry rules.
|
|
||||||
colls, err := setup.DB.GetCollectionsByUser(context.Background(), pgtype.UUID{Bytes: [16]byte(adminID), Valid: true})
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
var systemID string
|
var systemID string
|
||||||
for _, c := range colls {
|
for _, c := range colls {
|
||||||
@@ -295,20 +224,46 @@ func TestSmartCollections(t *testing.T) {
|
|||||||
}
|
}
|
||||||
require.NotEmpty(t, systemID, "admin user should have system collections")
|
require.NotEmpty(t, systemID, "admin user should have system collections")
|
||||||
|
|
||||||
sysResp := smartPostRule(t, setup, setup.Token, systemID, "genre", "equals", "x", nil)
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/collections/"+systemID+"/rules", nil)
|
||||||
require.Equal(t, http.StatusBadRequest, sysResp.StatusCode)
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
_ = sysResp.Body.Close()
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body)
|
||||||
|
assert.NotEqual(t, http.StatusOK, resp.StatusCode,
|
||||||
|
"system collections never render the smart editor")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("CollectionsListRespectsIncludeAutoWithLiveCounts", func(t *testing.T) {
|
t.Run("CollectionsListRespectsIncludeAutoWithLiveCounts", func(t *testing.T) {
|
||||||
_, _ = smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{
|
_, _ = smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{
|
||||||
"Smart List Book": "Science Fiction",
|
"Smart List Book": "Science Fiction",
|
||||||
})
|
})
|
||||||
collID := smartCreateCollection(t, setup, setup.Token, "Test Smart List "+uuid.NewString()[:8], "smart")
|
tree := map[string]interface{}{
|
||||||
|
"scope": "books", "mode": "all",
|
||||||
resp := smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil)
|
"children": []map[string]interface{}{
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
{"field": "genre", "op": "is", "values": []string{"Science Fiction"}},
|
||||||
_ = resp.Body.Close()
|
},
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"name": "Test Smart List " + uuid.NewString()[:8],
|
||||||
|
"filter_tree": tree,
|
||||||
|
})
|
||||||
|
req2, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections", bytes.NewBuffer(body))
|
||||||
|
req2.Header.Set("Content-Type", "application/json")
|
||||||
|
req2.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
resp2, err := http.DefaultClient.Do(req2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, http.StatusCreated, resp2.StatusCode)
|
||||||
|
var created struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.NewDecoder(resp2.Body).Decode(&created))
|
||||||
|
_ = resp2.Body.Close()
|
||||||
|
collID := created.ID
|
||||||
|
t.Cleanup(func() {
|
||||||
|
del, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/collections/"+collID, nil)
|
||||||
|
del.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
_, _ = http.DefaultClient.Do(del)
|
||||||
|
})
|
||||||
|
|
||||||
// Default list (the Android app's read): smart collections hidden.
|
// Default list (the Android app's read): smart collections hidden.
|
||||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/collections", nil)
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/collections", nil)
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
|||||||
params.AlternateInfo = []byte(v)
|
params.AlternateInfo = []byte(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
mediaItem, err := setup.DB.CreateMediaItem(ctx, params)
|
mediaItem, err := setup.DB.CreateMediaItem(seedCtx(), params)
|
||||||
require.NoError(t, err, "Should create media item")
|
require.NoError(t, err, "Should create media item")
|
||||||
|
|
||||||
mediaUUID, err := uuid.FromBytes(mediaItem.ID.Bytes[0:16])
|
mediaUUID, err := uuid.FromBytes(mediaItem.ID.Bytes[0:16])
|
||||||
@@ -596,7 +596,7 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
|||||||
Isbn: pgtype.Text{String: "9781234567890", Valid: true},
|
Isbn: pgtype.Text{String: "9781234567890", Valid: true},
|
||||||
}
|
}
|
||||||
|
|
||||||
mediaItem, err := setup.DB.CreateMediaItem(ctx, params)
|
mediaItem, err := setup.DB.CreateMediaItem(seedCtx(), params)
|
||||||
require.NoError(t, err, "Should create ebook")
|
require.NoError(t, err, "Should create ebook")
|
||||||
|
|
||||||
mediaUUID, err := uuid.FromBytes(mediaItem.ID.Bytes[0:16])
|
mediaUUID, err := uuid.FromBytes(mediaItem.ID.Bytes[0:16])
|
||||||
@@ -740,7 +740,7 @@ func TestComicMetadataDisplay_AllFieldsTogether(t *testing.T) {
|
|||||||
// Create a media item with ALL metadata fields populated
|
// Create a media item with ALL metadata fields populated
|
||||||
alternateInfoStr := `{"alternate_series":"Ultimate X-Men","alternate_number":1,"alternate_count":12}`
|
alternateInfoStr := `{"alternate_series":"Ultimate X-Men","alternate_number":1,"alternate_count":12}`
|
||||||
var mediaItem database.MediaItems
|
var mediaItem database.MediaItems
|
||||||
mediaItem, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
mediaItem, err = setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||||
LibraryID: pgtype.UUID{Bytes: parsedLibraryID, Valid: true},
|
LibraryID: pgtype.UUID{Bytes: parsedLibraryID, Valid: true},
|
||||||
Title: "Complete Metadata Comic",
|
Title: "Complete Metadata Comic",
|
||||||
FilePath: "/test/complete.cbz",
|
FilePath: "/test/complete.cbz",
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func TestComicMetadataExtraction(t *testing.T) {
|
|||||||
require.NoError(t, err, "Should parse library UUID")
|
require.NoError(t, err, "Should parse library UUID")
|
||||||
|
|
||||||
// Insert test media item with full comic metadata
|
// Insert test media item with full comic metadata
|
||||||
_, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
_, err = setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||||
LibraryID: pgtype.UUID{Bytes: parsedLibraryID, Valid: true},
|
LibraryID: pgtype.UUID{Bytes: parsedLibraryID, Valid: true},
|
||||||
Title: "Test Manga",
|
Title: "Test Manga",
|
||||||
FilePath: "/test/manga.cbz",
|
FilePath: "/test/manga.cbz",
|
||||||
@@ -64,7 +64,7 @@ func TestComicMetadataExtraction(t *testing.T) {
|
|||||||
parsedLibraryID, err := uuid.Parse(libraryID)
|
parsedLibraryID, err := uuid.Parse(libraryID)
|
||||||
require.NoError(t, err, "Should parse library UUID")
|
require.NoError(t, err, "Should parse library UUID")
|
||||||
|
|
||||||
_, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
_, err = setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||||
LibraryID: pgtype.UUID{Bytes: parsedLibraryID, Valid: true},
|
LibraryID: pgtype.UUID{Bytes: parsedLibraryID, Valid: true},
|
||||||
Title: "Test Comic",
|
Title: "Test Comic",
|
||||||
FilePath: "/test/comic.cbz",
|
FilePath: "/test/comic.cbz",
|
||||||
@@ -94,7 +94,7 @@ func TestComicMetadataExtraction(t *testing.T) {
|
|||||||
parsedLibraryID, err := uuid.Parse(libraryID)
|
parsedLibraryID, err := uuid.Parse(libraryID)
|
||||||
require.NoError(t, err, "Should parse library UUID")
|
require.NoError(t, err, "Should parse library UUID")
|
||||||
|
|
||||||
_, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
_, err = setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||||
LibraryID: pgtype.UUID{Bytes: parsedLibraryID, Valid: true},
|
LibraryID: pgtype.UUID{Bytes: parsedLibraryID, Valid: true},
|
||||||
Title: "Minimal Comic",
|
Title: "Minimal Comic",
|
||||||
FilePath: "/test/minimal.cbz",
|
FilePath: "/test/minimal.cbz",
|
||||||
@@ -136,7 +136,7 @@ func TestReadingDirectionAPI(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range testCases {
|
for _, tc := range testCases {
|
||||||
_, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
_, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||||
LibraryID: pgtype.UUID{Bytes: parsedLibraryID, Valid: true},
|
LibraryID: pgtype.UUID{Bytes: parsedLibraryID, Valid: true},
|
||||||
Title: tc.title,
|
Title: tc.title,
|
||||||
FilePath: "/test/" + tc.title + ".cbz",
|
FilePath: "/test/" + tc.title + ".cbz",
|
||||||
@@ -205,7 +205,7 @@ func TestUniversalMetadataFields(t *testing.T) {
|
|||||||
require.NoError(t, err, "Should parse ebook library UUID")
|
require.NoError(t, err, "Should parse ebook library UUID")
|
||||||
|
|
||||||
t.Run("Comic with universal fields", func(t *testing.T) {
|
t.Run("Comic with universal fields", func(t *testing.T) {
|
||||||
_, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
_, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||||
LibraryID: pgtype.UUID{Bytes: parsedComicLibraryID, Valid: true},
|
LibraryID: pgtype.UUID{Bytes: parsedComicLibraryID, Valid: true},
|
||||||
Title: "Comic with Universal Metadata",
|
Title: "Comic with Universal Metadata",
|
||||||
FilePath: "/test/comic.cbz",
|
FilePath: "/test/comic.cbz",
|
||||||
@@ -231,7 +231,7 @@ func TestUniversalMetadataFields(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("Ebook with universal fields", func(t *testing.T) {
|
t.Run("Ebook with universal fields", func(t *testing.T) {
|
||||||
_, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
_, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||||
LibraryID: pgtype.UUID{Bytes: parsedEbookLibraryID, Valid: true},
|
LibraryID: pgtype.UUID{Bytes: parsedEbookLibraryID, Valid: true},
|
||||||
Title: "Ebook with Universal Metadata",
|
Title: "Ebook with Universal Metadata",
|
||||||
FilePath: "/test/book.epub",
|
FilePath: "/test/book.epub",
|
||||||
@@ -270,7 +270,7 @@ func TestComicSpecificFields(t *testing.T) {
|
|||||||
t.Run("Alternate series info as JSONB", func(t *testing.T) {
|
t.Run("Alternate series info as JSONB", func(t *testing.T) {
|
||||||
alternateInfo := `{"alternate_series":"Ultimate X-Men","alternate_number":1,"alternate_count":12}`
|
alternateInfo := `{"alternate_series":"Ultimate X-Men","alternate_number":1,"alternate_count":12}`
|
||||||
|
|
||||||
_, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
_, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||||
LibraryID: pgtype.UUID{Bytes: parsedLibraryID, Valid: true},
|
LibraryID: pgtype.UUID{Bytes: parsedLibraryID, Valid: true},
|
||||||
Title: "X-Men with Alternate Series",
|
Title: "X-Men with Alternate Series",
|
||||||
FilePath: "/test/xmen.cbz",
|
FilePath: "/test/xmen.cbz",
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ func createContractFixture(t *testing.T, setup *TestServerSetup) contractFixture
|
|||||||
|
|
||||||
seriesName := fmt.Sprintf("Test Contract Series %d", time.Now().UnixNano())
|
seriesName := fmt.Sprintf("Test Contract Series %d", time.Now().UnixNano())
|
||||||
itemTitle := fmt.Sprintf("Test Contract Item %d", time.Now().UnixNano())
|
itemTitle := fmt.Sprintf("Test Contract Item %d", time.Now().UnixNano())
|
||||||
media, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
media, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||||
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
||||||
Title: itemTitle,
|
Title: itemTitle,
|
||||||
Authors: []string{"Test Contract Author"},
|
Authors: []string{"Test Contract Author"},
|
||||||
@@ -452,9 +452,15 @@ func TestContractPin_KoboSyncFromServer(t *testing.T) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// bookInfoKeys is the BookInfo JSON every card surface emits.
|
// bookInfoKeys is the BookInfo JSON every card surface emits.
|
||||||
|
// 2026-10-27 (search-progress batch): + viewer_percentage (nil = no
|
||||||
|
// viewer data; only the search-unified adapter populates it today)
|
||||||
|
// and + library_name (same deal). docs/contract_pins.md changelog.
|
||||||
var bookInfoKeys = []string{
|
var bookInfoKeys = []string{
|
||||||
"media_item_id", "title", "author", "cover_image_path",
|
"media_item_id", "title", "author", "cover_image_path",
|
||||||
"has_conflict", "format_group", "badge",
|
"has_conflict", "format_group", "badge",
|
||||||
|
"viewer_percentage", "library_name",
|
||||||
|
// Episode-collections extras (2.3 widening, every surface emits):
|
||||||
|
"is_episode", "feed_title", "download_state", "hidden",
|
||||||
}
|
}
|
||||||
|
|
||||||
func collectionURL(setup *TestServerSetup, id string) string {
|
func collectionURL(setup *TestServerSetup, id string) string {
|
||||||
@@ -494,8 +500,9 @@ func TestContractPin_CollectionsJSON(t *testing.T) {
|
|||||||
// payload; docs/contract_pins.md changelog).
|
// payload; docs/contract_pins.md changelog).
|
||||||
envelopeKeys := []string{
|
envelopeKeys := []string{
|
||||||
"id", "user_id", "name", "description", "color", "icon",
|
"id", "user_id", "name", "description", "color", "icon",
|
||||||
"auto_assign_rules", "view_settings", "books", "book_count",
|
"view_settings", "books", "book_count",
|
||||||
"is_smart", "created_at", "up_next",
|
"is_smart", "created_at", "up_next",
|
||||||
|
"filter_tree", // item-3.2 widening (docs/contract_pins.md)
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Run("user collection with library_id (badged branch)", func(t *testing.T) {
|
t.Run("user collection with library_id (badged branch)", func(t *testing.T) {
|
||||||
@@ -558,11 +565,14 @@ func TestContractPin_CollectionsJSON(t *testing.T) {
|
|||||||
assertArrayItemShape(t, "collection books[] (no library)", books, bookInfoKeys, bookInfoKeys)
|
assertArrayItemShape(t, "collection books[] (no library)", books, bookInfoKeys, bookInfoKeys)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("smart collection (rules branch)", func(t *testing.T) {
|
t.Run("smart collection (tree branch)", func(t *testing.T) {
|
||||||
resp := doReq(t, "POST", setup.Server.URL+"/api/collections", map[string]interface{}{
|
resp := doReq(t, "POST", setup.Server.URL+"/api/collections", map[string]interface{}{
|
||||||
"name": fmt.Sprintf("Test Contract Smart %d", time.Now().UnixNano()),
|
"name": fmt.Sprintf("Test Contract Smart %d", time.Now().UnixNano()),
|
||||||
"auto_assign_rules": []map[string]interface{}{
|
"filter_tree": map[string]interface{}{
|
||||||
{"id": "rule-1", "field": "genre", "operator": "equals", "value": "Science Fiction", "priority": 5},
|
"scope": "books", "mode": "any",
|
||||||
|
"children": []map[string]interface{}{
|
||||||
|
{"field": "genre", "op": "is", "values": []string{"Science Fiction"}},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}, setup.Token)
|
}, setup.Token)
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
@@ -667,6 +677,12 @@ func TestContractPin_SearchJSON(t *testing.T) {
|
|||||||
// (docs/contract_pins.md changelog).
|
// (docs/contract_pins.md changelog).
|
||||||
"subtitle", "explicit", "authors", "authors_search",
|
"subtitle", "explicit", "authors", "authors_search",
|
||||||
"publishers", "publishers_search", "narrators_search",
|
"publishers", "publishers_search", "narrators_search",
|
||||||
|
// 2026-10-27 (search-progress batch): + viewer_percentage —
|
||||||
|
// the requester's reading_progress.percentage (LEFT JOIN on
|
||||||
|
// the same user_id param the visibility join binds), null
|
||||||
|
// when they never opened the book (docs/contract_pins.md
|
||||||
|
// changelog).
|
||||||
|
"viewer_percentage",
|
||||||
}
|
}
|
||||||
|
|
||||||
url := fmt.Sprintf("%s/api/media-items/search?q=%s&library_id=%s", setup.Server.URL, url.QueryEscape(fx.itemTitle), fx.libraryID)
|
url := fmt.Sprintf("%s/api/media-items/search?q=%s&library_id=%s", setup.Server.URL, url.QueryEscape(fx.itemTitle), fx.libraryID)
|
||||||
@@ -742,6 +758,19 @@ func TestContractPin_MediaListBothBranches(t *testing.T) {
|
|||||||
"tags_search", "contributors_search", "narrators_search", "file_sha256",
|
"tags_search", "contributors_search", "narrators_search", "file_sha256",
|
||||||
"opf_identifier", "opf_uuid", "hash_confidence", "library_name",
|
"opf_identifier", "opf_uuid", "hash_confidence", "library_name",
|
||||||
"library_type_name_2",
|
"library_type_name_2",
|
||||||
|
// Podcast episode columns (2026-10-29 additive widening — plan
|
||||||
|
// podcast-integration.md tasks 1.1/1.10): columns on
|
||||||
|
// media_items, NULL for scan rows. See docs/contract_pins.md
|
||||||
|
// changelog.
|
||||||
|
"source", "source_feed_id", "episode_guid", "feed_position",
|
||||||
|
"season_number", "episode_number", "enclosure_url",
|
||||||
|
"declared_duration_seconds", "transcript_url", "chapters_url",
|
||||||
|
"hosts", "guests", "download_state", "download_error",
|
||||||
|
"download_attempts",
|
||||||
|
// §0 item 4 (2026-10-30 additive widening): the download pool's
|
||||||
|
// queue ticket — SEQUENCE-stamped at enqueue, NULL for scan rows
|
||||||
|
// and never-queued episodes. See docs/contract_pins.md changelog.
|
||||||
|
"download_queue_pos",
|
||||||
}
|
}
|
||||||
resolvedKeys := []string{
|
resolvedKeys := []string{
|
||||||
"id", "library_id", "title", "subtitle", "author", "authors", "isbn", "description",
|
"id", "library_id", "title", "subtitle", "author", "authors", "isbn", "description",
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"bookhoard/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The custom-section builder's save, end to end (2026-10-31 tree
|
||||||
|
// migration). The page posts the legacy rules vocabulary — rules as a
|
||||||
|
// JSON-encoded ARRAY inside a STRING (it has done so since the page was
|
||||||
|
// written, 2026-02-19; the save never once bound successfully before
|
||||||
|
// the migration) — plus match_type, hand-picked book ids, and the
|
||||||
|
// dashboard pin. The server converts the rules to a filter tree at
|
||||||
|
// write time, attaches the manual picks, and the dashboard section
|
||||||
|
// renders stored membership + the tree's live matches, deduped.
|
||||||
|
func TestCustomSectionBuilderSave(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
adminToken := setup.Token
|
||||||
|
regToken := setup.RegularToken
|
||||||
|
marker := fmt.Sprintf("csb%d", time.Now().UnixNano()%100000)
|
||||||
|
|
||||||
|
libID := createTestLibraryWithFolder(t, setup.Server, adminToken, "test CSB Library "+marker, true)
|
||||||
|
libUUID, err := uuid.Parse(libID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Two items the title rule matches, one it doesn't. Alpha is ALSO
|
||||||
|
// hand-picked — the section must render it once, not twice.
|
||||||
|
makeItem := func(title string) uuid.UUID {
|
||||||
|
id, cerr := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||||
|
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
||||||
|
Title: title,
|
||||||
|
Authors: []string{"CSB Author"},
|
||||||
|
FilePath: fmt.Sprintf("/tmp/csb/%s-%s.epub", marker, strings.ReplaceAll(title, " ", "-")),
|
||||||
|
FileSize: pgtype.Int8{Int64: 128, Valid: true},
|
||||||
|
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||||
|
})
|
||||||
|
require.NoError(t, cerr)
|
||||||
|
out, perr := uuid.FromBytes(id.ID.Bytes[:])
|
||||||
|
require.NoError(t, perr)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
alpha := makeItem("CSBRULE Alpha")
|
||||||
|
_ = makeItem("CSBRULE Beta")
|
||||||
|
manual := makeItem("CSB Manual Pick")
|
||||||
|
require.NotZero(t, alpha)
|
||||||
|
|
||||||
|
// The EXACT payload shape the builder page posts.
|
||||||
|
payload := fmt.Sprintf(`{
|
||||||
|
"library_id": %q,
|
||||||
|
"name": "test CSB Section %s",
|
||||||
|
"icon": "📚",
|
||||||
|
"description": "built by the custom-section builder",
|
||||||
|
"show_on_dashboard": true,
|
||||||
|
"auto_assign_rules": %[3]s,
|
||||||
|
"manual_book_ids": [%q, %q],
|
||||||
|
"match_type": "all"
|
||||||
|
}`, libID, marker,
|
||||||
|
`"[{\"field\":\"title\",\"operator\":\"contains\",\"value\":\"CSBRULE\"}]"`,
|
||||||
|
alpha.String(), manual.String())
|
||||||
|
|
||||||
|
code, body := podcastAuthed(t, setup, regToken, "POST", "/api/collections", payload)
|
||||||
|
require.Equal(t, http.StatusCreated, code, "%s", string(body))
|
||||||
|
var coll struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal([]byte(body), &coll))
|
||||||
|
collID := coll.ID
|
||||||
|
require.NotEmpty(t, collID)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
podcastAuthed(t, setup, regToken, "DELETE", "/api/collections/"+collID, "")
|
||||||
|
})
|
||||||
|
|
||||||
|
// The converted tree round-trips: root mode from match_type, the
|
||||||
|
// rule as a qf child, and the collection IS smart with no
|
||||||
|
// collection_type sent.
|
||||||
|
code, body = podcastAuthed(t, setup, regToken, "GET", "/api/collections/"+collID, "")
|
||||||
|
require.Equal(t, http.StatusOK, code)
|
||||||
|
var detail struct {
|
||||||
|
IsSmart bool `json:"is_smart"`
|
||||||
|
FilterTree struct {
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
Children []struct {
|
||||||
|
Field string `json:"field"`
|
||||||
|
Op string `json:"op"`
|
||||||
|
Values []string `json:"values"`
|
||||||
|
} `json:"children"`
|
||||||
|
} `json:"filter_tree"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal([]byte(body), &detail))
|
||||||
|
assert.True(t, detail.IsSmart, "converted rules make the collection smart")
|
||||||
|
assert.Equal(t, "all", detail.FilterTree.Mode, "match_type seeds the tree's root mode")
|
||||||
|
require.Len(t, detail.FilterTree.Children, 1)
|
||||||
|
assert.Equal(t, "title", detail.FilterTree.Children[0].Field)
|
||||||
|
assert.Equal(t, []string{"CSBRULE"}, detail.FilterTree.Children[0].Values)
|
||||||
|
|
||||||
|
// The dashboard section renders stored membership + live matches,
|
||||||
|
// deduped: Alpha (rule + manual), Beta (rule), Manual (manual).
|
||||||
|
code, body = podcastAuthed(t, setup, regToken, "GET",
|
||||||
|
"/api/dashboard/sections?library_id="+libID, "")
|
||||||
|
require.Equal(t, http.StatusOK, code, "%s", string(body))
|
||||||
|
var dash struct {
|
||||||
|
Sections []struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
CollectionID string `json:"collection_id"`
|
||||||
|
Items []struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
} `json:"items"`
|
||||||
|
} `json:"sections"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal([]byte(body), &dash))
|
||||||
|
var section *struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
CollectionID string `json:"collection_id"`
|
||||||
|
Items []struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
} `json:"items"`
|
||||||
|
}
|
||||||
|
for i := range dash.Sections {
|
||||||
|
if dash.Sections[i].CollectionID == collID {
|
||||||
|
section = &dash.Sections[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NotNil(t, section, "the custom section renders on the dashboard: %s", string(body))
|
||||||
|
titles := map[string]int{}
|
||||||
|
for _, it := range section.Items {
|
||||||
|
titles[it.Title]++
|
||||||
|
}
|
||||||
|
assert.Equal(t, 1, titles["CSBRULE Alpha"], "rule-matched AND hand-picked renders exactly once")
|
||||||
|
assert.Equal(t, 1, titles["CSBRULE Beta"], "rule match rides the tree")
|
||||||
|
assert.Equal(t, 1, titles["CSB Manual Pick"], "manual picks are attached")
|
||||||
|
assert.Len(t, section.Items, 3, "nothing else leaks into the section")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCustomSectionBuilderBadRule pins the adapter's rejection path: a
|
||||||
|
// rule the engine cannot honor is a 400 that names the field, never a
|
||||||
|
// stored 500 at render time.
|
||||||
|
func TestCustomSectionBuilderBadRule(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
regToken := setup.RegularToken
|
||||||
|
|
||||||
|
code, body := podcastAuthed(t, setup, regToken, "POST", "/api/collections",
|
||||||
|
`{"name":"test CSB Bad `+fmt.Sprint(time.Now().UnixNano())+`","auto_assign_rules":"[{\"field\":\"not_a_field\",\"operator\":\"contains\",\"value\":\"x\"}]"}`)
|
||||||
|
assert.Equal(t, http.StatusBadRequest, code, "%s", string(body))
|
||||||
|
assert.Contains(t, string(body), "section rule")
|
||||||
|
}
|
||||||
@@ -89,7 +89,7 @@ func TestFacetRegistryParamsFilter(t *testing.T) {
|
|||||||
if mutate != nil {
|
if mutate != nil {
|
||||||
mutate(&p)
|
mutate(&p)
|
||||||
}
|
}
|
||||||
_, err := setup.DB.CreateMediaItem(ctx, p)
|
_, err := setup.DB.CreateMediaItem(seedCtx(), p)
|
||||||
require.NoError(t, err, "create %s", title)
|
require.NoError(t, err, "create %s", title)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,7 +194,7 @@ func TestFacetsEndpoint(t *testing.T) {
|
|||||||
|
|
||||||
// Two items share the genre; one carries the language + narrator.
|
// Two items share the genre; one carries the language + narrator.
|
||||||
for _, title := range []string{"Facet List One", "Facet List Two"} {
|
for _, title := range []string{"Facet List One", "Facet List Two"} {
|
||||||
_, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
_, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||||
LibraryID: library.ID,
|
LibraryID: library.ID,
|
||||||
Title: title,
|
Title: title,
|
||||||
Authors: []string{"Facet Listing Author"},
|
Authors: []string{"Facet Listing Author"},
|
||||||
@@ -206,7 +206,7 @@ func TestFacetsEndpoint(t *testing.T) {
|
|||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
_, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
_, err = setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||||
LibraryID: library.ID,
|
LibraryID: library.ID,
|
||||||
Title: "Facet List Three",
|
Title: "Facet List Three",
|
||||||
Authors: []string{"Facet Listing Author"},
|
Authors: []string{"Facet Listing Author"},
|
||||||
@@ -318,7 +318,6 @@ func mergeParams(base, extra url.Values) url.Values {
|
|||||||
// "Baen Books") cross-match at 0.3+ (session gotcha).
|
// "Baen Books") cross-match at 0.3+ (session gotcha).
|
||||||
func facetFixtureBooks(t *testing.T, setup *TestServerSetup, libraryID pgtype.UUID, prefix string) {
|
func facetFixtureBooks(t *testing.T, setup *TestServerSetup, libraryID pgtype.UUID, prefix string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
ctx := context.Background()
|
|
||||||
books := []struct {
|
books := []struct {
|
||||||
title string
|
title string
|
||||||
genre string
|
genre string
|
||||||
@@ -329,7 +328,7 @@ func facetFixtureBooks(t *testing.T, setup *TestServerSetup, libraryID pgtype.UU
|
|||||||
{prefix + " Gamma", "history", "Pinecrest"},
|
{prefix + " Gamma", "history", "Pinecrest"},
|
||||||
}
|
}
|
||||||
for _, b := range books {
|
for _, b := range books {
|
||||||
_, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
_, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||||
LibraryID: libraryID,
|
LibraryID: libraryID,
|
||||||
Title: b.title,
|
Title: b.title,
|
||||||
Authors: []string{prefix + " Author"},
|
Authors: []string{prefix + " Author"},
|
||||||
@@ -426,7 +425,7 @@ func TestFacetCountsUnderFilters(t *testing.T) {
|
|||||||
facetFixtureBooks(t, setup, library.ID, "Counts")
|
facetFixtureBooks(t, setup, library.ID, "Counts")
|
||||||
// A book in the HIDDEN library: distinctive values that must never
|
// A book in the HIDDEN library: distinctive values that must never
|
||||||
// surface for the regular user.
|
// surface for the regular user.
|
||||||
_, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
_, err = setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||||
LibraryID: hiddenLib.ID,
|
LibraryID: hiddenLib.ID,
|
||||||
Title: "Counts Hidden Book",
|
Title: "Counts Hidden Book",
|
||||||
Authors: []string{"Counts Secret Author"},
|
Authors: []string{"Counts Secret Author"},
|
||||||
@@ -559,7 +558,7 @@ func TestFieldValuesAllLibraries(t *testing.T) {
|
|||||||
id pgtype.UUID
|
id pgtype.UUID
|
||||||
name string
|
name string
|
||||||
}{{visibleLib.ID, "Visible"}, {hiddenLib.ID, "Hidden"}} {
|
}{{visibleLib.ID, "Visible"}, {hiddenLib.ID, "Hidden"}} {
|
||||||
_, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
_, err = setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||||
LibraryID: lib.id,
|
LibraryID: lib.id,
|
||||||
Title: fmt.Sprintf("Field Values %s Book", lib.name),
|
Title: fmt.Sprintf("Field Values %s Book", lib.name),
|
||||||
Authors: []string{"Verdana Scribe"},
|
Authors: []string{"Verdana Scribe"},
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ func builderFixtureLib(t *testing.T, setup *TestServerSetup, userID uuid.UUID, n
|
|||||||
}
|
}
|
||||||
books[i].TagsSearch = lowered
|
books[i].TagsSearch = lowered
|
||||||
}
|
}
|
||||||
_, err := setup.DB.CreateMediaItem(ctx, books[i])
|
_, err := setup.DB.CreateMediaItem(seedCtx(), books[i])
|
||||||
require.NoError(t, err, "create %s", books[i].Title)
|
require.NoError(t, err, "create %s", books[i].Title)
|
||||||
}
|
}
|
||||||
return libUUID, library.ID
|
return libUUID, library.ID
|
||||||
@@ -267,11 +267,9 @@ func TestFilterComposedSemantics(t *testing.T) {
|
|||||||
assert.Len(t, got, 4, "mime_type is")
|
assert.Len(t, got, 4, "mime_type is")
|
||||||
|
|
||||||
// Series full membership: the alternate_info series matches too.
|
// Series full membership: the alternate_info series matches too.
|
||||||
ctx := context.Background()
|
seedExec(t, setup.DBPool,
|
||||||
_, err := setup.DBPool.Exec(ctx,
|
|
||||||
`UPDATE media_items SET alternate_info = '{"alternate_series":"Quill Chronicles"}'::jsonb
|
`UPDATE media_items SET alternate_info = '{"alternate_series":"Quill Chronicles"}'::jsonb
|
||||||
WHERE title = $1`, "Sem Fen")
|
WHERE title = $1`, "Sem Fen")
|
||||||
require.NoError(t, err)
|
|
||||||
got = titles(qfAll(qfRule("series", "contains", "Quill Chron")))
|
got = titles(qfAll(qfRule("series", "contains", "Quill Chron")))
|
||||||
assert.Equal(t, []string{"Sem Fen"}, got, "series matches alternate_info membership")
|
assert.Equal(t, []string{"Sem Fen"}, got, "series matches alternate_info membership")
|
||||||
got = titles(qfAll(qfRule("series", "is", "Quill Chronicles")))
|
got = titles(qfAll(qfRule("series", "is", "Quill Chronicles")))
|
||||||
@@ -297,6 +295,115 @@ func TestFilterComposedSemantics(t *testing.T) {
|
|||||||
assert.Equal(t, []string{"Sem Tide"}, got, "published_year at_least")
|
assert.Equal(t, []string{"Sem Tide"}, got, "published_year at_least")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestFilterScopesAndEpisodeFields pins item 3.1: the books scope
|
||||||
|
// excludes podcasts (the leak the composed head used to have), the
|
||||||
|
// episodes scope is episodes only, episode fields evaluate in SQL
|
||||||
|
// (feed-in, downloaded, played, published-date-within), and episode
|
||||||
|
// fields are 400s in books trees.
|
||||||
|
func TestFilterScopesAndEpisodeFields(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
t.Cleanup(func() { _ = setup.Close() })
|
||||||
|
|
||||||
|
regUUID := getRegularUserID(t, setup.DB)
|
||||||
|
builderFixtureLib(t, setup, regUUID, "Scope")
|
||||||
|
|
||||||
|
// Podcasts library + a subscribed feed with three downloaded
|
||||||
|
// episodes: two fresh/unplayed, one played (~finished) — the
|
||||||
|
// played-rule fixture.
|
||||||
|
feedLib, _ := createPodcastsLibrary(t, setup, "test Scope Podcasts")
|
||||||
|
feed, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{
|
||||||
|
LibraryID: feedLib.ID,
|
||||||
|
FeedUrl: "https://feed.invalid/scope-probe.xml",
|
||||||
|
Title: "Scope Probe Feed",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
epFresh := upsertTestEpisode(t, setup, feed, "scope-fresh", "Scope Fresh Episode", 1)
|
||||||
|
epPlayed := upsertTestEpisode(t, setup, feed, "scope-played", "Scope Played Episode", 2)
|
||||||
|
upsertTestEpisode(t, setup, feed, "scope-pending", "Scope Pending Episode", 3)
|
||||||
|
subscribeRaw(t, setup, regUUID, feed.ID)
|
||||||
|
adminScopeExec(t, setup, adminCtxValue(t, setup), `
|
||||||
|
UPDATE media_items SET download_state = 'downloaded', file_size = 1000,
|
||||||
|
date_published = CURRENT_DATE WHERE id IN ($1, $2)`, epFresh.ID, epPlayed.ID)
|
||||||
|
adminScopeExec(t, setup, adminCtxValue(t, setup), `
|
||||||
|
INSERT INTO reading_progress (media_item_id, user_id, percentage)
|
||||||
|
VALUES ($1, $2, 1.0) ON CONFLICT (media_item_id, user_id)
|
||||||
|
DO UPDATE SET percentage = 1.0`, epPlayed.ID, regUUID)
|
||||||
|
|
||||||
|
feedID := uuid.UUID(feed.ID.Bytes).String()
|
||||||
|
|
||||||
|
qfScoped := func(scope string, children ...map[string]interface{}) url.Values {
|
||||||
|
b, _ := json.Marshal(map[string]interface{}{"scope": scope, "mode": "all", "children": children})
|
||||||
|
return url.Values{"qf": {string(b)}}
|
||||||
|
}
|
||||||
|
const booksScope = "books"
|
||||||
|
const episodesScope = "episodes"
|
||||||
|
|
||||||
|
search := func(p url.Values) []string {
|
||||||
|
rows := p
|
||||||
|
status, body := qfSearch(t, setup, setup.RegularToken, rows)
|
||||||
|
// 404 is the endpoint's pinned no-results contract — an empty set.
|
||||||
|
require.Contains(t, []int{http.StatusOK, http.StatusNotFound}, status, "qf must parse: %v", rows)
|
||||||
|
return qfTitles(t, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("books_scope_excludes_podcasts", func(t *testing.T) {
|
||||||
|
// An exact-ish title hits BOTH a book fixture ("Scope Tide") and
|
||||||
|
// the episode fixture if the head leaks; books scope must return
|
||||||
|
// the book, never the episodes.
|
||||||
|
got := search(qfScoped(booksScope, qfRule("title", "is", "Scope Tide")))
|
||||||
|
assert.Equal(t, []string{"Scope Tide"}, got, "books scope returns the book")
|
||||||
|
got = search(qfScoped(booksScope, qfRule("title", "is", "Scope Fresh Episode")))
|
||||||
|
assert.Empty(t, got,
|
||||||
|
"books scope must never return podcast episodes (the composed-head leak is CLOSED)")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("episodes_scope_is_episodes_only", func(t *testing.T) {
|
||||||
|
got := search(qfScoped(episodesScope, qfRule("title", "is", "Scope Fresh Episode")))
|
||||||
|
assert.Equal(t, []string{"Scope Fresh Episode"}, got,
|
||||||
|
"episodes scope: only episodes — books drop")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("episode_rules_evaluate_in_sql", func(t *testing.T) {
|
||||||
|
got := search(qfScoped(episodesScope,
|
||||||
|
qfRule("feed", "in", feedID)))
|
||||||
|
assert.ElementsMatch(t, []string{"Scope Fresh Episode", "Scope Played Episode", "Scope Pending Episode"},
|
||||||
|
got, "feed-in matches every episode of the feed (download state is a separate rule)")
|
||||||
|
|
||||||
|
got = search(qfScoped(episodesScope,
|
||||||
|
qfRule("downloaded", "is_true")))
|
||||||
|
assert.ElementsMatch(t, []string{"Scope Fresh Episode", "Scope Played Episode"},
|
||||||
|
got, "downloaded:true drops the pending episode")
|
||||||
|
|
||||||
|
got = search(qfScoped(episodesScope,
|
||||||
|
qfRule("played", "is_false"),
|
||||||
|
qfRule("downloaded", "is_true")))
|
||||||
|
assert.ElementsMatch(t, []string{"Scope Fresh Episode"}, got,
|
||||||
|
"played:false + downloaded = the unplayed playable episode (viewer progress)")
|
||||||
|
|
||||||
|
got = search(qfScoped(episodesScope,
|
||||||
|
qfRule("played", "is_true")))
|
||||||
|
assert.ElementsMatch(t, []string{"Scope Played Episode"}, got,
|
||||||
|
"played:true matches the finished episode")
|
||||||
|
|
||||||
|
got = search(qfScoped(episodesScope,
|
||||||
|
qfRule("published_date", "within_days", "7"),
|
||||||
|
qfRule("downloaded", "is_true")))
|
||||||
|
assert.ElementsMatch(t, []string{"Scope Fresh Episode", "Scope Played Episode"},
|
||||||
|
got, "published-within includes the dated episodes")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("episode_fields_are_400_in_books_scope", func(t *testing.T) {
|
||||||
|
p := qfScoped(booksScope, qfRule("played", "is_true"))
|
||||||
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/search?"+p.Encode(), nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode,
|
||||||
|
"episode-only fields are rejected in books trees at parse time")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestFilterUserRelativeFields(t *testing.T) {
|
func TestFilterUserRelativeFields(t *testing.T) {
|
||||||
setup := setupTestServer(t)
|
setup := setupTestServer(t)
|
||||||
t.Cleanup(func() { _ = setup.Close() })
|
t.Cleanup(func() { _ = setup.Close() })
|
||||||
@@ -327,43 +434,39 @@ func TestFilterUserRelativeFields(t *testing.T) {
|
|||||||
// Reader A: reading Tide (50%), finished Stone, rated Tide 8,
|
// Reader A: reading Tide (50%), finished Stone, rated Tide 8,
|
||||||
// reviewed Fen, noted on Tide ("the catharsis scene"), highlighted
|
// reviewed Fen, noted on Tide ("the catharsis scene"), highlighted
|
||||||
// Stone, time on Wick (2h), collection holding Tide.
|
// Stone, time on Wick (2h), collection holding Tide.
|
||||||
_, err := setup.DBPool.Exec(ctx,
|
seedExec(t, setup.DBPool,
|
||||||
`INSERT INTO reading_progress (media_item_id, user_id, percentage, last_read_at) VALUES ($1,$2,0.5, now()), ($3,$4,1.0, now())`,
|
`INSERT INTO reading_progress (media_item_id, user_id, percentage, last_read_at) VALUES ($1,$2,0.5, now()), ($3,$4,1.0, now())`,
|
||||||
tideID, aID, stoneID, aID)
|
tideID, aID, stoneID, aID)
|
||||||
require.NoError(t, err)
|
seedExec(t, setup.DBPool,
|
||||||
_, err = setup.DBPool.Exec(ctx,
|
|
||||||
`INSERT INTO reading_progress (media_item_id, user_id, percentage, last_read_at) VALUES ($1,$2,1.0, now())`,
|
`INSERT INTO reading_progress (media_item_id, user_id, percentage, last_read_at) VALUES ($1,$2,1.0, now())`,
|
||||||
tideID, bID)
|
tideID, bID)
|
||||||
require.NoError(t, err)
|
seedExec(t, setup.DBPool,
|
||||||
_, err = setup.DBPool.Exec(ctx,
|
|
||||||
`INSERT INTO media_ratings (media_item_id, user_id, rating) VALUES ($1,$2,8)`, tideID, aID)
|
`INSERT INTO media_ratings (media_item_id, user_id, rating) VALUES ($1,$2,8)`, tideID, aID)
|
||||||
require.NoError(t, err)
|
seedExec(t, setup.DBPool,
|
||||||
_, err = setup.DBPool.Exec(ctx,
|
|
||||||
`INSERT INTO media_ratings (media_item_id, user_id, rating) VALUES ($1,$2,3)`, tideID, bID)
|
`INSERT INTO media_ratings (media_item_id, user_id, rating) VALUES ($1,$2,3)`, tideID, bID)
|
||||||
require.NoError(t, err)
|
seedExec(t, setup.DBPool,
|
||||||
_, err = setup.DBPool.Exec(ctx,
|
|
||||||
`INSERT INTO media_reviews (media_item_id, user_id, review) VALUES ($1,$2,'splendid')`, fenID, aID)
|
`INSERT INTO media_reviews (media_item_id, user_id, review) VALUES ($1,$2,'splendid')`, fenID, aID)
|
||||||
require.NoError(t, err)
|
seedExec(t, setup.DBPool,
|
||||||
_, err = setup.DBPool.Exec(ctx,
|
|
||||||
`INSERT INTO media_notes (media_item_id, user_id, content) VALUES ($1,$2,'remember the catharsis scene')`, tideID, aID)
|
`INSERT INTO media_notes (media_item_id, user_id, content) VALUES ($1,$2,'remember the catharsis scene')`, tideID, aID)
|
||||||
require.NoError(t, err)
|
seedExec(t, setup.DBPool,
|
||||||
_, err = setup.DBPool.Exec(ctx,
|
|
||||||
`INSERT INTO media_highlights (media_item_id, user_id, selection_text) VALUES ($1,$2,'the dark forest road')`, stoneID, aID)
|
`INSERT INTO media_highlights (media_item_id, user_id, selection_text) VALUES ($1,$2,'the dark forest road')`, stoneID, aID)
|
||||||
require.NoError(t, err)
|
seedExec(t, setup.DBPool,
|
||||||
_, err = setup.DBPool.Exec(ctx,
|
|
||||||
`INSERT INTO reading_history (user_id, media_item_id, time_spent_seconds, pages_read) VALUES ($1,$2,7200,120), ($3,$4,600,30)`,
|
`INSERT INTO reading_history (user_id, media_item_id, time_spent_seconds, pages_read) VALUES ($1,$2,7200,120), ($3,$4,600,30)`,
|
||||||
aID, wickID, aID, tideID)
|
aID, wickID, aID, tideID)
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
var collID pgtype.UUID
|
var collID pgtype.UUID
|
||||||
err = setup.DBPool.QueryRow(ctx,
|
// Seed as the OWNER (collections RLS, 2026-10-31): the insert
|
||||||
|
// predicate wants actor == owner, and the item/cleanup writes want
|
||||||
|
// the same identity — the admin write-escape covers SYSTEM rows only.
|
||||||
|
err := database.NewScopedDB(setup.DBPool).QueryRow(ownerSeedCtx(aID),
|
||||||
`INSERT INTO collections (user_id, name) VALUES ($1, $2) RETURNING id`,
|
`INSERT INTO collections (user_id, name) VALUES ($1, $2) RETURNING id`,
|
||||||
aID, fmt.Sprintf("Test Builder Coll %d", time.Now().UnixNano())).Scan(&collID)
|
aID, fmt.Sprintf("Test Builder Coll %d", time.Now().UnixNano())).Scan(&collID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
t.Cleanup(func() { _ = setup.DB.DeleteCollection(ctx, collID) })
|
t.Cleanup(func() {
|
||||||
_, err = setup.DBPool.Exec(ctx,
|
scopeExecAs(t, setup, aID, `DELETE FROM collections WHERE id = $1`, collID)
|
||||||
|
})
|
||||||
|
scopeExecAs(t, setup, aID,
|
||||||
`INSERT INTO collection_items (collection_id, media_item_id) VALUES ($1,$2)`, collID, tideID)
|
`INSERT INTO collection_items (collection_id, media_item_id) VALUES ($1,$2)`, collID, tideID)
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
titlesFor := func(token string, qf string) []string {
|
titlesFor := func(token string, qf string) []string {
|
||||||
p := url.Values{"library_id": {libUUID.String()}, "qf": {qf}}
|
p := url.Values{"library_id": {libUUID.String()}, "qf": {qf}}
|
||||||
@@ -444,8 +547,7 @@ func TestFilterSuggestionsUnderRules(t *testing.T) {
|
|||||||
libUUID, _ := builderFixtureLib(t, setup, userID, "Sug")
|
libUUID, _ := builderFixtureLib(t, setup, userID, "Sug")
|
||||||
|
|
||||||
// Add a second distinct author+genre pair (trigram-disjoint).
|
// Add a second distinct author+genre pair (trigram-disjoint).
|
||||||
ctx := context.Background()
|
_, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||||
_, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
|
||||||
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
||||||
Title: "Sug Dune",
|
Title: "Sug Dune",
|
||||||
Authors: []string{"Zephyrine Hale"},
|
Authors: []string{"Zephyrine Hale"},
|
||||||
@@ -548,6 +650,16 @@ func TestFilterSuggestionsUnderRules(t *testing.T) {
|
|||||||
}
|
}
|
||||||
assert.True(t, found, "the visible library is offered")
|
assert.True(t, found, "the visible library is offered")
|
||||||
|
|
||||||
|
// ID LOOKUP (2026-11-01): typing a stored ID resolves its label —
|
||||||
|
// the builder's post-Apply reload path resolves stored ids to names
|
||||||
|
// through this same call.
|
||||||
|
status, results = suggest("library", libUUID.String(), "")
|
||||||
|
require.Equal(t, http.StatusOK, status)
|
||||||
|
require.NotEmpty(t, results, "the id lookup returns its row")
|
||||||
|
assert.Equal(t, libUUID.String(), results[0]["value"])
|
||||||
|
disp, _ := results[0]["display"].(string)
|
||||||
|
assert.Contains(t, disp, "Test Builder", "the id resolves to the library NAME")
|
||||||
|
|
||||||
// BROWSE the library field UNDER rules (the blank-typed click): the
|
// BROWSE the library field UNDER rules (the blank-typed click): the
|
||||||
// id-source query must not choke on the unused typed parameter and
|
// id-source query must not choke on the unused typed parameter and
|
||||||
// must list exactly the library holding the surviving books.
|
// must list exactly the library holding the surviving books.
|
||||||
@@ -572,18 +684,22 @@ func TestFilterSuggestionsUnderRules(t *testing.T) {
|
|||||||
// offered with the surviving count; zero-match containers never are.
|
// offered with the surviving count; zero-match containers never are.
|
||||||
userA := getRegularUserID(t, setup.DB)
|
userA := getRegularUserID(t, setup.DB)
|
||||||
var collID pgtype.UUID
|
var collID pgtype.UUID
|
||||||
err = setup.DBPool.QueryRow(ctx,
|
// Seed as the OWNER (collections RLS, 2026-10-31) — same identity
|
||||||
|
// rule as the first seeding site in this file.
|
||||||
|
ownerID := pgtype.UUID{Bytes: [16]byte(userA), Valid: true}
|
||||||
|
err = database.NewScopedDB(setup.DBPool).QueryRow(ownerSeedCtx(ownerID),
|
||||||
`INSERT INTO collections (user_id, name) VALUES ($1, $2) RETURNING id`,
|
`INSERT INTO collections (user_id, name) VALUES ($1, $2) RETURNING id`,
|
||||||
pgtype.UUID{Bytes: [16]byte(userA), Valid: true},
|
ownerID,
|
||||||
fmt.Sprintf("Test Builder Sug Coll %d", time.Now().UnixNano())).Scan(&collID)
|
fmt.Sprintf("Test Builder Sug Coll %d", time.Now().UnixNano())).Scan(&collID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
t.Cleanup(func() { _ = setup.DB.DeleteCollection(ctx, collID) })
|
t.Cleanup(func() {
|
||||||
|
scopeExecAs(t, setup, ownerID, `DELETE FROM collections WHERE id = $1`, collID)
|
||||||
|
})
|
||||||
var tideID pgtype.UUID
|
var tideID pgtype.UUID
|
||||||
err = setup.DBPool.QueryRow(ctx, `SELECT id FROM media_items WHERE title = $1`, "Sug Tide").Scan(&tideID)
|
err = setup.DBPool.QueryRow(context.Background(), `SELECT id FROM media_items WHERE title = $1`, "Sug Tide").Scan(&tideID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
_, err = setup.DBPool.Exec(ctx,
|
scopeExecAs(t, setup, ownerID,
|
||||||
`INSERT INTO collection_items (collection_id, media_item_id) VALUES ($1, $2)`, collID, tideID)
|
`INSERT INTO collection_items (collection_id, media_item_id) VALUES ($1, $2)`, collID, tideID)
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
status, results = suggest("collection", " ", "")
|
status, results = suggest("collection", " ", "")
|
||||||
require.Equal(t, http.StatusOK, status)
|
require.Equal(t, http.StatusOK, status)
|
||||||
@@ -632,7 +748,7 @@ func TestFilterFamilyLens(t *testing.T) {
|
|||||||
id pgtype.UUID
|
id pgtype.UUID
|
||||||
title string
|
title string
|
||||||
}{{visibleID, "Lens Vis Book"}, {hiddenID, "Lens Hid Book"}} {
|
}{{visibleID, "Lens Vis Book"}, {hiddenID, "Lens Hid Book"}} {
|
||||||
_, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
_, err = setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||||
LibraryID: lib.id, Title: lib.title,
|
LibraryID: lib.id, Title: lib.title,
|
||||||
Authors: []string{"Lens Secret Author"},
|
Authors: []string{"Lens Secret Author"},
|
||||||
FilePath: fmt.Sprintf("/tmp/builder/%s-%d.epub", lib.title, time.Now().UnixNano()),
|
FilePath: fmt.Sprintf("/tmp/builder/%s-%d.epub", lib.title, time.Now().UnixNano()),
|
||||||
@@ -641,10 +757,9 @@ func TestFilterFamilyLens(t *testing.T) {
|
|||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
_, err = setup.DBPool.Exec(ctx,
|
seedExec(t, setup.DBPool,
|
||||||
`INSERT INTO library_visibility (library_id, user_id, is_visible) VALUES ($1, $2, false)`,
|
`INSERT INTO library_visibility (library_id, user_id, is_visible) VALUES ($1, $2, false)`,
|
||||||
hiddenID, pgtype.UUID{Bytes: [16]byte(userID), Valid: true})
|
hiddenID, pgtype.UUID{Bytes: [16]byte(userID), Valid: true})
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
search := func(params url.Values) (int, []map[string]interface{}) {
|
search := func(params url.Values) (int, []map[string]interface{}) {
|
||||||
return qfSearch(t, setup, setup.RegularToken, params)
|
return qfSearch(t, setup, setup.RegularToken, params)
|
||||||
@@ -788,3 +903,85 @@ func TestFilterFlatEquivalence(t *testing.T) {
|
|||||||
require.Equal(t, statusFlat, statusTree, "same result status")
|
require.Equal(t, statusFlat, statusTree, "same result status")
|
||||||
assert.ElementsMatch(t, qfTitles(t, flatRows), qfTitles(t, treeRows))
|
assert.ElementsMatch(t, qfTitles(t, flatRows), qfTitles(t, treeRows))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- deep group nesting (2026-11-01 group ALL/ANY header toggle): ----
|
||||||
|
// the UI can now produce 3+-level mixed-mode trees; pin the engine's
|
||||||
|
// parenthesized evaluation for the user's target structure
|
||||||
|
// GROUP(GROUP(Field and (Field or Field)) or GROUP(Field and Field and
|
||||||
|
// (Field or Field)) and GROUP(Field)) — plus a suggestion request
|
||||||
|
// carrying a nested-any constraint (the mode-aware menu under real
|
||||||
|
// nesting) — must be 200, never the empty-group 400.
|
||||||
|
|
||||||
|
func TestFilterDeepGroupNesting(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
t.Cleanup(func() { _ = setup.Close() })
|
||||||
|
|
||||||
|
userID := getRegularUserID(t, setup.DB)
|
||||||
|
libUUID, _ := builderFixtureLib(t, setup, userID, "Deep")
|
||||||
|
|
||||||
|
titles := func(qf string) []string {
|
||||||
|
p := url.Values{"library_id": {libUUID.String()}, "qf": {qf}}
|
||||||
|
status, rows := qfSearch(t, setup, setup.RegularToken, p)
|
||||||
|
require.Contains(t, []int{http.StatusOK, http.StatusNotFound}, status)
|
||||||
|
return qfTitles(t, rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// root ALL[
|
||||||
|
// ANY[ ALL[fantasy, ANY[>=250p, abridged]] , ALL[Dar, >=500p] ],
|
||||||
|
// author not_contains Quill
|
||||||
|
// ]
|
||||||
|
// => Tide (fantasy + 300p), Wick (Dar + 700p); Fen dies at the root
|
||||||
|
// AND (Quill); Stone fails branch A (history) and branch B (Voss).
|
||||||
|
deep := map[string]interface{}{
|
||||||
|
"mode": "all",
|
||||||
|
"children": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"mode": "any",
|
||||||
|
"children": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"mode": "all",
|
||||||
|
"children": []interface{}{
|
||||||
|
qfRule("genre", "is", "fantasy"),
|
||||||
|
map[string]interface{}{
|
||||||
|
"mode": "any",
|
||||||
|
"children": []interface{}{
|
||||||
|
qfRule("page_count", "at_least", "250"),
|
||||||
|
qfRule("abridged", "is_true"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
map[string]interface{}{
|
||||||
|
"mode": "all",
|
||||||
|
"children": []interface{}{
|
||||||
|
qfRule("author", "contains", "Dar"),
|
||||||
|
qfRule("page_count", "at_least", "500"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
qfRule("author", "not_contains", "Quill"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
deepJSON, merr := json.Marshal(deep)
|
||||||
|
require.NoError(t, merr)
|
||||||
|
got := titles(string(deepJSON))
|
||||||
|
assert.ElementsMatch(t, []string{"Deep Tide", "Deep Wick"}, got,
|
||||||
|
"3-level mixed-mode nesting evaluates parenthesized")
|
||||||
|
|
||||||
|
// The same shape riding the SUGGEST endpoint (the mode-aware menu's
|
||||||
|
// constraint) — nested any must be a 200, never the empty-group 400.
|
||||||
|
p := url.Values{
|
||||||
|
"library_id": {libUUID.String()},
|
||||||
|
"author": {""},
|
||||||
|
"limit": {"20"},
|
||||||
|
"qf": {string(deepJSON)},
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/search?"+p.Encode(), nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode,
|
||||||
|
"a nested-any constraint rides the suggestion endpoint cleanly: %s", p.Encode())
|
||||||
|
}
|
||||||
@@ -0,0 +1,444 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestMetadataWriteToFile pins the write-to-file endpoint (2026-10-27):
|
||||||
|
// POST /api/media-items/:id/write-metadata (admin only) persists the
|
||||||
|
// item's CURRENT database metadata into the artifact — embedded OPF for
|
||||||
|
// EPUBs, a Calibre-style sidecar for formats that can't carry metadata
|
||||||
|
// — then re-extracts. Sidecar naming: metadata.opf when the item is the
|
||||||
|
// only book in its directory, <basename>.opf otherwise (one book's
|
||||||
|
// sidecar must never poison dir-mates).
|
||||||
|
//
|
||||||
|
// Fixture files live under /app/uploads (the container's library
|
||||||
|
// folder) and are removed on cleanup; the test library delete
|
||||||
|
// cascades the items.
|
||||||
|
func TestMetadataWriteToFile(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping integration test in short mode")
|
||||||
|
}
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
libID := createTestLibraryWithFolder(t, setup.Server, setup.Token, "Test Metadata Write Library", true)
|
||||||
|
uploadsDir := "/app/uploads"
|
||||||
|
|
||||||
|
insertItem := func(relPath, title string) string {
|
||||||
|
var itemID string
|
||||||
|
err := setup.DBPool.QueryRow(ctx,
|
||||||
|
`INSERT INTO media_items (library_id, title, file_path, format_group, mime_type, file_size, created_at, imported_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, 'ebook', 'application/epub+zip', 100, NOW(), NOW(), NOW()) RETURNING id`,
|
||||||
|
libID, title, relPath,
|
||||||
|
).Scan(&itemID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return itemID
|
||||||
|
}
|
||||||
|
|
||||||
|
writeEPUB := func(absPath, title string) {
|
||||||
|
t.Helper()
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(absPath), 0o755))
|
||||||
|
var buf bytes.Buffer
|
||||||
|
w := zip.NewWriter(&buf)
|
||||||
|
mimetype, _ := w.CreateHeader(&zip.FileHeader{Name: "mimetype", Method: zip.Store})
|
||||||
|
_, _ = mimetype.Write([]byte("application/epub+zip"))
|
||||||
|
container, _ := w.Create("META-INF/container.xml")
|
||||||
|
_, _ = container.Write([]byte(`<?xml version="1.0"?><container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles></container>`))
|
||||||
|
opf, _ := w.Create("OEBPS/content.opf")
|
||||||
|
_, _ = opf.Write([]byte(fmt.Sprintf(`<?xml version="1.0"?><package version="3.0" xmlns="http://www.idpf.org/2007/opf" unique-identifier="pub-id"><metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title id="t1">%s</dc:title><dc:creator>Original Author</dc:creator><dc:identifier id="pub-id">urn:uuid:abcd</dc:identifier></metadata><manifest><item id="c1" href="c1.xhtml" media-type="application/xhtml+xml"/></manifest><spine><itemref idref="c1"/></spine></package>`, title)))
|
||||||
|
ch, _ := w.Create("OEBPS/c1.xhtml")
|
||||||
|
_, _ = ch.Write([]byte("<html><body>x</body></html>"))
|
||||||
|
require.NoError(t, w.Close())
|
||||||
|
require.NoError(t, os.WriteFile(absPath, buf.Bytes(), 0o644))
|
||||||
|
}
|
||||||
|
|
||||||
|
callWrite := func(token, itemID string) (int, map[string]interface{}) {
|
||||||
|
req, _ := http.NewRequest("POST", fmt.Sprintf("%s/api/media-items/%s/write-metadata", setup.Server.URL, itemID), nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err := (&http.Client{}).Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var body map[string]interface{}
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||||
|
return resp.StatusCode, body
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit the item's metadata the way the editor does (PUT), then ask
|
||||||
|
// the endpoint to persist it.
|
||||||
|
editItem := func(itemID string, fields map[string]interface{}) {
|
||||||
|
body, _ := json.Marshal(fields)
|
||||||
|
req, _ := http.NewRequest("PUT", fmt.Sprintf("%s/api/media-items/%s", setup.Server.URL, itemID), bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
resp, err := (&http.Client{}).Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode, "PUT should succeed")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("non-admin is forbidden", func(t *testing.T) {
|
||||||
|
rel := fmt.Sprintf("test-metadata-write-%d/forbidden/book.epub", os.Getpid())
|
||||||
|
abs := filepath.Join(uploadsDir, rel)
|
||||||
|
writeEPUB(abs, "Forbidden Book")
|
||||||
|
itemID := insertItem(rel, "Forbidden Book")
|
||||||
|
code, _ := callWrite(setup.RegularToken, itemID)
|
||||||
|
assert.Equal(t, http.StatusForbidden, code)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing file errors without sidecar litter", func(t *testing.T) {
|
||||||
|
itemID := insertItem(fmt.Sprintf("test-metadata-write-%d/ghost/book.epub", os.Getpid()), "Ghost Book")
|
||||||
|
code, body := callWrite(setup.Token, itemID)
|
||||||
|
assert.Equal(t, http.StatusInternalServerError, code)
|
||||||
|
assert.Contains(t, body["error"], "missing on disk")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("EPUB metadata is embedded and rescan keeps it", func(t *testing.T) {
|
||||||
|
rel := fmt.Sprintf("test-metadata-write-%d/solo/book.epub", os.Getpid())
|
||||||
|
abs := filepath.Join(uploadsDir, rel)
|
||||||
|
writeEPUB(abs, "Original EPUB Title")
|
||||||
|
itemID := insertItem(rel, "Original EPUB Title")
|
||||||
|
|
||||||
|
editItem(itemID, map[string]interface{}{
|
||||||
|
"title": "Embedded New Title",
|
||||||
|
"authors": []string{"Embedded Author"},
|
||||||
|
"series": "Embedded Series",
|
||||||
|
"series_number": 2,
|
||||||
|
})
|
||||||
|
|
||||||
|
code, body := callWrite(setup.Token, itemID)
|
||||||
|
require.Equal(t, http.StatusOK, code)
|
||||||
|
assert.Equal(t, "embedded-opf", body["metadata_target"])
|
||||||
|
|
||||||
|
// The zip on disk now carries the new values — verified by
|
||||||
|
// PARSING the rewritten OPF (not string matching), with the
|
||||||
|
// ORIGINAL unique-identifier surviving the splice.
|
||||||
|
reader, err := zip.OpenReader(abs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
var opfContent []byte
|
||||||
|
for _, f := range reader.File {
|
||||||
|
if f.Name == "OEBPS/content.opf" {
|
||||||
|
rc, err := f.Open()
|
||||||
|
require.NoError(t, err)
|
||||||
|
opfContent, err = io.ReadAll(rc)
|
||||||
|
require.NoError(t, rc.Close())
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.Close()
|
||||||
|
|
||||||
|
var opf struct {
|
||||||
|
Metadata struct {
|
||||||
|
Titles []string `xml:"http://purl.org/dc/elements/1.1/ title"`
|
||||||
|
Creators []string `xml:"http://purl.org/dc/elements/1.1/ creator"`
|
||||||
|
Metas []struct {
|
||||||
|
Name string `xml:"name,attr"`
|
||||||
|
Content string `xml:"content,attr"`
|
||||||
|
} `xml:"meta"`
|
||||||
|
} `xml:"metadata"`
|
||||||
|
}
|
||||||
|
require.NoError(t, xml.Unmarshal(opfContent, &opf))
|
||||||
|
require.NotEmpty(t, opf.Metadata.Titles, "dc:title present in written OPF")
|
||||||
|
assert.Equal(t, "Embedded New Title", opf.Metadata.Titles[0])
|
||||||
|
assert.NotContains(t, opf.Metadata.Titles, "Original EPUB Title", "old title replaced")
|
||||||
|
require.NotEmpty(t, opf.Metadata.Creators)
|
||||||
|
var seriesMeta string
|
||||||
|
for _, m := range opf.Metadata.Metas {
|
||||||
|
if m.Name == "calibre:series" {
|
||||||
|
seriesMeta = m.Content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.Equal(t, "Embedded Series", seriesMeta)
|
||||||
|
assert.Contains(t, string(opfContent), "urn:uuid:abcd", "original identifier preserved for unique-identifier")
|
||||||
|
|
||||||
|
// The inline rescan re-extracted from the file: the DB title
|
||||||
|
// still reads the written value.
|
||||||
|
var title string
|
||||||
|
require.NoError(t, setup.DBPool.QueryRow(ctx,
|
||||||
|
`SELECT title FROM media_items WHERE id = $1`, itemID).Scan(&title))
|
||||||
|
assert.Equal(t, "Embedded New Title", title)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("single-book dir gets a Calibre-style metadata.opf sidecar", func(t *testing.T) {
|
||||||
|
rel := fmt.Sprintf("test-metadata-write-%d/alone/book.pdf", os.Getpid())
|
||||||
|
abs := filepath.Join(uploadsDir, rel)
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(abs), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(abs, []byte("%PDF-1.4 fake"), 0o644))
|
||||||
|
|
||||||
|
var itemID string
|
||||||
|
err := setup.DBPool.QueryRow(ctx,
|
||||||
|
`INSERT INTO media_items (library_id, title, file_path, format_group, mime_type, file_size, created_at, imported_at, updated_at)
|
||||||
|
VALUES ($1, 'Sidecar Book', $2, 'ebook', 'application/pdf', 10, NOW(), NOW(), NOW()) RETURNING id`,
|
||||||
|
libID, rel,
|
||||||
|
).Scan(&itemID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
editItem(itemID, map[string]interface{}{"title": "Sidecar New Title"})
|
||||||
|
|
||||||
|
code, body := callWrite(setup.Token, itemID)
|
||||||
|
require.Equal(t, http.StatusOK, code)
|
||||||
|
assert.Equal(t, "sidecar-opf", body["metadata_target"])
|
||||||
|
|
||||||
|
sidecar := filepath.Join(filepath.Dir(abs), "metadata.opf")
|
||||||
|
data, err := os.ReadFile(sidecar)
|
||||||
|
require.NoError(t, err, "metadata.opf must exist next to a lone book")
|
||||||
|
|
||||||
|
// Parse the sidecar like the scanner does: title must be present
|
||||||
|
// as a dc:title ELEMENT, not just a substring.
|
||||||
|
var opf struct {
|
||||||
|
Metadata struct {
|
||||||
|
Titles []string `xml:"http://purl.org/dc/elements/1.1/ title"`
|
||||||
|
} `xml:"metadata"`
|
||||||
|
}
|
||||||
|
require.NoError(t, xml.Unmarshal(data, &opf))
|
||||||
|
require.NotEmpty(t, opf.Metadata.Titles)
|
||||||
|
assert.Equal(t, "Sidecar New Title", opf.Metadata.Titles[0])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("shared dir falls back to <basename>.opf", func(t *testing.T) {
|
||||||
|
// PDFs can't carry metadata, so they take the sidecar path —
|
||||||
|
// and with two books in one directory, the sidecar must be
|
||||||
|
// per-file or A's write would poison B's next scan.
|
||||||
|
dir := fmt.Sprintf("test-metadata-write-%d/shared", os.Getpid())
|
||||||
|
relA, relB := dir+"/a.pdf", dir+"/b.pdf"
|
||||||
|
absA, absB := filepath.Join(uploadsDir, relA), filepath.Join(uploadsDir, relB)
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(absA), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(absA, []byte("%PDF-1.4 fake a"), 0o644))
|
||||||
|
require.NoError(t, os.WriteFile(absB, []byte("%PDF-1.4 fake b"), 0o644))
|
||||||
|
|
||||||
|
var itemA string
|
||||||
|
err := setup.DBPool.QueryRow(ctx,
|
||||||
|
`INSERT INTO media_items (library_id, title, file_path, format_group, mime_type, file_size, created_at, imported_at, updated_at)
|
||||||
|
VALUES ($1, 'Book A', $2, 'ebook', 'application/pdf', 10, NOW(), NOW(), NOW()) RETURNING id`,
|
||||||
|
libID, relA,
|
||||||
|
).Scan(&itemA)
|
||||||
|
require.NoError(t, err)
|
||||||
|
var itemB string
|
||||||
|
err = setup.DBPool.QueryRow(ctx,
|
||||||
|
`INSERT INTO media_items (library_id, title, file_path, format_group, mime_type, file_size, created_at, imported_at, updated_at)
|
||||||
|
VALUES ($1, 'Book B', $2, 'ebook', 'application/pdf', 10, NOW(), NOW(), NOW()) RETURNING id`,
|
||||||
|
libID, relB,
|
||||||
|
).Scan(&itemB)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
editItem(itemA, map[string]interface{}{"title": "Book A Rewritten"})
|
||||||
|
|
||||||
|
code, body := callWrite(setup.Token, itemA)
|
||||||
|
require.Equal(t, http.StatusOK, code)
|
||||||
|
assert.Equal(t, "sidecar-opf", body["metadata_target"])
|
||||||
|
|
||||||
|
// a.opf carries A's metadata; NO dir-scoped metadata.opf
|
||||||
|
// may appear (it would poison B's next scan), and B gets no
|
||||||
|
// sidecar from A's write. The name strips the extension,
|
||||||
|
// matching the scanner's {basename}.jpg cover convention.
|
||||||
|
data, err := os.ReadFile(filepath.Join(uploadsDir, dir, "a.opf"))
|
||||||
|
require.NoError(t, err, "shared dir must use <basename>.opf")
|
||||||
|
assert.Contains(t, string(data), "Book A Rewritten")
|
||||||
|
_, err = os.Stat(filepath.Join(uploadsDir, dir, "metadata.opf"))
|
||||||
|
assert.True(t, os.IsNotExist(err), "no dir-scoped sidecar over a multi-book folder")
|
||||||
|
_, err = os.Stat(filepath.Join(uploadsDir, dir, "b.opf"))
|
||||||
|
assert.True(t, os.IsNotExist(err), "B gets no sidecar from A's write")
|
||||||
|
_ = itemB
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("audio tags rewrite round-trips for every container", func(t *testing.T) {
|
||||||
|
// Each single-file container gets its own generated fixture
|
||||||
|
// (ffmpeg at test runtime — no binary media in git), its own
|
||||||
|
// write, and an INDEPENDENT verification pass: ffprobe the
|
||||||
|
// rewritten file and assert every mapped key landed. This is the
|
||||||
|
// "check the file's metadata yourself" contract per format:
|
||||||
|
//
|
||||||
|
// album/artist/composer/grouping/date/genre → all four
|
||||||
|
// subtitle → mp3 + flac only
|
||||||
|
// (the mp4 muxer
|
||||||
|
// drops it)
|
||||||
|
cases := []struct {
|
||||||
|
ext string
|
||||||
|
audioCodec string
|
||||||
|
wantSubtitle bool
|
||||||
|
}{
|
||||||
|
{ext: ".mp3", audioCodec: "libmp3lame", wantSubtitle: true},
|
||||||
|
{ext: ".flac", audioCodec: "flac", wantSubtitle: true},
|
||||||
|
{ext: ".m4a", audioCodec: "aac", wantSubtitle: false},
|
||||||
|
{ext: ".m4b", audioCodec: "aac", wantSubtitle: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.ext, func(t *testing.T) {
|
||||||
|
rel := fmt.Sprintf("test-metadata-write-%d/audio-%s/book%s", os.Getpid(), strings.TrimPrefix(tc.ext, "."), tc.ext)
|
||||||
|
abs := filepath.Join(uploadsDir, rel)
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(abs), 0o755))
|
||||||
|
gen := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
|
||||||
|
"-f", "lavfi", "-i", "anullsrc=r=44100:cl=mono",
|
||||||
|
"-t", "2", "-c:a", tc.audioCodec,
|
||||||
|
"-metadata", "title=Generated Fixture",
|
||||||
|
"-metadata", "album=Generated Fixture",
|
||||||
|
abs)
|
||||||
|
if out, err := gen.CombinedOutput(); err != nil {
|
||||||
|
t.Skipf("fixture generation failed for %s (%v): %s", tc.ext, err, string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
var itemID string
|
||||||
|
err := setup.DBPool.QueryRow(ctx,
|
||||||
|
`INSERT INTO media_items (library_id, title, file_path, format_group, mime_type, file_size, created_at, imported_at, updated_at)
|
||||||
|
VALUES ($1, 'Generated Fixture', $2, 'audiobook', $3, 100, NOW(), NOW(), NOW()) RETURNING id`,
|
||||||
|
libID, rel, "audio/"+strings.TrimPrefix(tc.ext, "."),
|
||||||
|
).Scan(&itemID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Follow the real editor flow: PUT first (records
|
||||||
|
// metadata_overrides, so the inline rescan keeps the
|
||||||
|
// edited values instead of folder-structure derivations).
|
||||||
|
editItem(itemID, map[string]interface{}{
|
||||||
|
"title": "Audio New Title",
|
||||||
|
"subtitle": "Audio Sub",
|
||||||
|
"authors": []string{"Audio Author"},
|
||||||
|
"narrators": []string{"Narrator Person"},
|
||||||
|
"series": "Audio Series",
|
||||||
|
"series_number": 1.5,
|
||||||
|
"date_published": "1953-04-16",
|
||||||
|
"tags": []string{"Science"},
|
||||||
|
})
|
||||||
|
|
||||||
|
code, body := callWrite(setup.Token, itemID)
|
||||||
|
require.Equal(t, http.StatusOK, code, "%s write", tc.ext)
|
||||||
|
assert.Equal(t, "audio-tags", body["metadata_target"], tc.ext)
|
||||||
|
|
||||||
|
// ---- verify the FILE's metadata independently ----
|
||||||
|
probe := func() map[string]string {
|
||||||
|
out, err := exec.Command("ffprobe", "-v", "error", "-show_entries",
|
||||||
|
"format_tags", "-of", "json", abs).Output()
|
||||||
|
require.NoError(t, err)
|
||||||
|
var parsed struct {
|
||||||
|
Format struct {
|
||||||
|
Tags map[string]string `json:"tags"`
|
||||||
|
} `json:"format"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal(out, &parsed))
|
||||||
|
return parsed.Format.Tags
|
||||||
|
}
|
||||||
|
tags := probe()
|
||||||
|
assert.Equal(t, "Audio New Title", tags["album"], "%s: the scanner's album rung", tc.ext)
|
||||||
|
assert.Equal(t, "Audio New Title", tags["title"], tc.ext)
|
||||||
|
assert.Equal(t, "Audio Author", tags["artist"], tc.ext)
|
||||||
|
assert.Equal(t, "Narrator Person", tags["composer"], "%s: narrators ride composer", tc.ext)
|
||||||
|
assert.Equal(t, "Audio Series", tags["grouping"], tc.ext)
|
||||||
|
assert.Equal(t, "Science", tags["genre"], tc.ext)
|
||||||
|
assert.Equal(t, "1953-04-16", tags["date"], tc.ext)
|
||||||
|
assert.NotContains(t, tags["album"], "Generated Fixture", "%s: old tag replaced", tc.ext)
|
||||||
|
if tc.wantSubtitle {
|
||||||
|
assert.Equal(t, "Audio Sub", tags["subtitle"], "%s: subtitle round-trips", tc.ext)
|
||||||
|
} else {
|
||||||
|
assert.Empty(t, tags["subtitle"], "%s: subtitle must not be written where it can't round-trip", tc.ext)
|
||||||
|
writeMap := body["write"].(map[string]interface{})
|
||||||
|
assert.NotContains(t, writeMap["fields_written"], "subtitle", "%s: honest fields_written", tc.ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Still valid, same-length audio after the rewrite.
|
||||||
|
durOut, err := exec.Command("ffprobe", "-v", "error", "-show_entries",
|
||||||
|
"format=duration", "-of", "default=noprint_wrappers=1:nokey=1", abs).Output()
|
||||||
|
require.NoError(t, err)
|
||||||
|
dur, err := strconv.ParseFloat(strings.TrimSpace(string(durOut)), 64)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Greater(t, dur, 1.5, "%s: audio survived the rewrite", tc.ext)
|
||||||
|
|
||||||
|
// The inline rescan re-read the tags: the DB title stays
|
||||||
|
// the written one.
|
||||||
|
var title string
|
||||||
|
require.NoError(t, setup.DBPool.QueryRow(ctx,
|
||||||
|
`SELECT title FROM media_items WHERE id = $1`, itemID).Scan(&title))
|
||||||
|
assert.Equal(t, "Audio New Title", title, tc.ext)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("CBZ gets ComicInfo.xml injected and rescan keeps it", func(t *testing.T) {
|
||||||
|
rel := fmt.Sprintf("test-metadata-write-%d/comic/book.cbz", os.Getpid())
|
||||||
|
abs := filepath.Join(uploadsDir, rel)
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(abs), 0o755))
|
||||||
|
// Minimal archive: one "page" file (a text stub — cover
|
||||||
|
// extraction failing is tolerated by the scanner) and no
|
||||||
|
// ComicInfo.xml yet.
|
||||||
|
var buf bytes.Buffer
|
||||||
|
w := zip.NewWriter(&buf)
|
||||||
|
page, _ := w.Create("page_0001.jpg")
|
||||||
|
_, _ = page.Write([]byte("not-a-real-jpeg"))
|
||||||
|
require.NoError(t, w.Close())
|
||||||
|
require.NoError(t, os.WriteFile(abs, buf.Bytes(), 0o644))
|
||||||
|
|
||||||
|
var itemID string
|
||||||
|
err := setup.DBPool.QueryRow(ctx,
|
||||||
|
`INSERT INTO media_items (library_id, title, file_path, format_group, mime_type, file_size, created_at, imported_at, updated_at)
|
||||||
|
VALUES ($1, 'Generated Comic', $2, 'comic_archive', 'application/zip', 100, NOW(), NOW(), NOW()) RETURNING id`,
|
||||||
|
libID, rel,
|
||||||
|
).Scan(&itemID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
editItem(itemID, map[string]interface{}{
|
||||||
|
"title": "Comic New Title",
|
||||||
|
"series": "Comic Series",
|
||||||
|
"series_number": 4,
|
||||||
|
"authors": []string{"Comic Writer"},
|
||||||
|
"tags": []string{"Superhero"},
|
||||||
|
})
|
||||||
|
|
||||||
|
code, body := callWrite(setup.Token, itemID)
|
||||||
|
require.Equal(t, http.StatusOK, code)
|
||||||
|
assert.Equal(t, "comicinfo", body["metadata_target"])
|
||||||
|
|
||||||
|
// ---- verify the FILE's ComicInfo.xml by parsing it ----
|
||||||
|
reader, err := zip.OpenReader(abs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
var infoData []byte
|
||||||
|
for _, f := range reader.File {
|
||||||
|
if f.Name == "ComicInfo.xml" {
|
||||||
|
rc, err := f.Open()
|
||||||
|
require.NoError(t, err)
|
||||||
|
infoData, err = io.ReadAll(rc)
|
||||||
|
require.NoError(t, rc.Close())
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.Close()
|
||||||
|
require.NotEmpty(t, infoData, "ComicInfo.xml must exist inside the CBZ")
|
||||||
|
|
||||||
|
var info struct {
|
||||||
|
Title string `xml:"Title"`
|
||||||
|
Series string `xml:"Series"`
|
||||||
|
Number int `xml:"Number"`
|
||||||
|
Writer string `xml:"Writer"`
|
||||||
|
}
|
||||||
|
require.NoError(t, xml.Unmarshal(infoData, &info))
|
||||||
|
assert.Equal(t, "Comic New Title", info.Title)
|
||||||
|
assert.Equal(t, "Comic Series", info.Series)
|
||||||
|
assert.Equal(t, 4, info.Number)
|
||||||
|
assert.Equal(t, "Comic Writer", info.Writer)
|
||||||
|
|
||||||
|
// The inline rescan reads the ComicInfo back: the DB title and
|
||||||
|
// series stay the written values.
|
||||||
|
var title, series string
|
||||||
|
require.NoError(t, setup.DBPool.QueryRow(ctx,
|
||||||
|
`SELECT title, series FROM media_items WHERE id = $1`, itemID).Scan(&title, &series))
|
||||||
|
assert.Equal(t, "Comic New Title", title)
|
||||||
|
assert.Equal(t, "Comic Series", series)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = os.RemoveAll(filepath.Join(uploadsDir, fmt.Sprintf("test-metadata-write-%d", os.Getpid())))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -85,7 +85,7 @@ func (s *OwnershipAndAdminTestSuite) createItemViaDB(withFolder bool) (itemID, l
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
item, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
item, err := db.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||||
LibraryID: library.ID,
|
LibraryID: library.ID,
|
||||||
Title: "Ownership Suite Item",
|
Title: "Ownership Suite Item",
|
||||||
Authors: []string{"Suite Author"},
|
Authors: []string{"Suite Author"},
|
||||||
@@ -269,7 +269,7 @@ func (s *OwnershipAndAdminTestSuite) TestDelete_StorageDownRefused() {
|
|||||||
})
|
})
|
||||||
require.NoError(s.T(), err)
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
item, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
item, err := db.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||||
LibraryID: library.ID,
|
LibraryID: library.ID,
|
||||||
Title: "Offline Storage Item",
|
Title: "Offline Storage Item",
|
||||||
FilePath: "offline.epub",
|
FilePath: "offline.epub",
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ func TestManuallyEditedFilter(t *testing.T) {
|
|||||||
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
||||||
|
|
||||||
createBook := func(path string) pgtype.UUID {
|
createBook := func(path string) pgtype.UUID {
|
||||||
created, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
created, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||||
LibraryID: libID,
|
LibraryID: libID,
|
||||||
Title: path,
|
Title: path,
|
||||||
Authors: []string{},
|
Authors: []string{},
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import (
|
|||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"bookhoard/internal/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
// A2c end-to-end: an ABS sidecar whose series array mixes a real series and
|
// A2c end-to-end: an ABS sidecar whose series array mixes a real series and
|
||||||
@@ -67,13 +69,14 @@ func TestPlaylistCollectionsEndToEnd(t *testing.T) {
|
|||||||
require.NotEmpty(t, foundPath)
|
require.NotEmpty(t, foundPath)
|
||||||
|
|
||||||
// (b) The playlist collection exists with provenance, dashboard pin,
|
// (b) The playlist collection exists with provenance, dashboard pin,
|
||||||
// and the item at reading position 7.
|
// and the item at reading position 7. Owner-scoped read: collections
|
||||||
|
// RLS (2026-10-31) hides the row from the pool session's SeedIdentity.
|
||||||
adminUUIDStr := adminID.String()
|
adminUUIDStr := adminID.String()
|
||||||
var collID uuid.UUID
|
var collID uuid.UUID
|
||||||
var collName, collDesc string
|
var collName, collDesc string
|
||||||
var showOnDashboard bool
|
var showOnDashboard bool
|
||||||
var viewSettingsRaw []byte
|
var viewSettingsRaw []byte
|
||||||
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
|
require.NoError(t, database.NewScopedDB(setup.DBPool).QueryRow(ownerSeedCtx(adminID),
|
||||||
`SELECT id, name, description, show_on_dashboard, view_settings
|
`SELECT id, name, description, show_on_dashboard, view_settings
|
||||||
FROM collections WHERE user_id = $1 AND name = 'The Tagged Saga (Reading Order)'`,
|
FROM collections WHERE user_id = $1 AND name = 'The Tagged Saga (Reading Order)'`,
|
||||||
adminID).Scan(&collID, &collName, &collDesc, &showOnDashboard, &viewSettingsRaw))
|
adminID).Scan(&collID, &collName, &collDesc, &showOnDashboard, &viewSettingsRaw))
|
||||||
@@ -85,7 +88,9 @@ func TestPlaylistCollectionsEndToEnd(t *testing.T) {
|
|||||||
|
|
||||||
var position *int32
|
var position *int32
|
||||||
var itemCount int
|
var itemCount int
|
||||||
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
|
// Owner-scoped reads: collections RLS (2026-10-31) — items ride the
|
||||||
|
// parent's readability, so the pool session's SeedIdentity sees none.
|
||||||
|
require.NoError(t, database.NewScopedDB(setup.DBPool).QueryRow(ownerSeedCtx(adminID),
|
||||||
`SELECT ci.position, COUNT(*) OVER () FROM collection_items ci
|
`SELECT ci.position, COUNT(*) OVER () FROM collection_items ci
|
||||||
WHERE ci.collection_id = $1`, collID).Scan(&position, &itemCount))
|
WHERE ci.collection_id = $1`, collID).Scan(&position, &itemCount))
|
||||||
require.Equal(t, 1, itemCount)
|
require.Equal(t, 1, itemCount)
|
||||||
@@ -96,11 +101,11 @@ func TestPlaylistCollectionsEndToEnd(t *testing.T) {
|
|||||||
// (c) Rescan: no duplicate collection, no duplicate membership.
|
// (c) Rescan: no duplicate collection, no duplicate membership.
|
||||||
scanTestFolder(t, setup, adminID, folder)
|
scanTestFolder(t, setup, adminID, folder)
|
||||||
var collectionCount, membershipCount int
|
var collectionCount, membershipCount int
|
||||||
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
|
require.NoError(t, database.NewScopedDB(setup.DBPool).QueryRow(ownerSeedCtx(adminID),
|
||||||
`SELECT COUNT(*) FROM collections WHERE user_id = $1 AND name = 'The Tagged Saga (Reading Order)'`,
|
`SELECT COUNT(*) FROM collections WHERE user_id = $1 AND name = 'The Tagged Saga (Reading Order)'`,
|
||||||
adminID).Scan(&collectionCount))
|
adminID).Scan(&collectionCount))
|
||||||
require.Equal(t, 1, collectionCount)
|
require.Equal(t, 1, collectionCount)
|
||||||
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
|
require.NoError(t, database.NewScopedDB(setup.DBPool).QueryRow(ownerSeedCtx(adminID),
|
||||||
`SELECT COUNT(*) FROM collection_items WHERE collection_id = $1`, collID).Scan(&membershipCount))
|
`SELECT COUNT(*) FROM collection_items WHERE collection_id = $1`, collID).Scan(&membershipCount))
|
||||||
require.Equal(t, 1, membershipCount)
|
require.Equal(t, 1, membershipCount)
|
||||||
|
|
||||||
@@ -131,7 +136,9 @@ func TestPlaylistCollectionsEndToEnd(t *testing.T) {
|
|||||||
// (e) hide_finished + Up Next + completion marker.
|
// (e) hide_finished + Up Next + completion marker.
|
||||||
// Flip the collection's flag on, mark the single item finished for the
|
// Flip the collection's flag on, mark the single item finished for the
|
||||||
// admin, and the dashboard section becomes Complete with no items.
|
// admin, and the dashboard section becomes Complete with no items.
|
||||||
_, err = setup.DBPool.Exec(context.Background(),
|
// Owner-or-admin UPDATE policy: run as ADMIN (collections RLS,
|
||||||
|
// 2026-10-31).
|
||||||
|
adminScopeExec(t, setup, adminCtxValue(t, setup),
|
||||||
`UPDATE collections SET view_settings = view_settings || '{"hide_finished": true}'::jsonb WHERE id = $1`, collID)
|
`UPDATE collections SET view_settings = view_settings || '{"hide_finished": true}'::jsonb WHERE id = $1`, collID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
_, err = setup.DBPool.Exec(context.Background(),
|
_, err = setup.DBPool.Exec(context.Background(),
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,213 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Retry + scoped-scan integration tests (2026-10 Processing Issues work).
|
||||||
|
//
|
||||||
|
// - Per-issue retry re-imports a row-less import_failed ledger entry
|
||||||
|
// through the individual scanner (NOT a library scan) and closes it.
|
||||||
|
// - A missing file retries to 409 with the real reason.
|
||||||
|
// - An item-backed issue retries through the book-detail rescan
|
||||||
|
// mechanism and closes.
|
||||||
|
// - POST /api/libraries/:id/scan with folder_path must be a registered
|
||||||
|
// folder (validated, never trusted).
|
||||||
|
// - A single-folder scan must NOT run archive reconciliation for the
|
||||||
|
// library's other folders — without that guard the scoped walk would
|
||||||
|
// mark every item under the unscanned folders missing and archive them.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"bookhoard/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
func retryIssueViaAPI(t *testing.T, setup *TestServerSetup, libraryID pgtype.UUID, issueID uuid.UUID) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
libUUID, err := uuid.FromBytes(libraryID.Bytes[:])
|
||||||
|
require.NoError(t, err)
|
||||||
|
req := httptest.NewRequest("POST", "/api/libraries/"+libUUID.String()+"/issues/"+issueID.String()+"/retry", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
func openIssuesFor(t *testing.T, setup *TestServerSetup, libraryID pgtype.UUID) []database.ListProcessingIssuesByLibraryRow {
|
||||||
|
t.Helper()
|
||||||
|
rows, err := setup.DB.ListProcessingIssuesByLibrary(context.Background(), libraryID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedRowlessIssue(t *testing.T, setup *TestServerSetup, libraryID pgtype.UUID, relPath string) {
|
||||||
|
t.Helper()
|
||||||
|
_, err := setup.DBPool.Exec(context.Background(),
|
||||||
|
`INSERT INTO processing_issues (media_item_id, library_id, issue_type, issue_description, severity, file_path)
|
||||||
|
VALUES (NULL, $1, 'import_failed', 'seeded failure for retry test', 'error', $2)`,
|
||||||
|
libraryID, relPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRetryProcessingIssueRowless(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
defer setup.Close()
|
||||||
|
admin, _ := moveTestAdminID(t, setup)
|
||||||
|
libID, folder := createTypedTestLibrary(t, setup, fmt.Sprintf("Test Retry Rowless %d", time.Now().UnixNano()), "ebooks")
|
||||||
|
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
||||||
|
|
||||||
|
// A real file whose import "failed" earlier (the ledger row exists,
|
||||||
|
// the media_items row does not) — exactly the production shape the
|
||||||
|
// retry button targets.
|
||||||
|
rel := "Author/retrybook.epub"
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(folder, "Author"), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(folder, rel), []byte("dummy epub content"), 0o644))
|
||||||
|
seedRowlessIssue(t, setup, libID, filepath.ToSlash(rel))
|
||||||
|
|
||||||
|
issues := openIssuesFor(t, setup, libID)
|
||||||
|
require.Len(t, issues, 1)
|
||||||
|
issueID, err := uuid.FromBytes(issues[0].ID.Bytes[:])
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
rec := retryIssueViaAPI(t, setup, libID, issueID)
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
|
||||||
|
// The file imported through the individual scanner...
|
||||||
|
var count int
|
||||||
|
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
|
||||||
|
`SELECT count(*) FROM media_items WHERE library_id = $1 AND file_path = $2`,
|
||||||
|
libID, filepath.ToSlash(rel)).Scan(&count))
|
||||||
|
require.Equal(t, 1, count, "retry must import the file")
|
||||||
|
|
||||||
|
// ...and the ledger closed (import self-heal + explicit path resolve).
|
||||||
|
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
|
||||||
|
`SELECT count(*) FROM processing_issues WHERE id = $1 AND resolved AND resolved_at IS NOT NULL`,
|
||||||
|
issues[0].ID).Scan(&count))
|
||||||
|
require.Equal(t, 1, count, "successful retry must resolve the issue")
|
||||||
|
require.Len(t, openIssuesFor(t, setup, libID), 0)
|
||||||
|
|
||||||
|
// Missing file: retry answers 409 with the real reason, issue stays.
|
||||||
|
seedRowlessIssue(t, setup, libID, "Author/vanished.epub")
|
||||||
|
issues = openIssuesFor(t, setup, libID)
|
||||||
|
require.Len(t, issues, 1)
|
||||||
|
vanishedID, err := uuid.FromBytes(issues[0].ID.Bytes[:])
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
rec = retryIssueViaAPI(t, setup, libID, vanishedID)
|
||||||
|
require.Equal(t, http.StatusConflict, rec.Code)
|
||||||
|
assert.Contains(t, rec.Body.String(), "not found on disk")
|
||||||
|
|
||||||
|
// Item-backed issue: retry runs the book-detail rescan mechanism.
|
||||||
|
itemRow, err := setup.DB.GetMediaItemByLibraryAndPath(context.Background(),
|
||||||
|
database.GetMediaItemByLibraryAndPathParams{LibraryID: libID, FilePath: filepath.ToSlash(rel)})
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = setup.DBPool.Exec(context.Background(),
|
||||||
|
`INSERT INTO processing_issues (media_item_id, library_id, issue_type, issue_description, severity)
|
||||||
|
VALUES ($1, $2, 'metadata_extraction_failed', 'seeded item issue for retry test', 'warning')`,
|
||||||
|
itemRow.ID, libID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// vanished.epub's issue is still open — pick the item-backed one.
|
||||||
|
var itemIssueID uuid.UUID
|
||||||
|
for _, open := range openIssuesFor(t, setup, libID) {
|
||||||
|
if open.MediaItemID.Valid {
|
||||||
|
itemIssueID, err = uuid.FromBytes(open.ID.Bytes[:])
|
||||||
|
require.NoError(t, err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NotEqual(t, uuid.Nil, itemIssueID, "item-backed fixture")
|
||||||
|
|
||||||
|
rec = retryIssueViaAPI(t, setup, libID, itemIssueID)
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
|
||||||
|
var resolvedCount int
|
||||||
|
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
|
||||||
|
`SELECT count(*) FROM processing_issues WHERE id = $1 AND resolved`,
|
||||||
|
itemIssueID).Scan(&resolvedCount))
|
||||||
|
require.Equal(t, 1, resolvedCount, "successful item retry must resolve the issue")
|
||||||
|
|
||||||
|
_ = admin // scanner attribution is implicit via the library owner
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanLibraryFolderPathValidation(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
defer setup.Close()
|
||||||
|
libID, folder := createTypedTestLibrary(t, setup, fmt.Sprintf("Test Scan Validate %d", time.Now().UnixNano()), "ebooks")
|
||||||
|
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
||||||
|
|
||||||
|
post := func(folderPath string) *httptest.ResponseRecorder {
|
||||||
|
body, _ := json.Marshal(map[string]string{"folder_path": folderPath})
|
||||||
|
req := httptest.NewRequest("POST", "/api/libraries/"+libID.String()+"/scan", bytes.NewBuffer(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// An unregistered path is rejected, never scanned.
|
||||||
|
rec := post("/tmp/definitely-not-registered")
|
||||||
|
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||||
|
assert.Contains(t, rec.Body.String(), "not one of this library's registered folders")
|
||||||
|
|
||||||
|
// A registered folder scopes the scan and enqueues normally.
|
||||||
|
rec = post(folder)
|
||||||
|
require.Equal(t, http.StatusAccepted, rec.Code, "body: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanSingleFolderSkipsArchiveReconciliation(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
defer setup.Close()
|
||||||
|
ctx := context.Background()
|
||||||
|
admin, _ := moveTestAdminID(t, setup)
|
||||||
|
libID, folderA := createTypedTestLibrary(t, setup, fmt.Sprintf("Test Archive Guard %d", time.Now().UnixNano()), "ebooks")
|
||||||
|
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
||||||
|
|
||||||
|
// Second registered folder: the scoped scan below covers only this one.
|
||||||
|
folderB := t.TempDir()
|
||||||
|
_, err := setup.DB.AddLibraryFolder(ctx, database.AddLibraryFolderParams{
|
||||||
|
LibraryID: libID,
|
||||||
|
FolderPath: folderB,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// A healthy item under folder A.
|
||||||
|
rel := "Author/archguard.epub"
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(folderA, "Author"), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(folderA, rel), []byte("dummy epub content"), 0o644))
|
||||||
|
scanTestFolder(t, setup, admin, folderA)
|
||||||
|
|
||||||
|
item, err := setup.DB.GetMediaItemByLibraryAndPath(ctx,
|
||||||
|
database.GetMediaItemByLibraryAndPathParams{LibraryID: libID, FilePath: filepath.ToSlash(rel)})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, item.ArchivedAt.Valid)
|
||||||
|
require.Equal(t, int32(0), item.MissingScanCount)
|
||||||
|
|
||||||
|
// Scan ONLY folder B. Without the partial-scope guard the archive
|
||||||
|
// pass would see nothing from folder A and mark this item missing
|
||||||
|
// (1/2 toward archival) — the exact false-archive the guard exists
|
||||||
|
// to prevent.
|
||||||
|
scanTestFolder(t, setup, admin, folderB)
|
||||||
|
|
||||||
|
after, err := setup.DB.GetMediaItem(ctx, item.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, after.ArchivedAt.Valid, "scoped scan must not archive items under unscanned folders")
|
||||||
|
require.Equal(t, int32(0), after.MissingScanCount, "scoped scan must not mark unscanned-folder items missing")
|
||||||
|
|
||||||
|
// Control: the item's own folder scans clean as before.
|
||||||
|
scanTestFolder(t, setup, admin, folderA)
|
||||||
|
after, err = setup.DB.GetMediaItem(ctx, item.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, after.ArchivedAt.Valid)
|
||||||
|
require.Equal(t, int32(0), after.MissingScanCount)
|
||||||
|
}
|
||||||
@@ -48,7 +48,7 @@ func createTestQueueItem(t *testing.T, db *database.Queries, userID uuid.UUID) (
|
|||||||
require.NoError(t, err, "Should create library")
|
require.NoError(t, err, "Should create library")
|
||||||
|
|
||||||
// 4. Create media item
|
// 4. Create media item
|
||||||
mediaItem, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
mediaItem, err := db.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||||
LibraryID: library.ID,
|
LibraryID: library.ID,
|
||||||
Title: "Test Book for Queue",
|
Title: "Test Book for Queue",
|
||||||
Authors: []string{"Test Author"},
|
Authors: []string{"Test Author"},
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Resume rewind (per-user server setting): seconds before the saved
|
||||||
|
// position the audiobook/podcast player starts (0 = exact resume).
|
||||||
|
// Contract: the profile endpoint owns reads/writes with a strict
|
||||||
|
// 0/5/10 allow-list (the request shape is the keep_screen_awake string
|
||||||
|
// precedent — the form select must be able to send an explicit Off),
|
||||||
|
// and the chapters payload carries the value so every client (web +
|
||||||
|
// Android, incl. the Auto service) gets it with its existing fetch.
|
||||||
|
// All writes ride the fresh per-test regular user — the shared admin
|
||||||
|
// account is never touched (AGENTS doctrine). Fixture book lives in a
|
||||||
|
// test-named library that cascades away with the library delete.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAudioResumeRewind(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
t.Cleanup(func() { _ = setup.Close() })
|
||||||
|
|
||||||
|
admin, _ := moveTestAdminID(t, setup)
|
||||||
|
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Resume Rewind %d", time.Now().UnixNano()))
|
||||||
|
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
||||||
|
|
||||||
|
// 664 Hz tone: not the shared helper's 440 or the speed test's 552,
|
||||||
|
// so the scanner's content dedup never skips this book.
|
||||||
|
writeTestTrackTone(t, filepath.Join(folder, "Rewind Book", "01 one.mp3"), 664)
|
||||||
|
scanTestFolder(t, setup, admin, folder)
|
||||||
|
|
||||||
|
var bookPath string
|
||||||
|
for _, item := range listLibraryAudioItems(t, setup, libID) {
|
||||||
|
if strings.Contains(filepath.ToSlash(item.FilePath), "Rewind Book") && bookPath == "" {
|
||||||
|
bookPath = item.FilePath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NotEmpty(t, bookPath, "Rewind Book item must exist")
|
||||||
|
book, found := findAudioItemByPath(t, setup, libID, bookPath)
|
||||||
|
require.True(t, found)
|
||||||
|
bookID := uuid.UUID(book.ID.Bytes)
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
token := setup.RegularToken
|
||||||
|
authed := func(method, url, body string) (*http.Response, func()) {
|
||||||
|
var req *http.Request
|
||||||
|
if body == "" {
|
||||||
|
req, _ = http.NewRequest(method, url, nil)
|
||||||
|
} else {
|
||||||
|
req, _ = http.NewRequest(method, url, strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return resp, func() { _ = resp.Body.Close() }
|
||||||
|
}
|
||||||
|
getPayload := func(itemID uuid.UUID) (rewind int) {
|
||||||
|
resp, closer := authed("GET", setup.Server.URL+"/api/media-items/"+itemID.String()+"/chapters", "")
|
||||||
|
defer closer()
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
var data struct {
|
||||||
|
ResumeRewindSeconds int `json:"resume_rewind_seconds"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&data))
|
||||||
|
return data.ResumeRewindSeconds
|
||||||
|
}
|
||||||
|
getProfileRewind := func() int {
|
||||||
|
resp, closer := authed("GET", setup.Server.URL+"/api/auth/profile", "")
|
||||||
|
defer closer()
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
var profile struct {
|
||||||
|
AudioResumeRewind int `json:"audio_resume_rewind_seconds"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&profile))
|
||||||
|
return profile.AudioResumeRewind
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("defaults to off (exact resume)", func(t *testing.T) {
|
||||||
|
require.Equal(t, 0, getProfileRewind())
|
||||||
|
require.Equal(t, 0, getPayload(bookID))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("profile PUT selects 5 and rides the chapters payload", func(t *testing.T) {
|
||||||
|
resp, closer := authed("PUT", setup.Server.URL+"/api/auth/profile", `{"audio_resume_rewind_seconds":"5"}`)
|
||||||
|
defer closer()
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
require.Equal(t, 5, getProfileRewind())
|
||||||
|
require.Equal(t, 5, getPayload(bookID))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("non-allow-listed values are rejected and change nothing", func(t *testing.T) {
|
||||||
|
for _, body := range []string{`{"audio_resume_rewind_seconds":"7"}`, `{"audio_resume_rewind_seconds":"-5"}`, `{"audio_resume_rewind_seconds":"abc"}`} {
|
||||||
|
resp, closer := authed("PUT", setup.Server.URL+"/api/auth/profile", body)
|
||||||
|
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
|
closer()
|
||||||
|
}
|
||||||
|
require.Equal(t, 5, getProfileRewind())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("explicit off resets to 0 everywhere", func(t *testing.T) {
|
||||||
|
resp, closer := authed("PUT", setup.Server.URL+"/api/auth/profile", `{"audio_resume_rewind_seconds":"0"}`)
|
||||||
|
defer closer()
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
require.Equal(t, 0, getProfileRewind())
|
||||||
|
require.Equal(t, 0, getPayload(bookID))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("JSON clients that omit the key leave it untouched", func(t *testing.T) {
|
||||||
|
resp, closer := authed("PUT", setup.Server.URL+"/api/auth/profile", `{"timezone":"UTC"}`)
|
||||||
|
defer closer()
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
require.Equal(t, 0, getProfileRewind())
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// rls_visibility_test.go — the PERMANENT black-box auditor for per-user
|
||||||
|
// library visibility (the "family lens").
|
||||||
|
//
|
||||||
|
// The one sentence it asserts, in the user's language:
|
||||||
|
//
|
||||||
|
// A user with a library hidden sees NOTHING from that library, on
|
||||||
|
// ANY media-returning surface — while an admin still sees it (the
|
||||||
|
// hide is per-user, never global).
|
||||||
|
//
|
||||||
|
// Implementation-agnostic on purpose: it speaks only HTTP + response
|
||||||
|
// bodies, so it stays valid regardless of HOW enforcement is done
|
||||||
|
// (per-query visible_library_ids clauses today, Postgres RLS in the
|
||||||
|
// RLS rollout, anything tomorrow). It is the auditor that makes a
|
||||||
|
// forgotten surface a failing test instead of a silent leak.
|
||||||
|
//
|
||||||
|
// Red/green history: RED at introduction on the dashboard-section and
|
||||||
|
// collection-detail (mobile View All) surfaces, whose queries never
|
||||||
|
// carried the visible_library_ids clause; GREEN once enforcement
|
||||||
|
// covers every surface (plan:
|
||||||
|
// ~/.opencode/plan/server-rls-library-visibility.md).
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"bookhoard/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// uuidInBody finds candidate collection ids in a dashboard-sections
|
||||||
|
// body so the View-All detail endpoint can be probed without coupling
|
||||||
|
// the test to unrelated ids in the body.
|
||||||
|
|
||||||
|
func TestRlsInvariant(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
adminToken := setup.Token
|
||||||
|
regularToken := setup.RegularToken
|
||||||
|
regularID := getRegularUserID(t, setup.DB)
|
||||||
|
|
||||||
|
// Unique markers: one for the hidden library's whole subtree
|
||||||
|
// (library name + both book titles — a single string that must
|
||||||
|
// appear NOWHERE), one for the visible control book.
|
||||||
|
hiddenMarker := fmt.Sprintf("RLSHIDE%s", uuid.New().String()[:8])
|
||||||
|
controlMarker := fmt.Sprintf("RLSCTRL%s", uuid.New().String()[:8])
|
||||||
|
|
||||||
|
libType, err := setup.DB.GetLibraryTypeByName(ctx, "ebooks")
|
||||||
|
require.NoError(t, err, "ebooks library type should exist")
|
||||||
|
|
||||||
|
createLib := func(name string) database.Libraries {
|
||||||
|
lib, cerr := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{
|
||||||
|
Name: name,
|
||||||
|
Description: pgtype.Text{String: "rls invariant test library", Valid: true},
|
||||||
|
LibraryTypeID: libType.ID,
|
||||||
|
CreatedByAdminID: pgtype.UUID{Bytes: [16]byte(regularID), Valid: true},
|
||||||
|
})
|
||||||
|
require.NoError(t, cerr, "create library %q", name)
|
||||||
|
// Deleting the library cascades its media_items AND its
|
||||||
|
// library_visibility rows (FK ON DELETE CASCADE) — one cleanup
|
||||||
|
// covers the whole subtree.
|
||||||
|
t.Cleanup(func() { _ = setup.DB.DeleteLibrary(ctx, lib.ID) })
|
||||||
|
return lib
|
||||||
|
}
|
||||||
|
libH := createLib("RLS Test Hidden Library " + hiddenMarker)
|
||||||
|
libV := createLib("RLS Test Visible Library " + controlMarker)
|
||||||
|
|
||||||
|
createBook := func(libraryID pgtype.UUID, title string) {
|
||||||
|
_, cerr := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||||
|
LibraryID: libraryID,
|
||||||
|
Title: title,
|
||||||
|
Authors: []string{"RLS Invariant Author"},
|
||||||
|
FilePath: fmt.Sprintf("/tmp/rls-invariant/%s-%s.epub", title, uuid.New().String()),
|
||||||
|
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
|
||||||
|
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||||
|
})
|
||||||
|
require.NoError(t, cerr, "create book %q", title)
|
||||||
|
}
|
||||||
|
createBook(libH.ID, hiddenMarker+" Hidden Alpha")
|
||||||
|
createBook(libH.ID, hiddenMarker+" Hidden Beta")
|
||||||
|
createBook(libV.ID, controlMarker+" Visible Gamma")
|
||||||
|
|
||||||
|
// Hide libH from the REGULAR user — the same row the admin
|
||||||
|
// visibility UI writes (harness idiom: SetLibraryVisibility).
|
||||||
|
_, err = setup.DB.SetLibraryVisibility(ctx, database.SetLibraryVisibilityParams{
|
||||||
|
UserID: pgtype.UUID{Bytes: [16]byte(regularID), Valid: true},
|
||||||
|
LibraryID: libH.ID,
|
||||||
|
IsVisible: false,
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "hide libH from regular user")
|
||||||
|
|
||||||
|
// OPDS device owned by the REGULAR user (OPDS identity = device owner).
|
||||||
|
opdsToken := "dev_" + uuid.New().String()
|
||||||
|
device, err := setup.DB.CreateDevice(ctx, database.CreateDeviceParams{
|
||||||
|
UserID: pgtype.UUID{Bytes: [16]byte(regularID), Valid: true},
|
||||||
|
DeviceName: "RLS Invariant OPDS Device",
|
||||||
|
DeviceType: "koreader",
|
||||||
|
DeviceIdentifier: "rls-invariant-" + uuid.New().String()[:8],
|
||||||
|
AuthToken: opdsToken,
|
||||||
|
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
|
||||||
|
AutoSync: pgtype.Bool{Bool: true, Valid: true},
|
||||||
|
SyncFrequencyMinutes: pgtype.Int4{Int32: 60, Valid: true},
|
||||||
|
DeviceMetadata: []byte("{}"),
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "create OPDS device for regular user")
|
||||||
|
t.Cleanup(func() { _ = setup.DB.DeleteDevice(ctx, device.ID) })
|
||||||
|
|
||||||
|
rlsGet := func(token, path string) (int, string) {
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, setup.Server.URL+path, nil)
|
||||||
|
if token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err, "GET %s", path)
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
var sb strings.Builder
|
||||||
|
buf := make([]byte, 32*1024)
|
||||||
|
for {
|
||||||
|
n, err := resp.Body.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
sb.Write(buf[:n])
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resp.StatusCode, sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Surfaces that render media TITLES (books appear by name).
|
||||||
|
titleSurfaces := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
auth string
|
||||||
|
}{
|
||||||
|
{"media-items list", "/api/media-items?limit=500", regularToken},
|
||||||
|
{"dashboard sections", "/api/dashboard/sections?limit=500", regularToken},
|
||||||
|
{"opds catalog", "/opds/devices/" + device.ID.String() + "/catalog", opdsToken},
|
||||||
|
}
|
||||||
|
// Surfaces that return media-adjacent data (counts, series names,
|
||||||
|
// aggregates) — hidden-subtree leakage would show up as marker
|
||||||
|
// series names / inventory rows, not book titles.
|
||||||
|
aggregateSurfaces := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
auth string
|
||||||
|
}{
|
||||||
|
{"collections list", "/api/collections", regularToken},
|
||||||
|
{"series list", "/api/series", regularToken},
|
||||||
|
{"stats summary", "/api/stats/summary", regularToken},
|
||||||
|
{"stats library-usage", "/api/stats/library-usage", regularToken},
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("restricted_user_hidden_library_absent_everywhere", func(t *testing.T) {
|
||||||
|
for _, s := range titleSurfaces {
|
||||||
|
t.Run(s.name, func(t *testing.T) {
|
||||||
|
code, body := rlsGet(s.auth, s.path)
|
||||||
|
require.Equal(t, http.StatusOK, code, "surface %s must be authorized (a 401 here is a harness bug, not a pass)", s.path)
|
||||||
|
// THE INVARIANT: nothing from the hidden subtree —
|
||||||
|
// neither its books nor the library itself.
|
||||||
|
assert.NotContains(t, body, hiddenMarker,
|
||||||
|
"LEAK: hidden library content surfaced on %s", s.path)
|
||||||
|
// Control: the visible library's book IS present —
|
||||||
|
// guards against false greens from empty/anonymous
|
||||||
|
// responses.
|
||||||
|
if s.name != "opds catalog" { // root catalog may nest books behind library subsections
|
||||||
|
assert.Contains(t, body, controlMarker,
|
||||||
|
"control book missing from %s — response is not listing media (false-green guard)", s.path)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, s := range aggregateSurfaces {
|
||||||
|
t.Run(s.name, func(t *testing.T) {
|
||||||
|
code, body := rlsGet(s.auth, s.path)
|
||||||
|
require.Equal(t, http.StatusOK, code, "surface %s must be authorized", s.path)
|
||||||
|
assert.NotContains(t, body, hiddenMarker,
|
||||||
|
"LEAK: hidden library content surfaced on %s", s.path)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("restricted_user_dashboard_scoped_to_hidden_library", func(t *testing.T) {
|
||||||
|
code, body := rlsGet(regularToken, "/api/dashboard/sections?library_id="+uuid.UUID(libH.ID.Bytes).String())
|
||||||
|
assert.NotContains(t, body, hiddenMarker,
|
||||||
|
"LEAK: dashboard scoped to the hidden library leaked its content (status %d)", code)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("restricted_user_collection_detail_view_all", func(t *testing.T) {
|
||||||
|
// The mobile View All path: GET /api/collections/{id} for every
|
||||||
|
// collection the dashboard references (system sections included).
|
||||||
|
// Structured decode — probe exactly the collection ids the
|
||||||
|
// sections payload declares (its own contract field), not
|
||||||
|
// whatever UUIDs happen to appear in the body.
|
||||||
|
_, sectionsBody := rlsGet(regularToken, "/api/dashboard/sections?limit=500")
|
||||||
|
var sections struct {
|
||||||
|
Sections []struct {
|
||||||
|
CollectionID string `json:"collection_id"`
|
||||||
|
} `json:"sections"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal([]byte(sectionsBody), §ions),
|
||||||
|
"dashboard sections should decode; body: %s", sectionsBody)
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, sec := range sections.Sections {
|
||||||
|
id := sec.CollectionID
|
||||||
|
if id == "" || seen[id] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = true
|
||||||
|
code, body := rlsGet(regularToken, "/api/collections/"+id)
|
||||||
|
if code != http.StatusOK {
|
||||||
|
continue // not visible to this user — absence trivially holds
|
||||||
|
}
|
||||||
|
assert.NotContains(t, body, hiddenMarker,
|
||||||
|
"LEAK: collection detail %s (View All surface) returned hidden-library content", id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("restricted_user_explicit_hidden_library_scoped_requests_rejected", func(t *testing.T) {
|
||||||
|
// Pinned contract (AGENTS.md family-lens rule): naming a hidden
|
||||||
|
// library explicitly is a 403, never a silent pass.
|
||||||
|
libID := uuid.UUID(libH.ID.Bytes).String()
|
||||||
|
for _, path := range []string{
|
||||||
|
"/api/media-items?library_id=" + libID,
|
||||||
|
"/api/series?library_id=" + libID,
|
||||||
|
"/api/series/books?library_id=" + libID + "&name=RLS",
|
||||||
|
} {
|
||||||
|
code, body := rlsGet(regularToken, path)
|
||||||
|
assert.Equal(t, http.StatusForbidden, code,
|
||||||
|
"scoped request %s must be 403 for a hidden library (got %d)", path, code)
|
||||||
|
assert.NotContains(t, body, hiddenMarker)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("admin_still_sees_hidden_library_user_scoped_not_global", func(t *testing.T) {
|
||||||
|
// The hide is PER-USER: an admin (no hidden rows) must still see
|
||||||
|
// everything. This catches "fixing" the leak by hiding the
|
||||||
|
// library globally.
|
||||||
|
for _, s := range []struct{ name, path string }{
|
||||||
|
{"media-items list", "/api/media-items?limit=500"},
|
||||||
|
{"dashboard sections", "/api/dashboard/sections?limit=500"},
|
||||||
|
} {
|
||||||
|
t.Run(s.name, func(t *testing.T) {
|
||||||
|
code, body := rlsGet(adminToken, s.path)
|
||||||
|
require.Equal(t, http.StatusOK, code)
|
||||||
|
assert.Contains(t, body, hiddenMarker,
|
||||||
|
"admin must still see the hidden library on %s (hide is user-scoped)", s.path)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
t.Run("opds catalog", func(t *testing.T) {
|
||||||
|
// OPDS identity = device owner; an ADMIN-owned device sees all.
|
||||||
|
adminDevToken := "dev_" + uuid.New().String()
|
||||||
|
adminID := getTestUserID(t, setup.DB)
|
||||||
|
adminDevice, derr := setup.DB.CreateDevice(ctx, database.CreateDeviceParams{
|
||||||
|
UserID: pgtype.UUID{Bytes: [16]byte(adminID), Valid: true},
|
||||||
|
DeviceName: "RLS Invariant Admin OPDS Device",
|
||||||
|
DeviceType: "koreader",
|
||||||
|
DeviceIdentifier: "rls-invariant-admin-" + uuid.New().String()[:8],
|
||||||
|
AuthToken: adminDevToken,
|
||||||
|
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
|
||||||
|
AutoSync: pgtype.Bool{Bool: true, Valid: true},
|
||||||
|
SyncFrequencyMinutes: pgtype.Int4{Int32: 60, Valid: true},
|
||||||
|
DeviceMetadata: []byte("{}"),
|
||||||
|
})
|
||||||
|
require.NoError(t, derr, "create OPDS device for admin")
|
||||||
|
t.Cleanup(func() { _ = setup.DB.DeleteDevice(ctx, adminDevice.ID) })
|
||||||
|
|
||||||
|
code, body := rlsGet(adminDevToken, "/opds/devices/"+adminDevice.ID.String()+"/catalog")
|
||||||
|
require.Equal(t, http.StatusOK, code)
|
||||||
|
assert.Contains(t, body, hiddenMarker,
|
||||||
|
"admin's OPDS catalog must still expose the hidden library (user-scoped hide)")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -78,7 +78,7 @@ func TestScanStateBulkLoad(t *testing.T) {
|
|||||||
require.NoError(t, os.WriteFile(apiFile, []byte("api made book content"), 0644))
|
require.NoError(t, os.WriteFile(apiFile, []byte("api made book content"), 0644))
|
||||||
apiStat, err := os.Stat(apiFile)
|
apiStat, err := os.Stat(apiFile)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
created, err := setup.DB.CreateMediaItem(context.Background(), database.CreateMediaItemParams{
|
created, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||||
LibraryID: libID,
|
LibraryID: libID,
|
||||||
Title: "API Made Book",
|
Title: "API Made Book",
|
||||||
Authors: []string{"API Author"},
|
Authors: []string{"API Author"},
|
||||||
@@ -207,12 +207,13 @@ func TestScanManifestLifecycle(t *testing.T) {
|
|||||||
require.NoError(t, os.WriteFile(opfPath, []byte(`{"title":"Manifesu Book","series":["Lifecycle Saga (Reading Order) #1"]}`), 0644))
|
require.NoError(t, os.WriteFile(opfPath, []byte(`{"title":"Manifesu Book","series":["Lifecycle Saga (Reading Order) #1"]}`), 0644))
|
||||||
scanTestFolder(t, setup, admin, folder)
|
scanTestFolder(t, setup, admin, folder)
|
||||||
var collCount int
|
var collCount int
|
||||||
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
|
// Owner-scoped read: collections RLS (2026-10-31).
|
||||||
|
require.NoError(t, database.NewScopedDB(setup.DBPool).QueryRow(ownerSeedCtx(admin),
|
||||||
`SELECT COUNT(*) FROM collections WHERE name = 'Lifecycle Saga (Reading Order)' AND user_id = $1`, admin,
|
`SELECT COUNT(*) FROM collections WHERE name = 'Lifecycle Saga (Reading Order)' AND user_id = $1`, admin,
|
||||||
).Scan(&collCount))
|
).Scan(&collCount))
|
||||||
assert.Equal(t, 1, collCount, "reading-order sidecar added to an existing book creates its collection")
|
assert.Equal(t, 1, collCount, "reading-order sidecar added to an existing book creates its collection")
|
||||||
scanTestFolder(t, setup, admin, folder)
|
scanTestFolder(t, setup, admin, folder)
|
||||||
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
|
require.NoError(t, database.NewScopedDB(setup.DBPool).QueryRow(ownerSeedCtx(admin),
|
||||||
`SELECT COUNT(*) FROM collections WHERE name = 'Lifecycle Saga (Reading Order)' AND user_id = $1`, admin,
|
`SELECT COUNT(*) FROM collections WHERE name = 'Lifecycle Saga (Reading Order)' AND user_id = $1`, admin,
|
||||||
).Scan(&collCount))
|
).Scan(&collCount))
|
||||||
assert.Equal(t, 1, collCount, "rescan is idempotent — no duplicate collection")
|
assert.Equal(t, 1, collCount, "rescan is idempotent — no duplicate collection")
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSearchViewerProgress pins the search-progress batch (2026-10-27):
|
||||||
|
// the unified search row carries the REQUESTING user's reading progress
|
||||||
|
// (viewer_percentage) via a LEFT JOIN on the same user_id param the
|
||||||
|
// visibility join binds, the JSON endpoint serializes it, and the
|
||||||
|
// layout=dropdown HX branch renders templ rows with the completed check
|
||||||
|
// / partial bar. Fixture: three books in a test library; the FRESH
|
||||||
|
// regular user finished one (1.0), is mid-read on another (0.42) and
|
||||||
|
// never opened the third (NULL). The shared admin's search response is
|
||||||
|
// read but never written — their progress stays untouched.
|
||||||
|
func TestSearchViewerProgress(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping integration test in short mode")
|
||||||
|
}
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
libID := createTestLibraryWithFolder(t, setup.Server, setup.Token, "Test Search Progress Library", false)
|
||||||
|
viewerID := getRegularUserID(t, setup.DB)
|
||||||
|
|
||||||
|
books := []struct {
|
||||||
|
title string
|
||||||
|
percentage string // "" = no progress row at all
|
||||||
|
}{
|
||||||
|
{title: "Progress Probe Alpha", percentage: "1.0"},
|
||||||
|
{title: "Progress Probe Beta", percentage: "0.42"},
|
||||||
|
{title: "Progress Probe Gamma", percentage: ""},
|
||||||
|
}
|
||||||
|
itemIDs := map[string]string{}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, _ = setup.DBPool.Exec(ctx, "DELETE FROM libraries WHERE id = $1", libID)
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, b := range books {
|
||||||
|
var itemID string
|
||||||
|
err := setup.DBPool.QueryRow(ctx,
|
||||||
|
`INSERT INTO media_items (library_id, title, file_path, created_at, imported_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, '2026-02-01 00:00:00+00', '2026-02-01 00:00:00+00', '2026-02-01 00:00:00+00') RETURNING id`,
|
||||||
|
libID, b.title, fmt.Sprintf("/tmp/test-search-progress/%s.epub", b.title),
|
||||||
|
).Scan(&itemID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
itemIDs[b.title] = itemID
|
||||||
|
if b.percentage != "" {
|
||||||
|
_, err = setup.DBPool.Exec(ctx,
|
||||||
|
`INSERT INTO reading_progress (media_item_id, user_id, percentage, last_read_at)
|
||||||
|
VALUES ($1, $2, $3::float, '2026-02-02 00:00:00+00')`,
|
||||||
|
itemID, viewerID, b.percentage,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("JSON rows carry the viewer's own percentage", func(t *testing.T) {
|
||||||
|
rows := searchJSON(t, setup, setup.RegularToken, "Progress Probe", libID)
|
||||||
|
require.Len(t, rows, 3)
|
||||||
|
byTitle := map[string]map[string]interface{}{}
|
||||||
|
for _, row := range rows {
|
||||||
|
byTitle[row["title"].(string)] = row
|
||||||
|
}
|
||||||
|
assert.Equal(t, float64(1), byTitle["Progress Probe Alpha"]["viewer_percentage"])
|
||||||
|
assert.InDelta(t, 0.42, byTitle["Progress Probe Beta"]["viewer_percentage"], 0.0001)
|
||||||
|
require.Nil(t, byTitle["Progress Probe Gamma"]["viewer_percentage"], "never-opened book must be null")
|
||||||
|
|
||||||
|
// Per-user join: the shared admin (no progress on any fixture
|
||||||
|
// book) sees null across the board — the join binds the
|
||||||
|
// REQUESTER, not a global.
|
||||||
|
adminRows := searchJSON(t, setup, setup.Token, "Progress Probe", libID)
|
||||||
|
require.Len(t, adminRows, 3)
|
||||||
|
for _, row := range adminRows {
|
||||||
|
assert.Nil(t, row["viewer_percentage"], "admin must see null for %v", row["title"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("dropdown partial renders progress overlays", func(t *testing.T) {
|
||||||
|
req, err := http.NewRequest("GET",
|
||||||
|
setup.Server.URL+"/api/media-items/search?layout=dropdown&q=Progress+Probe&library_id="+libID, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
req.Header.Set("Cookie", "token="+setup.RegularToken)
|
||||||
|
req.Header.Set("HX-Request", "true")
|
||||||
|
resp, err := (&http.Client{}).Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
bodyBytes, err := io.ReadAll(resp.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
body := string(bodyBytes)
|
||||||
|
|
||||||
|
assert.Contains(t, body, "3 results for", "count label")
|
||||||
|
// Completed book: the check overlay + title (completed ≥ 0.999).
|
||||||
|
assert.Contains(t, body, "Completed", "completed overlay")
|
||||||
|
// Partial book: the bar at 42%.
|
||||||
|
assert.Contains(t, body, "width: 42.0%", "partial progress bar")
|
||||||
|
// Server-rendered rows, one per hit.
|
||||||
|
assert.Equal(t, 3, strings.Count(body, `class="search-result-item`), "one row per result")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// searchJSON runs a search as the given token and decodes the row array
|
||||||
|
// (library-scoped: the unified search is fuzzy, so an unscoped q would
|
||||||
|
// sweep in live dev rows).
|
||||||
|
func searchJSON(t *testing.T, setup *TestServerSetup, token, query, libraryID string) []map[string]interface{} {
|
||||||
|
t.Helper()
|
||||||
|
req, err := http.NewRequest("GET",
|
||||||
|
fmt.Sprintf("%s/api/media-items/search?q=%s&library_id=%s", setup.Server.URL, url.QueryEscape(query), libraryID), nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err := (&http.Client{}).Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
var rows []map[string]interface{}
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&rows))
|
||||||
|
return rows
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSeriesSortAndFilter pins the series page's sort/filter contract
|
||||||
|
// (2026-10-27 feature): the /series SSR page and the /api/series JSON
|
||||||
|
// endpoint share one sort registry (services.ResolveSeriesSort) and one
|
||||||
|
// SQL CASE cascade (GetDistinctSeries). The fixture gives three series
|
||||||
|
// DISTINCT answers per sort key so ordering bugs can't hide:
|
||||||
|
//
|
||||||
|
// created author books viewer progress
|
||||||
|
// Probe Zebra 01-03 Zeta Zed 2 1 of 2 read (01-09) → reading
|
||||||
|
// Probe Mango 01-02 Alpha One 3 3 of 3 read (01-10) → finished
|
||||||
|
// Probe Apple 01-01 Midway Two 1 none → unstarted
|
||||||
|
//
|
||||||
|
// All viewer progress belongs to the FRESH regular user
|
||||||
|
// (getRegularUserID) and cascades away with the test library — the
|
||||||
|
// shared admin's reading data is never touched.
|
||||||
|
func TestSeriesSortAndFilter(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping integration test in short mode")
|
||||||
|
}
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
libID := createTestLibraryWithFolder(t, setup.Server, setup.Token, "Test Series Filter Library", false)
|
||||||
|
viewerID := getRegularUserID(t, setup.DB)
|
||||||
|
|
||||||
|
type seriesFixture struct {
|
||||||
|
name string
|
||||||
|
author string
|
||||||
|
createdAt string
|
||||||
|
books []struct {
|
||||||
|
title string
|
||||||
|
percentage string
|
||||||
|
lastReadAt string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fixtures := []seriesFixture{
|
||||||
|
{
|
||||||
|
name: "Probe Zebra Series", author: "Zeta Zed", createdAt: "2026-01-03 12:00:00+00",
|
||||||
|
books: []struct {
|
||||||
|
title string
|
||||||
|
percentage string
|
||||||
|
lastReadAt string
|
||||||
|
}{
|
||||||
|
{title: "Zebra Book 1", percentage: "1.0", lastReadAt: "2026-01-09 08:00:00+00"},
|
||||||
|
{title: "Zebra Book 2", percentage: "0.5", lastReadAt: "2026-01-09 08:00:00+00"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Probe Mango Series", author: "Alpha One", createdAt: "2026-01-02 12:00:00+00",
|
||||||
|
books: []struct {
|
||||||
|
title string
|
||||||
|
percentage string
|
||||||
|
lastReadAt string
|
||||||
|
}{
|
||||||
|
{title: "Mango Book 1", percentage: "1.0", lastReadAt: "2026-01-10 08:00:00+00"},
|
||||||
|
{title: "Mango Book 2", percentage: "1.0", lastReadAt: "2026-01-10 08:00:00+00"},
|
||||||
|
{title: "Mango Book 3", percentage: "1.0", lastReadAt: "2026-01-10 08:00:00+00"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Probe Apple Series", author: "Midway Two", createdAt: "2026-01-01 12:00:00+00",
|
||||||
|
books: []struct {
|
||||||
|
title string
|
||||||
|
percentage string
|
||||||
|
lastReadAt string
|
||||||
|
}{
|
||||||
|
{title: "Apple Book 1", percentage: "0", lastReadAt: ""},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
stripComments := regexp.MustCompile(`<!--.*?-->`)
|
||||||
|
// Card headings only: the empty-state panels use different h3
|
||||||
|
// classes ("text-xl font-semibold mb-2"), and "No Series Match"
|
||||||
|
// must not read as a rendered series.
|
||||||
|
nameRegex := regexp.MustCompile(`<h3 class="font-semibold text-sm line-clamp-2"[^>]*>([^<]+)</h3>`)
|
||||||
|
|
||||||
|
renderedNames := func(query string) []string {
|
||||||
|
code, body := ssrGet(t, setup.Server.URL+"/series?library_id="+libID+query, setup.RegularToken)
|
||||||
|
if code != 200 {
|
||||||
|
t.Logf("series request %q -> %d; body head: %.500s", query, code, body)
|
||||||
|
}
|
||||||
|
require.Equal(t, 200, code)
|
||||||
|
var names []string
|
||||||
|
for _, m := range nameRegex.FindAllStringSubmatch(stripComments.ReplaceAllString(body, ""), -1) {
|
||||||
|
names = append(names, strings.TrimSpace(m[1]))
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, _ = setup.DBPool.Exec(ctx, "DELETE FROM libraries WHERE id = $1", libID)
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, fx := range fixtures {
|
||||||
|
for _, b := range fx.books {
|
||||||
|
var itemID string
|
||||||
|
err := setup.DBPool.QueryRow(ctx,
|
||||||
|
`INSERT INTO media_items (library_id, title, file_path, series, authors, created_at, imported_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $6, $6) RETURNING id`,
|
||||||
|
libID, b.title, fmt.Sprintf("/tmp/test-series-filter/%s.epub", b.title), fx.name, []string{fx.author}, fx.createdAt,
|
||||||
|
).Scan(&itemID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
if b.percentage != "0" && b.percentage != "" {
|
||||||
|
_, err = setup.DBPool.Exec(ctx,
|
||||||
|
`INSERT INTO reading_progress (media_item_id, user_id, percentage, last_read_at)
|
||||||
|
VALUES ($1, $2, $3::float, $4::timestamptz)`,
|
||||||
|
itemID, viewerID, b.percentage, b.lastReadAt,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("default sort is recently added", func(t *testing.T) {
|
||||||
|
assert.Equal(t, []string{"Probe Zebra Series", "Probe Mango Series", "Probe Apple Series"}, renderedNames(""))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("every sort key orders the grid", func(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
sort string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"sort=recent", []string{"Zebra", "Mango", "Apple"}},
|
||||||
|
{"sort=recent_asc", []string{"Apple", "Mango", "Zebra"}},
|
||||||
|
{"sort=name_asc", []string{"Apple", "Mango", "Zebra"}},
|
||||||
|
{"sort=name_desc", []string{"Zebra", "Mango", "Apple"}},
|
||||||
|
{"sort=author_asc", []string{"Mango", "Apple", "Zebra"}},
|
||||||
|
{"sort=author_desc", []string{"Zebra", "Apple", "Mango"}},
|
||||||
|
{"sort=books_desc", []string{"Mango", "Zebra", "Apple"}},
|
||||||
|
{"sort=books_asc", []string{"Apple", "Zebra", "Mango"}},
|
||||||
|
{"sort=progress_desc", []string{"Mango", "Zebra", "Apple"}},
|
||||||
|
{"sort=progress_asc", []string{"Apple", "Zebra", "Mango"}},
|
||||||
|
{"sort=last_read_desc", []string{"Mango", "Zebra", "Apple"}},
|
||||||
|
{"sort=last_read_asc", []string{"Zebra", "Mango", "Apple"}},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
got := renderedNames("&" + tc.sort)
|
||||||
|
require.Len(t, got, 3, tc.sort)
|
||||||
|
for i, want := range tc.want {
|
||||||
|
assert.Contains(t, got[i], want, "%s: position %d", tc.sort, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unknown sort falls back to the default", func(t *testing.T) {
|
||||||
|
// Unknown keys resolve to "recent" in the registry; the SQL never
|
||||||
|
// interpolates the value (bound-param CASE cascade), so a hostile
|
||||||
|
// sort value is inert by construction.
|
||||||
|
assert.Equal(t, []string{"Probe Zebra Series", "Probe Mango Series", "Probe Apple Series"}, renderedNames("&sort=bogus-key"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("base sort + direction toggle composes registry keys", func(t *testing.T) {
|
||||||
|
// The toolbar sends base keys + dir; defaults apply when dir is
|
||||||
|
// absent, and both directions of every base key are reachable.
|
||||||
|
cases := []struct {
|
||||||
|
query string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"sort=name", []string{"Apple", "Mango", "Zebra"}}, // name asc default
|
||||||
|
{"sort=name&dir=desc", []string{"Zebra", "Mango", "Apple"}}, // toggle flips
|
||||||
|
{"sort=books&dir=asc", []string{"Apple", "Zebra", "Mango"}}, // books desc default, flipped
|
||||||
|
{"sort=progress&dir=asc", []string{"Apple", "Zebra", "Mango"}}, // progress desc default, flipped
|
||||||
|
{"sort=author", []string{"Mango", "Apple", "Zebra"}}, // author asc default
|
||||||
|
// THE toggle regression (2026-10-27): "recent" is both a base
|
||||||
|
// key and a registry key — dir=asc used to be swallowed.
|
||||||
|
{"sort=recent&dir=asc", []string{"Apple", "Mango", "Zebra"}},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
got := renderedNames("&" + tc.query)
|
||||||
|
require.Len(t, got, 3, tc.query)
|
||||||
|
for i, want := range tc.want {
|
||||||
|
assert.Contains(t, got[i], want, "%s: position %d", tc.query, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("name query filters", func(t *testing.T) {
|
||||||
|
assert.Equal(t, []string{"Probe Mango Series"}, renderedNames("&q=Mango"))
|
||||||
|
// ILIKE is case-insensitive and substring-shaped.
|
||||||
|
assert.Equal(t, []string{"Probe Mango Series"}, renderedNames("&q=mango"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("multi-field query matches authors and book titles", func(t *testing.T) {
|
||||||
|
// Author of Mango is "Alpha One" — the query no longer needs the
|
||||||
|
// separate author box.
|
||||||
|
assert.Equal(t, []string{"Probe Mango Series"}, renderedNames("&q=alpha+one"))
|
||||||
|
// "Zebra Book 2" is a book TITLE inside the Zebra series: the
|
||||||
|
// series must surface for a title match too.
|
||||||
|
assert.Equal(t, []string{"Probe Zebra Series"}, renderedNames("&q=zebra+book+2"))
|
||||||
|
// Nothing matches anywhere.
|
||||||
|
assert.Empty(t, renderedNames("&q=nobody-nowhere"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("author param (API back-compat) filters on any book's author", func(t *testing.T) {
|
||||||
|
// The toolbar no longer renders a separate author box (the
|
||||||
|
// multi-field q covers authors), but the param stays live for
|
||||||
|
// the JSON API and deep links.
|
||||||
|
assert.Equal(t, []string{"Probe Mango Series"}, renderedNames("&author=alpha"))
|
||||||
|
assert.Empty(t, renderedNames("&author=nobody"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("status filter matches the viewer's progress", func(t *testing.T) {
|
||||||
|
assert.Equal(t, []string{"Probe Mango Series"}, renderedNames("&status=finished"), "all books finished")
|
||||||
|
assert.Equal(t, []string{"Probe Zebra Series"}, renderedNames("&status=reading"), "some book mid-read")
|
||||||
|
assert.Equal(t, []string{"Probe Apple Series"}, renderedNames("&status=unstarted"), "no progress anywhere")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("filters compose", func(t *testing.T) {
|
||||||
|
assert.Equal(t, []string{"Probe Zebra Series"}, renderedNames("&q=probe&status=reading"))
|
||||||
|
assert.Equal(t, []string{"Probe Mango Series"}, renderedNames("&q=probe&status=finished&sort=progress_desc"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("cards carry the viewer's read count", func(t *testing.T) {
|
||||||
|
_, body := ssrGet(t, setup.Server.URL+"/series?library_id="+libID, setup.RegularToken)
|
||||||
|
assert.Contains(t, body, "3 of 3 read")
|
||||||
|
assert.Contains(t, body, "1 of 2 read")
|
||||||
|
assert.NotContains(t, body, "of 1 read", "unstarted series must not render a progress line")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("JSON API shares the sort/filter contract", func(t *testing.T) {
|
||||||
|
req, err := http.NewRequest("GET", setup.Server.URL+"/api/series?library_id="+libID+"&sort=author_asc&status=finished", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
|
||||||
|
resp, err := (&http.Client{}).Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
require.Equal(t, 200, resp.StatusCode)
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
Series []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
ReadCount int64 `json:"read_count"`
|
||||||
|
} `json:"series"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&payload))
|
||||||
|
require.Len(t, payload.Series, 1)
|
||||||
|
assert.Equal(t, "Probe Mango Series", payload.Series[0].Name)
|
||||||
|
assert.Equal(t, int64(3), payload.Series[0].ReadCount)
|
||||||
|
assert.Equal(t, 1, payload.Total)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -253,7 +253,7 @@ func TestReadingStatsSummary(t *testing.T) {
|
|||||||
media, err = setup.DB.GetMediaItem(ctx, uuidToPGType(uuid.MustParse(reuseMediaID[0])))
|
media, err = setup.DB.GetMediaItem(ctx, uuidToPGType(uuid.MustParse(reuseMediaID[0])))
|
||||||
require.NoError(t, err, "reuse seeded media item")
|
require.NoError(t, err, "reuse seeded media item")
|
||||||
} else {
|
} else {
|
||||||
media, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
media, err = setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||||
LibraryID: pgtype.UUID{Bytes: [16]byte(libUUID), Valid: true},
|
LibraryID: pgtype.UUID{Bytes: [16]byte(libUUID), Valid: true},
|
||||||
Title: title,
|
Title: title,
|
||||||
FilePath: fmt.Sprintf("/tmp/stats-%s-%d.epub", title, time.Now().UnixNano()),
|
FilePath: fmt.Sprintf("/tmp/stats-%s-%d.epub", title, time.Now().UnixNano()),
|
||||||
@@ -448,7 +448,7 @@ func TestReadingStatsWeekLibraryScope(t *testing.T) {
|
|||||||
libB := uuid.MustParse(createLibraryDirect(t, setup.DB, userID, fmt.Sprintf("Test Scope LibB %d", time.Now().UnixNano()), "ebooks"))
|
libB := uuid.MustParse(createLibraryDirect(t, setup.DB, userID, fmt.Sprintf("Test Scope LibB %d", time.Now().UnixNano()), "ebooks"))
|
||||||
|
|
||||||
seed := func(lib uuid.UUID, title string, daysAgo int, minutes int32) {
|
seed := func(lib uuid.UUID, title string, daysAgo int, minutes int32) {
|
||||||
media, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
media, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||||
LibraryID: pgtype.UUID{Bytes: [16]byte(lib), Valid: true},
|
LibraryID: pgtype.UUID{Bytes: [16]byte(lib), Valid: true},
|
||||||
Title: title,
|
Title: title,
|
||||||
FilePath: fmt.Sprintf("/tmp/%s-%d.epub", title, time.Now().UnixNano()),
|
FilePath: fmt.Sprintf("/tmp/%s-%d.epub", title, time.Now().UnixNano()),
|
||||||
@@ -526,7 +526,6 @@ func TestReadingStatsWeekLibraryScope(t *testing.T) {
|
|||||||
func TestLibraryUsageStats(t *testing.T) {
|
func TestLibraryUsageStats(t *testing.T) {
|
||||||
setup := setupTestServer(t)
|
setup := setupTestServer(t)
|
||||||
userID := getRegularUserID(t, setup.DB)
|
userID := getRegularUserID(t, setup.DB)
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
ebooksID := uuid.MustParse(createLibraryDirect(t, setup.DB, userID,
|
ebooksID := uuid.MustParse(createLibraryDirect(t, setup.DB, userID,
|
||||||
fmt.Sprintf("Test Usage Ebooks %d", time.Now().UnixNano()), "ebooks"))
|
fmt.Sprintf("Test Usage Ebooks %d", time.Now().UnixNano()), "ebooks"))
|
||||||
@@ -541,7 +540,7 @@ func TestLibraryUsageStats(t *testing.T) {
|
|||||||
if durationSeconds > 0 {
|
if durationSeconds > 0 {
|
||||||
audioMeta = []byte(fmt.Sprintf(`{"duration_seconds":%d,"chapters":[]}`, durationSeconds))
|
audioMeta = []byte(fmt.Sprintf(`{"duration_seconds":%d,"chapters":[]}`, durationSeconds))
|
||||||
}
|
}
|
||||||
media, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
media, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||||
LibraryID: pgtype.UUID{Bytes: [16]byte(lib), Valid: true},
|
LibraryID: pgtype.UUID{Bytes: [16]byte(lib), Valid: true},
|
||||||
Title: title,
|
Title: title,
|
||||||
Authors: []string{author},
|
Authors: []string{author},
|
||||||
@@ -689,7 +688,6 @@ func TestLibraryUsageStats(t *testing.T) {
|
|||||||
func TestLibraryUsageStats_Visibility(t *testing.T) {
|
func TestLibraryUsageStats_Visibility(t *testing.T) {
|
||||||
setup := setupTestServer(t)
|
setup := setupTestServer(t)
|
||||||
userID := getRegularUserID(t, setup.DB)
|
userID := getRegularUserID(t, setup.DB)
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
visibleID := uuid.MustParse(createLibraryDirect(t, setup.DB, userID,
|
visibleID := uuid.MustParse(createLibraryDirect(t, setup.DB, userID,
|
||||||
fmt.Sprintf("Test Usage Visible %d", time.Now().UnixNano()), "ebooks"))
|
fmt.Sprintf("Test Usage Visible %d", time.Now().UnixNano()), "ebooks"))
|
||||||
@@ -697,7 +695,7 @@ func TestLibraryUsageStats_Visibility(t *testing.T) {
|
|||||||
fmt.Sprintf("Test Usage Hidden %d", time.Now().UnixNano()), "ebooks"))
|
fmt.Sprintf("Test Usage Hidden %d", time.Now().UnixNano()), "ebooks"))
|
||||||
|
|
||||||
seed := func(lib uuid.UUID, title string, size int64) {
|
seed := func(lib uuid.UUID, title string, size int64) {
|
||||||
_, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
_, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||||
LibraryID: pgtype.UUID{Bytes: [16]byte(lib), Valid: true},
|
LibraryID: pgtype.UUID{Bytes: [16]byte(lib), Valid: true},
|
||||||
Title: title,
|
Title: title,
|
||||||
FilePath: fmt.Sprintf("/tmp/usage-vis-%s-%d.bin", title, time.Now().UnixNano()),
|
FilePath: fmt.Sprintf("/tmp/usage-vis-%s-%d.bin", title, time.Now().UnixNano()),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bookhoard/internal/config"
|
"bookhoard/internal/config"
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
|
"bookhoard/internal/rls"
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -10,6 +11,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -26,11 +28,16 @@ func setupSyncTestDB(t *testing.T) *database.Queries {
|
|||||||
dbConfig, err := pgxpool.ParseConfig(dbURL)
|
dbConfig, err := pgxpool.ParseConfig(dbURL)
|
||||||
require.NoError(t, err, "Failed to parse database URL")
|
require.NoError(t, err, "Failed to parse database URL")
|
||||||
dbConfig.MaxConns = 1
|
dbConfig.MaxConns = 1
|
||||||
|
// Harness-only session identity (plan §5): see setupTestServer.
|
||||||
|
dbConfig.BeforeAcquire = func(ctx context.Context, conn *pgx.Conn) bool {
|
||||||
|
_, err := conn.Exec(ctx, `SELECT set_config('app.user_id', $1, false)`, rls.SeedIdentity.String())
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
dbPool, err := pgxpool.NewWithConfig(ctx, dbConfig)
|
dbPool, err := pgxpool.NewWithConfig(ctx, dbConfig)
|
||||||
require.NoError(t, err, "Failed to connect to test database")
|
require.NoError(t, err, "Failed to connect to test database")
|
||||||
|
|
||||||
db := database.New(dbPool)
|
db := database.New(database.NewSeedDB(dbPool))
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
// Scoped wipes ONLY: reading_history now carries real captured
|
// Scoped wipes ONLY: reading_history now carries real captured
|
||||||
// sessions for the shared dev accounts, so a table-wide DELETE
|
// sessions for the shared dev accounts, so a table-wide DELETE
|
||||||
@@ -90,9 +97,8 @@ func createSyncTestDevice(t *testing.T, db *database.Queries, userID pgtype.UUID
|
|||||||
}
|
}
|
||||||
|
|
||||||
func createSyncTestMedia(t *testing.T, db *database.Queries, libraryID pgtype.UUID) pgtype.UUID {
|
func createSyncTestMedia(t *testing.T, db *database.Queries, libraryID pgtype.UUID) pgtype.UUID {
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
media, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
media, err := db.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||||
LibraryID: libraryID,
|
LibraryID: libraryID,
|
||||||
Title: "Sync Test Book",
|
Title: "Sync Test Book",
|
||||||
Authors: []string{"Test Author"},
|
Authors: []string{"Test Author"},
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"bookhoard/internal/handlers"
|
"bookhoard/internal/handlers"
|
||||||
"bookhoard/internal/middleware"
|
"bookhoard/internal/middleware"
|
||||||
ratelimit "bookhoard/internal/middleware"
|
ratelimit "bookhoard/internal/middleware"
|
||||||
|
"bookhoard/internal/rls"
|
||||||
"bookhoard/internal/router"
|
"bookhoard/internal/router"
|
||||||
"bookhoard/internal/services"
|
"bookhoard/internal/services"
|
||||||
wsync "bookhoard/internal/sync"
|
wsync "bookhoard/internal/sync"
|
||||||
@@ -317,6 +318,53 @@ func createRegularUserOnce(t *testing.T, db *database.Queries) UserTestData {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// seedCtx is the context harness-level media seeding runs under. It
|
||||||
|
// carries rls.SeedIdentity — a valid UUID with NO library_visibility
|
||||||
|
// rows, so RLS grants it full visibility (needed because sqlc :one
|
||||||
|
// inserts use RETURNING, which requires SELECT visibility of the new
|
||||||
|
// row). setup.DB additionally falls back to this identity for calls
|
||||||
|
// that pass a bare context. Product code never uses this; requests
|
||||||
|
// carry real personas.
|
||||||
|
func seedCtx() context.Context {
|
||||||
|
return rls.WithUser(context.Background(), rls.SeedIdentity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ownerSeedCtx returns a context carrying a fixture user's identity,
|
||||||
|
// for seeding rows OWNED by that user. Collections RLS (2026-10-31):
|
||||||
|
// the write predicates want actor == owner for non-system rows (the
|
||||||
|
// admin write-escape covers SYSTEM collections only), and reads under
|
||||||
|
// the pool session's SeedIdentity see nothing.
|
||||||
|
func ownerSeedCtx(userID pgtype.UUID) context.Context {
|
||||||
|
return rls.WithUser(context.Background(), userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// scopeExecAs is adminScopeExec's generic form: raw SQL under ANY
|
||||||
|
// declared identity. Use it (with the row's OWNER) for fixture writes
|
||||||
|
// to non-system collections — see ownerSeedCtx.
|
||||||
|
func scopeExecAs(t *testing.T, setup *TestServerSetup, userID pgtype.UUID, sql string, args ...any) {
|
||||||
|
adminScopeExec(t, setup, userID, sql, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedExec runs a raw statement against the pool under the seed
|
||||||
|
// identity. The bare pool bypasses ScopedDB (nothing reads the ctx for
|
||||||
|
// the GUC), so governed tables need the identity declared manually
|
||||||
|
// inside a transaction.
|
||||||
|
func seedExec(t *testing.T, pool *pgxpool.Pool, sql string, args ...any) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := rls.WithUser(context.Background(), rls.SeedIdentity)
|
||||||
|
tx, err := pool.Begin(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
if _, err := tx.Exec(ctx, `SELECT set_config('app.user_id', $1, true)`, rls.SeedIdentity.String()); err != nil {
|
||||||
|
_ = tx.Rollback(ctx)
|
||||||
|
require.NoError(t, err, "declare seed identity")
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, sql, args...); err != nil {
|
||||||
|
_ = tx.Rollback(ctx)
|
||||||
|
require.NoError(t, err, "seed exec")
|
||||||
|
}
|
||||||
|
require.NoError(t, tx.Commit(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
// uuidToPGType converts uuid.UUID to pgtype.UUID
|
// uuidToPGType converts uuid.UUID to pgtype.UUID
|
||||||
func uuidToPGType(u uuid.UUID) pgtype.UUID {
|
func uuidToPGType(u uuid.UUID) pgtype.UUID {
|
||||||
return pgtype.UUID{Bytes: [16]byte(u), Valid: true}
|
return pgtype.UUID{Bytes: [16]byte(u), Valid: true}
|
||||||
@@ -324,7 +372,12 @@ func uuidToPGType(u uuid.UUID) pgtype.UUID {
|
|||||||
|
|
||||||
// createDefaultCollectionsForUser creates the 4 default system collections for a user
|
// createDefaultCollectionsForUser creates the 4 default system collections for a user
|
||||||
func createDefaultCollectionsForUser(t *testing.T, db *database.Queries, userID pgtype.UUID) {
|
func createDefaultCollectionsForUser(t *testing.T, db *database.Queries, userID pgtype.UUID) {
|
||||||
ctx := context.Background()
|
// Owner-scoped seeding: the collections RLS policies (2026-10-31)
|
||||||
|
// need an acting identity, and the harness seeds before any JWT —
|
||||||
|
// the same bootstrap fix as CreateDefaultCollectionsForUser in
|
||||||
|
// internal/handlers/auth.go. Owner == actor, so SELECT sees the
|
||||||
|
// rows, the INSERT passes, and the heal-UPDATE passes.
|
||||||
|
ctx := rls.WithUser(context.Background(), userID)
|
||||||
// Canonical Title-case names — the same list the registration seed
|
// Canonical Title-case names — the same list the registration seed
|
||||||
// (CreateDefaultCollectionsForUser) and RestoreSystemCollection use.
|
// (CreateDefaultCollectionsForUser) and RestoreSystemCollection use.
|
||||||
// A stale kebab-case copy lived here once and duplicated every
|
// A stale kebab-case copy lived here once and duplicated every
|
||||||
@@ -491,10 +544,29 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
|||||||
dbConfig, err := pgxpool.ParseConfig(cfg.DatabaseURL())
|
dbConfig, err := pgxpool.ParseConfig(cfg.DatabaseURL())
|
||||||
require.NoError(t, err, "Failed to parse database URL")
|
require.NoError(t, err, "Failed to parse database URL")
|
||||||
dbConfig.MaxConns = 1
|
dbConfig.MaxConns = 1
|
||||||
|
// Harness-only session identity (plan §5): every connection in this
|
||||||
|
// pool runs as rls.SeedIdentity at SESSION scope, so raw-pool
|
||||||
|
// statements in tests (DBPool.Exec/QueryRow against governed tables)
|
||||||
|
// pass the read policy. No bleed risk: this pool never serves a
|
||||||
|
// second identity — A3 applies to the server's request pool.
|
||||||
|
dbConfig.BeforeAcquire = func(ctx context.Context, conn *pgx.Conn) bool {
|
||||||
|
_, err := conn.Exec(ctx, `SELECT set_config('app.user_id', $1, false)`, rls.SeedIdentity.String())
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
dbPool, err := pgxpool.NewWithConfig(context.Background(), dbConfig)
|
dbPool, err := pgxpool.NewWithConfig(context.Background(), dbConfig)
|
||||||
require.NoError(t, err, "Failed to connect to test database")
|
require.NoError(t, err, "Failed to connect to test database")
|
||||||
|
|
||||||
queries := database.New(dbPool)
|
// ScopedDB: the test server must behave EXACTLY like the production
|
||||||
|
// wiring (main.go) — every statement through Queries declares the
|
||||||
|
// acting user for RLS, including the search service's raw DBTX path.
|
||||||
|
// TestServerSetup.DB below is the SEED handle — also ScopedDB-wired
|
||||||
|
// (required: sqlc :one inserts use RETURNING, which evaluates the
|
||||||
|
// SELECT policy even on INSERT) — but its callers pass seedCtx(), a
|
||||||
|
// synthetic identity with no visibility rows, so seeding sees and
|
||||||
|
// writes everything. Real personas only exist at the HTTP layer.
|
||||||
|
scopedDB := database.NewScopedDB(dbPool)
|
||||||
|
queries := database.New(scopedDB)
|
||||||
|
seedDB := database.New(database.NewSeedDB(dbPool))
|
||||||
|
|
||||||
// Wire the tunable settings registry exactly like main.go: Load
|
// Wire the tunable settings registry exactly like main.go: Load
|
||||||
// populates the cache (missing rows fall back to compiled defaults)
|
// populates the cache (missing rows fall back to compiled defaults)
|
||||||
@@ -545,20 +617,31 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
|||||||
worker := services.NewWorker(3, connManager)
|
worker := services.NewWorker(3, connManager)
|
||||||
services.WorkerInstance = worker
|
services.WorkerInstance = worker
|
||||||
jobsHandler := handlers.NewJobsHandler(queries, worker)
|
jobsHandler := handlers.NewJobsHandler(queries, worker)
|
||||||
collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager)
|
// One composed-engine service for search AND the collection
|
||||||
|
// subsystem (item 3.2 — mirrors main.go).
|
||||||
|
collectionSearchService := services.NewSearchService(queries, scopedDB)
|
||||||
|
collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager, collectionSearchService)
|
||||||
filtersHandler := handlers.NewFiltersHandler(queries)
|
filtersHandler := handlers.NewFiltersHandler(queries)
|
||||||
dashboardService := services.NewDashboardService(queries)
|
dashboardService := services.NewDashboardService(queries, collectionSearchService)
|
||||||
dashboardHandler := handlers.NewDashboardHandler(queries)
|
dashboardHandler := handlers.NewDashboardHandler(queries, collectionSearchService)
|
||||||
statisticsService := services.NewStatisticsService(queries)
|
statisticsService := services.NewStatisticsService(queries)
|
||||||
statsHandler := handlers.NewStatsHandler(statisticsService)
|
statsHandler := handlers.NewStatsHandler(statisticsService)
|
||||||
seriesHandler := handlers.NewSeriesHandler(queries)
|
seriesHandler := handlers.NewSeriesHandler(queries)
|
||||||
mediaHandler := handlers.NewMediaHandler(queries, dbPool, libraryService, progressService, annotationService, worker)
|
mediaHandler := handlers.NewMediaHandler(queries, scopedDB, libraryService, progressService, annotationService, worker)
|
||||||
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
|
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
|
||||||
|
|
||||||
// Create conversion service for OPDS
|
// Create conversion service for OPDS
|
||||||
conversionService := services.NewConversionService(queries, getCachePath())
|
conversionService := services.NewConversionService(queries, getCachePath())
|
||||||
opdsHandler := handlers.NewOPDSHandler(queries, libraryService, conversionService)
|
opdsHandler := handlers.NewOPDSHandler(queries, libraryService, conversionService)
|
||||||
|
|
||||||
|
// Podcast subsystem (mirrors main.go wiring): fetcher service +
|
||||||
|
// download worker + handler. The poller is NOT started in tests
|
||||||
|
// (no background ticks against the shared dev DB); the worker loop
|
||||||
|
// only drains when a test queues work.
|
||||||
|
podcastService := services.NewPodcastService(queries)
|
||||||
|
podcastWorker := services.NewPodcastDownloadWorker(queries, podcastService)
|
||||||
|
podcastHandler := handlers.NewPodcastHandler(queries, podcastService, podcastWorker)
|
||||||
|
|
||||||
// Create Echo instance
|
// Create Echo instance
|
||||||
e := echo.New()
|
e := echo.New()
|
||||||
|
|
||||||
@@ -621,6 +704,9 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
|||||||
// hands the kobo/kepub CFI converter a typed-nil (kobo markup
|
// hands the kobo/kepub CFI converter a typed-nil (kobo markup
|
||||||
// panic-recovered as 500s in TestProgressWeb_KoboIntegration).
|
// panic-recovered as 500s in TestProgressWeb_KoboIntegration).
|
||||||
LibraryService: libraryService,
|
LibraryService: libraryService,
|
||||||
|
PodcastHandler: podcastHandler,
|
||||||
|
PodcastService: podcastService,
|
||||||
|
PodcastWorker: podcastWorker,
|
||||||
}
|
}
|
||||||
|
|
||||||
router.RegisterRoutes(routerConfig)
|
router.RegisterRoutes(routerConfig)
|
||||||
@@ -707,11 +793,15 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
|||||||
// run's creates die on UNIQUE(user_id, name) — the "collections
|
// run's creates die on UNIQUE(user_id, name) — the "collections
|
||||||
// 500-panic" rot. System collections are skipped (permanent rows).
|
// 500-panic" rot. System collections are skipped (permanent rows).
|
||||||
// Same convention as the library pass: names containing "test".
|
// Same convention as the library pass: names containing "test".
|
||||||
adminColls, cerr := queries.GetCollectionsByUser(ctx, pgtype.UUID{Bytes: adminUUID, Valid: true})
|
// Swept under the OWNER's identity: collections RLS (2026-10-31)
|
||||||
|
// makes unscoped reads return nothing, which would silently turn
|
||||||
|
// this cleanup into a no-op.
|
||||||
|
adminScope := rls.WithUser(ctx, pgtype.UUID{Bytes: adminUUID, Valid: true})
|
||||||
|
adminColls, cerr := queries.GetCollectionsByUser(adminScope, pgtype.UUID{Bytes: adminUUID, Valid: true})
|
||||||
if cerr == nil {
|
if cerr == nil {
|
||||||
for _, coll := range adminColls {
|
for _, coll := range adminColls {
|
||||||
if !coll.IsSystemCollection.Bool && strings.Contains(strings.ToLower(coll.Name), "test") {
|
if !coll.IsSystemCollection.Bool && strings.Contains(strings.ToLower(coll.Name), "test") {
|
||||||
queries.DeleteCollection(ctx, coll.ID)
|
queries.DeleteCollection(adminScope, coll.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -756,7 +846,7 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
|||||||
// Create TestServerSetup struct with all resources
|
// Create TestServerSetup struct with all resources
|
||||||
setup := &TestServerSetup{
|
setup := &TestServerSetup{
|
||||||
Server: ts,
|
Server: ts,
|
||||||
DB: queries,
|
DB: seedDB, // harness seeding/cleanup handle (see comment above)
|
||||||
DBPool: dbPool,
|
DBPool: dbPool,
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
ConnManager: connManager,
|
ConnManager: connManager,
|
||||||
@@ -785,11 +875,13 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
|||||||
|
|
||||||
// Same pass for test-created COLLECTIONS (the "collections
|
// Same pass for test-created COLLECTIONS (the "collections
|
||||||
// 500-panic" rot: they lingered forever and poisoned later runs).
|
// 500-panic" rot: they lingered forever and poisoned later runs).
|
||||||
adminColls, cerr := queries.GetCollectionsByUser(ctx, pgtype.UUID{Bytes: adminUUID, Valid: true})
|
// Owner-scoped for the same RLS reason as the setup sweep.
|
||||||
|
adminScope := rls.WithUser(ctx, pgtype.UUID{Bytes: adminUUID, Valid: true})
|
||||||
|
adminColls, cerr := queries.GetCollectionsByUser(adminScope, pgtype.UUID{Bytes: adminUUID, Valid: true})
|
||||||
if cerr == nil {
|
if cerr == nil {
|
||||||
for _, coll := range adminColls {
|
for _, coll := range adminColls {
|
||||||
if !coll.IsSystemCollection.Bool && strings.Contains(strings.ToLower(coll.Name), "test") {
|
if !coll.IsSystemCollection.Bool && strings.Contains(strings.ToLower(coll.Name), "test") {
|
||||||
queries.DeleteCollection(ctx, coll.ID)
|
queries.DeleteCollection(adminScope, coll.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+433
-6
@@ -18,7 +18,8 @@ INSERT INTO library_types (name, description, allowed_extensions) VALUES
|
|||||||
('ebooks', 'Ebook files including EPUB, PDF, MOBI, etc.', ARRAY['.epub', '.pdf', '.mobi', '.azw', '.azw3', '.txt', '.rtf', '.doc', '.docx', '.lit', '.fb2', '.pdb']),
|
('ebooks', 'Ebook files including EPUB, PDF, MOBI, etc.', ARRAY['.epub', '.pdf', '.mobi', '.azw', '.azw3', '.txt', '.rtf', '.doc', '.docx', '.lit', '.fb2', '.pdb']),
|
||||||
('comics', 'Comic book archives and image formats', ARRAY['.cbz', '.cbr', '.cb7', '.cbt', '.epub', '.pdf']),
|
('comics', 'Comic book archives and image formats', ARRAY['.cbz', '.cbr', '.cb7', '.cbt', '.epub', '.pdf']),
|
||||||
('manga', 'Manga files including archives and image folders', ARRAY['.cbz', '.cbr', '.epub', '.pdf', '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.avif', '.tiff', '.tif']),
|
('manga', 'Manga files including archives and image folders', ARRAY['.cbz', '.cbr', '.epub', '.pdf', '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.avif', '.tiff', '.tif']),
|
||||||
('audiobooks', 'Audiobook files; m4b first, breadth formats registered from day one', ARRAY['.m4b', '.m4a', '.mp3', '.flac'])
|
('audiobooks', 'Audiobook files; m4b first, breadth formats registered from day one', ARRAY['.m4b', '.m4a', '.mp3', '.flac']),
|
||||||
|
('podcasts', 'RSS podcast/feed libraries: feeds subscribe-and-download; the scanner never walks these folders', ARRAY[]::TEXT[])
|
||||||
ON CONFLICT (name) DO NOTHING;
|
ON CONFLICT (name) DO NOTHING;
|
||||||
|
|
||||||
-- Create users table
|
-- Create users table
|
||||||
@@ -47,6 +48,14 @@ ALTER TABLE users ADD COLUMN IF NOT EXISTS default_audio_speed REAL NOT NULL DEF
|
|||||||
-- mid-session. On by default; the profile page's On/Off dropdown owns it.
|
-- mid-session. On by default; the profile page's On/Off dropdown owns it.
|
||||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS keep_screen_awake BOOLEAN NOT NULL DEFAULT TRUE;
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS keep_screen_awake BOOLEAN NOT NULL DEFAULT TRUE;
|
||||||
|
|
||||||
|
-- Audio resume rewind: seconds before the saved position the
|
||||||
|
-- audiobook/podcast player starts (on open-resume AND again on every
|
||||||
|
-- genuine unpause — the re-orientation gap, never stacked). Strict
|
||||||
|
-- allow-list 0/5/10 enforced handler-side; 0 = exact resume. The
|
||||||
|
-- profile page's dropdown owns it; the chapters payload carries it to
|
||||||
|
-- every client (web + Android, incl. the car path).
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS audio_resume_rewind_seconds INT NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
-- Create system_settings table
|
-- Create system_settings table
|
||||||
CREATE TABLE IF NOT EXISTS system_settings (
|
CREATE TABLE IF NOT EXISTS system_settings (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
@@ -99,7 +108,11 @@ INSERT INTO system_settings (setting_key, setting_value, description, setting_ty
|
|||||||
('sync_queue_interval_seconds', '5', 'How often the sync queue flushes', 'int', '1', '3600', TRUE, 'sync'),
|
('sync_queue_interval_seconds', '5', 'How often the sync queue flushes', 'int', '1', '3600', TRUE, 'sync'),
|
||||||
('sync_queue_batch_size', '50', 'Maximum items processed per sync queue flush', 'int', '1', '10000', TRUE, 'sync'),
|
('sync_queue_batch_size', '50', 'Maximum items processed per sync queue flush', 'int', '1', '10000', TRUE, 'sync'),
|
||||||
('worker_pool_size', '3', 'Number of background worker goroutines', 'int', '1', '100', TRUE, 'performance'),
|
('worker_pool_size', '3', 'Number of background worker goroutines', 'int', '1', '100', TRUE, 'performance'),
|
||||||
('worker_queue_cap', '100', 'Background worker job queue capacity', 'int', '1', '10000', TRUE, 'performance')
|
('worker_queue_cap', '100', 'Background worker job queue capacity', 'int', '1', '10000', TRUE, 'performance'),
|
||||||
|
-- podcasts
|
||||||
|
('podcast_poll_interval_seconds', '3600', 'How often to poll podcast feeds for new episodes (seconds; complete feeds back off)', 'int', '300', '604800', FALSE, 'podcasts'),
|
||||||
|
('podcast_download_concurrency', '4', 'Podcast episode downloads in flight server-wide (the connection is the limit; fair-share spreads them across feeds)', 'int', '1', '16', FALSE, 'podcasts'),
|
||||||
|
('podcast_download_speed_mbps', '0', 'Shared bandwidth cap for podcast downloads in megabits/second (0 = unlimited)', 'float', '0', '10000', FALSE, 'podcasts')
|
||||||
ON CONFLICT (setting_key) DO NOTHING;
|
ON CONFLICT (setting_key) DO NOTHING;
|
||||||
|
|
||||||
-- Create refresh_tokens table
|
-- Create refresh_tokens table
|
||||||
@@ -174,7 +187,7 @@ CREATE TABLE IF NOT EXISTS media_items (
|
|||||||
language VARCHAR(10) DEFAULT 'en', -- Language code (ISO 639-1)
|
language VARCHAR(10) DEFAULT 'en', -- Language code (ISO 639-1)
|
||||||
edition VARCHAR(100), -- Edition information
|
edition VARCHAR(100), -- Edition information
|
||||||
page_count INTEGER, -- Total page count
|
page_count INTEGER, -- Total page count
|
||||||
genre VARCHAR(100), -- Genre classification
|
genre TEXT, -- Genre classification (smart-genre shelf OR raw tag free text — Audible category strings exceed 100 chars)
|
||||||
copyright_year INTEGER, -- Copyright/publication year
|
copyright_year INTEGER, -- Copyright/publication year
|
||||||
goodreads_id VARCHAR(50), -- Goodreads identifier
|
goodreads_id VARCHAR(50), -- Goodreads identifier
|
||||||
openlibrary_id VARCHAR(50), -- Open Library identifier
|
openlibrary_id VARCHAR(50), -- Open Library identifier
|
||||||
@@ -279,6 +292,14 @@ CREATE INDEX IF NOT EXISTS idx_media_items_contributors_gin ON media_items USING
|
|||||||
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS tags_search TEXT[];
|
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS tags_search TEXT[];
|
||||||
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS contributors_search TEXT[];
|
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS contributors_search TEXT[];
|
||||||
|
|
||||||
|
-- Widen genre for existing databases: the audio tag fallback stores the
|
||||||
|
-- raw file genre atom (Audible category strings exceed 100 chars —
|
||||||
|
-- e.g. "Politicians, Presidents & Heads of State, United States, ..."),
|
||||||
|
-- and VARCHAR(100) rejected the whole import/update at the database
|
||||||
|
-- layer (SQLSTATE 22001). VARCHAR -> TEXT is a metadata-only change,
|
||||||
|
-- safe to re-run (media_highlights position widening precedent).
|
||||||
|
ALTER TABLE media_items ALTER COLUMN genre TYPE TEXT;
|
||||||
|
|
||||||
-- Audiobook editorial facts (A2a): narrators are user-editable first-class
|
-- Audiobook editorial facts (A2a): narrators are user-editable first-class
|
||||||
-- (search/filter/badge surface); abridged rides along; audio_metadata keeps
|
-- (search/filter/badge surface); abridged rides along; audio_metadata keeps
|
||||||
-- the scan-derived tech record (duration/codec/chapters).
|
-- the scan-derived tech record (duration/codec/chapters).
|
||||||
@@ -1328,7 +1349,9 @@ CREATE TABLE IF NOT EXISTS collections (
|
|||||||
description TEXT,
|
description TEXT,
|
||||||
color VARCHAR(7),
|
color VARCHAR(7),
|
||||||
icon VARCHAR(50),
|
icon VARCHAR(50),
|
||||||
auto_assign_rules JSONB,
|
-- The unified filter tree (plan item 3.2): when present the qf
|
||||||
|
-- engine drives membership; see FilterTree. NULL = static.
|
||||||
|
filter_tree JSONB,
|
||||||
view_settings JSONB,
|
view_settings JSONB,
|
||||||
show_on_dashboard BOOLEAN DEFAULT false,
|
show_on_dashboard BOOLEAN DEFAULT false,
|
||||||
query_type TEXT DEFAULT 'filter',
|
query_type TEXT DEFAULT 'filter',
|
||||||
@@ -1344,12 +1367,23 @@ CREATE TABLE IF NOT EXISTS collections (
|
|||||||
-- the row id (and everything referencing it) never churn.
|
-- the row id (and everything referencing it) never churn.
|
||||||
ALTER TABLE collections ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
ALTER TABLE collections ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
-- Legacy rules-array columns RETIRED (2026-10-31): the unified filter
|
||||||
|
-- tree is the only smart-membership storage. No production row ever
|
||||||
|
-- carried a rules array or a non-default match mode (verified), so the
|
||||||
|
-- columns drop clean; the DROPs are the idempotent heal for databases
|
||||||
|
-- created before this change.
|
||||||
|
ALTER TABLE collections DROP COLUMN IF EXISTS auto_assign_rules;
|
||||||
|
ALTER TABLE collections DROP COLUMN IF EXISTS rule_match_mode;
|
||||||
|
|
||||||
|
-- Smart collections in the UNIFIED filter engine (plan item 3.2): the
|
||||||
|
-- qf tree (same DSL as saved filters, See FilterTree). NULL = static.
|
||||||
|
ALTER TABLE collections ADD COLUMN IF NOT EXISTS filter_tree JSONB;
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_collections_user_id ON collections(user_id);
|
CREATE INDEX IF NOT EXISTS idx_collections_user_id ON collections(user_id);
|
||||||
|
|
||||||
-- Index for dashboard queries
|
-- Index for dashboard queries
|
||||||
CREATE INDEX IF NOT EXISTS idx_collections_dashboard ON collections(user_id, show_on_dashboard, priority)
|
CREATE INDEX IF NOT EXISTS idx_collections_dashboard ON collections(user_id, show_on_dashboard, priority)
|
||||||
WHERE show_on_dashboard = true;
|
WHERE show_on_dashboard = true;
|
||||||
|
|
||||||
-- Create user_dashboard_preferences table
|
-- Create user_dashboard_preferences table
|
||||||
CREATE TABLE IF NOT EXISTS user_dashboard_preferences (
|
CREATE TABLE IF NOT EXISTS user_dashboard_preferences (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
@@ -1615,12 +1649,19 @@ CREATE INDEX IF NOT EXISTS idx_dictionary_word ON dictionary_cache(word);
|
|||||||
CREATE TABLE IF NOT EXISTS reader_settings (
|
CREATE TABLE IF NOT EXISTS reader_settings (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
setting_key VARCHAR(50) NOT NULL,
|
setting_key VARCHAR(120) NOT NULL,
|
||||||
setting_value JSONB NOT NULL,
|
setting_value JSONB NOT NULL,
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
UNIQUE(user_id, setting_key)
|
UNIQUE(user_id, setting_key)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Key-width headroom (podcast plan §10 R5, item 2.1): the composite
|
||||||
|
-- `podcast_order:<uuid>` key sits at exactly 50 chars, and future
|
||||||
|
-- per-feed/per-collection preference keys must not be forced to
|
||||||
|
-- payload-stuff by a column width. Widening a varchar is metadata-only
|
||||||
|
-- (no table rewrite); idempotent on every boot.
|
||||||
|
ALTER TABLE reader_settings ALTER COLUMN setting_key TYPE VARCHAR(120);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_reader_settings_user ON reader_settings(user_id);
|
CREATE INDEX IF NOT EXISTS idx_reader_settings_user ON reader_settings(user_id);
|
||||||
|
|
||||||
-- PDF bookmarks (custom user bookmarks)
|
-- PDF bookmarks (custom user bookmarks)
|
||||||
@@ -1869,3 +1910,389 @@ CREATE INDEX IF NOT EXISTS idx_hash_conflicts_status ON hash_conflicts(status);
|
|||||||
|
|
||||||
-- Widen for databases created before the resolution format settled (no-op otherwise)
|
-- Widen for databases created before the resolution format settled (no-op otherwise)
|
||||||
ALTER TABLE hash_conflicts ALTER COLUMN resolution TYPE VARCHAR(50);
|
ALTER TABLE hash_conflicts ALTER COLUMN resolution TYPE VARCHAR(50);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- PODCASTS / RSS FEEDS
|
||||||
|
-- Plan: ~/.opencode/plan/podcast-integration.md (§6).
|
||||||
|
-- Feeds are per-library entities (UNIQUE(library_id, feed_url)); users
|
||||||
|
-- subscribe personally (podcast_subscriptions). Episodes are
|
||||||
|
-- media_items rows with source='podcast_episode' — per-episode
|
||||||
|
-- progress/stats for free — grouped by source_feed_id. Visibility is
|
||||||
|
-- RLS-enforced (see the policy block at the end of this file): an
|
||||||
|
-- episode/feed row is SELECT-visible only to subscribers of the feed
|
||||||
|
-- and to admins. The fetcher/download worker is the SOLE writer of
|
||||||
|
-- feed/episode metadata; users own only their subscription row and the
|
||||||
|
-- reader_settings order override. ALL DDL IDEMPOTENT: schema.sql
|
||||||
|
-- re-executes on EVERY boot.
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- First per-library boolean knob (plain column, not KV): when false,
|
||||||
|
-- only admins may add feeds to the library (members keep subscribe
|
||||||
|
-- rights to existing feeds).
|
||||||
|
ALTER TABLE libraries ADD COLUMN IF NOT EXISTS allow_member_feeds BOOLEAN NOT NULL DEFAULT TRUE;
|
||||||
|
|
||||||
|
-- Folder purpose: 'scan' (default; the FS walker's root) vs 'podcast'
|
||||||
|
-- (a podcasts library's download root — scanner skips it wholesale,
|
||||||
|
-- belt-and-braces on top of the library-type skip).
|
||||||
|
ALTER TABLE library_folders ADD COLUMN IF NOT EXISTS purpose VARCHAR(20) NOT NULL DEFAULT 'scan';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS podcast_feeds (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
library_id UUID NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
|
||||||
|
feed_url TEXT NOT NULL,
|
||||||
|
-- podcast:guid — the feed's canonical identity; dedupes across URL
|
||||||
|
-- migrations (fetcher honors itunes:new-feed-url + redirects).
|
||||||
|
podcast_guid TEXT,
|
||||||
|
title VARCHAR(500) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
-- Channel itunes:author (semantically the host) and itunes:owner.
|
||||||
|
host VARCHAR(500),
|
||||||
|
publisher VARCHAR(500),
|
||||||
|
owner_name VARCHAR(500),
|
||||||
|
owner_email VARCHAR(500),
|
||||||
|
language VARCHAR(50),
|
||||||
|
tags TEXT[] DEFAULT '{}',
|
||||||
|
explicit BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
artwork_path VARCHAR(500),
|
||||||
|
-- itunes:type: 'serial' | 'episodic'
|
||||||
|
feed_type VARCHAR(20),
|
||||||
|
-- podcast:medium: 'audiobook' | 'podcast' | 'music' | ...
|
||||||
|
medium VARCHAR(20),
|
||||||
|
-- itunes:complete: poller backs off once the publisher ends the feed.
|
||||||
|
is_complete BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
-- Conditional-GET validators (opaque; echoed verbatim).
|
||||||
|
etag VARCHAR(500),
|
||||||
|
last_modified VARCHAR(500),
|
||||||
|
last_fetched_at TIMESTAMPTZ,
|
||||||
|
last_build_date TIMESTAMPTZ,
|
||||||
|
created_by_admin_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE(library_id, feed_url)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_podcast_feeds_library ON podcast_feeds(library_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_podcast_feeds_guid ON podcast_feeds(podcast_guid);
|
||||||
|
|
||||||
|
-- User-owned subscription row: the ONLY podcast state a user writes.
|
||||||
|
-- display_name NULL renders as the feed title. auto_download:
|
||||||
|
-- 'off' | 'all' | 'newest' (+ auto_download_count for newest N).
|
||||||
|
CREATE TABLE IF NOT EXISTS podcast_subscriptions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
feed_id UUID NOT NULL REFERENCES podcast_feeds(id) ON DELETE CASCADE,
|
||||||
|
display_name VARCHAR(500),
|
||||||
|
auto_download VARCHAR(20) NOT NULL DEFAULT 'off',
|
||||||
|
auto_download_count INT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE(user_id, feed_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_podcast_subs_user ON podcast_subscriptions(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_podcast_subs_feed ON podcast_subscriptions(feed_id);
|
||||||
|
|
||||||
|
-- Episode columns (NULL for scan-sourced items). See the plan §5 for
|
||||||
|
-- the full RSS/iTunes/Podcasting-2.0 field mapping — existing columns
|
||||||
|
-- (title, subtitle, description, authors, explicit, date_published,
|
||||||
|
-- cover_image_path, file_size, mime_type, language, genre/tags) carry
|
||||||
|
-- the shared fields; only genuinely podcast-only storage lives here.
|
||||||
|
ALTER TABLE media_items
|
||||||
|
ADD COLUMN IF NOT EXISTS source VARCHAR(20) NOT NULL DEFAULT 'scan',
|
||||||
|
ADD COLUMN IF NOT EXISTS source_feed_id UUID REFERENCES podcast_feeds(id) ON DELETE CASCADE,
|
||||||
|
-- Stable reorder-proof addressing key (invariant 3): progress
|
||||||
|
-- anchors on guid, never ordinal.
|
||||||
|
ADD COLUMN IF NOT EXISTS episode_guid TEXT,
|
||||||
|
-- Native order as of the latest fetch (renumbered per fetch).
|
||||||
|
ADD COLUMN IF NOT EXISTS feed_position INTEGER,
|
||||||
|
ADD COLUMN IF NOT EXISTS season_number INTEGER,
|
||||||
|
ADD COLUMN IF NOT EXISTS episode_number INTEGER,
|
||||||
|
ADD COLUMN IF NOT EXISTS enclosure_url TEXT,
|
||||||
|
-- itunes:duration claim (often HH:MM:SS or prose); display until
|
||||||
|
-- ffprobe reconciles after download.
|
||||||
|
ADD COLUMN IF NOT EXISTS declared_duration_seconds INTEGER,
|
||||||
|
ADD COLUMN IF NOT EXISTS transcript_url TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS chapters_url TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS hosts TEXT[] DEFAULT '{}',
|
||||||
|
ADD COLUMN IF NOT EXISTS guests TEXT[] DEFAULT '{}',
|
||||||
|
-- Download state machine: pending | queued | downloading |
|
||||||
|
-- downloaded | failed (+ reason text + attempt count). The episode
|
||||||
|
-- list renders this inline (D8/D19) — never Processing Issues.
|
||||||
|
ADD COLUMN IF NOT EXISTS download_state VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||||
|
ADD COLUMN IF NOT EXISTS download_error TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS download_attempts INT NOT NULL DEFAULT 0,
|
||||||
|
-- Monotonic queue ticket (Postgres SEQUENCE nextval at enqueue
|
||||||
|
-- time): the download pool claims in ticket order, so the enqueue
|
||||||
|
-- loop's ordering IS the download order (effective listening order
|
||||||
|
-- — §0 items 4/5). NULL for rows never queued.
|
||||||
|
ADD COLUMN IF NOT EXISTS download_queue_pos BIGINT;
|
||||||
|
|
||||||
|
-- Queue ticket source: atomic across concurrent enqueues (per-feed
|
||||||
|
-- locks serialize within a feed; the sequence covers across feeds).
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS podcast_queue_seq;
|
||||||
|
|
||||||
|
-- Identity + ordering + worker-worklist indexes (all partial: scan
|
||||||
|
-- rows never touch these columns).
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_media_items_feed_guid
|
||||||
|
ON media_items(source_feed_id, episode_guid) WHERE source_feed_id IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_media_items_feed_pos
|
||||||
|
ON media_items(source_feed_id, feed_position) WHERE source_feed_id IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_media_items_feed_undownloaded
|
||||||
|
ON media_items(source_feed_id) WHERE source_feed_id IS NOT NULL AND download_state <> 'downloaded';
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_media_items_queue
|
||||||
|
ON media_items(download_queue_pos) WHERE source = 'podcast_episode' AND download_state = 'queued';
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- RLS: PER-USER LIBRARY VISIBILITY (ONE SOURCE OF TRUTH)
|
||||||
|
-- Plan: ~/.opencode/plan/server-rls-library-visibility.md REV 2.
|
||||||
|
-- The policy joins library_visibility DIRECTLY — the same table the
|
||||||
|
-- visibility UI writes. The app declares the acting user per
|
||||||
|
-- transaction via the LOCAL GUC app.user_id (ScopedDB, set_config
|
||||||
|
-- is_local=true). Unset identity FAILS LOUD, never fails open:
|
||||||
|
-- - GUC never set in the session -> current_setting() raises
|
||||||
|
-- ("unrecognized configuration parameter")
|
||||||
|
-- - GUC set-then-transaction-ended -> reverts to '' -> the ::uuid
|
||||||
|
-- cast raises
|
||||||
|
-- - NULL GUC (unreachable via ScopedDB) -> IS NOT NULL guard ->
|
||||||
|
-- zero rows (fail-closed)
|
||||||
|
-- The runtime role bookhoard_srv OWNS these tables and is subject to
|
||||||
|
-- the policies via FORCE (z_rls_role.sh). Never connect the app as
|
||||||
|
-- the bootstrap superuser — superusers bypass RLS unconditionally.
|
||||||
|
-- IDEMPOTENT: schema.sql re-executes on EVERY app boot
|
||||||
|
-- (database.Initialize -> executeSchema); DROP .. IF EXISTS + CREATE
|
||||||
|
-- matches the repo's trigger idiom. DO NOT make this block
|
||||||
|
-- non-idempotent — the second boot would brick the app.
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS p_media_items_visible ON media_items;
|
||||||
|
DROP POLICY IF EXISTS p_media_items_insert ON media_items;
|
||||||
|
DROP POLICY IF EXISTS p_media_items_update ON media_items;
|
||||||
|
DROP POLICY IF EXISTS p_media_items_delete ON media_items;
|
||||||
|
|
||||||
|
ALTER TABLE media_items ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE media_items FORCE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- READ policy: EXACTLY the app's COALESCE(lv.is_visible, true)
|
||||||
|
-- semantics — row absent -> visible; row true -> visible; row false
|
||||||
|
-- -> hidden. No new index needed: library_visibility's
|
||||||
|
-- UNIQUE(user_id, library_id) constraint already indexes the join.
|
||||||
|
-- PODCAST EPISODES (plan D20): source='podcast_episode' rows carry the
|
||||||
|
-- ADDITIONAL subscription predicate — visible only to subscribers of
|
||||||
|
-- source_feed_id, or to admins (admins manage feeds regardless of
|
||||||
|
-- subscription; the escape also satisfies background jobs, which
|
||||||
|
-- declare a first-admin identity). Scan rows pass unchanged via the
|
||||||
|
-- IS DISTINCT FROM guard — the predicate costs them one NULL compare.
|
||||||
|
-- Unset identity FAILS LOUD, never fails open (see the block header).
|
||||||
|
CREATE POLICY p_media_items_visible ON media_items
|
||||||
|
FOR SELECT
|
||||||
|
USING (
|
||||||
|
current_setting('app.user_id') IS NOT NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM library_visibility lv
|
||||||
|
WHERE lv.user_id = current_setting('app.user_id')::uuid
|
||||||
|
AND lv.library_id = media_items.library_id
|
||||||
|
AND lv.is_visible = false
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
media_items.source IS DISTINCT FROM 'podcast_episode'
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM podcast_subscriptions s
|
||||||
|
WHERE s.user_id = current_setting('app.user_id')::uuid
|
||||||
|
AND s.feed_id = media_items.source_feed_id
|
||||||
|
)
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM users u
|
||||||
|
WHERE u.id = current_setting('app.user_id')::uuid
|
||||||
|
AND u.role = 'admin'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- WRITE policies: PERMISSIVE STAND-INS for the deferred scoped write
|
||||||
|
-- lens. MANDATORY at ENABLE time — with RLS enabled and no applicable
|
||||||
|
-- policy, INSERT fails (42501) and UPDATE/DELETE silently affect zero
|
||||||
|
-- rows, bricking the media scanner. Per-command split is deliberate:
|
||||||
|
-- a single FOR ALL policy with USING (true) would OR into SELECT and
|
||||||
|
-- disable the read policy above. Writes still need SELECT visibility
|
||||||
|
-- of the target rows (the read policy governs that), which internal
|
||||||
|
-- identities satisfy (they have no hidden rows).
|
||||||
|
-- ON CONFLICT upserts (scanner: (library_id, file_path)) need the
|
||||||
|
-- conflicting row SELECT-visible to the acting identity — holds for
|
||||||
|
-- every internal identity by construction.
|
||||||
|
CREATE POLICY p_media_items_insert ON media_items
|
||||||
|
FOR INSERT WITH CHECK (true);
|
||||||
|
CREATE POLICY p_media_items_update ON media_items
|
||||||
|
FOR UPDATE USING (true) WITH CHECK (true);
|
||||||
|
CREATE POLICY p_media_items_delete ON media_items
|
||||||
|
FOR DELETE USING (true);
|
||||||
|
|
||||||
|
-- Other tables: collection_items reaches media only via INNER
|
||||||
|
-- JOIN media_items (the join drops filtered rows). collections
|
||||||
|
-- metadata (book_count) is a separate counts question — plan A13.
|
||||||
|
|
||||||
|
-- PODCAST FEEDS (plan D20): governed the same way. SELECT-visible to
|
||||||
|
-- subscribers of the feed (their subscription IS the grant — this is
|
||||||
|
-- the predicate's lookup table, podcast_subscriptions itself stays
|
||||||
|
-- UNGOVERNED) plus the library-visibility join (a hidden library
|
||||||
|
-- hides its feeds too), and to admins. WRITE policies are permissive
|
||||||
|
-- stand-ins for the same reason as media_items above: mandatory at
|
||||||
|
-- ENABLE time, and the fetcher/worker (first-admin identity) plus the
|
||||||
|
-- add-feed upsert (ON CONFLICT DO UPDATE needs no separate SELECT)
|
||||||
|
-- cover every legitimate writer.
|
||||||
|
DROP POLICY IF EXISTS p_podcast_feeds_visible ON podcast_feeds;
|
||||||
|
DROP POLICY IF EXISTS p_podcast_feeds_insert ON podcast_feeds;
|
||||||
|
DROP POLICY IF EXISTS p_podcast_feeds_update ON podcast_feeds;
|
||||||
|
DROP POLICY IF EXISTS p_podcast_feeds_delete ON podcast_feeds;
|
||||||
|
|
||||||
|
ALTER TABLE podcast_feeds ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE podcast_feeds FORCE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY p_podcast_feeds_visible ON podcast_feeds
|
||||||
|
FOR SELECT
|
||||||
|
USING (
|
||||||
|
current_setting('app.user_id') IS NOT NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM library_visibility lv
|
||||||
|
WHERE lv.user_id = current_setting('app.user_id')::uuid
|
||||||
|
AND lv.library_id = podcast_feeds.library_id
|
||||||
|
AND lv.is_visible = false
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM podcast_subscriptions s
|
||||||
|
WHERE s.user_id = current_setting('app.user_id')::uuid
|
||||||
|
AND s.feed_id = podcast_feeds.id
|
||||||
|
)
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM users u
|
||||||
|
WHERE u.id = current_setting('app.user_id')::uuid
|
||||||
|
AND u.role = 'admin'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY p_podcast_feeds_insert ON podcast_feeds
|
||||||
|
FOR INSERT WITH CHECK (true);
|
||||||
|
CREATE POLICY p_podcast_feeds_update ON podcast_feeds
|
||||||
|
FOR UPDATE USING (true) WITH CHECK (true);
|
||||||
|
CREATE POLICY p_podcast_feeds_delete ON podcast_feeds
|
||||||
|
FOR DELETE USING (true);
|
||||||
|
|
||||||
|
-- podcast_subscriptions: UNGOVERNED, deliberately — it IS the read
|
||||||
|
-- policy's lookup table, and a user must always see their own
|
||||||
|
-- subscription rows (subscribe -> visibility is a single INSERT).
|
||||||
|
-- ============================================================
|
||||||
|
-- ── collections + collection_items (2026-10-31): ownership at the DB ──
|
||||||
|
-- layer, same doctrine as media_items/podcast_feeds above. The
|
||||||
|
-- predicates live in FUNCTIONS (not inline) so a future sharing model
|
||||||
|
-- edits exactly one place: add the share-table EXISTS branch inside
|
||||||
|
-- app_can_read_collection / app_can_write_collection. The functions are
|
||||||
|
-- PURE over their arguments — they never query collections — so the
|
||||||
|
-- policies cannot recurse, FORCE RLS safe. Reads are authenticated-
|
||||||
|
-- allow (household-readable: any signed-in identity, SeedIdentity
|
||||||
|
-- harness included); WRITES are owner-or-admin — that is the
|
||||||
|
-- enforcement of record for Update/Delete/AddBooks, which have no
|
||||||
|
-- app-level gate.
|
||||||
|
-- ===========================================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION app_is_admin() RETURNS BOOLEAN
|
||||||
|
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = pg_catalog, public AS $$
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM users u
|
||||||
|
WHERE u.id = NULLIF(current_setting('app.user_id', true), '')::uuid
|
||||||
|
AND u.role = 'admin'
|
||||||
|
);
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION app_can_read_collection(
|
||||||
|
p_collection_id UUID, p_owner UUID
|
||||||
|
) RETURNS BOOLEAN
|
||||||
|
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = pg_catalog, public AS $$
|
||||||
|
SELECT NULLIF(current_setting('app.user_id', true), '') IS NOT NULL
|
||||||
|
AND (
|
||||||
|
p_owner = current_setting('app.user_id', true)::uuid
|
||||||
|
OR app_is_admin()
|
||||||
|
-- FUTURE SHARING slot: OR EXISTS (
|
||||||
|
-- SELECT 1 FROM collection_shares cs
|
||||||
|
-- WHERE cs.collection_id = p_collection_id
|
||||||
|
-- AND cs.shared_with_user_id = current_setting('app.user_id', true)::uuid)
|
||||||
|
);
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION app_can_write_collection(
|
||||||
|
p_collection_id UUID, p_owner UUID, p_system BOOLEAN
|
||||||
|
) RETURNS BOOLEAN
|
||||||
|
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = pg_catalog, public AS $$
|
||||||
|
SELECT NULLIF(current_setting('app.user_id', true), '') IS NOT NULL
|
||||||
|
AND (
|
||||||
|
p_owner = current_setting('app.user_id', true)::uuid
|
||||||
|
OR (COALESCE(p_system, false) AND app_is_admin())
|
||||||
|
-- FUTURE SHARING slot: OR EXISTS (
|
||||||
|
-- SELECT 1 FROM collection_shares cs
|
||||||
|
-- WHERE cs.collection_id = p_collection_id
|
||||||
|
-- AND cs.shared_with_user_id = current_setting('app.user_id', true)::uuid
|
||||||
|
-- AND cs.permission IN ('edit'))
|
||||||
|
);
|
||||||
|
$$;
|
||||||
|
|
||||||
|
ALTER TABLE collections ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE collections FORCE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS p_collections_select ON collections;
|
||||||
|
CREATE POLICY p_collections_select ON collections
|
||||||
|
FOR SELECT USING (app_can_read_collection(id, user_id));
|
||||||
|
DROP POLICY IF EXISTS p_collections_insert ON collections;
|
||||||
|
CREATE POLICY p_collections_insert ON collections
|
||||||
|
FOR INSERT WITH CHECK (
|
||||||
|
user_id = NULLIF(current_setting('app.user_id', true), '')::uuid
|
||||||
|
OR app_is_admin()
|
||||||
|
);
|
||||||
|
DROP POLICY IF EXISTS p_collections_update ON collections;
|
||||||
|
CREATE POLICY p_collections_update ON collections
|
||||||
|
FOR UPDATE
|
||||||
|
USING (app_can_write_collection(id, user_id, is_system_collection))
|
||||||
|
WITH CHECK (app_can_write_collection(id, user_id, is_system_collection));
|
||||||
|
DROP POLICY IF EXISTS p_collections_delete ON collections;
|
||||||
|
CREATE POLICY p_collections_delete ON collections
|
||||||
|
FOR DELETE USING (app_can_write_collection(id, user_id, is_system_collection));
|
||||||
|
|
||||||
|
ALTER TABLE collection_items ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE collection_items FORCE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- Items ride their PARENT's accessibility: the collections SELECT
|
||||||
|
-- policy auto-applies inside these EXISTS subqueries.
|
||||||
|
DROP POLICY IF EXISTS p_collection_items_select ON collection_items;
|
||||||
|
CREATE POLICY p_collection_items_select ON collection_items
|
||||||
|
FOR SELECT USING (EXISTS (
|
||||||
|
SELECT 1 FROM collections c
|
||||||
|
WHERE c.id = collection_items.collection_id
|
||||||
|
));
|
||||||
|
DROP POLICY IF EXISTS p_collection_items_insert ON collection_items;
|
||||||
|
CREATE POLICY p_collection_items_insert ON collection_items
|
||||||
|
FOR INSERT WITH CHECK (EXISTS (
|
||||||
|
SELECT 1 FROM collections c
|
||||||
|
WHERE c.id = collection_items.collection_id
|
||||||
|
AND app_can_write_collection(c.id, c.user_id, c.is_system_collection)
|
||||||
|
));
|
||||||
|
DROP POLICY IF EXISTS p_collection_items_update ON collection_items;
|
||||||
|
CREATE POLICY p_collection_items_update ON collection_items
|
||||||
|
FOR UPDATE
|
||||||
|
USING (EXISTS (
|
||||||
|
SELECT 1 FROM collections c
|
||||||
|
WHERE c.id = collection_items.collection_id
|
||||||
|
AND app_can_write_collection(c.id, c.user_id, c.is_system_collection)
|
||||||
|
))
|
||||||
|
WITH CHECK (EXISTS (
|
||||||
|
SELECT 1 FROM collections c
|
||||||
|
WHERE c.id = collection_items.collection_id
|
||||||
|
AND app_can_write_collection(c.id, c.user_id, c.is_system_collection)
|
||||||
|
));
|
||||||
|
DROP POLICY IF EXISTS p_collection_items_delete ON collection_items;
|
||||||
|
CREATE POLICY p_collection_items_delete ON collection_items
|
||||||
|
FOR DELETE USING (EXISTS (
|
||||||
|
SELECT 1 FROM collections c
|
||||||
|
WHERE c.id = collection_items.collection_id
|
||||||
|
AND app_can_write_collection(c.id, c.user_id, c.is_system_collection)
|
||||||
|
));
|
||||||
|
|
||||||
|
|
||||||
@@ -12,6 +12,8 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: ./Dockerfile
|
dockerfile: ./Dockerfile
|
||||||
|
args:
|
||||||
|
GIT_COMMIT: ${GIT_COMMIT:-none}
|
||||||
|
|
||||||
# Integration Tests - runs against containerized app and db (dev only)
|
# Integration Tests - runs against containerized app and db (dev only)
|
||||||
tests:
|
tests:
|
||||||
@@ -22,10 +24,12 @@ services:
|
|||||||
container_name: bookhoard_tests
|
container_name: bookhoard_tests
|
||||||
environment:
|
environment:
|
||||||
# Database Configuration
|
# Database Configuration
|
||||||
|
# Runtime role = bookhoard_srv (non-superuser OWNER, subject to RLS
|
||||||
|
# via FORCE) — same role the app service uses.
|
||||||
DATABASE_HOST: db
|
DATABASE_HOST: db
|
||||||
DATABASE_PORT: ${DB_PORT:-5432}
|
DATABASE_PORT: ${DB_PORT:-5432}
|
||||||
DATABASE_USER: postgres
|
DATABASE_USER: bookhoard_srv
|
||||||
DATABASE_PASSWORD: ${DBPASS}
|
DATABASE_PASSWORD: ${DBSRVPASS}
|
||||||
DATABASE_NAME: bookhoard
|
DATABASE_NAME: bookhoard
|
||||||
COOKIE_SECURE: false
|
COOKIE_SECURE: false
|
||||||
|
|
||||||
|
|||||||
+59
-5
@@ -11,9 +11,6 @@ services:
|
|||||||
PGPORT: ${DB_PORT:-5432}
|
PGPORT: ${DB_PORT:-5432}
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
- ./database/schema:/docker-entrypoint-initdb.d
|
|
||||||
# Make other volumes as needed
|
|
||||||
- ./uploads:/app/uploads
|
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:${DB_PORT:-5432}:${DB_PORT:-5432}"
|
- "127.0.0.1:${DB_PORT:-5432}:${DB_PORT:-5432}"
|
||||||
- "[::1]:${DB_PORT:-5432}:${DB_PORT:-5432}"
|
- "[::1]:${DB_PORT:-5432}:${DB_PORT:-5432}"
|
||||||
@@ -26,6 +23,58 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
|
||||||
|
# One-shot bootstrap: creates the bookhoard_srv runtime role and
|
||||||
|
# transfers object ownership to it (RLS prerequisite). Idempotent —
|
||||||
|
# safe on every startup; the app waits for its success. Passwords
|
||||||
|
# accept inline values or .env, like the rest of this file.
|
||||||
|
# NOTE: plpgsql $$ tags are written $$$$ — compose eats one dollar
|
||||||
|
# per pair before the container sees the SQL.
|
||||||
|
db-init:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
container_name: bookhoard_db_init
|
||||||
|
environment:
|
||||||
|
PGHOST: db
|
||||||
|
PGPORT: ${DB_PORT:-5432}
|
||||||
|
PGUSER: postgres
|
||||||
|
PGDATABASE: bookhoard
|
||||||
|
PGPASSWORD: ${DBPASS}
|
||||||
|
DBSRVPASS: ${DBSRVPASS}
|
||||||
|
entrypoint: ["bash", "-c"]
|
||||||
|
command:
|
||||||
|
- |
|
||||||
|
set -e
|
||||||
|
: "${DBSRVPASS:?DBSRVPASS required in .env}"
|
||||||
|
psql -v ON_ERROR_STOP=1 -v role_pass="$DBSRVPASS" <<'EOSQL'
|
||||||
|
DO $$$$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'bookhoard_srv') THEN
|
||||||
|
CREATE ROLE bookhoard_srv LOGIN;
|
||||||
|
END IF;
|
||||||
|
END $$$$;
|
||||||
|
ALTER ROLE bookhoard_srv LOGIN PASSWORD :'role_pass';
|
||||||
|
DO $$$$
|
||||||
|
DECLARE r RECORD;
|
||||||
|
BEGIN
|
||||||
|
FOR r IN SELECT tablename FROM pg_tables WHERE schemaname = 'public' LOOP
|
||||||
|
EXECUTE format('ALTER TABLE public.%I OWNER TO bookhoard_srv', r.tablename);
|
||||||
|
END LOOP;
|
||||||
|
FOR r IN SELECT sequencename FROM pg_sequences WHERE schemaname = 'public' LOOP
|
||||||
|
EXECUTE format('ALTER SEQUENCE public.%I OWNER TO bookhoard_srv', r.sequencename);
|
||||||
|
END LOOP;
|
||||||
|
FOR r IN SELECT p.oid::regprocedure AS fn FROM pg_proc p
|
||||||
|
JOIN pg_namespace n ON p.pronamespace = n.oid
|
||||||
|
WHERE n.nspname = 'public' AND p.prokind = 'f' LOOP
|
||||||
|
EXECUTE format('ALTER FUNCTION %s OWNER TO bookhoard_srv', r.fn);
|
||||||
|
END LOOP;
|
||||||
|
END $$$$;
|
||||||
|
ALTER SCHEMA public OWNER TO bookhoard_srv;
|
||||||
|
ALTER DATABASE bookhoard OWNER TO bookhoard_srv;
|
||||||
|
EOSQL
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
# Bookhoard Application
|
# Bookhoard Application
|
||||||
# In production this image is pulled from the Gitea container registry.
|
# In production this image is pulled from the Gitea container registry.
|
||||||
# Override IMAGE_TAG in .env to pin or rollback a specific version (defaults to "latest").
|
# Override IMAGE_TAG in .env to pin or rollback a specific version (defaults to "latest").
|
||||||
@@ -35,10 +84,13 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
# Database Configuration
|
# Database Configuration
|
||||||
|
# Runtime role = bookhoard_srv (non-superuser OWNER, subject to RLS
|
||||||
|
# via FORCE). POSTGRES_USER above is the bootstrap account ONLY —
|
||||||
|
# the app must NEVER connect as it (superusers bypass RLS).
|
||||||
DATABASE_HOST: db
|
DATABASE_HOST: db
|
||||||
DATABASE_PORT: ${DB_PORT:-5432}
|
DATABASE_PORT: ${DB_PORT:-5432}
|
||||||
DATABASE_USER: postgres
|
DATABASE_USER: bookhoard_srv
|
||||||
DATABASE_PASSWORD: ${DBPASS}
|
DATABASE_PASSWORD: ${DBSRVPASS}
|
||||||
DATABASE_NAME: bookhoard
|
DATABASE_NAME: bookhoard
|
||||||
|
|
||||||
# Application Configuration
|
# Application Configuration
|
||||||
@@ -75,6 +127,8 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
db-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
volumes:
|
volumes:
|
||||||
- ./uploads:/app/uploads
|
- ./uploads:/app/uploads
|
||||||
- bookhoard_conversion_cache:/app/cache/kepub
|
- bookhoard_conversion_cache:/app/cache/kepub
|
||||||
|
|||||||
+325
-2
@@ -37,14 +37,15 @@ These are the response SHAPES (JSON key sets, not values) frozen by the
|
|||||||
| `CollectionsJSON` | `GET /api/collections/:id` (all 4 branches) | envelope `id, user_id, name, description, color, icon, auto_assign_rules, view_settings, books[], book_count, is_smart, created_at`; books = `BookInfo` |
|
| `CollectionsJSON` | `GET /api/collections/:id` (all 4 branches) | envelope `id, user_id, name, description, color, icon, auto_assign_rules, view_settings, books[], book_count, is_smart, created_at`; books = `BookInfo` |
|
||||||
| | `POST /api/collections/preview` | bare `{items: BookInfo[]}` |
|
| | `POST /api/collections/preview` | bare `{items: BookInfo[]}` |
|
||||||
| `SeriesJSON` | `GET /api/series/books?name=&library_id=` | `{name, library_id, books: BookInfo[], total}` |
|
| `SeriesJSON` | `GET /api/series/books?name=&library_id=` | `{name, library_id, books: BookInfo[], total}` |
|
||||||
| `SearchJSON` | `GET /api/media-items/search?q=&library_id=` | top-level array of `SearchMediaItemsUnifiedRow` (47 keys) |
|
| `SearchJSON` | `GET /api/media-items/search?q=&library_id=` | top-level array of `SearchMediaItemsUnifiedRow` (47 keys + viewer_percentage) |
|
||||||
| `DashboardSections` | `GET /api/dashboard/sections` | `{sections[]: {id, collection_id, is_system, title, description, icon, items: BookInfo[], view_all_url, priority}}` |
|
| `DashboardSections` | `GET /api/dashboard/sections` | `{sections[]: {id, collection_id, is_system, title, description, icon, items: BookInfo[], view_all_url, priority}}` |
|
||||||
| `MediaListBothBranches` | `GET /api/media-items` (unscoped) | `{data: [38-key resolved map]}` (intentionally divergent from the scoped branch until the row-shape collapse batch) |
|
| `MediaListBothBranches` | `GET /api/media-items` (unscoped) | `{data: [38-key resolved map]}` (intentionally divergent from the scoped branch until the row-shape collapse batch) |
|
||||||
| | `GET /api/media-items?library_id=&sort=` | `{data: [raw ListMediaItemsSortedRow, 70 sqlc keys]}` |
|
| | `GET /api/media-items?library_id=&sort=` | `{data: [raw ListMediaItemsSortedRow, 70 sqlc keys]}` |
|
||||||
| `SSRPages` | `/bookshelf`, `/collections`, `/collections/:id`, `/series`, `/series/detail`, `/tags/detail` | 200 + stable title/substring markers |
|
| `SSRPages` | `/bookshelf`, `/collections`, `/collections/:id`, `/series`, `/series/detail`, `/tags/detail` | 200 + stable title/substring markers |
|
||||||
|
|
||||||
`BookInfo` (every card surface): `media_item_id, title, author,
|
`BookInfo` (every card surface): `media_item_id, title, author,
|
||||||
cover_image_path, has_conflict, format_group, badge`.
|
cover_image_path, has_conflict, format_group, badge, viewer_percentage,
|
||||||
|
library_name`.
|
||||||
|
|
||||||
## Changelog (additive keys per batch)
|
## Changelog (additive keys per batch)
|
||||||
|
|
||||||
@@ -414,3 +415,325 @@ cover_image_path, has_conflict, format_group, badge`.
|
|||||||
BEFORE Rescan All — force rescan stamps books with the current
|
BEFORE Rescan All — force rescan stamps books with the current
|
||||||
setting, so bump-first + force = one pass of work; force-first +
|
setting, so bump-first + force = one pass of work; force-first +
|
||||||
bump = the trickle re-derives everything again (wasted, not harmful).
|
bump = the trickle re-derives everything again (wasted, not harmful).
|
||||||
|
|
||||||
|
## 2026-10-27 — Search-progress batch: viewer progress on cards
|
||||||
|
- **`SearchJSON`**: `SearchMediaItemsUnifiedRow` + `viewer_percentage`
|
||||||
|
(float 0..1, NULL when the requester never opened the book). The
|
||||||
|
query LEFT JOINs `reading_progress` on the SAME `user_id` param the
|
||||||
|
library-visibility join already binds — one requester param, no new
|
||||||
|
wire inputs; the join is row-neutral (UNIQUE(media_item, user)).
|
||||||
|
- **`BookInfo`** (CollectionsJSON / SeriesJSON / preview /
|
||||||
|
DashboardSections / search cards): + `viewer_percentage` (JSON null
|
||||||
|
when nil) and + `library_name` (empty on feeds that don't load it —
|
||||||
|
only the search-unified adapter populates both today). Adapters
|
||||||
|
other than BookCardsFromUnified keep emitting null/""; templ cards
|
||||||
|
render the progress bar/completed check only when populated.
|
||||||
|
- The header-search dropdown now renders the templ `SearchResults`
|
||||||
|
partial (layout=dropdown on the same endpoint, HX-Request) instead
|
||||||
|
of the retired innerHTML renderer in web/src/search.ts — same
|
||||||
|
endpoint, same auth, content server-rendered.
|
||||||
|
|
||||||
|
## 2026-10-29 — RLS visibility: item-scoped gates may answer 404 as well as 403
|
||||||
|
|
||||||
|
With library visibility enforced by Postgres RLS (see
|
||||||
|
database/schema/schema.sql), a hidden library's rows are INVISIBLE to
|
||||||
|
the engine — item-scoped endpoints (chapters, tracks, download, detail)
|
||||||
|
can no longer run their Go-level 403 gate because the row never arrives.
|
||||||
|
Both answers are gated: 403 (Go-level explicit-library checks remain on
|
||||||
|
listing endpoints) and 404 (row-level invisibility — no existence leak).
|
||||||
|
Pins asserting 403 on item-scoped content endpoints widen to accept
|
||||||
|
{403, 404}. Listing/scoped endpoints keep the strict 403 contract.
|
||||||
|
|
||||||
|
## 2026-10-29 — Podcast episode columns join media_items (additive widening)
|
||||||
|
|
||||||
|
The podcast integration (plan podcast-integration.md tasks 1.1/1.10) adds
|
||||||
|
fifteen nullable/no-null-default columns to `media_items` — they surface in
|
||||||
|
raw sqlc rows ONLY (`library-scoped list emits raw sqlc rows`), never in the
|
||||||
|
resolved unscoped map:
|
||||||
|
|
||||||
|
`source` ('scan' default), `source_feed_id`, `episode_guid`, `feed_position`,
|
||||||
|
`season_number`, `episode_number`, `enclosure_url`,
|
||||||
|
`declared_duration_seconds`, `transcript_url`, `chapters_url`, `hosts`,
|
||||||
|
`guests`, `download_state`, `download_error`, `download_attempts`.
|
||||||
|
|
||||||
|
Scan-sourced rows carry the inert defaults ('scan' / NULLs / 'pending');
|
||||||
|
raw-row consumers that filter `source='scan'` (scanner paths) are unaffected.
|
||||||
|
Nothing removed, nothing renamed.
|
||||||
|
|
||||||
|
## 2026-10-30 — download_queue_pos joins media_items (additive widening)
|
||||||
|
|
||||||
|
§0 item 4 of podcast-integration.md: the download pool's monotonic
|
||||||
|
queue ticket. `download_queue_pos BIGINT` (NULL for scan rows and
|
||||||
|
never-queued episodes) is stamped from the `podcast_queue_seq` SEQUENCE
|
||||||
|
at enqueue time so claim order = enqueue order (the effective listening
|
||||||
|
order). Raw-row pins (TestContractPin_MediaListBothBranches →
|
||||||
|
rawRowKeys) whitelist the key. Nothing removed, nothing renamed.
|
||||||
|
|
||||||
|
## 2026-10-30 — episode collections (additive widening, plan item 2/2.3/2.4)
|
||||||
|
|
||||||
|
Two shapes widen, both ADDITIVE — nothing removed, nothing renamed:
|
||||||
|
|
||||||
|
- **Collections envelope** (`GET /api/collections/:id` → envelopeKeys)
|
||||||
|
gains `rule_match_mode` (smart-collection match semantics, 'any'
|
||||||
|
legacy default | 'all'). Static collections emit the default value.
|
||||||
|
- **BookInfo** (every card surface → bookInfoKeys) gains four STABLE
|
||||||
|
keys — `is_episode`, `feed_title`, `download_state`, `hidden` — the
|
||||||
|
episode-collections extras. Adapters that predate episode awareness
|
||||||
|
emit their zero values (`false`/`""`), matching the
|
||||||
|
viewer_percentage precedent (nil-representable, key always present).
|
||||||
|
|
||||||
|
## 2026-10-30 — collections envelope gains filter_tree (additive widening, item 3.2)
|
||||||
|
|
||||||
|
`GET /api/collections/:id` adds `filter_tree` to the envelope: the
|
||||||
|
unified filter tree when the smart collection carries one (item 3.2 —
|
||||||
|
tree-driven membership), `null` for legacy/static collections. The
|
||||||
|
tree is the same qf DSL as saved filters (`{scope?, mode, children}`).
|
||||||
|
Nothing removed, nothing renamed.
|
||||||
|
|
||||||
|
## 2026-10-30 — rules endpoints RETIRED (filter-system unification, item 3.4)
|
||||||
|
|
||||||
|
The per-rule CRUD endpoints and the preview endpoint are DELETED — the
|
||||||
|
unified filter tree (item 3.2) is the only smart-membership authoring
|
||||||
|
surface, and the rules PAGE is now the filter builder in the
|
||||||
|
collection's scope:
|
||||||
|
|
||||||
|
- `POST /api/collections/:id/rules`, `PUT/DELETE
|
||||||
|
/api/collections/:id/rules/:ruleId` — gone (legacy rule arrays still
|
||||||
|
EVALUATE until the collection is rewritten; conversions run at the
|
||||||
|
builder's first Apply via RulesToFilterTree).
|
||||||
|
- `POST /api/collections/test-rules` — gone (the builder's live count
|
||||||
|
via /api/media-items/filter-count replaces the preview).
|
||||||
|
- `POST /api/collections/preview` STAYS (the dashboard
|
||||||
|
custom-section-builder still consumes it — flagged for its own
|
||||||
|
migration, not silently broken).
|
||||||
|
- Legacy compatibility KEPT: `auto_assign_rules` still round-trips in
|
||||||
|
the collections envelope; `PUT /api/collections/:id/match-mode`
|
||||||
|
still serves rules-array collections.
|
||||||
|
|
||||||
|
Test-side deletions follow the asymmetry rule: subtests exercising the
|
||||||
|
deleted endpoints are gone; their surviving SEMANTICS are pinned in
|
||||||
|
their tree form (TestSmartCollections rewritten on trees,
|
||||||
|
TestEpisodeCollectionsFilterTree, TestPodcastSmartCollections).
|
||||||
|
|
||||||
|
## 2026-10-31 — collections RLS: cross-user detail/writes tighten to 404/no-op
|
||||||
|
|
||||||
|
`collections` and `collection_items` joined the RLS-governed tables
|
||||||
|
(policies `p_collections_*` / `p_collection_items_*`, predicates
|
||||||
|
`app_can_read_collection` / `app_can_write_collection` — the same
|
||||||
|
doctrine as the media_items family lens, 2026-10-29). Contract
|
||||||
|
changes:
|
||||||
|
|
||||||
|
- `GET /api/collections/:id` for another user's collection: was 200
|
||||||
|
(by-id readability), now 404 (row invisible).
|
||||||
|
- `PUT /api/collections/:id` cross-user: was ungated success (no
|
||||||
|
app-level ownership check), now 404 — the RLS update touches no
|
||||||
|
rows, and the handler maps ErrNoRows to 404.
|
||||||
|
- `POST /api/collections/:id/books` cross-user: was ungated, now 404
|
||||||
|
(rejectSmartMutation's GetCollectionData is RLS-invisible).
|
||||||
|
- `DELETE /api/collections/:id` cross-user: still 204, but now a true
|
||||||
|
DB-level no-op (zero rows deleted) instead of an app-level free hit.
|
||||||
|
- `PUT /api/collections/:id/view-settings` cross-user: 404 (the owner
|
||||||
|
gate stays as defense-in-depth; the DB is enforcement of record).
|
||||||
|
- `PUT .../match-mode` and `.../items/order` keep their app-level 403
|
||||||
|
gates; the DB now backs them.
|
||||||
|
- Reads: owner or admin (policy escape); non-owner non-admin users see
|
||||||
|
nothing, anonymous included. Future sharing rides the predicate
|
||||||
|
functions — one EXISTS branch in app_can_read/write_collection, no
|
||||||
|
policy migration.
|
||||||
|
|
||||||
|
Test-side: TestCollectionsRLS pins the cross-user matrix end-to-end;
|
||||||
|
fixture writes to non-system collections run as the row's OWNER
|
||||||
|
(scopeExecAs/ownerSeedCtx) because the admin write-escape covers
|
||||||
|
system collections only.
|
||||||
|
|
||||||
|
## 2026-10-31 — legacy collection rules surface RETIRED (tree-only storage)
|
||||||
|
|
||||||
|
The pre-tree rules machinery is gone — no production row ever carried a
|
||||||
|
rules array or a non-default match mode (verified in the dev DB), the
|
||||||
|
web never reads the envelope keys, and the Android app consumes none of
|
||||||
|
it. Deletions:
|
||||||
|
|
||||||
|
- `PUT /api/collections/:id/match-mode` — endpoint retired (it had no
|
||||||
|
doc page). The tree's root `mode` owns any|all semantics now.
|
||||||
|
- Envelope: `auto_assign_rules` and `rule_match_mode` are REMOVED from
|
||||||
|
the create/list/detail/update payloads. This entry retracts the
|
||||||
|
2026-10-30 pins that listed them. `collection_type` and `is_smart`
|
||||||
|
stay.
|
||||||
|
- Request: `auto_assign_rules`/`rule_match_mode` are no longer accepted
|
||||||
|
on create/update. A create carrying `auto_assign_rules` answers 400
|
||||||
|
naming the retirement — the custom-section builder's legacy save
|
||||||
|
(already failing to bind, verified live) fails loudly instead of
|
||||||
|
silently creating a hollow static section.
|
||||||
|
- Storage: the `collections.auto_assign_rules` and
|
||||||
|
`collections.rule_match_mode` columns are DROPPED (idempotent
|
||||||
|
`DROP COLUMN IF EXISTS` in schema.sql).
|
||||||
|
- Service: the rules CRUD layer (AddRule/SetRuleEnabled/RemoveRule +
|
||||||
|
validateRule + the UpdateCollectionRulesJSON query),
|
||||||
|
ParseRules/EnabledRules/IsSmartCollection, RulesToFilterTree (+ the
|
||||||
|
SSR preload branch), and the legacy branch of EvaluateSmartMembership
|
||||||
|
are deleted. IsCollectionSmart = tree presence.
|
||||||
|
|
||||||
|
KEPT (live consumers): `POST /api/collections/preview` still accepts
|
||||||
|
`rules` and evaluates them in memory (the custom-section builder's
|
||||||
|
preview button); `include_auto` on the list stays (the Android app
|
||||||
|
sends it). The builder's SAVE remains the flagged §12 tree migration.
|
||||||
|
|
||||||
|
## 2026-10-31 — custom-section builder save: the flagged tree migration lands
|
||||||
|
|
||||||
|
Supersedes the same-day retirement entry's loud-400 stance for the
|
||||||
|
builder's payload. `POST /api/collections` now ACCEPTS the builder's
|
||||||
|
shape and adapts it at write time:
|
||||||
|
|
||||||
|
- `auto_assign_rules` (JSON-encoded array — string or bare) converts to
|
||||||
|
a filter tree via the restored `RulesToFilterTree`; converted trees
|
||||||
|
validate under `ParseFilterTree`, so an unhonorable rule is a 400 at
|
||||||
|
write naming the problem.
|
||||||
|
- `match_type` seeds the tree's root mode (`any` default).
|
||||||
|
- `manual_book_ids` attach to the created collection after create
|
||||||
|
(unparsable ids skipped — the AddBooks leniency).
|
||||||
|
- `show_on_dashboard` persists (new create field).
|
||||||
|
- `library_id` remains selection context only (not stored).
|
||||||
|
|
||||||
|
Dashboard sections: smart user sections evaluate their tree LIVE —
|
||||||
|
`GetUserCollectionItems` merges stored membership with the composed
|
||||||
|
engine's matches (deduped, library-scoped, capped at the section
|
||||||
|
limit). This exposed and fixed a real wiring bug: every production
|
||||||
|
`NewDashboardService` call omitted the search service, so tree
|
||||||
|
evaluation through the dashboard could never work (the test harness
|
||||||
|
wired it correctly — the gap was production-only). The dashboard
|
||||||
|
handler and both inline sites now receive it, and evaluation failures
|
||||||
|
log instead of silently emptying a section.
|
||||||
|
|
||||||
|
## 2026-11-01 — feed operator vocabulary collapsed (Option A): one name per semantic
|
||||||
|
|
||||||
|
The feed field's menu offered `is`/`isn't` alongside `in`/`isn't in` —
|
||||||
|
exact synonyms (the clause compiles both pairs to the same `ANY`). The
|
||||||
|
MENUS now offer `in`/`isn't in` (+ has/missing) only. This is an
|
||||||
|
offered-vocabulary narrowing, not a parse change:
|
||||||
|
|
||||||
|
- Stored trees and saved filters carrying `feed is X` / `feed is_not X`
|
||||||
|
PARSE AND EVALUATE FOREVER (`legacyFieldOps` accepts the synonyms at
|
||||||
|
validation — the stored wire is never invalidated).
|
||||||
|
- The fields payload (the client's operator menus) no longer carries
|
||||||
|
the duplicates.
|
||||||
|
- Client id-cells are uniform: every operator on an id-valued field
|
||||||
|
accepts a set, so all id cells say "pick one or more" and accumulate
|
||||||
|
picks — no per-operator placeholder/pick distinction to explain.
|
||||||
|
|
||||||
|
## 2026-11-01 — id labels resolve at render (SSR embed): refresh keeps names
|
||||||
|
|
||||||
|
Pages that render a filter tree embed the tree's id→name map at render
|
||||||
|
time (`data-id-labels` on the builder mount) — the collection editor
|
||||||
|
(stored tree) and the bookshelf (URL `qf`). Before this, refresh showed
|
||||||
|
raw uuids: only ids persist in trees; the client's id→label cache dies
|
||||||
|
with the page, and the quiet suggest fetch reused the MEDIA-CONSTRAINED
|
||||||
|
facet, so an id whose media doesn't match the tree's sibling rules got
|
||||||
|
no row and no label (live repro: `feed in [A,B,C] AND title contains
|
||||||
|
Chapter` labeled one feed of three — two feeds had no "Chapter"
|
||||||
|
episodes).
|
||||||
|
|
||||||
|
- `SearchService.FilterTreeLabels` reads the ENTITY tables (no media
|
||||||
|
join, no tree constraint): podcast_feeds and collections are
|
||||||
|
RLS-governed through the scoped handle (invisible ids resolve to
|
||||||
|
nothing); libraries mirror the facet's visibility join; added_by is a
|
||||||
|
plain username lookup. A zero-episode feed resolves its title —
|
||||||
|
pinned (TestRulesPageEmbedsIdLabels), as is the hidden-library
|
||||||
|
non-resolution.
|
||||||
|
- The bookshelf drawer also HYDRATES at component init now (no
|
||||||
|
data-tree mount → read the form): the parent's x-init
|
||||||
|
(initBookshelf) runs BEFORE the nested builder component exists
|
||||||
|
(Alpine initializes parents first), so its builderData() call was
|
||||||
|
null and a URL-qf refresh left the drawer with an empty seeded rule
|
||||||
|
while the grid stayed filtered. Init-order contract pinned in
|
||||||
|
filter_builder.test.ts.
|
||||||
|
- The quiet suggest fetch remains only as a fallback for trees the SSR
|
||||||
|
never saw (client-side apply flows); embedded labels make it a no-op
|
||||||
|
on refresh.
|
||||||
|
|
||||||
|
## 2026-11-01 — unset podcast order defaults to reversed (audiobook-first)
|
||||||
|
|
||||||
|
The per-feed listening order (the D5 `podcast_order:<feedID>` KV shown
|
||||||
|
in the Order dropdown) resolved its UNSET state to "native" — feed
|
||||||
|
document order, which runs newest-first for classic RSS, so an
|
||||||
|
audiobook feed played its LAST chapter first. The unset default is now
|
||||||
|
`reverse` at every resolution site of that one setting: the podcast
|
||||||
|
page list + dropdown (`podcastOrderForFeed`), the player's feed chain
|
||||||
|
(reader.go), and the download worker (`orderOverrideFor`).
|
||||||
|
|
||||||
|
- Stored overrides are untouched: anyone who picked an order keeps it
|
||||||
|
(native remains a valid, savable choice).
|
||||||
|
- No ordering semantics changed — `OrderEpisodes` and the four-option
|
||||||
|
vocabulary are as they were; only the unset fallback flipped.
|
||||||
|
- Pinned: the player sequence config carries `"order":"reverse"` and
|
||||||
|
feed position 1 plays LAST for an unset feed; the podcast page's
|
||||||
|
dropdown renders `reverse` selected (TestPodcastPlayerOrderControl,
|
||||||
|
replacing the former native pin).
|
||||||
|
|
||||||
|
## 2026-11-01 — new podcast smart collections are BORN reversed
|
||||||
|
|
||||||
|
`POST /api/collections` with an EPISODES-scope `filter_tree` seeds
|
||||||
|
`view_settings.chain_order = "reverse"` on the new row (merge semantics:
|
||||||
|
an explicit client `chain_order` is never clobbered). The dropdown's
|
||||||
|
default selection and the chain/page behavior come from the STORED
|
||||||
|
value — no resolution-logic changes; books-scope smart collections and
|
||||||
|
statics get nothing seeded; existing collections are untouched.
|
||||||
|
|
||||||
|
Pinned (TestSmartCollectionBornReversed): episodes seed, explicit-keep,
|
||||||
|
books-scope no-seed, static no-seed; TestEpisodeCollectionsFilterTree's
|
||||||
|
born-reversed chain/page pins replace the former native expectations
|
||||||
|
(which changelog here per the expectation-change rule). One misplaced
|
||||||
|
pin from 81e54f89 was corrected while flipping: the API envelope's
|
||||||
|
match list is NOT chain-ordered and never was — order pins live on the
|
||||||
|
SSR page and the reader chain.
|
||||||
|
|
||||||
|
## 2026-11-01 — label endpoint replaces the suggest-facet fallback
|
||||||
|
|
||||||
|
`GET /api/filter/labels?field=X&ids=a,b,c` names id-kind filter values
|
||||||
|
from their source tables (entity rows only — no media join, no tree
|
||||||
|
constraint; visibility/RLS ride the request identity). Non-id fields
|
||||||
|
are a 400; ids truncate at the same 100 cap the SSR embed uses.
|
||||||
|
|
||||||
|
The client's label resolution (`resolveIdLabels`/`resolveLabelsAsync`)
|
||||||
|
now uses ONLY this endpoint. The former fallback — the quiet suggest
|
||||||
|
fetch through the MEDIA-CONSTRAINED facet — is deleted: it labeled only
|
||||||
|
the ids whose media matched the tree's sibling rules (the live feed
|
||||||
|
repro), which mattered exactly where the SSR embed can't reach:
|
||||||
|
applying a SAVED filter from a cold bookshelf. The apply flow now
|
||||||
|
resolves through the endpoint and rebuilds the chips bar when labels
|
||||||
|
land. Pinned (TestFilterLabelsEndpoint): a zero-episode feed resolves
|
||||||
|
its title, comma-joined ids resolve together, an unsubscribed feed id
|
||||||
|
stays unnamed (RLS), non-id fields are 400.
|
||||||
|
|
||||||
|
## 2026-11-01 — the rules-Apply path seeds the reversed default too
|
||||||
|
|
||||||
|
PUT /api/collections/:id carrying an EPISODES-scope filter_tree seeds
|
||||||
|
`chain_order = "reverse"` when neither the request nor the stored
|
||||||
|
view_settings names one — the creation seed's twin: a static collection
|
||||||
|
becoming smart lands with the same default a born-smart one gets.
|
||||||
|
Explicit values (client-sent or stored — e.g. a deliberate native)
|
||||||
|
are never clobbered. Pinned (TestSmartCollectionBornReversed):
|
||||||
|
apply-seeds-when-unordered, respects stored native, respects client
|
||||||
|
native.
|
||||||
|
|
||||||
|
TestPodcastSmartCollections' acceptance pin updated with the same flip:
|
||||||
|
the briefing acceptance now starts from the page's FIRST card (feed
|
||||||
|
position 5 — the born-reversed default's book order) and chains into
|
||||||
|
feed position 1. The acceptance meaning is unchanged: play from what
|
||||||
|
you see, chained back-to-back within the live briefing set.
|
||||||
|
|
||||||
|
## 2026-11-01 — audio resume rewind setting
|
||||||
|
|
||||||
|
- `GET /api/media-items/:id/chapters` gained one ADDITIVE response key:
|
||||||
|
`resume_rewind_seconds` (int, the requesting user's setting; 0 for
|
||||||
|
anonymous). Existing keys unchanged. The web player and the Android
|
||||||
|
app both gate the value through the 0/5/10 allow-list, so servers
|
||||||
|
without the key (absent → 0) degrade to exact resume.
|
||||||
|
- `GET /api/auth/profile` now always carries
|
||||||
|
`audio_resume_rewind_seconds` (int, never omitted — 0 is a real
|
||||||
|
value, exact resume). `PUT /api/auth/profile` accepts the key as the
|
||||||
|
keep_screen_awake-style STRING ("0"/"5"/"10") so the profile form's
|
||||||
|
select can send an explicit Off; JSON clients that omit it leave the
|
||||||
|
column untouched, and anything outside {0,5,10} is 400.
|
||||||
|
Pinned (TestAudioResumeRewind): default 0, PUT "5" rides the
|
||||||
|
chapters payload, non-allow-listed rejections change nothing,
|
||||||
|
explicit "0" resets everywhere, omitted key leaves it untouched.
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
---
|
|
||||||
method: POST
|
|
||||||
path: /api/collections/:id/rules
|
|
||||||
auth: jwt
|
|
||||||
area: collections
|
|
||||||
---
|
|
||||||
<!-- source: internal/handlers/collections.go:877 CollectionHandler.AddRule -->
|
|
||||||
|
|
||||||
# Add Auto-Assign Rule
|
|
||||||
|
|
||||||
Add an automatic book assignment rule to a collection.
|
|
||||||
|
|
||||||
**Endpoint**: `POST /api/collections/{id}/rules`
|
|
||||||
**Auth**: Required
|
|
||||||
**Content-Type**: `application/json`
|
|
||||||
|
|
||||||
## Path Parameters
|
|
||||||
|
|
||||||
| Parameter | Type | Required | Description |
|
|
||||||
| --------- | ------------- | -------- | --------------- |
|
|
||||||
| id | string (UUID) | Yes | Collection UUID |
|
|
||||||
|
|
||||||
## Request Body
|
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
|
||||||
| -------- | ------------- | -------- | ----------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| field | string | Yes | Field to match on (genre, author, series, language, publisher, copyright_year, tags) |
|
|
||||||
| operator | string | Yes | Comparison operator (equals, not_equals, contains, not_contains, starts_with, ends_with, greater_than, less_than) |
|
|
||||||
| value | string/number | Yes | Value to compare against |
|
|
||||||
| priority | integer | No | Rule priority (1 = highest, default: 1) |
|
|
||||||
| enabled | boolean | No | Whether rule is active (default: true) |
|
|
||||||
|
|
||||||
### Supported Fields
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
| -------------- | ------ | ------------------------------------- |
|
|
||||||
| genre | string | Book genre |
|
|
||||||
| author | string | Book author |
|
|
||||||
| series | string | Book series name |
|
|
||||||
| language | string | Book language |
|
|
||||||
| publisher | string | Publisher name |
|
|
||||||
| copyright_year | number | Publication year (numeric comparison) |
|
|
||||||
| tags | string | Book tags |
|
|
||||||
|
|
||||||
### Supported Operators
|
|
||||||
|
|
||||||
| Operator | Type | Description |
|
|
||||||
| ------------ | ------ | ------------------------------------- |
|
|
||||||
| equals | all | Exact match |
|
|
||||||
| not_equals | all | Not equal |
|
|
||||||
| contains | string | Contains substring (case-insensitive) |
|
|
||||||
| not_contains | string | Does not contain |
|
|
||||||
| starts_with | string | Starts with (case-insensitive) |
|
|
||||||
| ends_with | string | Ends with (case-insensitive) |
|
|
||||||
| greater_than | number | Greater than |
|
|
||||||
| less_than | number | Less than |
|
|
||||||
|
|
||||||
### Example Request
|
|
||||||
|
|
||||||
```json example-request
|
|
||||||
{
|
|
||||||
"field": "genre",
|
|
||||||
"operator": "equals",
|
|
||||||
"value": "Science Fiction",
|
|
||||||
"priority": 1,
|
|
||||||
"enabled": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Response (201 Created)
|
|
||||||
|
|
||||||
```json example-response
|
|
||||||
{
|
|
||||||
"collection_id": null,
|
|
||||||
"is_smart": null,
|
|
||||||
"rule": null
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Error Responses
|
|
||||||
|
|
||||||
| Code | Description |
|
|
||||||
| ---- | ----------------------------------- |
|
|
||||||
| 400 | Invalid request (validation failed) |
|
|
||||||
| 401 | Authentication required |
|
|
||||||
| 404 | Collection not found |
|
|
||||||
| 500 | Internal server error |
|
|
||||||
|
|
||||||
## Try It Out
|
|
||||||
@@ -23,7 +23,6 @@ Creating a collection whose name already exists for the user returns **409**.
|
|||||||
"color": "#00FF00",
|
"color": "#00FF00",
|
||||||
"icon": "📖",
|
"icon": "📖",
|
||||||
"collection_type": "static",
|
"collection_type": "static",
|
||||||
"auto_assign_rules": [],
|
|
||||||
"view_settings": { "kobo": { "view_mode": "grid" } }
|
"view_settings": { "kobo": { "view_mode": "grid" } }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -39,7 +38,6 @@ Creating a collection whose name already exists for the user returns **409**.
|
|||||||
"color": "#00FF00",
|
"color": "#00FF00",
|
||||||
"icon": "📖",
|
"icon": "📖",
|
||||||
"collection_type": "static",
|
"collection_type": "static",
|
||||||
"auto_assign_rules": null,
|
|
||||||
"view_settings": { "kobo": { "view_mode": "grid" } },
|
"view_settings": { "kobo": { "view_mode": "grid" } },
|
||||||
"book_count": 0,
|
"book_count": 0,
|
||||||
"created_at": "2026-10-24 13:57:01.81234 +0000 UTC"
|
"created_at": "2026-10-24 13:57:01.81234 +0000 UTC"
|
||||||
@@ -57,20 +55,13 @@ RFC3339. Treat it as display text; do not parse it as an ISO timestamp.
|
|||||||
| description | string | no | |
|
| description | string | no | |
|
||||||
| color | string | no | hex color, e.g. `"#FF5733"` |
|
| color | string | no | hex color, e.g. `"#FF5733"` |
|
||||||
| icon | string | no | emoji |
|
| icon | string | no | emoji |
|
||||||
| collection_type | string | no | `"static"` (default) or `"smart"`; also implied by non-empty `auto_assign_rules` |
|
| collection_type | string | no | `"static"` (default) or `"smart"`; also implied by a `filter_tree` |
|
||||||
| auto_assign_rules | array | no | rule objects, see below |
|
|
||||||
| view_settings | object | no | per-device display preferences |
|
| view_settings | object | no | per-device display preferences |
|
||||||
|
| filter_tree | object | no | the unified filter tree (qf DSL); presence makes the collection smart |
|
||||||
### Auto-assign rule object
|
| auto_assign_rules | string \| array | no | custom-section-builder payload — converted to `filter_tree` at write (see custom-section-builder doc) |
|
||||||
|
| match_type | string | no | builder payload: `any` (default) or `all` — seeds the converted tree's root mode |
|
||||||
| Field | Type | Required | Notes |
|
| manual_book_ids | string\[\] | no | builder payload: attached to the collection after create |
|
||||||
| ----- | ---- | -------- | ----- |
|
| show_on_dashboard | boolean | no | pins the collection as a dashboard section |
|
||||||
| id | string | no | rule id |
|
|
||||||
| field | string | yes | one of `genre`, `series`, `author`, `language`, `publisher`, `copyright_year`, `tags` |
|
|
||||||
| operator | string | yes | one of `equals`, `not_equals`, `contains`, `not_contains`, `starts_with`, `ends_with`, `greater_than`, `less_than` |
|
|
||||||
| value | string | yes | value to compare against |
|
|
||||||
| priority | number | no | 1-10; higher values take precedence |
|
|
||||||
| enabled | boolean | no | absent = enabled (rules written before this field existed keep matching) |
|
|
||||||
|
|
||||||
## Errors
|
## Errors
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ GET /api/collections/550e8400-e29b-41d4-a716-446655440000
|
|||||||
"description": "Books I want to read soon",
|
"description": "Books I want to read soon",
|
||||||
"color": "#00FF00",
|
"color": "#00FF00",
|
||||||
"icon": "📖",
|
"icon": "📖",
|
||||||
"auto_assign_rules": null,
|
|
||||||
"view_settings": { "kobo": { "view_mode": "grid" } },
|
"view_settings": { "kobo": { "view_mode": "grid" } },
|
||||||
"books": [],
|
"books": [],
|
||||||
"book_count": 0,
|
"book_count": 0,
|
||||||
@@ -57,8 +56,8 @@ GET /api/collections/550e8400-e29b-41d4-a716-446655440000
|
|||||||
| description | string | |
|
| description | string | |
|
||||||
| color | string | |
|
| color | string | |
|
||||||
| icon | string | |
|
| icon | string | |
|
||||||
| auto_assign_rules | array \| null | |
|
|
||||||
| view_settings | object | |
|
| view_settings | object | |
|
||||||
|
| filter_tree | object \| null | the unified filter tree (qf DSL); null = static |
|
||||||
| books | array | book cards in this collection |
|
| books | array | book cards in this collection |
|
||||||
| book_count | number | length of `books` |
|
| book_count | number | length of `books` |
|
||||||
| is_smart | boolean | |
|
| is_smart | boolean | |
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ GET /api/collections?include_auto=true&sort_by=name
|
|||||||
"description": "Books I want to read soon",
|
"description": "Books I want to read soon",
|
||||||
"color": "#00FF00",
|
"color": "#00FF00",
|
||||||
"icon": "📖",
|
"icon": "📖",
|
||||||
"auto_assign_rules": null,
|
|
||||||
"created_at": "2026-10-24 13:57:01.81234 +0000 UTC",
|
"created_at": "2026-10-24 13:57:01.81234 +0000 UTC",
|
||||||
"book_count": 12,
|
"book_count": 12,
|
||||||
"is_smart": false
|
"is_smart": false
|
||||||
@@ -65,7 +64,6 @@ GET /api/collections?include_auto=true&sort_by=name
|
|||||||
| description | string | |
|
| description | string | |
|
||||||
| color | string | |
|
| color | string | |
|
||||||
| icon | string | |
|
| icon | string | |
|
||||||
| auto_assign_rules | array \| null | raw rules JSON; non-null implies smart |
|
|
||||||
| created_at | string | Go `time.String()` format |
|
| created_at | string | Go `time.String()` format |
|
||||||
| book_count | number | live count; respects `library_id` for smart collections |
|
| book_count | number | live count; respects `library_id` for smart collections |
|
||||||
| is_smart | boolean | |
|
| is_smart | boolean | |
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
---
|
|
||||||
method: DELETE
|
|
||||||
path: /api/collections/:id/rules/:ruleId
|
|
||||||
auth: jwt
|
|
||||||
area: collections
|
|
||||||
---
|
|
||||||
<!-- source: internal/handlers/collections.go:960 CollectionHandler.RemoveRule -->
|
|
||||||
|
|
||||||
# Remove Auto-Assign Rule
|
|
||||||
|
|
||||||
Remove an automatic book assignment rule from a collection.
|
|
||||||
|
|
||||||
**Endpoint**: `DELETE /api/collections/{collectionId}/rules/{ruleId}`
|
|
||||||
**Auth**: Required
|
|
||||||
**Content-Type**: `application/json`
|
|
||||||
|
|
||||||
## Path Parameters
|
|
||||||
|
|
||||||
| Parameter | Type | Required | Description |
|
|
||||||
| ------------ | ------------- | -------- | --------------- |
|
|
||||||
| collectionId | string (UUID) | Yes | Collection UUID |
|
|
||||||
| ruleId | string (UUID) | Yes | Rule UUID |
|
|
||||||
|
|
||||||
## Response (204 No Content)
|
|
||||||
|
|
||||||
Rule deleted successfully. No response body.
|
|
||||||
|
|
||||||
```json example-response
|
|
||||||
{
|
|
||||||
"collection_id": "550e8400-e29b-41d4-a716-446655440000",
|
|
||||||
"is_smart": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`is_smart` reflects the collection state AFTER removal — removing the
|
|
||||||
last rule converts a smart collection back to static.
|
|
||||||
|
|
||||||
## Error Responses
|
|
||||||
|
|
||||||
| Code | Description |
|
|
||||||
| ---- | ---------------------------- |
|
|
||||||
| 401 | Authentication required |
|
|
||||||
| 404 | Collection or rule not found |
|
|
||||||
| 500 | Internal server error |
|
|
||||||
|
|
||||||
## Try It Out
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
---
|
||||||
|
method: PUT
|
||||||
|
path: /api/collections/:id/view-settings
|
||||||
|
auth: jwt
|
||||||
|
area: collections
|
||||||
|
---
|
||||||
|
<!-- source: internal/handlers/collections.go CollectionHandler.SetCollectionChainOrder -->
|
||||||
|
|
||||||
|
# Set Collection Chain Order
|
||||||
|
|
||||||
|
Sets the curated listening order for the collection's episode sequence
|
||||||
|
(plan §10 R2): the chain producer (`buildSmartCollectionSequenceContext`)
|
||||||
|
plays `Play as Sequence` in this order. The key merges into the
|
||||||
|
collection's `view_settings` JSONB — playlist toggles that share the
|
||||||
|
document survive the write. Owner-only.
|
||||||
|
|
||||||
|
## Example request
|
||||||
|
|
||||||
|
```json example-request
|
||||||
|
{
|
||||||
|
"chain_order": "newest"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example response
|
||||||
|
|
||||||
|
```json example-response
|
||||||
|
{
|
||||||
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"chain_order": "newest",
|
||||||
|
"view_settings": { "chain_order": "newest" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fields
|
||||||
|
|
||||||
|
| Field | Type | Required | Notes |
|
||||||
|
| ----- | ---- | -------- | ----- |
|
||||||
|
| chain_order | string | Yes | One of `native`, `newest`, `oldest`, `reverse`; `native`/empty removes the stored key |
|
||||||
|
|
||||||
|
## Errors
|
||||||
|
|
||||||
|
| Status | Body | When |
|
||||||
|
| ------ | ---- | ---- |
|
||||||
|
| 400 | `{"error": "invalid collection id"}` | malformed id |
|
||||||
|
| 400 | `{"error": "chain_order must be one of: native, newest, oldest, reverse"}` | unknown order |
|
||||||
|
| 404 | `{"error": "collection not found"}` | unknown id |
|
||||||
|
| 401 / 403 | — | missing JWT / not the owner |
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
---
|
|
||||||
method: POST
|
|
||||||
path: /api/collections/test-rules
|
|
||||||
auth: jwt
|
|
||||||
area: collections
|
|
||||||
---
|
|
||||||
<!-- source: internal/handlers/collections.go:996 CollectionHandler.TestRules -->
|
|
||||||
|
|
||||||
# Test Rule
|
|
||||||
|
|
||||||
Test which books would match given rules without saving.
|
|
||||||
|
|
||||||
**Endpoint**: `POST /api/collections/test-rules`
|
|
||||||
**Auth**: Required
|
|
||||||
**Content-Type**: `application/json`
|
|
||||||
|
|
||||||
## Request Body
|
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
|
||||||
| ----- | ----- | -------- | ----------------------------- |
|
|
||||||
| rules | array | Yes | Array of rule objects to test |
|
|
||||||
|
|
||||||
### Rule Object
|
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
|
||||||
| -------- | ------------- | -------- | ----------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| field | string | Yes | Field to match on (genre, author, series, language, publisher, copyright_year, tags) |
|
|
||||||
| operator | string | Yes | Comparison operator (equals, not_equals, contains, not_contains, starts_with, ends_with, greater_than, less_than) |
|
|
||||||
| value | string/number | Yes | Value to compare against |
|
|
||||||
|
|
||||||
### Example Request
|
|
||||||
|
|
||||||
```json example-request
|
|
||||||
{
|
|
||||||
"rules": [
|
|
||||||
{
|
|
||||||
"field": "genre",
|
|
||||||
"operator": "equals",
|
|
||||||
"value": "Science Fiction"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"field": "author",
|
|
||||||
"operator": "contains",
|
|
||||||
"value": "Asimov"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Response (200 OK)
|
|
||||||
|
|
||||||
```json example-response
|
|
||||||
{
|
|
||||||
"matches": [
|
|
||||||
{
|
|
||||||
"media_item_id": "660e8400-e29b-41d4-a716-446655440000",
|
|
||||||
"title": "Foundation",
|
|
||||||
"author": "Isaac Asimov",
|
|
||||||
"cover_image_path": "/covers/foundation.jpg",
|
|
||||||
"match_reason": "Matched rule: genre equals Science Fiction"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"total": 42
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Use Case
|
|
||||||
|
|
||||||
Test rules before creating a collection to verify correct book matching. This endpoint shows which books would be added without actually modifying any collections.
|
|
||||||
|
|
||||||
## Error Responses
|
|
||||||
|
|
||||||
| Code | Description |
|
|
||||||
| ---- | ----------------------------------- |
|
|
||||||
| 400 | Invalid request (validation failed) |
|
|
||||||
| 401 | Authentication required |
|
|
||||||
| 500 | Internal server error |
|
|
||||||
|
|
||||||
## Try It Out
|
|
||||||
@@ -24,7 +24,6 @@ set `view_settings.hide_finished` / `view_settings.show_up_next` directly.
|
|||||||
"description": "Updated description",
|
"description": "Updated description",
|
||||||
"color": "#33CCFF",
|
"color": "#33CCFF",
|
||||||
"icon": "📚",
|
"icon": "📚",
|
||||||
"auto_assign_rules": [],
|
|
||||||
"view_settings": { "hide_finished": true },
|
"view_settings": { "hide_finished": true },
|
||||||
"hide_finished": ["true"],
|
"hide_finished": ["true"],
|
||||||
"show_up_next": ["true"]
|
"show_up_next": ["true"]
|
||||||
@@ -41,7 +40,6 @@ set `view_settings.hide_finished` / `view_settings.show_up_next` directly.
|
|||||||
"description": "Updated description",
|
"description": "Updated description",
|
||||||
"color": "#33CCFF",
|
"color": "#33CCFF",
|
||||||
"icon": "📚",
|
"icon": "📚",
|
||||||
"auto_assign_rules": null,
|
|
||||||
"view_settings": { "hide_finished": true, "show_up_next": true },
|
"view_settings": { "hide_finished": true, "show_up_next": true },
|
||||||
"created_at": "2026-10-24 13:57:01.81234 +0000 UTC"
|
"created_at": "2026-10-24 13:57:01.81234 +0000 UTC"
|
||||||
}
|
}
|
||||||
@@ -57,8 +55,8 @@ set `view_settings.hide_finished` / `view_settings.show_up_next` directly.
|
|||||||
| description | string | no | |
|
| description | string | no | |
|
||||||
| color | string | no | |
|
| color | string | no | |
|
||||||
| icon | string | no | |
|
| icon | string | no | |
|
||||||
| auto_assign_rules | array | no | replaces the whole rule set (rule object shape: see Create Collection) |
|
|
||||||
| view_settings | object | no | merged into stored settings |
|
| view_settings | object | no | merged into stored settings |
|
||||||
|
| filter_tree | object | no | replaces the stored filter tree (omitting it keeps the stored one) |
|
||||||
| hide_finished | array | no | single-element string array `["true"]`/`["false"]`; merged into view_settings |
|
| hide_finished | array | no | single-element string array `["true"]`/`["false"]`; merged into view_settings |
|
||||||
| show_up_next | array | no | same shape as `hide_finished` |
|
| show_up_next | array | no | same shape as `hide_finished` |
|
||||||
|
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
---
|
|
||||||
method: PUT
|
|
||||||
path: /api/collections/:id/rules/:ruleId
|
|
||||||
auth: jwt
|
|
||||||
area: collections
|
|
||||||
---
|
|
||||||
<!-- source: internal/handlers/collections.go:922 CollectionHandler.UpdateRule (SetRuleEnabled) -->
|
|
||||||
|
|
||||||
# Update Collection Rule
|
|
||||||
|
|
||||||
Despite the route name, this endpoint **toggles a rule's enabled state only**.
|
|
||||||
It does not update the rule's field/operator/value — rules are replaced wholesale
|
|
||||||
via [Create Collection](create_collection.md) (`auto_assign_rules`) or added via
|
|
||||||
the rules endpoint.
|
|
||||||
|
|
||||||
## Example request
|
|
||||||
|
|
||||||
```json example-request
|
|
||||||
{
|
|
||||||
"enabled": false
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Example response
|
|
||||||
|
|
||||||
```json example-response
|
|
||||||
{
|
|
||||||
"collection_id": "550e8400-e29b-41d4-a716-446655440000",
|
|
||||||
"is_smart": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Request fields
|
|
||||||
|
|
||||||
| Field | Type | Required | Notes |
|
|
||||||
| ----- | ---- | -------- | ----- |
|
|
||||||
| enabled | boolean | yes | toggles the rule on/off |
|
|
||||||
|
|
||||||
## Errors
|
|
||||||
|
|
||||||
| Status | Body | When |
|
|
||||||
| ------ | ---- | ---- |
|
|
||||||
| 400 | `{"error": "invalid collection id"}` / `{"error": "rule id required"}` / `{"error": "invalid request"}` | malformed |
|
|
||||||
| 404 | `{"error": "rule not found"}` | no such rule |
|
|
||||||
| 500 | `{"error": "..."}` | unexpected server error |
|
|
||||||
@@ -68,6 +68,15 @@ Creates a new custom collection with filter rules and/or manual book selection.
|
|||||||
|
|
||||||
**Request Body:**
|
**Request Body:**
|
||||||
|
|
||||||
|
**Save contract (2026-10-31 tree migration):** the rules array travels
|
||||||
|
as a JSON-encoded string and the server converts it to a filter tree at
|
||||||
|
write time — `match_type` seeds the tree's root mode (`any` default),
|
||||||
|
`manual_book_ids` attach to the created collection, and
|
||||||
|
`show_on_dashboard` persists. `library_id` is selection context only
|
||||||
|
(sections scope to the dashboard's active library at render). Invalid
|
||||||
|
rules are a 400 at write naming the problem; the section renders stored
|
||||||
|
picks + the tree's live matches, deduped.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"name": "My Custom Section",
|
"name": "My Custom Section",
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ Add a folder to an existing library (Admin only).
|
|||||||
"id": "uuid",
|
"id": "uuid",
|
||||||
"library_id": "uuid",
|
"library_id": "uuid",
|
||||||
"folder_path": "/path/to/comics",
|
"folder_path": "/path/to/comics",
|
||||||
"created_at": "2026-01-31T10:00:00Z"
|
"created_at": "2026-01-31T10:00:00Z",
|
||||||
|
"purpose": "scan"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ Create a new library (Admin only).
|
|||||||
"created_at": "2026-01-31T10:00:00Z",
|
"created_at": "2026-01-31T10:00:00Z",
|
||||||
"created_by_admin_id": null,
|
"created_by_admin_id": null,
|
||||||
"library_type_id": null,
|
"library_type_id": null,
|
||||||
"updated_at": null
|
"updated_at": null,
|
||||||
|
"allow_member_feeds": false
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
|||||||
"created_at": null,
|
"created_at": null,
|
||||||
"created_by_admin_id": null,
|
"created_by_admin_id": null,
|
||||||
"library_type_id": null,
|
"library_type_id": null,
|
||||||
"updated_at": null
|
"updated_at": null,
|
||||||
|
"allow_member_feeds": false
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ Lists the filesystem folders configured for a library.
|
|||||||
"id": "aa0e8400-e29b-41d4-a716-446655440000",
|
"id": "aa0e8400-e29b-41d4-a716-446655440000",
|
||||||
"library_id": "990e8400-e29b-41d4-a716-446655440000",
|
"library_id": "990e8400-e29b-41d4-a716-446655440000",
|
||||||
"folder_path": "/books/manga",
|
"folder_path": "/books/manga",
|
||||||
"created_at": "2026-01-01T00:00:00Z"
|
"created_at": "2026-01-01T00:00:00Z",
|
||||||
|
"purpose": "scan"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ A top-level JSON **array** of library rows:
|
|||||||
"updated_at": "2026-01-31T10:00:00Z",
|
"updated_at": "2026-01-31T10:00:00Z",
|
||||||
"type_name": "ebooks",
|
"type_name": "ebooks",
|
||||||
"type_description": "Ebook libraries",
|
"type_description": "Ebook libraries",
|
||||||
"is_visible": true
|
"is_visible": true,
|
||||||
|
"allow_member_feeds": false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
method: POST
|
||||||
|
path: /api/libraries/:id/issues/:issueId/retry
|
||||||
|
auth: jwt+admin
|
||||||
|
area: libraries
|
||||||
|
---
|
||||||
|
<!-- source: internal/handlers/processing_issues.go RetryProcessingIssue -->
|
||||||
|
|
||||||
|
# Retry Processing Issue
|
||||||
|
|
||||||
|
Re-runs the work that produced an open issue through the individual
|
||||||
|
scanner — deliberately NOT a library-wide rescan. Two shapes:
|
||||||
|
|
||||||
|
- **Item-backed issues** (media item exists): the item is rescanned via
|
||||||
|
the same mechanism as the book-detail Rescan action — self-resolving
|
||||||
|
folders, multi-file audiobook routing, write-after-success guards.
|
||||||
|
- **Row-less `import_failed` issues** (no media item): the single file
|
||||||
|
is re-imported through the scanner's standalone per-file path.
|
||||||
|
|
||||||
|
On success the issue is closed. On failure the response carries the REAL
|
||||||
|
error (e.g. the underlying SQL failure), so the UI can show why the issue
|
||||||
|
still exists. The issue's library must be in the requester's visible set.
|
||||||
|
|
||||||
|
## Example response
|
||||||
|
|
||||||
|
```json example-response
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "File imported; issue resolved"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Errors
|
||||||
|
|
||||||
|
| Status | Body | When |
|
||||||
|
| ------ | ---- | ---- |
|
||||||
|
| 400 | `{"error": "Invalid issue ID"}` | malformed path param |
|
||||||
|
| 403 | — | issue's library not in requester's visible set |
|
||||||
|
| 404 | `{"error": "Issue not found"}` | no such issue |
|
||||||
|
| 409 | `{"error": "Issue is already resolved"}` / `{"error": "media file not found on disk: …"}` | nothing to retry, or the file vanished |
|
||||||
|
| 500 | `{"error": "…"}` | the retried work failed — body carries the underlying error |
|
||||||
@@ -8,10 +8,17 @@ area: libraries
|
|||||||
|
|
||||||
# Scan Library
|
# Scan Library
|
||||||
|
|
||||||
Enqueues a full scan of one library's configured folders. The library id comes
|
Enqueues a scan of one library's configured folders. The library id comes
|
||||||
from the path — no request body is needed (the generic `POST /api/scanner/scan`
|
from the path — no request body is needed (the generic `POST /api/scanner/scan`
|
||||||
variant is the one that takes `folder_paths`).
|
variant is the one that takes `folder_paths`).
|
||||||
|
|
||||||
|
**Per-folder scope**: an optional body of `{"folder_path": "…"}` restricts the
|
||||||
|
scan to ONE of the library's registered folders (the per-folder Scan buttons
|
||||||
|
in the library panel). The path must be a registered folder — anything else is
|
||||||
|
a **400**. A scoped scan never runs archive reconciliation for the library's
|
||||||
|
other folders, so items outside the scanned folder cannot be marked missing
|
||||||
|
by it.
|
||||||
|
|
||||||
Scans are **coalesced**: if a scan for this library is already pending or
|
Scans are **coalesced**: if a scan for this library is already pending or
|
||||||
running you get **409** with the active job id instead of a second scan.
|
running you get **409** with the active job id instead of a second scan.
|
||||||
|
|
||||||
@@ -29,7 +36,7 @@ running you get **409** with the active job id instead of a second scan.
|
|||||||
|
|
||||||
| Status | Body | When |
|
| Status | Body | When |
|
||||||
| ------ | ---- | ---- |
|
| ------ | ---- | ---- |
|
||||||
| 400 | `{"error": "invalid library id"}` / `{"error": "library has no folders configured"}` | malformed id / empty library |
|
| 400 | `{"error": "invalid library id"}` / `{"error": "library has no folders configured"}` / `{"error": "folder_path is not one of this library's registered folders"}` | malformed id / empty library / unregistered folder scope |
|
||||||
| 404 | `{"error": "library not found or has no folders"}` | unknown library |
|
| 404 | `{"error": "library not found or has no folders"}` | unknown library |
|
||||||
| 409 | `{"error": "a scan for this library is already pending or running", "active_job_id": "..."}` | scan coalescing |
|
| 409 | `{"error": "a scan for this library is already pending or running", "active_job_id": "..."}` | scan coalescing |
|
||||||
| 500 | `{"error": "failed to enqueue scan job: ..."}` | queue failure |
|
| 500 | `{"error": "failed to enqueue scan job: ..."}` | queue failure |
|
||||||
@@ -46,7 +46,8 @@ Update a library's information.
|
|||||||
"created_at": "2026-02-08T10:00:00Z",
|
"created_at": "2026-02-08T10:00:00Z",
|
||||||
"updated_at": "2026-02-08T11:00:00Z",
|
"updated_at": "2026-02-08T11:00:00Z",
|
||||||
"created_by_admin_id": null,
|
"created_by_admin_id": null,
|
||||||
"description": null
|
"description": null,
|
||||||
|
"allow_member_feeds": false
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -164,7 +164,23 @@ Tags and contributors are automatically normalized:
|
|||||||
"summary": null,
|
"summary": null,
|
||||||
"total_characters": null,
|
"total_characters": null,
|
||||||
"volume": null,
|
"volume": null,
|
||||||
"web_url": null
|
"web_url": null,
|
||||||
|
"source": null,
|
||||||
|
"source_feed_id": null,
|
||||||
|
"feed_position": null,
|
||||||
|
"episode_number": null,
|
||||||
|
"season_number": null,
|
||||||
|
"episode_guid": null,
|
||||||
|
"enclosure_url": null,
|
||||||
|
"chapters_url": null,
|
||||||
|
"transcript_url": null,
|
||||||
|
"hosts": null,
|
||||||
|
"guests": null,
|
||||||
|
"declared_duration_seconds": null,
|
||||||
|
"download_state": null,
|
||||||
|
"download_attempts": null,
|
||||||
|
"download_queue_pos": null,
|
||||||
|
"download_error": null
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -231,7 +231,23 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
|||||||
"total_characters": null,
|
"total_characters": null,
|
||||||
"updated_at": null,
|
"updated_at": null,
|
||||||
"volume": null,
|
"volume": null,
|
||||||
"web_url": null
|
"web_url": null,
|
||||||
|
"source": null,
|
||||||
|
"source_feed_id": null,
|
||||||
|
"feed_position": null,
|
||||||
|
"episode_number": null,
|
||||||
|
"season_number": null,
|
||||||
|
"episode_guid": null,
|
||||||
|
"enclosure_url": null,
|
||||||
|
"chapters_url": null,
|
||||||
|
"transcript_url": null,
|
||||||
|
"hosts": null,
|
||||||
|
"guests": null,
|
||||||
|
"declared_duration_seconds": null,
|
||||||
|
"download_state": null,
|
||||||
|
"download_attempts": null,
|
||||||
|
"download_queue_pos": null,
|
||||||
|
"download_error": null
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -128,7 +128,23 @@ Update media item metadata (Admin only).
|
|||||||
"tags_search": null,
|
"tags_search": null,
|
||||||
"total_characters": null,
|
"total_characters": null,
|
||||||
"volume": null,
|
"volume": null,
|
||||||
"web_url": null
|
"web_url": null,
|
||||||
|
"source": null,
|
||||||
|
"source_feed_id": null,
|
||||||
|
"feed_position": null,
|
||||||
|
"episode_number": null,
|
||||||
|
"season_number": null,
|
||||||
|
"episode_guid": null,
|
||||||
|
"enclosure_url": null,
|
||||||
|
"chapters_url": null,
|
||||||
|
"transcript_url": null,
|
||||||
|
"hosts": null,
|
||||||
|
"guests": null,
|
||||||
|
"declared_duration_seconds": null,
|
||||||
|
"download_state": null,
|
||||||
|
"download_attempts": null,
|
||||||
|
"download_queue_pos": null,
|
||||||
|
"download_error": null
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,10 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
|||||||
"username": "john",
|
"username": "john",
|
||||||
"first_name": "John",
|
"first_name": "John",
|
||||||
"last_name": "Doe",
|
"last_name": "Doe",
|
||||||
"role": "user"
|
"role": "user",
|
||||||
|
"default_audio_speed": 1.5,
|
||||||
|
"audio_resume_rewind_seconds": 5,
|
||||||
|
"keep_screen_awake": true
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+14
-62
@@ -1,7 +1,7 @@
|
|||||||
openapi: 3.0.3
|
openapi: 3.0.3
|
||||||
info:
|
info:
|
||||||
title: Bookhoard API
|
title: Bookhoard API
|
||||||
version: '2026.10.24'
|
version: '2026.10.27'
|
||||||
description: |
|
description: |
|
||||||
Generated from the canonical API docs (docs/developer/api/**) by
|
Generated from the canonical API docs (docs/developer/api/**) by
|
||||||
internal/docs (go test ./internal/docs -run TestBrunoMirrorsDocs -update).
|
internal/docs (go test ./internal/docs -run TestBrunoMirrorsDocs -update).
|
||||||
@@ -229,7 +229,6 @@ paths:
|
|||||||
"color": "#00FF00",
|
"color": "#00FF00",
|
||||||
"icon": "📖",
|
"icon": "📖",
|
||||||
"collection_type": "static",
|
"collection_type": "static",
|
||||||
"auto_assign_rules": [],
|
|
||||||
"view_settings": { "kobo": { "view_mode": "grid" } }
|
"view_settings": { "kobo": { "view_mode": "grid" } }
|
||||||
}
|
}
|
||||||
responses:
|
responses:
|
||||||
@@ -277,33 +276,6 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Success
|
description: Success
|
||||||
"/api/collections/test-rules":
|
|
||||||
post:
|
|
||||||
tags: [collections]
|
|
||||||
summary: "Test Rule"
|
|
||||||
security:
|
|
||||||
- bearerAuth: []
|
|
||||||
requestBody:
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
example: |-
|
|
||||||
{
|
|
||||||
"rules": [
|
|
||||||
{
|
|
||||||
"field": "genre",
|
|
||||||
"operator": "equals",
|
|
||||||
"value": "Science Fiction"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"field": "author",
|
|
||||||
"operator": "contains",
|
|
||||||
"value": "Asimov"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: OK
|
|
||||||
"/api/collections/{id}":
|
"/api/collections/{id}":
|
||||||
delete:
|
delete:
|
||||||
tags: [collections]
|
tags: [collections]
|
||||||
@@ -335,7 +307,6 @@ paths:
|
|||||||
"description": "Updated description",
|
"description": "Updated description",
|
||||||
"color": "#33CCFF",
|
"color": "#33CCFF",
|
||||||
"icon": "📚",
|
"icon": "📚",
|
||||||
"auto_assign_rules": [],
|
|
||||||
"view_settings": { "hide_finished": true },
|
"view_settings": { "hide_finished": true },
|
||||||
"hide_finished": ["true"],
|
"hide_finished": ["true"],
|
||||||
"show_up_next": ["true"]
|
"show_up_next": ["true"]
|
||||||
@@ -409,38 +380,10 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Success
|
description: Success
|
||||||
"/api/collections/{id}/rules":
|
"/api/collections/{id}/view-settings":
|
||||||
post:
|
|
||||||
tags: [collections]
|
|
||||||
summary: "Add Auto-Assign Rule"
|
|
||||||
security:
|
|
||||||
- bearerAuth: []
|
|
||||||
requestBody:
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
example: |-
|
|
||||||
{
|
|
||||||
"field": "genre",
|
|
||||||
"operator": "equals",
|
|
||||||
"value": "Science Fiction",
|
|
||||||
"priority": 1,
|
|
||||||
"enabled": true
|
|
||||||
}
|
|
||||||
responses:
|
|
||||||
'201':
|
|
||||||
description: Created
|
|
||||||
"/api/collections/{id}/rules/{ruleId}":
|
|
||||||
delete:
|
|
||||||
tags: [collections]
|
|
||||||
summary: "Remove Auto-Assign Rule"
|
|
||||||
security:
|
|
||||||
- bearerAuth: []
|
|
||||||
responses:
|
|
||||||
'204':
|
|
||||||
description: No Content
|
|
||||||
put:
|
put:
|
||||||
tags: [collections]
|
tags: [collections]
|
||||||
summary: "Update Collection Rule"
|
summary: "Set Collection Chain Order"
|
||||||
security:
|
security:
|
||||||
- bearerAuth: []
|
- bearerAuth: []
|
||||||
requestBody:
|
requestBody:
|
||||||
@@ -448,11 +391,11 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
example: |-
|
example: |-
|
||||||
{
|
{
|
||||||
"enabled": false
|
"chain_order": "newest"
|
||||||
}
|
}
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Success
|
description: OK
|
||||||
"/api/conflicts":
|
"/api/conflicts":
|
||||||
get:
|
get:
|
||||||
tags: [conflicts]
|
tags: [conflicts]
|
||||||
@@ -1141,6 +1084,15 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Success
|
description: Success
|
||||||
|
"/api/libraries/{id}/issues/{issueId}/retry":
|
||||||
|
post:
|
||||||
|
tags: [libraries]
|
||||||
|
summary: "Retry Processing Issue"
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Success
|
||||||
"/api/libraries/{id}/issues/{issueId}/{mediaItemId}/resolve":
|
"/api/libraries/{id}/issues/{issueId}/{mediaItemId}/resolve":
|
||||||
post:
|
post:
|
||||||
tags: [libraries]
|
tags: [libraries]
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
|
|||||||
- **Sync Configuration**
|
- **Sync Configuration**
|
||||||
- [Universal Sync Guide](user/sync-guide.md) - Understanding sync, book matching, conflicts
|
- [Universal Sync Guide](user/sync-guide.md) - Understanding sync, book matching, conflicts
|
||||||
|
|
||||||
|
- **Filters**
|
||||||
|
- [Filters — the complete guide](user/filters/index.md) - rules, groups (ALL/ANY), saved filters, smart collections
|
||||||
|
- [Filter cookbook](user/filters/cookbook.md) - ready-made recipes, exact clicks
|
||||||
|
- [Every filter field](user/filters/fields.md) - the generated, always-current reference
|
||||||
|
|
||||||
- **Frontend & Settings** (Coming Soon)
|
- **Frontend & Settings** (Coming Soon)
|
||||||
- Frontend Guide
|
- Frontend Guide
|
||||||
- User Areas Guide
|
- User Areas Guide
|
||||||
|
|||||||
@@ -298,6 +298,35 @@ A: Calibre Web uses the same `metadata.opf` files, so yes - Bookhoard can scan a
|
|||||||
|
|
||||||
A: Bookhoard has its own sorting logic and doesn't import Calibre sort fields. This allows for consistent sorting across all books.
|
A: Bookhoard has its own sorting logic and doesn't import Calibre sort fields. This allows for consistent sorting across all books.
|
||||||
|
|
||||||
|
## Writing metadata back to files (admin)
|
||||||
|
|
||||||
|
The book page's metadata editor has a **Write to file** action alongside
|
||||||
|
Save. Save updates Bookhoard's database only; Write to file persists the
|
||||||
|
currently saved values into the artifact on disk, then re-extracts so
|
||||||
|
everything stays in sync:
|
||||||
|
|
||||||
|
- **EPUB/KEPUB** — the OPF embedded in the book is rewritten in place
|
||||||
|
(cover, chapters and reading position are untouched). The file's
|
||||||
|
checksum changes, so device syncs (Kobo, KOReader) re-key the book on
|
||||||
|
their next sync.
|
||||||
|
- **Single-file audio (MP3/FLAC/M4A/M4B)** — the container's own tags
|
||||||
|
are rewritten (audio streams copied untouched, chapters and cover art
|
||||||
|
preserved). Only fields the format can carry are written: title,
|
||||||
|
subtitle (MP3/FLAC), authors, narrators (composer tag — Audible's
|
||||||
|
convention), series, date and genre.
|
||||||
|
- **CBZ** — a ComicInfo.xml is written inside the archive (injected or
|
||||||
|
replaced). CBR/CB7 archives can't be rewritten in place, so they take
|
||||||
|
the sidecar path.
|
||||||
|
- **Everything else** (PDF, folder books, CBR/CB7) — a Calibre-style
|
||||||
|
sidecar is written next to the file:
|
||||||
|
`metadata.opf` when the book is alone in its folder (Calibre's own
|
||||||
|
convention, so Calibre reads it too), or `<filename>.opf` when several
|
||||||
|
books share a folder, so one book's sidecar can never leak onto its
|
||||||
|
neighbours.
|
||||||
|
|
||||||
|
The write mirrors exactly what the scanner reads back, and the original
|
||||||
|
file is never touched if anything fails (temp file + atomic rename).
|
||||||
|
|
||||||
## Resources
|
## Resources
|
||||||
|
|
||||||
- [Calibre Website](https://calibre-ebook.com/)
|
- [Calibre Website](https://calibre-ebook.com/)
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Filter cookbook
|
||||||
|
|
||||||
|
Real questions, exact clicks. Every recipe uses the same builder described in
|
||||||
|
[the guide](index.md) — same fields, same rules, wherever filters live.
|
||||||
|
|
||||||
|
Recipes marked **collection** become smart collections that stay current forever; the rest are
|
||||||
|
drawer filters you can save with a name.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## "New chapters of 7th Son, automatically" — collection
|
||||||
|
|
||||||
|
1. **Collections** → open (or create) a collection → **Rules**.
|
||||||
|
2. The scope is already *Episodes*. Add a rule: **Feed** → **in** → pick
|
||||||
|
*7th Son: Book One* (it becomes a chip — hit the × if you pick wrong).
|
||||||
|
3. Add a second rule: **Title** → **contains** → `Chapter`.
|
||||||
|
4. The header should read **MATCH ALL** (it does by default — every rule must match).
|
||||||
|
5. **Apply to Collection**. Done: new chapters appear on their own, and the listening order
|
||||||
|
starts at **Feed order (reversed)** — chapter 1 first.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## "Lewis Carroll or George Orwell" — drawer
|
||||||
|
|
||||||
|
1. **Filters** on the All Books page. Add: **Author** → **contains** → `Carroll`.
|
||||||
|
2. Add a second rule: **Author** → **contains** → `Orwell`.
|
||||||
|
3. Click the group header **MATCH ALL** once — it flips to **MATCH ANY** (or click the `AND`
|
||||||
|
chip between the two rules, same effect).
|
||||||
|
4. **Apply Filters**. Either author is enough.
|
||||||
|
|
||||||
|
*Flip it back to ALL and you'd get only books they both wrote — which is none, and the counter
|
||||||
|
will tell you so before you apply.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## "Long fantasy books I haven't started" — drawer
|
||||||
|
|
||||||
|
1. **Genre** → **is** → `fantasy`.
|
||||||
|
2. **Page count** → **at least** → `400`.
|
||||||
|
3. **My reading** → **Reading status** → **is** → *Unfinished* (or your app's wording — the
|
||||||
|
status list is the fixed dropdown on that field).
|
||||||
|
4. **MATCH ALL** stays on: all three must hold.
|
||||||
|
5. Apply — then **Save** it as *"Big unread fantasy"* and it's in the Load menu forever.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## "Downloaded podcast episodes I haven't played" — collection
|
||||||
|
|
||||||
|
1. A collection with an Episodes scope (see the first recipe — drop the Title rule if you want
|
||||||
|
whole feeds).
|
||||||
|
2. **Downloaded** → **is true**.
|
||||||
|
3. **Played** → **is false**.
|
||||||
|
4. **MATCH ALL** on. **Apply to Collection**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## "Added to my library this month" — drawer
|
||||||
|
|
||||||
|
1. **Date added** → **within the last** → `30` (the days box — the field's page in
|
||||||
|
[Every filter field](fields.md) has its exact questions).
|
||||||
|
2. Optionally **MATCH ALL** with **Library** → **in** → your audiobooks library.
|
||||||
|
3. Save as *"New arrivals"*.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## "A dashboard shelf that builds itself" — dashboard
|
||||||
|
|
||||||
|
1. Create a collection with the rules above (or open one).
|
||||||
|
2. On the dashboard, add a section pointed at that collection.
|
||||||
|
3. The shelf re-fills itself as your library changes — no maintenance.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sharing a filtered view
|
||||||
|
|
||||||
|
Apply any filter, then copy the address bar. The filter rides in the URL — send it to another
|
||||||
|
user of your server and they open the same narrowed view (they see it through their own
|
||||||
|
libraries, not yours).
|
||||||
@@ -0,0 +1,755 @@
|
|||||||
|
<!-- GENERATED by TestFieldReferenceDocIsCurrent (internal/services/filter_docs_test.go)
|
||||||
|
from the filter registry — the same source the real menus render. DO NOT EDIT by hand:
|
||||||
|
regenerate with FILTER_DOCS_REGEN=1 go test ./internal/services -run TestFieldReferenceDocIsCurrent -->
|
||||||
|
|
||||||
|
# Every filter field
|
||||||
|
|
||||||
|
This is the complete list of everything the filters can ask about, generated straight from the
|
||||||
|
same source the app's menus render — it cannot drift from what you see in the UI.
|
||||||
|
|
||||||
|
Filters come in two worlds:
|
||||||
|
|
||||||
|
- **Books** — rules about books and your reading of them.
|
||||||
|
- **Episodes** — rules about podcast episodes (feeds, downloaded, played).
|
||||||
|
|
||||||
|
Every rule is **a field + a question + your values**. The "Ask" column is the question;
|
||||||
|
the "Your values" column is what you provide. The **default** question is what a new rule
|
||||||
|
starts with.
|
||||||
|
|
||||||
|
## Books
|
||||||
|
|
||||||
|
### Book facts
|
||||||
|
|
||||||
|
**Title** — Type words.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is | matches exactly |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Subtitle** — Type words.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is | matches exactly |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Description** — Type words.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is | matches exactly |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Summary** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| has | has a value *(default)* |
|
||||||
|
| missing | is empty or missing |
|
||||||
|
|
||||||
|
**Language** — Type words.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is | matches exactly |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Page count** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Chapter count** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Total characters** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Community rating** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Date added** — Type a number of days.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| within the last | — *(default)* |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Date imported** — Type a number of days.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| within the last | — *(default)* |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Web URL** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| has | has a value *(default)* |
|
||||||
|
| missing | is empty or missing |
|
||||||
|
|
||||||
|
**Metadata notes** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| has | has a value *(default)* |
|
||||||
|
| missing | is empty or missing |
|
||||||
|
|
||||||
|
**ISBN** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is | is one of the ones you pick |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| has | has a value *(default)* |
|
||||||
|
| missing | is empty or missing |
|
||||||
|
|
||||||
|
**ASIN** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is | is one of the ones you pick |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| has | has a value *(default)* |
|
||||||
|
| missing | is empty or missing |
|
||||||
|
|
||||||
|
**File size** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**File availability** — Choose from a fixed list of options.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is | matches exactly *(default)* |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
|
||||||
|
**Explicit** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is true | is switched on *(default)* |
|
||||||
|
| is false | is switched off |
|
||||||
|
|
||||||
|
**Abridged** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is true | is switched on *(default)* |
|
||||||
|
| is false | is switched off |
|
||||||
|
|
||||||
|
**Manually edited** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is true | is switched on *(default)* |
|
||||||
|
| is false | is switched off |
|
||||||
|
|
||||||
|
**Cover** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| has | has a value *(default)* |
|
||||||
|
| missing | is empty or missing |
|
||||||
|
|
||||||
|
**Added by** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is | is one of the ones you pick |
|
||||||
|
| has | has a value *(default)* |
|
||||||
|
| missing | is empty or missing |
|
||||||
|
|
||||||
|
### Comics & manga
|
||||||
|
|
||||||
|
**Series** — Type words.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is | matches exactly |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Series number** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Series count** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Volume** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Format group** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is | is one of the ones you pick *(default)* |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
|
||||||
|
**File type (mime)** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is | is one of the ones you pick *(default)* |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
|
||||||
|
**Reflowable** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is true | is switched on *(default)* |
|
||||||
|
| is false | is switched off |
|
||||||
|
|
||||||
|
**Fixed layout** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is true | is switched on *(default)* |
|
||||||
|
| is false | is switched off |
|
||||||
|
|
||||||
|
**Black & white** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is true | is switched on *(default)* |
|
||||||
|
| is false | is switched off |
|
||||||
|
|
||||||
|
**Manga type** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is | is one of the ones you pick *(default)* |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Reading direction** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is | is one of the ones you pick *(default)* |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
### Library
|
||||||
|
|
||||||
|
**Library** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is | is one of the ones you pick *(default)* |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
|
||||||
|
### My reading
|
||||||
|
|
||||||
|
**Reading status** — Choose from a fixed list of options.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is | matches exactly *(default)* |
|
||||||
|
|
||||||
|
**Progress percent** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Last read** — Type a number of days.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| within the last | — *(default)* |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**My rating** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is | matches exactly |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
| unrated | — |
|
||||||
|
|
||||||
|
**Has review** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| has | has a value *(default)* |
|
||||||
|
| missing | is empty or missing |
|
||||||
|
|
||||||
|
**Notes** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| has | has a value *(default)* |
|
||||||
|
| missing | is empty or missing |
|
||||||
|
| within the last | — |
|
||||||
|
|
||||||
|
**Highlights** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| has | has a value *(default)* |
|
||||||
|
| missing | is empty or missing |
|
||||||
|
| within the last | — |
|
||||||
|
|
||||||
|
**Note text** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Highlight text** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Time spent (minutes)** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
|
||||||
|
**Pages read** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
|
||||||
|
**In collection** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| in | is one of the ones you pick (any one is enough) *(default)* |
|
||||||
|
| isn't in | is none of the ones you pick |
|
||||||
|
| in any | is in at least one of the collections you pick |
|
||||||
|
| in none | — |
|
||||||
|
|
||||||
|
### People
|
||||||
|
|
||||||
|
**Author** — Type words.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is | matches exactly |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Narrator** — Type words.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is | matches exactly |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Contributor** — Type words.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is | matches exactly |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
### Publishing
|
||||||
|
|
||||||
|
**Publisher** — Type words.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is | matches exactly |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Imprint** — Type words.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is | matches exactly |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Edition** — Type words.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is | matches exactly |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Published year** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Published date** — Type a number of days.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| within the last | — *(default)* |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
### Topics
|
||||||
|
|
||||||
|
**Genre** — Type words.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is | matches exactly |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Tags** — Type words.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is | matches exactly |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Story arc** — Type words.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is | matches exactly |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Age rating** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is | is one of the ones you pick *(default)* |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
## Podcast episodes
|
||||||
|
|
||||||
|
### Book facts
|
||||||
|
|
||||||
|
**Title** — Type words.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is | matches exactly |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Description** — Type words.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is | matches exactly |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Language** — Type words.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is | matches exactly |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Chapter count** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Date added** — Type a number of days.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| within the last | — *(default)* |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Date imported** — Type a number of days.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| within the last | — *(default)* |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**File size** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**File availability** — Choose from a fixed list of options.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is | matches exactly *(default)* |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
|
||||||
|
**Explicit** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is true | is switched on *(default)* |
|
||||||
|
| is false | is switched off |
|
||||||
|
|
||||||
|
### Comics & manga
|
||||||
|
|
||||||
|
**File type (mime)** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is | is one of the ones you pick *(default)* |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
|
||||||
|
### Episodes
|
||||||
|
|
||||||
|
**Feed** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| in | is one of the ones you pick (any one is enough) *(default)* |
|
||||||
|
| isn't in | is none of the ones you pick |
|
||||||
|
| has | has a value |
|
||||||
|
| missing | is empty or missing |
|
||||||
|
|
||||||
|
**Downloaded** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is true | is switched on *(default)* |
|
||||||
|
| is false | is switched off |
|
||||||
|
|
||||||
|
**Played** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is true | is switched on |
|
||||||
|
| is false | is switched off *(default)* |
|
||||||
|
|
||||||
|
### Library
|
||||||
|
|
||||||
|
**Library** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is | is one of the ones you pick *(default)* |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
|
||||||
|
### My reading
|
||||||
|
|
||||||
|
**Progress percent** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Last read** — Type a number of days.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| within the last | — *(default)* |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**My rating** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is | matches exactly |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
| unrated | — |
|
||||||
|
|
||||||
|
**Has review** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| has | has a value *(default)* |
|
||||||
|
| missing | is empty or missing |
|
||||||
|
|
||||||
|
**Notes** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| has | has a value *(default)* |
|
||||||
|
| missing | is empty or missing |
|
||||||
|
| within the last | — |
|
||||||
|
|
||||||
|
**Highlights** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| has | has a value *(default)* |
|
||||||
|
| missing | is empty or missing |
|
||||||
|
| within the last | — |
|
||||||
|
|
||||||
|
**Note text** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Highlight text** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Time spent (minutes)** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
|
||||||
|
**In collection** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| in | is one of the ones you pick (any one is enough) *(default)* |
|
||||||
|
| isn't in | is none of the ones you pick |
|
||||||
|
| in any | is in at least one of the collections you pick |
|
||||||
|
| in none | — |
|
||||||
|
|
||||||
|
### Publishing
|
||||||
|
|
||||||
|
**Published year** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Published date** — Type a number of days.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| within the last | — *(default)* |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Anything here that doesn't match what the app actually shows is a bug — this page is generated
|
||||||
|
from the app's own menus and a test fails if they ever disagree.*
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# Filters — the complete guide
|
||||||
|
|
||||||
|
Filters answer one question: *of everything in my library, show me the ones that match.*
|
||||||
|
|
||||||
|
You'll meet the same filter system in three places:
|
||||||
|
|
||||||
|
| Place | What it's for |
|
||||||
|
| --- | --- |
|
||||||
|
| **The Filters drawer** (All Books / Podcasts pages) | A quick, temporary narrowing of what you see. Save it if you want to keep it. |
|
||||||
|
| **Smart collections** (a collection's **Rules** page) | A filter that *lives* — the collection's contents update automatically forever. |
|
||||||
|
| **Dashboard sections** | The same rules, feeding a shelf on your home screen. |
|
||||||
|
|
||||||
|
One system, one set of skills, three payoffs.
|
||||||
|
|
||||||
|
## Two worlds: Books and Episodes
|
||||||
|
|
||||||
|
Rules about **books** (authors, series, your reading progress) and rules about **podcast episodes**
|
||||||
|
(feeds, downloaded, played) are two different worlds. The field menus only show the fields that
|
||||||
|
make sense where you are — you'll never see a podcast field on the All Books page. The complete
|
||||||
|
list of every field is in [Every filter field](fields.md).
|
||||||
|
|
||||||
|
## Anatomy of a rule
|
||||||
|
|
||||||
|
Every rule is **a field + a question + your values**:
|
||||||
|
|
||||||
|
> **Author** *contains* `asc`
|
||||||
|
|
||||||
|
> **Feed** *in* `7th Son: Book One`
|
||||||
|
|
||||||
|
> **Page count** *at least* `400`
|
||||||
|
|
||||||
|
The field is *what* you're asking about. The question (contains, at least, is empty…) is *how*.
|
||||||
|
Your values are *what counts as a match*. The complete questions per field are in
|
||||||
|
[Every filter field](fields.md).
|
||||||
|
|
||||||
|
## Picking values
|
||||||
|
|
||||||
|
Two kinds of value boxes:
|
||||||
|
|
||||||
|
- **Dropdown boxes** (Feed, Library, Collection, Added by): start typing and real names appear,
|
||||||
|
with live counts of how much matches. Click to pick — each pick becomes a **chip** with a little
|
||||||
|
**×** to remove it. Pick as many as you like; *any one of them* matching is enough.
|
||||||
|
- **Typed boxes** (Author, Title, Page count…): just type. Several words means "contains these
|
||||||
|
words"; number boxes take numbers; date boxes take a number of days.
|
||||||
|
|
||||||
|
The dropdown's counts are **live under your other rules**: if you've already narrowed to
|
||||||
|
*Title contains Chapter*, the feed menu shows how many Chapter-titled episodes each of your feeds
|
||||||
|
has. Rules already picked disappear from their own menu (removing a chip brings them back).
|
||||||
|
|
||||||
|
## Groups: ALL and ANY
|
||||||
|
|
||||||
|
When you have several rules, the group header — **MATCH ALL** or **MATCH ANY** — decides how they
|
||||||
|
combine:
|
||||||
|
|
||||||
|
- **MATCH ALL** — *every* rule must match. (The default, and what you want almost always.)
|
||||||
|
- **MATCH ANY** — *at least one* rule matching is enough. This is how you ask for
|
||||||
|
"Lewis Carroll **or** George Orwell".
|
||||||
|
|
||||||
|
**Click the header to switch it.** It says what it does, and clicking flips it.
|
||||||
|
|
||||||
|
`+ group` adds a nested group with its own header (**GROUP ALL / GROUP ANY**), so bigger shapes
|
||||||
|
build naturally:
|
||||||
|
|
||||||
|
> Match ALL: Author *contains* `Carroll` **AND** GROUP ANY (Title contains `Alice` **OR** Title
|
||||||
|
> contains `Wonderland`)
|
||||||
|
|
||||||
|
For fine-tuning inside an ALL group, the small **AND / OR** chip between two rules mixes them —
|
||||||
|
click `AND` and it becomes `OR`, joining those neighbors together. (The header switch and these
|
||||||
|
chips are two different tools: the header sets the group's overall combine; a chip makes just two
|
||||||
|
neighboring rules special.)
|
||||||
|
|
||||||
|
## The live count
|
||||||
|
|
||||||
|
While you build, the counter at the bottom — *N books match* — runs your rules against the real
|
||||||
|
library. It's the exact number you'll get when you apply.
|
||||||
|
|
||||||
|
## Defaults worth knowing
|
||||||
|
|
||||||
|
- **Podcast listening order**: podcast pages and new podcast collections start at
|
||||||
|
**Feed order (reversed)** — book order, chapter 1 first. Change it per feed or collection from
|
||||||
|
the order dropdown; your choice sticks.
|
||||||
|
- **Hidden libraries** (family lens) never match — a filter can't accidentally surface a library
|
||||||
|
that's hidden from you.
|
||||||
|
- **Episodes that aren't downloaded** stay out of playback chains (they still appear in lists).
|
||||||
|
|
||||||
|
## Keeping a filter
|
||||||
|
|
||||||
|
- **Save** (drawer): name it; it appears in the **Load** menu from then on.
|
||||||
|
- **Apply to Collection** (rules page): the filter becomes the smart collection — live forever.
|
||||||
|
- Filtered views are **shareable**: the URL carries the filter, so a bookmark or sent link opens
|
||||||
|
the same view.
|
||||||
@@ -35,3 +35,12 @@ You can combine tags with other filters:
|
|||||||
- Tags use fuzzy matching - "Sci Fi" will match "Science Fiction"
|
- Tags use fuzzy matching - "Sci Fi" will match "Science Fiction"
|
||||||
- Best matching tags appear first in results
|
- Best matching tags appear first in results
|
||||||
- Autocomplete shows existing tags as you type with relevance scores
|
- Autocomplete shows existing tags as you type with relevance scores
|
||||||
|
|
||||||
|
|
||||||
|
## The complete filter guide
|
||||||
|
|
||||||
|
The filters described above are one system that also powers smart collections and dashboard
|
||||||
|
sections — groups with ALL/ANY, saved filters, and every field listed. The full manual lives in
|
||||||
|
[this folder](filters/index.md): the [guide](filters/index.md), the
|
||||||
|
[cookbook](filters/cookbook.md) of ready-made recipes, and the generated
|
||||||
|
[field reference](filters/fields.md).
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { test, expect, type APIRequestContext, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
const BASE = "http://localhost:8765";
|
||||||
|
const STAMP = Date.now();
|
||||||
|
const EMAIL = `e2e-search-${STAMP}@example.com`;
|
||||||
|
const USERNAME = `e2e-search-${STAMP}`;
|
||||||
|
const PASSWORD = "E2e-Playwright-2026!";
|
||||||
|
|
||||||
|
let token = "";
|
||||||
|
|
||||||
|
const consoleFailures: string[] = [];
|
||||||
|
|
||||||
|
function watchPage(page: Page): void {
|
||||||
|
page.on("pageerror", (err) => {
|
||||||
|
consoleFailures.push(`pageerror: ${err.message}`);
|
||||||
|
});
|
||||||
|
page.on("console", (msg) => {
|
||||||
|
if (msg.type() === "error") {
|
||||||
|
consoleFailures.push(`console.error: ${msg.text()}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectNoClientErrors(): void {
|
||||||
|
const bad = consoleFailures.filter((f) =>
|
||||||
|
/(is not defined|is not a function|headerSearch|alpine)/i.test(f),
|
||||||
|
);
|
||||||
|
expect(bad, `uncaught client errors:\n${bad.join("\n")}`).toEqual([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiLogin(request: APIRequestContext): Promise<string> {
|
||||||
|
const res = await request.post("/api/auth/login", {
|
||||||
|
data: { login: EMAIL, password: PASSWORD },
|
||||||
|
});
|
||||||
|
expect(res.status(), "login should succeed").toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
return body.access_token as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe.configure({ mode: "serial" });
|
||||||
|
|
||||||
|
test.describe("Header search + audio scrubber (client wiring)", () => {
|
||||||
|
let page: Page;
|
||||||
|
|
||||||
|
test.beforeAll(async ({ browser, request }) => {
|
||||||
|
const reg = await request.post("/api/auth/register", {
|
||||||
|
data: { email: EMAIL, username: USERNAME, password: PASSWORD },
|
||||||
|
});
|
||||||
|
expect([201, 200, 409]).toContain(reg.status());
|
||||||
|
token = await apiLogin(request);
|
||||||
|
expect(token).toBeTruthy();
|
||||||
|
|
||||||
|
page = await browser.newPage();
|
||||||
|
watchPage(page);
|
||||||
|
await page.context().addCookies([{ name: "token", value: token, url: BASE }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
expectNoClientErrors();
|
||||||
|
await page?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("header search dropdown opens on typing (regression: tree-shaken module)", async () => {
|
||||||
|
// Drive the header on TWO pages: the bundle is shared, but this
|
||||||
|
// catches any page-scoped wiring surprise.
|
||||||
|
for (const pagePath of ["/bookshelf", "/series"]) {
|
||||||
|
await page.goto(pagePath);
|
||||||
|
const input = page.locator("#header-search");
|
||||||
|
await expect(input).toBeVisible();
|
||||||
|
|
||||||
|
// The regression this pins: search.ts (which registers the
|
||||||
|
// headerSearch Alpine component) was tree-shaken from the bundle,
|
||||||
|
// so typing did nothing — no request, no dropdown. Type with REAL
|
||||||
|
// key events (not fill) and assert the request carries the query.
|
||||||
|
await input.click();
|
||||||
|
await input.pressSequentially("algo", { delay: 90 });
|
||||||
|
|
||||||
|
const requestPromise = page.waitForRequest(
|
||||||
|
(req) =>
|
||||||
|
req.url().includes("/api/media-items/search") &&
|
||||||
|
req.url().includes("q=algo") &&
|
||||||
|
(req.headers()["hx-request"] === "true" || req.headers()["hx-request"] === "true"),
|
||||||
|
{ timeout: 10_000 },
|
||||||
|
);
|
||||||
|
const req = await requestPromise;
|
||||||
|
expect(req.url()).toContain("layout=dropdown");
|
||||||
|
|
||||||
|
const results = page.locator("#search-results");
|
||||||
|
await expect(results).toBeVisible({ timeout: 10_000 });
|
||||||
|
await expect(results.locator(".search-result-item").first()).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Escape closes the dropdown (keyboard contract).
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(results).toBeHidden();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("audio scrubber: grabbing the handle does not teleport (plain and Shift)", async ({ request }) => {
|
||||||
|
// Find an audiobook in the shared dev library (plain media list,
|
||||||
|
// then filter client-side — the search endpoint's facet behavior
|
||||||
|
// with empty q isn't a listing API).
|
||||||
|
const res = await request.get("/api/media-items", {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const body = (await res.json()) as { data?: Array<{ id: string; format_group?: string }> };
|
||||||
|
const rows = (body.data ?? []).filter((r) => r.format_group === "audiobook");
|
||||||
|
test.skip(rows.length === 0, "no audiobooks in the dev library");
|
||||||
|
const bookId = rows[0].id;
|
||||||
|
|
||||||
|
await page.goto(`/readers/${bookId}`);
|
||||||
|
const scrubArea = page.locator("#scrub-area");
|
||||||
|
await expect(scrubArea).toBeVisible();
|
||||||
|
|
||||||
|
// The fresh user has never played this book — park the playhead at
|
||||||
|
// 30% via the progress API so the grab tests run mid-book.
|
||||||
|
const put = await request.put(`/api/media-items/${bookId}/progress`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
data: { percentage: 0.3, device_id: "e2e-scrubber" },
|
||||||
|
});
|
||||||
|
expect([200, 204]).toContain(put.status());
|
||||||
|
await page.reload();
|
||||||
|
await expect(scrubArea).toBeVisible();
|
||||||
|
|
||||||
|
const box = await scrubArea.boundingBox();
|
||||||
|
test.skip(!box, "scrub bar not laid out");
|
||||||
|
const y = box!.y + box!.height / 2;
|
||||||
|
|
||||||
|
// Where is the thumb right now? The native range input mirrors the
|
||||||
|
// playhead; read its aria-valuenow + max to compute the thumb X.
|
||||||
|
const thumbX = await page.evaluate(() => {
|
||||||
|
const input = document.getElementById("audio-scrub") as HTMLInputElement;
|
||||||
|
const area = document.getElementById("scrub-area")!;
|
||||||
|
const rect = area.getBoundingClientRect();
|
||||||
|
const value = Number(input.value || 0);
|
||||||
|
const max = Number(input.max || 1);
|
||||||
|
return rect.left + (value / max) * rect.width;
|
||||||
|
});
|
||||||
|
|
||||||
|
const readScrubValue = () =>
|
||||||
|
page.evaluate(() => Number((document.getElementById("audio-scrub") as HTMLInputElement).value));
|
||||||
|
|
||||||
|
const before = await readScrubValue();
|
||||||
|
expect(before).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// --- plain grab: pointerdown ON the thumb must not move it ---
|
||||||
|
await page.mouse.move(thumbX, y);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.waitForTimeout(150);
|
||||||
|
const atGrab = await readScrubValue();
|
||||||
|
expect(Math.abs(atGrab - before)).toBeLessThan(
|
||||||
|
Math.max(1, before * 0.0005),
|
||||||
|
"grabbing the handle must not jump the playhead",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Drag left by 60px: the handle follows RELATIVELY.
|
||||||
|
await page.mouse.move(thumbX - 60, y, { steps: 5 });
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
const afterDrag = await readScrubValue();
|
||||||
|
expect(afterDrag).toBeLessThan(atGrab, "dragging left scrubs backward");
|
||||||
|
|
||||||
|
await page.mouse.up();
|
||||||
|
await page.waitForTimeout(400); // seek commits
|
||||||
|
|
||||||
|
// --- Shift+grab: the precision rail appears, no teleport ---
|
||||||
|
const railBefore = await readScrubValue();
|
||||||
|
// Re-locate the thumb after the commit.
|
||||||
|
const thumbX2 = await page.evaluate(() => {
|
||||||
|
const input = document.getElementById("audio-scrub") as HTMLInputElement;
|
||||||
|
const area = document.getElementById("scrub-area")!;
|
||||||
|
const rect = area.getBoundingClientRect();
|
||||||
|
const value = Number(input.value || 0);
|
||||||
|
const max = Number(input.max || 1);
|
||||||
|
return rect.left + (value / max) * rect.width;
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.keyboard.down("Shift");
|
||||||
|
await page.mouse.move(thumbX2, y);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.waitForTimeout(150);
|
||||||
|
const railAtGrab = await readScrubValue();
|
||||||
|
expect(Math.abs(railAtGrab - railBefore)).toBeLessThan(
|
||||||
|
Math.max(1, railBefore * 0.0005),
|
||||||
|
"Shift+grab must not teleport either",
|
||||||
|
);
|
||||||
|
// The rail is the feedback for Shift+grab — it must be visible.
|
||||||
|
await expect(page.locator('div[x-show="railVisible"]')).toBeVisible();
|
||||||
|
|
||||||
|
await page.keyboard.up("Shift");
|
||||||
|
await page.mouse.up();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { test, expect, type APIRequestContext, type Page } from "@playwright/test";
|
||||||
|
import { execSync } from "child_process";
|
||||||
|
|
||||||
|
const BASE = "http://localhost:8765";
|
||||||
|
const STAMP = Date.now();
|
||||||
|
const EMAIL = `e2e-setfb-${STAMP}@example.com`;
|
||||||
|
const USERNAME = `e2e-setfb-${STAMP}`;
|
||||||
|
const PASSWORD = "E2e-Playwright-2026!";
|
||||||
|
|
||||||
|
// Settings-page save feedback: every save surface must acknowledge the
|
||||||
|
// click (toast) and the scan form must NOT swap the handler's JSON body
|
||||||
|
// over its card (the pre-toast behavior — raw JSON replaced the form).
|
||||||
|
//
|
||||||
|
// Dev-state safety: every success save re-submits the value ALREADY in
|
||||||
|
// the input (read from the page first), so nothing in the shared dev DB
|
||||||
|
// changes; the error-path request is rejected server-side before any
|
||||||
|
// write.
|
||||||
|
|
||||||
|
let token = "";
|
||||||
|
let page = null as Page | null;
|
||||||
|
// system_config.updated_by audit restore: the config save handler stamps
|
||||||
|
// the requesting admin's id onto system_config rows. The harness cleanup
|
||||||
|
// deletes transient example.com users, and this FK (no cascade) would
|
||||||
|
// block the delete and fail unrelated integration tests. Capture before
|
||||||
|
// any save, restore after.
|
||||||
|
let e2eUserID = "";
|
||||||
|
let origUpdatedBy = "";
|
||||||
|
|
||||||
|
function psql(sql: string): string {
|
||||||
|
const flat = sql.replace(/\s+/g, " ");
|
||||||
|
return execSync(
|
||||||
|
`docker exec bookhoard_db psql -U postgres -d bookhoard -tAc ${JSON.stringify(flat)}`,
|
||||||
|
)
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiLogin(request: APIRequestContext): Promise<string> {
|
||||||
|
const res = await request.post("/api/auth/login", {
|
||||||
|
data: { login: EMAIL, password: PASSWORD },
|
||||||
|
});
|
||||||
|
expect(res.status(), "login should succeed").toBe(200);
|
||||||
|
return (await res.json()).access_token as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe.configure({ mode: "serial" });
|
||||||
|
|
||||||
|
test.describe("Settings save feedback (toasts + disabled buttons)", () => {
|
||||||
|
test.beforeAll(async ({ browser, request }) => {
|
||||||
|
// Fresh user, promoted to admin in the dev DB (the harness cleanup
|
||||||
|
// sweeps transient example.com users afterwards).
|
||||||
|
const reg = await request.post("/api/auth/register", {
|
||||||
|
data: { email: EMAIL, username: USERNAME, password: PASSWORD },
|
||||||
|
});
|
||||||
|
expect([201, 200, 409]).toContain(reg.status());
|
||||||
|
psql(`UPDATE users SET role='admin' WHERE email='${EMAIL}'`);
|
||||||
|
token = await apiLogin(request);
|
||||||
|
expect(token).toBeTruthy();
|
||||||
|
|
||||||
|
e2eUserID = psql(`SELECT id FROM users WHERE email='${EMAIL}'`);
|
||||||
|
origUpdatedBy = psql(`SELECT COALESCE(updated_by::text, '') FROM system_config WHERE key='base_url'`);
|
||||||
|
|
||||||
|
page = await browser.newPage();
|
||||||
|
await page.context().addCookies([{ name: "token", value: token, url: BASE }]);
|
||||||
|
await page.goto("/admin/settings");
|
||||||
|
await expect(page.locator("h1")).toHaveText("System Settings");
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await page?.close();
|
||||||
|
// Hand system_config.updated_by back to whoever held it (or NULL),
|
||||||
|
// so the transient user can be swept.
|
||||||
|
if (e2eUserID) {
|
||||||
|
const restore = origUpdatedBy
|
||||||
|
? `'${origUpdatedBy}'::uuid`
|
||||||
|
: "NULL";
|
||||||
|
psql(`UPDATE system_config SET updated_by = ${restore} WHERE updated_by = '${e2eUserID}'::uuid`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Base URL save shows a success toast — twice (twin-form drift pin)", async () => {
|
||||||
|
const input = page.locator("#settings-form input[name='base_url']");
|
||||||
|
await expect(input).toBeVisible();
|
||||||
|
|
||||||
|
// First save goes through the templ form…
|
||||||
|
await page.locator("#settings-form button[type='submit']").first().click();
|
||||||
|
await expect(page.getByText("Settings saved", { exact: true })).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// …the second through the Go-source twin the handler swaps in after
|
||||||
|
// the first save. If the twin ever drops data-settings-save (the
|
||||||
|
// handlers→templates import cycle is why the twin exists), THIS is
|
||||||
|
// the assertion that fails.
|
||||||
|
await page.waitForTimeout(300); // let the swap settle
|
||||||
|
await expect(page.locator("#settings-form")).toBeVisible();
|
||||||
|
await page.locator("#settings-form button[type='submit']").first().click();
|
||||||
|
await expect(page.getByText("Settings saved", { exact: true })).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Scan settings save shows a toast and never swaps raw JSON over the card", async () => {
|
||||||
|
const section = page.locator("#scan-settings-section");
|
||||||
|
await expect(section).toBeVisible();
|
||||||
|
|
||||||
|
await section.locator("button[type='submit']").click();
|
||||||
|
await expect(page.getByText("Scan settings saved", { exact: true })).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The regression: the scan handler responds JSON; with the old
|
||||||
|
// hx-swap="outerHTML" the card was replaced by the literal JSON text.
|
||||||
|
await expect(section).toBeVisible();
|
||||||
|
await expect(section.locator("form")).toBeVisible();
|
||||||
|
const body = await page.locator("body").innerText();
|
||||||
|
expect(body).not.toContain("scan settings updated successfully");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Tunable row save shows a toast and the per-row status span", async () => {
|
||||||
|
const row = page.locator("form[data-settings-save='tunable']").first();
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
const key = await row.locator("input[name='key']").getAttribute("value");
|
||||||
|
expect(key).toBeTruthy();
|
||||||
|
|
||||||
|
await row.locator("button[type='submit']").click();
|
||||||
|
await expect(page.getByText("Setting saved", { exact: true })).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
// Server-driven per-row status (kept alongside the toast; it carries
|
||||||
|
// the "restart required" nuance).
|
||||||
|
await expect(page.locator(`#status-${key}`)).toContainText("Saved", {
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Invalid value shows an error toast and the form survives", async () => {
|
||||||
|
// Inject a genuinely invalid option and select it: setting a select
|
||||||
|
// to a value with no matching option silently serializes as NO field
|
||||||
|
// at all (no selected option → nothing submitted), which would save
|
||||||
|
// fine instead of failing. With a real option present, the server
|
||||||
|
// rejects with 400 {"error":"invalid timezone"} before any write.
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const select = document.getElementById("default_timezone") as HTMLSelectElement;
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = "Not/AZone";
|
||||||
|
opt.textContent = "E2E invalid";
|
||||||
|
select.appendChild(opt);
|
||||||
|
select.value = "Not/AZone";
|
||||||
|
});
|
||||||
|
await page.locator("#settings-form button[type='submit']").first().click();
|
||||||
|
|
||||||
|
await expect(page.getByText("Save failed: invalid timezone")).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
// htmx does not swap non-2xx bodies: the form is intact.
|
||||||
|
await expect(page.locator("#settings-form")).toBeVisible();
|
||||||
|
await expect(page.locator("#settings-form input[name='base_url']")).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import { test, expect, type APIRequestContext, type Page } from "@playwright/test";
|
||||||
|
import { execSync } from "child_process";
|
||||||
|
import fs from "fs";
|
||||||
|
import os from "os";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const REPO = process.cwd(); // uploads/ here is bind-mounted at /app/uploads
|
||||||
|
const BASE = "http://localhost:8765";
|
||||||
|
const STAMP = Date.now();
|
||||||
|
const EMAIL = `e2e-meta-${STAMP}@example.com`;
|
||||||
|
const USERNAME = `e2e-meta-${STAMP}`;
|
||||||
|
const PASSWORD = "E2e-Playwright-2026!";
|
||||||
|
|
||||||
|
// Host-side upload dir (bind-mounted into the app container).
|
||||||
|
const UPLOADS_HOST = path.join(REPO, "uploads", `e2e-meta-${STAMP}`);
|
||||||
|
const UPLOADS_CONT = `/app/uploads/e2e-meta-${STAMP}`;
|
||||||
|
|
||||||
|
interface FormatSeed {
|
||||||
|
ext: string;
|
||||||
|
group: string;
|
||||||
|
mime: string;
|
||||||
|
id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every write-capable file type, through the REAL admin UI, verified by
|
||||||
|
// reading each artifact back independently afterwards (this test IS the
|
||||||
|
// "choose each file type, then check the file's metadata yourself"
|
||||||
|
// contract):
|
||||||
|
//
|
||||||
|
// epub → embedded OPF splice; verify by parsing the OPF XML
|
||||||
|
// pdf → Calibre metadata.opf sidecar (lone book); parse XML
|
||||||
|
// cbz → ComicInfo.xml injected; parse XML
|
||||||
|
// mp3/flac/m4a/m4b → ffprobe the rewritten tags
|
||||||
|
const FORMATS: FormatSeed[] = [
|
||||||
|
{ ext: "epub", group: "ebook", mime: "application/epub+zip" },
|
||||||
|
{ ext: "pdf", group: "ebook", mime: "application/pdf" },
|
||||||
|
{ ext: "cbz", group: "comic_archive", mime: "application/zip" },
|
||||||
|
{ ext: "mp3", group: "audiobook", mime: "audio/mpeg" },
|
||||||
|
{ ext: "flac", group: "audiobook", mime: "audio/flac" },
|
||||||
|
{ ext: "m4a", group: "audiobook", mime: "audio/mp4" },
|
||||||
|
{ ext: "m4b", group: "audiobook", mime: "audio/mp4" },
|
||||||
|
];
|
||||||
|
|
||||||
|
let token = "";
|
||||||
|
let libraryID = "";
|
||||||
|
|
||||||
|
function psql(sql: string): string {
|
||||||
|
// Collapse whitespace (JSON.stringify would bind newlines as literal
|
||||||
|
// "\n" through the shell and break the SQL).
|
||||||
|
const flat = sql.replace(/\s+/g, " ");
|
||||||
|
return execSync(
|
||||||
|
`docker exec bookhoard_db psql -U postgres -d bookhoard -tAc ${JSON.stringify(flat)}`,
|
||||||
|
)
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(cmd: string): string {
|
||||||
|
return execSync(cmd, { encoding: "utf8" }).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function writePython(script: string): void {
|
||||||
|
run(`python3 - <<'PYEOF'\n${script}\nPYEOF`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeEPUB(dest: string, title: string): void {
|
||||||
|
const script = `import zipfile, shutil
|
||||||
|
arc = ${JSON.stringify(dest)}
|
||||||
|
tmp = arc + ".tmp"
|
||||||
|
with zipfile.ZipFile(tmp, "w") as z:
|
||||||
|
z.writestr(zipfile.ZipInfo("mimetype"), "application/epub+zip", compress_type=zipfile.ZIP_STORED)
|
||||||
|
z.writestr("META-INF/container.xml", '<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles></container>')
|
||||||
|
z.writestr("OEBPS/content.opf", '<?xml version="1.0"?><package version="3.0" xmlns="http://www.idpf.org/2007/opf" unique-identifier="pub-id"><metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title id="t1">' + ${JSON.stringify(title)} + '</dc:title><dc:identifier id="pub-id">urn:uuid:e2e</dc:identifier></metadata><manifest><item id="c1" href="c1.xhtml" media-type="application/xhtml+xml"/></manifest><spine><itemref idref="c1"/></spine></package>')
|
||||||
|
z.writestr("OEBPS/c1.xhtml", "<html><body>chapter</body></html>")
|
||||||
|
shutil.move(tmp, arc)`;
|
||||||
|
writePython(script);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCBZ(dest: string): void {
|
||||||
|
writePython(`import zipfile, shutil
|
||||||
|
arc = ${JSON.stringify(dest)}
|
||||||
|
tmp = arc + ".tmp"
|
||||||
|
with zipfile.ZipFile(tmp, "w") as z:
|
||||||
|
z.writestr("page_0001.jpg", "not-a-real-jpeg-but-fine")
|
||||||
|
shutil.move(tmp, arc)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Independent artifact readers — no app code involved. The python is
|
||||||
|
* written to a temp file (never -c through the shell: paths with
|
||||||
|
* quotes/newlines are unreadable that way). */
|
||||||
|
function pythonRead(script: string, absPath: string): string {
|
||||||
|
const scriptPath = path.join(os.tmpdir(), `e2e-meta-read-${STAMP}.py`);
|
||||||
|
fs.writeFileSync(scriptPath, script);
|
||||||
|
return run(`python3 ${JSON.stringify(scriptPath)} ${JSON.stringify(absPath)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function artifactTitle(ext: string, absPath: string): string {
|
||||||
|
switch (ext) {
|
||||||
|
case "epub":
|
||||||
|
return pythonRead(`import zipfile, sys, xml.etree.ElementTree as ET
|
||||||
|
with zipfile.ZipFile(sys.argv[1]) as z:
|
||||||
|
root = ET.fromstring(z.read('OEBPS/content.opf'))
|
||||||
|
ns = {'dc': 'http://purl.org/dc/elements/1.1/'}
|
||||||
|
titles = [t.text or '' for t in root.findall('.//dc:title', ns)]
|
||||||
|
print(titles[0] if titles else '')`, absPath);
|
||||||
|
case "cbz":
|
||||||
|
return pythonRead(`import zipfile, sys, xml.etree.ElementTree as ET
|
||||||
|
with zipfile.ZipFile(sys.argv[1]) as z:
|
||||||
|
root = ET.fromstring(z.read('ComicInfo.xml'))
|
||||||
|
print(root.findtext('Title') or '')`, absPath);
|
||||||
|
case "pdf":
|
||||||
|
// one-book dir → the Calibre-style sidecar carries the metadata
|
||||||
|
return pythonRead(`import os, sys, xml.etree.ElementTree as ET
|
||||||
|
sidecar = os.path.join(os.path.dirname(sys.argv[1]), 'metadata.opf')
|
||||||
|
root = ET.parse(sidecar).getroot()
|
||||||
|
ns = {'dc': 'http://purl.org/dc/elements/1.1/'}
|
||||||
|
print(root.findtext('.//dc:title', default='', namespaces=ns))`, absPath);
|
||||||
|
default:
|
||||||
|
// audio containers: ffprobe the rewritten album tag
|
||||||
|
return run(
|
||||||
|
`ffprobe -v error -show_entries format_tags=album -of default=noprint_wrappers=1:nokey=1 ${JSON.stringify(absPath)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiLogin(request: APIRequestContext): Promise<string> {
|
||||||
|
const res = await request.post("/api/auth/login", {
|
||||||
|
data: { login: EMAIL, password: PASSWORD },
|
||||||
|
});
|
||||||
|
expect(res.status(), "login should succeed").toBe(200);
|
||||||
|
return (await res.json()).access_token as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe.configure({ mode: "serial" });
|
||||||
|
|
||||||
|
test.describe("Write-to-file per format (real admin UI + artifact verification)", () => {
|
||||||
|
let page: Page;
|
||||||
|
|
||||||
|
test.beforeAll(async ({ browser, request }) => {
|
||||||
|
fs.mkdirSync(UPLOADS_HOST, { recursive: true });
|
||||||
|
|
||||||
|
// Fresh user, promoted to admin in the dev DB (the harness cleanup
|
||||||
|
// sweeps transient example.com users afterwards).
|
||||||
|
const reg = await request.post("/api/auth/register", {
|
||||||
|
data: { email: EMAIL, username: USERNAME, password: PASSWORD },
|
||||||
|
});
|
||||||
|
expect([201, 200, 409]).toContain(reg.status());
|
||||||
|
|
||||||
|
psql(`UPDATE users SET role='admin' WHERE email='${EMAIL}'`);
|
||||||
|
token = await apiLogin(request);
|
||||||
|
expect(token).toBeTruthy();
|
||||||
|
|
||||||
|
// Test library with a folder at our upload dir.
|
||||||
|
const libRes = await request.post("/api/libraries", {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
data: { name: `E2E Meta Write ${STAMP}`, type: "ebooks" },
|
||||||
|
});
|
||||||
|
expect(libRes.status()).toBe(201);
|
||||||
|
libraryID = ((await libRes.json()) as { id: string }).id;
|
||||||
|
const foldRes = await request.post(`/api/libraries/${libraryID}/folders`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
data: { folder_path: UPLOADS_CONT },
|
||||||
|
});
|
||||||
|
expect(foldRes.status()).toBe(201);
|
||||||
|
|
||||||
|
// Seed one fixture per format: file on disk + media_items row.
|
||||||
|
for (const f of FORMATS) {
|
||||||
|
const rel = `${f.group}/${f.ext}/e2e-fixture.${f.ext}`;
|
||||||
|
const abs = path.join(UPLOADS_HOST, rel);
|
||||||
|
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||||
|
if (f.ext === "epub") writeEPUB(abs, "E2E Original Title");
|
||||||
|
else if (f.ext === "cbz") writeCBZ(abs);
|
||||||
|
else if (f.ext === "pdf") fs.writeFileSync(abs, "%PDF-1.4 e2e stub\n");
|
||||||
|
else {
|
||||||
|
const codec = f.ext === "mp3" ? "libmp3lame" : f.ext === "flac" ? "flac" : "aac";
|
||||||
|
run(
|
||||||
|
`ffmpeg -hide_banner -loglevel error -y -f lavfi -i anullsrc=r=44100:cl=mono -t 2 -c:a ${codec} -metadata title='E2E Original Title' -metadata album='E2E Original Title' ${JSON.stringify(abs)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
f.id = psql(
|
||||||
|
`INSERT INTO media_items (library_id, title, file_path, format_group, mime_type, file_size, created_at, imported_at, updated_at)
|
||||||
|
VALUES ('${libraryID}', 'E2E Original Title', '${rel}', '${f.group}', '${f.mime}', 100, NOW(), NOW(), NOW())
|
||||||
|
RETURNING id`,
|
||||||
|
);
|
||||||
|
// -tAc prints the id row first, then psql's command tag
|
||||||
|
// ("INSERT 0 1") last; take the first non-empty line.
|
||||||
|
f.id = f.id.split("\n").find((l) => l.trim())!.trim();
|
||||||
|
expect(f.id).toMatch(/^[0-9a-f-]{36}$/i);
|
||||||
|
}
|
||||||
|
|
||||||
|
page = await browser.newPage();
|
||||||
|
await page.context().addCookies([{ name: "token", value: token, url: BASE }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await page?.close();
|
||||||
|
// Best-effort cleanup: the library delete cascades the items; the
|
||||||
|
// psql fallback + rm sweep anything the API couldn't.
|
||||||
|
try {
|
||||||
|
await fetch(`${BASE}/api/libraries/${libraryID}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* best effort */
|
||||||
|
}
|
||||||
|
fs.rmSync(UPLOADS_HOST, { recursive: true, force: true });
|
||||||
|
try {
|
||||||
|
psql(`DELETE FROM libraries WHERE id='${libraryID}'`);
|
||||||
|
} catch {
|
||||||
|
/* already gone */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const f of FORMATS) {
|
||||||
|
test(`writes metadata into ${f.ext} and the file content verifies`, async () => {
|
||||||
|
const newTitle = `E2E WRITTEN ${f.ext.toUpperCase()}`;
|
||||||
|
const abs = path.join(UPLOADS_HOST, f.group, f.ext, `e2e-fixture.${f.ext}`);
|
||||||
|
expect(f.id).toBeTruthy();
|
||||||
|
|
||||||
|
// Real admin UI flow: book page → Edit → change title → the
|
||||||
|
// in-app dialog → Save & write.
|
||||||
|
await page.goto(`/media/${f.id}`);
|
||||||
|
await page.click('button:has-text("Edit")');
|
||||||
|
const modal = page.locator("#metadata-editor-modal");
|
||||||
|
await expect(modal).toBeVisible();
|
||||||
|
await page.fill('#metadata-editor-modal input[name="title"]', newTitle);
|
||||||
|
|
||||||
|
await page.click('#metadata-editor-modal button:has-text("Write to file")');
|
||||||
|
const dialog = page.locator("#write-confirm-dialog");
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await dialog.locator('button:has-text("Save & write")').click();
|
||||||
|
|
||||||
|
// Wait for the request + inline rescan + page reload to settle.
|
||||||
|
await page.waitForTimeout(2500);
|
||||||
|
|
||||||
|
// Then verify the FILE's metadata — independently of the app
|
||||||
|
// (read the artifact back with python/ffprobe, not via the app).
|
||||||
|
let seen = "";
|
||||||
|
let lastErr = "";
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
try {
|
||||||
|
seen = artifactTitle(f.ext, abs);
|
||||||
|
} catch (e) {
|
||||||
|
lastErr = String(e);
|
||||||
|
seen = "";
|
||||||
|
}
|
||||||
|
if (seen.includes(newTitle)) break;
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
}
|
||||||
|
if (!seen.includes(newTitle)) {
|
||||||
|
console.error(`[${f.ext}] artifact read failed: ${lastErr}`);
|
||||||
|
}
|
||||||
|
expect(seen, `${f.ext}: the FILE must carry the written metadata`).toContain(newTitle);
|
||||||
|
expect(seen, `${f.ext}: the old title must be replaced`).not.toContain("E2E Original Title");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.31.1
|
// sqlc v1.31.0
|
||||||
|
|
||||||
package database
|
package database
|
||||||
|
|
||||||
|
|||||||
+86
-30
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.31.1
|
// sqlc v1.31.0
|
||||||
|
|
||||||
package database
|
package database
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ type Collections struct {
|
|||||||
Description pgtype.Text `db:"description" json:"description"`
|
Description pgtype.Text `db:"description" json:"description"`
|
||||||
Color pgtype.Text `db:"color" json:"color"`
|
Color pgtype.Text `db:"color" json:"color"`
|
||||||
Icon pgtype.Text `db:"icon" json:"icon"`
|
Icon pgtype.Text `db:"icon" json:"icon"`
|
||||||
AutoAssignRules []byte `db:"auto_assign_rules" json:"auto_assign_rules"`
|
FilterTree []byte `db:"filter_tree" json:"filter_tree"`
|
||||||
ViewSettings []byte `db:"view_settings" json:"view_settings"`
|
ViewSettings []byte `db:"view_settings" json:"view_settings"`
|
||||||
ShowOnDashboard pgtype.Bool `db:"show_on_dashboard" json:"show_on_dashboard"`
|
ShowOnDashboard pgtype.Bool `db:"show_on_dashboard" json:"show_on_dashboard"`
|
||||||
QueryType pgtype.Text `db:"query_type" json:"query_type"`
|
QueryType pgtype.Text `db:"query_type" json:"query_type"`
|
||||||
@@ -150,6 +150,7 @@ type Libraries struct {
|
|||||||
CreatedByAdminID pgtype.UUID `db:"created_by_admin_id" json:"created_by_admin_id"`
|
CreatedByAdminID pgtype.UUID `db:"created_by_admin_id" json:"created_by_admin_id"`
|
||||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||||
|
AllowMemberFeeds bool `db:"allow_member_feeds" json:"allow_member_feeds"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type LibraryFolders struct {
|
type LibraryFolders struct {
|
||||||
@@ -157,6 +158,7 @@ type LibraryFolders struct {
|
|||||||
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||||
FolderPath string `db:"folder_path" json:"folder_path"`
|
FolderPath string `db:"folder_path" json:"folder_path"`
|
||||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||||
|
Purpose string `db:"purpose" json:"purpose"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type LibraryTypes struct {
|
type LibraryTypes struct {
|
||||||
@@ -324,20 +326,36 @@ type MediaItems struct {
|
|||||||
// Scan information from ComicInfo.xml (scanner group, resolution, etc.)
|
// Scan information from ComicInfo.xml (scanner group, resolution, etc.)
|
||||||
ScanInformation pgtype.Text `db:"scan_information" json:"scan_information"`
|
ScanInformation pgtype.Text `db:"scan_information" json:"scan_information"`
|
||||||
// Summary from ComicInfo.xml (may be merged with description from Calibre)
|
// Summary from ComicInfo.xml (may be merged with description from Calibre)
|
||||||
Summary pgtype.Text `db:"summary" json:"summary"`
|
Summary pgtype.Text `db:"summary" json:"summary"`
|
||||||
MetadataOverrides []string `db:"metadata_overrides" json:"metadata_overrides"`
|
MetadataOverrides []string `db:"metadata_overrides" json:"metadata_overrides"`
|
||||||
MissingScanCount int32 `db:"missing_scan_count" json:"missing_scan_count"`
|
MissingScanCount int32 `db:"missing_scan_count" json:"missing_scan_count"`
|
||||||
ArchivedAt pgtype.Timestamptz `db:"archived_at" json:"archived_at"`
|
ArchivedAt pgtype.Timestamptz `db:"archived_at" json:"archived_at"`
|
||||||
ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"`
|
ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"`
|
||||||
LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"`
|
LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"`
|
||||||
AudioMetadata []byte `db:"audio_metadata" json:"audio_metadata"`
|
AudioMetadata []byte `db:"audio_metadata" json:"audio_metadata"`
|
||||||
TagsSearch []string `db:"tags_search" json:"tags_search"`
|
TagsSearch []string `db:"tags_search" json:"tags_search"`
|
||||||
ContributorsSearch []string `db:"contributors_search" json:"contributors_search"`
|
ContributorsSearch []string `db:"contributors_search" json:"contributors_search"`
|
||||||
NarratorsSearch []string `db:"narrators_search" json:"narrators_search"`
|
NarratorsSearch []string `db:"narrators_search" json:"narrators_search"`
|
||||||
FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"`
|
FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"`
|
||||||
OpfIdentifier pgtype.Text `db:"opf_identifier" json:"opf_identifier"`
|
OpfIdentifier pgtype.Text `db:"opf_identifier" json:"opf_identifier"`
|
||||||
OpfUuid pgtype.Text `db:"opf_uuid" json:"opf_uuid"`
|
OpfUuid pgtype.Text `db:"opf_uuid" json:"opf_uuid"`
|
||||||
HashConfidence pgtype.Text `db:"hash_confidence" json:"hash_confidence"`
|
HashConfidence pgtype.Text `db:"hash_confidence" json:"hash_confidence"`
|
||||||
|
Source string `db:"source" json:"source"`
|
||||||
|
SourceFeedID pgtype.UUID `db:"source_feed_id" json:"source_feed_id"`
|
||||||
|
EpisodeGuid pgtype.Text `db:"episode_guid" json:"episode_guid"`
|
||||||
|
FeedPosition pgtype.Int4 `db:"feed_position" json:"feed_position"`
|
||||||
|
SeasonNumber pgtype.Int4 `db:"season_number" json:"season_number"`
|
||||||
|
EpisodeNumber pgtype.Int4 `db:"episode_number" json:"episode_number"`
|
||||||
|
EnclosureUrl pgtype.Text `db:"enclosure_url" json:"enclosure_url"`
|
||||||
|
DeclaredDurationSeconds pgtype.Int4 `db:"declared_duration_seconds" json:"declared_duration_seconds"`
|
||||||
|
TranscriptUrl pgtype.Text `db:"transcript_url" json:"transcript_url"`
|
||||||
|
ChaptersUrl pgtype.Text `db:"chapters_url" json:"chapters_url"`
|
||||||
|
Hosts []string `db:"hosts" json:"hosts"`
|
||||||
|
Guests []string `db:"guests" json:"guests"`
|
||||||
|
DownloadState string `db:"download_state" json:"download_state"`
|
||||||
|
DownloadError pgtype.Text `db:"download_error" json:"download_error"`
|
||||||
|
DownloadAttempts int32 `db:"download_attempts" json:"download_attempts"`
|
||||||
|
DownloadQueuePos pgtype.Int8 `db:"download_queue_pos" json:"download_queue_pos"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MediaNotes struct {
|
type MediaNotes struct {
|
||||||
@@ -405,6 +423,43 @@ type PanelData struct {
|
|||||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PodcastFeeds struct {
|
||||||
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||||
|
FeedUrl string `db:"feed_url" json:"feed_url"`
|
||||||
|
PodcastGuid pgtype.Text `db:"podcast_guid" json:"podcast_guid"`
|
||||||
|
Title string `db:"title" json:"title"`
|
||||||
|
Description pgtype.Text `db:"description" json:"description"`
|
||||||
|
Host pgtype.Text `db:"host" json:"host"`
|
||||||
|
Publisher pgtype.Text `db:"publisher" json:"publisher"`
|
||||||
|
OwnerName pgtype.Text `db:"owner_name" json:"owner_name"`
|
||||||
|
OwnerEmail pgtype.Text `db:"owner_email" json:"owner_email"`
|
||||||
|
Language pgtype.Text `db:"language" json:"language"`
|
||||||
|
Tags []string `db:"tags" json:"tags"`
|
||||||
|
Explicit bool `db:"explicit" json:"explicit"`
|
||||||
|
ArtworkPath pgtype.Text `db:"artwork_path" json:"artwork_path"`
|
||||||
|
FeedType pgtype.Text `db:"feed_type" json:"feed_type"`
|
||||||
|
Medium pgtype.Text `db:"medium" json:"medium"`
|
||||||
|
IsComplete bool `db:"is_complete" json:"is_complete"`
|
||||||
|
Etag pgtype.Text `db:"etag" json:"etag"`
|
||||||
|
LastModified pgtype.Text `db:"last_modified" json:"last_modified"`
|
||||||
|
LastFetchedAt pgtype.Timestamptz `db:"last_fetched_at" json:"last_fetched_at"`
|
||||||
|
LastBuildDate pgtype.Timestamptz `db:"last_build_date" json:"last_build_date"`
|
||||||
|
CreatedByAdminID pgtype.UUID `db:"created_by_admin_id" json:"created_by_admin_id"`
|
||||||
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PodcastSubscriptions struct {
|
||||||
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||||
|
FeedID pgtype.UUID `db:"feed_id" json:"feed_id"`
|
||||||
|
DisplayName pgtype.Text `db:"display_name" json:"display_name"`
|
||||||
|
AutoDownload string `db:"auto_download" json:"auto_download"`
|
||||||
|
AutoDownloadCount pgtype.Int4 `db:"auto_download_count" json:"auto_download_count"`
|
||||||
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
// Tracks media that failed processing in a library: item-backed issues (library-type mismatches, metadata extraction failures) and row-less import failures (no media_items row exists; keyed by library + file_path). Auto-resolved by later successful scans.
|
// Tracks media that failed processing in a library: item-backed issues (library-type mismatches, metadata extraction failures) and row-less import failures (no media_items row exists; keyed by library + file_path). Auto-resolved by later successful scans.
|
||||||
type ProcessingIssues struct {
|
type ProcessingIssues struct {
|
||||||
ID pgtype.UUID `db:"id" json:"id"`
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
@@ -585,18 +640,19 @@ type UserDashboardPreferencesGlobal struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Users struct {
|
type Users struct {
|
||||||
ID pgtype.UUID `db:"id" json:"id"`
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
Email string `db:"email" json:"email"`
|
Email string `db:"email" json:"email"`
|
||||||
Username string `db:"username" json:"username"`
|
Username string `db:"username" json:"username"`
|
||||||
PasswordHash string `db:"password_hash" json:"password_hash"`
|
PasswordHash string `db:"password_hash" json:"password_hash"`
|
||||||
FirstName pgtype.Text `db:"first_name" json:"first_name"`
|
FirstName pgtype.Text `db:"first_name" json:"first_name"`
|
||||||
LastName pgtype.Text `db:"last_name" json:"last_name"`
|
LastName pgtype.Text `db:"last_name" json:"last_name"`
|
||||||
Role string `db:"role" json:"role"`
|
Role string `db:"role" json:"role"`
|
||||||
Theme pgtype.Text `db:"theme" json:"theme"`
|
Theme pgtype.Text `db:"theme" json:"theme"`
|
||||||
MaxDevices pgtype.Int4 `db:"max_devices" json:"max_devices"`
|
MaxDevices pgtype.Int4 `db:"max_devices" json:"max_devices"`
|
||||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||||
Timezone pgtype.Text `db:"timezone" json:"timezone"`
|
Timezone pgtype.Text `db:"timezone" json:"timezone"`
|
||||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||||
DefaultAudioSpeed float32 `db:"default_audio_speed" json:"default_audio_speed"`
|
DefaultAudioSpeed float32 `db:"default_audio_speed" json:"default_audio_speed"`
|
||||||
KeepScreenAwake bool `db:"keep_screen_awake" json:"keep_screen_awake"`
|
KeepScreenAwake bool `db:"keep_screen_awake" json:"keep_screen_awake"`
|
||||||
|
AudioResumeRewindSeconds int32 `db:"audio_resume_rewind_seconds" json:"audio_resume_rewind_seconds"`
|
||||||
}
|
}
|
||||||
+212
-15
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.31.1
|
// sqlc v1.31.0
|
||||||
|
|
||||||
package database
|
package database
|
||||||
|
|
||||||
@@ -22,6 +22,8 @@ type Querier interface {
|
|||||||
// ============================================
|
// ============================================
|
||||||
AddBookToKoboShelf(ctx context.Context, arg AddBookToKoboShelfParams) (KoboShelves, error)
|
AddBookToKoboShelf(ctx context.Context, arg AddBookToKoboShelfParams) (KoboShelves, error)
|
||||||
// Library Folders queries
|
// Library Folders queries
|
||||||
|
// purpose: 'scan' (FS-walker root, default) | 'podcast' (download root;
|
||||||
|
// scanner-skipped, written by the podcast download worker instead).
|
||||||
AddLibraryFolder(ctx context.Context, arg AddLibraryFolderParams) (LibraryFolders, error)
|
AddLibraryFolder(ctx context.Context, arg AddLibraryFolderParams) (LibraryFolders, error)
|
||||||
// Second consecutive missing scan: hide the item from all browsing while
|
// Second consecutive missing scan: hide the item from all browsing while
|
||||||
// preserving reading history in case the file returns.
|
// preserving reading history in case the file returns.
|
||||||
@@ -30,6 +32,11 @@ type Querier interface {
|
|||||||
BulkUpdateFormatGroups(ctx context.Context) error
|
BulkUpdateFormatGroups(ctx context.Context) error
|
||||||
BulkUpdateProgressFromSync(ctx context.Context, arg BulkUpdateProgressFromSyncParams) ([]interface{}, error)
|
BulkUpdateProgressFromSync(ctx context.Context, arg BulkUpdateProgressFromSyncParams) ([]interface{}, error)
|
||||||
CheckForProgressConflicts(ctx context.Context, arg CheckForProgressConflictsParams) (int64, error)
|
CheckForProgressConflicts(ctx context.Context, arg CheckForProgressConflictsParams) (int64, error)
|
||||||
|
// Atomic claim (§0 item 4): converts queued -> downloading in ONE
|
||||||
|
// statement. Two claimants racing on the same row: exactly one UPDATE
|
||||||
|
// matches download_state='queued'; the loser gets no rows (ErrNoRows).
|
||||||
|
// This is the SKIP-LOCKED guarantee without a second round-trip.
|
||||||
|
ClaimQueuedPodcastEpisode(ctx context.Context, id pgtype.UUID) (MediaItems, error)
|
||||||
// Cleanup expired OPDS tokens
|
// Cleanup expired OPDS tokens
|
||||||
CleanupExpiredOpdsTokens(ctx context.Context) error
|
CleanupExpiredOpdsTokens(ctx context.Context) error
|
||||||
CleanupExpiredRefreshTokens(ctx context.Context, dollar_1 float64) error
|
CleanupExpiredRefreshTokens(ctx context.Context, dollar_1 float64) error
|
||||||
@@ -53,6 +60,19 @@ type Querier interface {
|
|||||||
// collections page); same live filter as GetCollectionItems, plus the
|
// collections page); same live filter as GetCollectionItems, plus the
|
||||||
// future-hybrid excluded exclusion and an optional library scope.
|
// future-hybrid excluded exclusion and an optional library scope.
|
||||||
CountCollectionLiveItems(ctx context.Context, arg CountCollectionLiveItemsParams) (int32, error)
|
CountCollectionLiveItems(ctx context.Context, arg CountCollectionLiveItemsParams) (int32, error)
|
||||||
|
CountDownloadedEpisodes(ctx context.Context, sourceFeedID pgtype.UUID) (int64, error)
|
||||||
|
// Sibling-book count for sidecar naming (MetadataWriter): a dir-scoped
|
||||||
|
// metadata.opf may only be written when NO other non-archived item
|
||||||
|
// shares the directory; otherwise the sidecar goes to
|
||||||
|
// <basename>.opf so one book's metadata can never poison dir-mates.
|
||||||
|
CountOtherMediaItemsInDir(ctx context.Context, arg CountOtherMediaItemsInDirParams) (int32, error)
|
||||||
|
CountPodcastEpisodesForFeed(ctx context.Context, sourceFeedID pgtype.UUID) (int64, error)
|
||||||
|
CountPodcastFeedSubscribers(ctx context.Context, feedID pgtype.UUID) (int64, error)
|
||||||
|
// Sidebar/page gate (D13): >=1 podcasts-type library the user can see
|
||||||
|
// (COALESCE(is_visible,true) semantics — mirrors GetUserVisibleLibraries).
|
||||||
|
CountPodcastLibrariesForUser(ctx context.Context, userID pgtype.UUID) (int64, error)
|
||||||
|
CountQueuedEpisodes(ctx context.Context) (int64, error)
|
||||||
|
CountUndownloadedEpisodes(ctx context.Context, sourceFeedID pgtype.UUID) (int64, error)
|
||||||
// Count unlinked books for a device (ignored rows stay hidden)
|
// Count unlinked books for a device (ignored rows stay hidden)
|
||||||
CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error)
|
CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error)
|
||||||
// Banner count: unresolved, non-ignored queue rows across the caller's own
|
// Banner count: unresolved, non-ignored queue rows across the caller's own
|
||||||
@@ -111,6 +131,22 @@ type Querier interface {
|
|||||||
// ============================================
|
// ============================================
|
||||||
CreateOrUpdateKoboEntitlement(ctx context.Context, arg CreateOrUpdateKoboEntitlementParams) (KoboEntitlements, error)
|
CreateOrUpdateKoboEntitlement(ctx context.Context, arg CreateOrUpdateKoboEntitlementParams) (KoboEntitlements, error)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
// PODCASTS / RSS FEEDS (plan ~/.opencode/plan/podcast-integration.md §6/§8)
|
||||||
|
// Visibility is RLS-enforced (schema policy block) — NO query here carries
|
||||||
|
// visibility clauses. Feed reads run under the acting user's identity
|
||||||
|
// (subscriber-or-admin policy); the fetcher/worker run as first-admin.
|
||||||
|
// The fetcher/download worker is the SOLE writer of feed/episode metadata;
|
||||||
|
// users write only podcast_subscriptions rows.
|
||||||
|
// ============================================================================
|
||||||
|
// Add-feed upsert keyed on the per-library UNIQUE(library_id, feed_url):
|
||||||
|
// a second subscriber adding the same URL attaches to the existing row
|
||||||
|
// instead of erroring. DO UPDATE (not DO NOTHING) so the RETURNING works
|
||||||
|
// even when the conflicting row is invisible to the actor under RLS
|
||||||
|
// (non-admin creators cannot SELECT other users' feeds). Metadata columns
|
||||||
|
// are filled by the fetcher right after; this only establishes identity.
|
||||||
|
CreatePodcastFeed(ctx context.Context, arg CreatePodcastFeedParams) (PodcastFeeds, error)
|
||||||
|
CreatePodcastLibraryFolder(ctx context.Context, arg CreatePodcastLibraryFolderParams) (LibraryFolders, error)
|
||||||
|
// ============================================================================
|
||||||
// PROCESSING ISSUES QUERIES
|
// PROCESSING ISSUES QUERIES
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Widened upsert (faceted plan Phase 11): media_item_id and file_path
|
// Widened upsert (faceted plan Phase 11): media_item_id and file_path
|
||||||
@@ -155,6 +191,11 @@ type Querier interface {
|
|||||||
DeleteMediaItemFormat(ctx context.Context, id pgtype.UUID) error
|
DeleteMediaItemFormat(ctx context.Context, id pgtype.UUID) error
|
||||||
DeleteMediaRating(ctx context.Context, arg DeleteMediaRatingParams) error
|
DeleteMediaRating(ctx context.Context, arg DeleteMediaRatingParams) error
|
||||||
DeleteMediaReview(ctx context.Context, arg DeleteMediaReviewParams) error
|
DeleteMediaReview(ctx context.Context, arg DeleteMediaReviewParams) error
|
||||||
|
// Admin-only removal (handler checks role); episodes cascade via
|
||||||
|
// media_items.source_feed_id ON DELETE CASCADE.
|
||||||
|
DeletePodcastFeed(ctx context.Context, id pgtype.UUID) (int64, error)
|
||||||
|
// Unsubscribe NEVER deletes feed/episode/file state (D6).
|
||||||
|
DeletePodcastSubscription(ctx context.Context, arg DeletePodcastSubscriptionParams) (int64, error)
|
||||||
DeleteProcessingIssue(ctx context.Context, id pgtype.UUID) (ProcessingIssues, error)
|
DeleteProcessingIssue(ctx context.Context, id pgtype.UUID) (ProcessingIssues, error)
|
||||||
DeleteReadingProgress(ctx context.Context, arg DeleteReadingProgressParams) error
|
DeleteReadingProgress(ctx context.Context, arg DeleteReadingProgressParams) error
|
||||||
DeleteSavedFilter(ctx context.Context, arg DeleteSavedFilterParams) (SavedFilters, error)
|
DeleteSavedFilter(ctx context.Context, arg DeleteSavedFilterParams) (SavedFilters, error)
|
||||||
@@ -165,7 +206,14 @@ type Querier interface {
|
|||||||
// Delete unlinked book
|
// Delete unlinked book
|
||||||
DeleteUnlinkedBook(ctx context.Context, id pgtype.UUID) error
|
DeleteUnlinkedBook(ctx context.Context, id pgtype.UUID) error
|
||||||
DeleteUser(ctx context.Context, id pgtype.UUID) error
|
DeleteUser(ctx context.Context, id pgtype.UUID) error
|
||||||
|
DeleteUserSettingByKey(ctx context.Context, arg DeleteUserSettingByKeyParams) error
|
||||||
DeleteUserSystemCollection(ctx context.Context, arg DeleteUserSystemCollectionParams) error
|
DeleteUserSystemCollection(ctx context.Context, arg DeleteUserSystemCollectionParams) error
|
||||||
|
// Device deletion (deregister on logout): detach the device's reading
|
||||||
|
// history instead of deleting it — these rows are real captured stats
|
||||||
|
// (the Users section reads them); reading_history.device_id is NO ACTION
|
||||||
|
// so a blind device delete 500s once any history anchors to the device.
|
||||||
|
// device_id is nullable: NULL keeps the session, loses the association.
|
||||||
|
DetachReadingHistoryFromDevice(ctx context.Context, deviceID pgtype.UUID) error
|
||||||
// Device existence probe for capture: the web/app progress paths
|
// Device existence probe for capture: the web/app progress paths
|
||||||
// historically stuff the user id into DeviceID (it only fed a broadcast
|
// historically stuff the user id into DeviceID (it only fed a broadcast
|
||||||
// label before stats); a capture row must only reference a REAL device.
|
// label before stats); a capture row must only reference a REAL device.
|
||||||
@@ -214,11 +262,25 @@ type Querier interface {
|
|||||||
// rename can't spawn duplicates on the next scan.
|
// rename can't spawn duplicates on the next scan.
|
||||||
GetCollectionByImportedSeries(ctx context.Context, arg GetCollectionByImportedSeriesParams) (Collections, error)
|
GetCollectionByImportedSeries(ctx context.Context, arg GetCollectionByImportedSeriesParams) (Collections, error)
|
||||||
GetCollectionByUserAndName(ctx context.Context, arg GetCollectionByUserAndNameParams) (Collections, error)
|
GetCollectionByUserAndName(ctx context.Context, arg GetCollectionByUserAndNameParams) (Collections, error)
|
||||||
|
// The collection queue's downloaded episodes in collection order
|
||||||
|
// (plan item 2.3 "Play collection as sequence"): position-first, then
|
||||||
|
// added_at. INNER JOIN — the chain steps only through episodes the
|
||||||
|
// requester can see (RLS drops unsubscribed feeds) and that are
|
||||||
|
// downloaded (invariant 6: un-downloaded episodes are never tracked).
|
||||||
|
GetCollectionEpisodesOrdered(ctx context.Context, collectionID pgtype.UUID) ([]GetCollectionEpisodesOrderedRow, error)
|
||||||
// Get collection items
|
// Get collection items
|
||||||
// A2c: position-first ordering (playlist reading order); un-positioned
|
// A2c: position-first ordering (playlist reading order); un-positioned
|
||||||
// legacy items keep added_at ASC after the positioned tail — NOT DESC,
|
// legacy items keep added_at ASC after the positioned tail — NOT DESC,
|
||||||
// which walked a playlist backwards.
|
// which walked a playlist backwards.
|
||||||
GetCollectionItems(ctx context.Context, collectionID pgtype.UUID) ([]GetCollectionItemsRow, error)
|
GetCollectionItems(ctx context.Context, collectionID pgtype.UUID) ([]GetCollectionItemsRow, error)
|
||||||
|
// Episode-collections detail (plan item 2.3): the collection's full
|
||||||
|
// membership for the SSR detail page, with per-row visibility. LEFT
|
||||||
|
// JOIN + RLS: rows whose media is invisible to the requester
|
||||||
|
// (unsubscribed episode — or a family-lens hidden book) survive with
|
||||||
|
// NULL media columns and visible=false, so the page can render an
|
||||||
|
// "unavailable" hint instead of silently vanishing them. Rows visible
|
||||||
|
// but archived/missing-scan keep the old INNER-JOIN behavior (dropped).
|
||||||
|
GetCollectionItemsDetailed(ctx context.Context, collectionID pgtype.UUID) ([]GetCollectionItemsDetailedRow, error)
|
||||||
// The excluded filter lives here (was: callers filtered ci.excluded in
|
// The excluded filter lives here (was: callers filtered ci.excluded in
|
||||||
// Go over a 69-field row + a 45-field converter to MediaItems; embed
|
// Go over a 69-field row + a 45-field converter to MediaItems; embed
|
||||||
// returns the real row so neither exists).
|
// returns the real row so neither exists).
|
||||||
@@ -275,12 +337,24 @@ type Querier interface {
|
|||||||
// Series are grouped PER LIBRARY: the same series name in two libraries
|
// Series are grouped PER LIBRARY: the same series name in two libraries
|
||||||
// (novels vs manga adaptations, LN splits) stays two entries. Visible
|
// (novels vs manga adaptations, LN splits) stays two entries. Visible
|
||||||
// filter enforces per-user library visibility at the source.
|
// filter enforces per-user library visibility at the source.
|
||||||
// Tiebreaker REQUIRED for LIMIT/OFFSET pagination: bulk imports in one
|
//
|
||||||
// transaction share a single NOW() timestamp, leaving many groups tied on
|
// Series-page sort/filter contract (extensible registry in
|
||||||
// MAX(created_at). Postgres does not guarantee tie order between
|
// services.ResolveSeriesSort): SeriesQuery is a series-NAME substring so
|
||||||
// executions, so page boundaries could repeat or drop series. The GROUP BY
|
// it filters in WHERE (the group key — counts stay true); AuthorQuery
|
||||||
// keys make the sort total and deterministic.
|
// and Status filter via HAVING so COUNT(*) keeps reporting the whole
|
||||||
|
// series. ViewerUserId scopes the per-user progress aggregates (the rp
|
||||||
|
// join can't multiply rows: reading_progress is UNIQUE per
|
||||||
|
// media_item+user). Sort is a BOUND PARAM compared inside the CASE
|
||||||
|
// cascade — never interpolated — and the GROUP BY tiebreaker stays
|
||||||
|
// REQUIRED for LIMIT/OFFSET determinism: bulk imports in one
|
||||||
|
// transaction share a single NOW() timestamp, leaving many groups tied
|
||||||
|
// on MAX(created_at), and Postgres does not guarantee tie order between
|
||||||
|
// executions, so page boundaries could repeat or drop series.
|
||||||
GetDistinctSeries(ctx context.Context, arg GetDistinctSeriesParams) ([]GetDistinctSeriesRow, error)
|
GetDistinctSeries(ctx context.Context, arg GetDistinctSeriesParams) ([]GetDistinctSeriesRow, error)
|
||||||
|
// Count counterpart of GetDistinctSeries: identical WHERE + JOIN +
|
||||||
|
// HAVING so the pagination total matches what the page query returns
|
||||||
|
// under the same filters (Sort is a page-ORDER concern, not a set
|
||||||
|
// membership one, so it is absent here by design).
|
||||||
GetDistinctSeriesCount(ctx context.Context, arg GetDistinctSeriesCountParams) (int32, error)
|
GetDistinctSeriesCount(ctx context.Context, arg GetDistinctSeriesCountParams) (int32, error)
|
||||||
GetFailedSyncQueueItems(ctx context.Context, limit int32) ([]SyncQueue, error)
|
GetFailedSyncQueueItems(ctx context.Context, limit int32) ([]SyncQueue, error)
|
||||||
GetFirstAdmin(ctx context.Context) (pgtype.UUID, error)
|
GetFirstAdmin(ctx context.Context) (pgtype.UUID, error)
|
||||||
@@ -398,6 +472,25 @@ type Querier interface {
|
|||||||
// Get OPDS tokens by device
|
// Get OPDS tokens by device
|
||||||
GetOpdsTokensByDevice(ctx context.Context, deviceID pgtype.UUID) ([]OpdsTokens, error)
|
GetOpdsTokensByDevice(ctx context.Context, deviceID pgtype.UUID) ([]OpdsTokens, error)
|
||||||
GetPanelData(ctx context.Context, arg GetPanelDataParams) (PanelData, error)
|
GetPanelData(ctx context.Context, arg GetPanelDataParams) (PanelData, error)
|
||||||
|
// Continue aggregation (plan 2.5): the caller's IN-PROGRESS episodes
|
||||||
|
// (0 < pct < finishedThreshold) across ALL subscribed feeds, most
|
||||||
|
// recent first. Go groups per feed — the first row per feed IS the
|
||||||
|
// continue target.
|
||||||
|
GetPodcastContinueForUser(ctx context.Context, userID pgtype.UUID) ([]GetPodcastContinueForUserRow, error)
|
||||||
|
GetPodcastEpisode(ctx context.Context, id pgtype.UUID) (MediaItems, error)
|
||||||
|
// Per-user progress for a feed's episodes (row badges + the Phase-2
|
||||||
|
// continue fields). reading_progress is UNIQUE per (media_item, user).
|
||||||
|
GetPodcastEpisodeProgress(ctx context.Context, arg GetPodcastEpisodeProgressParams) ([]GetPodcastEpisodeProgressRow, error)
|
||||||
|
GetPodcastFeed(ctx context.Context, id pgtype.UUID) (PodcastFeeds, error)
|
||||||
|
// URL-migration dedupe: same canonical guid within the library reuses
|
||||||
|
// the existing row.
|
||||||
|
GetPodcastFeedByGuid(ctx context.Context, arg GetPodcastFeedByGuidParams) (PodcastFeeds, error)
|
||||||
|
// The download root (purpose='podcast') of a podcasts library.
|
||||||
|
GetPodcastLibraryFolder(ctx context.Context, libraryID pgtype.UUID) ([]LibraryFolders, error)
|
||||||
|
GetPodcastSubscription(ctx context.Context, arg GetPodcastSubscriptionParams) (PodcastSubscriptions, error)
|
||||||
|
// The podcasts library/ies visible to the user (add-feed target list;
|
||||||
|
// a self-hosted instance typically has exactly one).
|
||||||
|
GetPodcastsLibraryForUser(ctx context.Context, userID pgtype.UUID) ([]GetPodcastsLibraryForUserRow, error)
|
||||||
// Get most popular books for a user
|
// Get most popular books for a user
|
||||||
GetPopularBooks(ctx context.Context, arg GetPopularBooksParams) ([]GetPopularBooksRow, error)
|
GetPopularBooks(ctx context.Context, arg GetPopularBooksParams) ([]GetPopularBooksRow, error)
|
||||||
// Single-issue read for the resolve/delete endpoints' family-lens gate.
|
// Single-issue read for the resolve/delete endpoints' family-lens gate.
|
||||||
@@ -498,14 +591,14 @@ type Querier interface {
|
|||||||
// END instant (the push time; NULL falls back to created_at) — the same
|
// END instant (the push time; NULL falls back to created_at) — the same
|
||||||
// value the capture stamps every write with.
|
// value the capture stamps every write with.
|
||||||
GetUserReadingSessionsRange(ctx context.Context, arg GetUserReadingSessionsRangeParams) ([]GetUserReadingSessionsRangeRow, error)
|
GetUserReadingSessionsRange(ctx context.Context, arg GetUserReadingSessionsRangeParams) ([]GetUserReadingSessionsRangeRow, error)
|
||||||
|
// ============================================================================
|
||||||
|
// GENERIC reader_settings KV (podcast order override, plan D5: key
|
||||||
|
// podcast_order:<feed_id>). The pre-existing reader_settings queries are
|
||||||
|
// hard-keyed to 'reader_settings'; these two are the generic form.
|
||||||
|
// ============================================================================
|
||||||
|
GetUserSettingByKey(ctx context.Context, arg GetUserSettingByKeyParams) ([]byte, error)
|
||||||
GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]GetUserVisibleLibrariesRow, error)
|
GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]GetUserVisibleLibrariesRow, error)
|
||||||
GetVisibleLibraryMediaCounts(ctx context.Context, userID pgtype.UUID) ([]GetVisibleLibraryMediaCountsRow, error)
|
GetVisibleLibraryMediaCounts(ctx context.Context, userID pgtype.UUID) ([]GetVisibleLibraryMediaCountsRow, error)
|
||||||
// Smart ("dynamic") collections: rules are evaluated LIVE at render, so
|
|
||||||
// membership comes from the candidate book set instead of collection_items.
|
|
||||||
// The set is scoped to the requesting user's visible libraries (family
|
|
||||||
// lens enforced at the source) and live items only — the same filters the
|
|
||||||
// static collection paths apply.
|
|
||||||
GetVisibleLiveMediaForRules(ctx context.Context, arg GetVisibleLiveMediaForRulesParams) ([]MediaItems, error)
|
|
||||||
HasRecentConflictResolution(ctx context.Context, arg HasRecentConflictResolutionParams) (bool, error)
|
HasRecentConflictResolution(ctx context.Context, arg HasRecentConflictResolutionParams) (bool, error)
|
||||||
// Soft-dismiss one row (ownership is checked by the caller against the
|
// Soft-dismiss one row (ownership is checked by the caller against the
|
||||||
// device's user_id before calling this).
|
// device's user_id before calling this).
|
||||||
@@ -517,6 +610,12 @@ type Querier interface {
|
|||||||
// Link unlinked book to media item
|
// Link unlinked book to media item
|
||||||
LinkUnlinkedBook(ctx context.Context, arg LinkUnlinkedBookParams) (UnlinkedBooks, error)
|
LinkUnlinkedBook(ctx context.Context, arg LinkUnlinkedBookParams) (UnlinkedBooks, error)
|
||||||
ListAllConflictsByUserAndStatus(ctx context.Context, arg ListAllConflictsByUserAndStatusParams) ([]ListAllConflictsByUserAndStatusRow, error)
|
ListAllConflictsByUserAndStatus(ctx context.Context, arg ListAllConflictsByUserAndStatusParams) ([]ListAllConflictsByUserAndStatusRow, error)
|
||||||
|
// Admin all-podcasts view (§0 item 8 / Phase-4 item 1): EVERY feed from
|
||||||
|
// EVERY user with its maintenance aggregates. Runs under the first-
|
||||||
|
// admin identity (invariant 12 — same contract as the poller). Sorted
|
||||||
|
// by disk usage DESC: the maintenance question is "what's eating the
|
||||||
|
// disk", so the hogs surface first.
|
||||||
|
ListAllPodcastFeedsAdmin(ctx context.Context) ([]ListAllPodcastFeedsAdminRow, error)
|
||||||
ListAllSyncQueueItems(ctx context.Context, arg ListAllSyncQueueItemsParams) ([]ListAllSyncQueueItemsRow, error)
|
ListAllSyncQueueItems(ctx context.Context, arg ListAllSyncQueueItemsParams) ([]ListAllSyncQueueItemsRow, error)
|
||||||
ListConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListConflictsByUserRow, error)
|
ListConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListConflictsByUserRow, error)
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -529,6 +628,9 @@ type Querier interface {
|
|||||||
ListDeletedAnnotationsForBook(ctx context.Context, arg ListDeletedAnnotationsForBookParams) ([]ListDeletedAnnotationsForBookRow, error)
|
ListDeletedAnnotationsForBook(ctx context.Context, arg ListDeletedAnnotationsForBookParams) ([]ListDeletedAnnotationsForBookRow, error)
|
||||||
ListDevicesByType(ctx context.Context, deviceType string) ([]Devices, error)
|
ListDevicesByType(ctx context.Context, deviceType string) ([]Devices, error)
|
||||||
ListDevicesByUser(ctx context.Context, userID pgtype.UUID) ([]Devices, error)
|
ListDevicesByUser(ctx context.Context, userID pgtype.UUID) ([]Devices, error)
|
||||||
|
// Download-worker policy union (D6): most aggressive policy wins for
|
||||||
|
// what arrives. Runs under an admin identity (background job).
|
||||||
|
ListFeedSubscribers(ctx context.Context, feedID pgtype.UUID) ([]ListFeedSubscribersRow, error)
|
||||||
// Items hidden from libraries: either missing from disk (waiting on the
|
// Items hidden from libraries: either missing from disk (waiting on the
|
||||||
// second scan that archives them) or already archived and pending purge.
|
// second scan that archives them) or already archived and pending purge.
|
||||||
// Feeds the admin archived-items page. match_* columns identify an active
|
// Feeds the admin archived-items page. match_* columns identify an active
|
||||||
@@ -540,6 +642,9 @@ type Querier interface {
|
|||||||
// collapsible "Ignored" section so a misclick is recoverable.
|
// collapsible "Ignored" section so a misclick is recoverable.
|
||||||
ListIgnoredUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) ([]ListIgnoredUnlinkedBooksForUserRow, error)
|
ListIgnoredUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) ([]ListIgnoredUnlinkedBooksForUserRow, error)
|
||||||
ListLibraries(ctx context.Context) ([]ListLibrariesRow, error)
|
ListLibraries(ctx context.Context) ([]ListLibrariesRow, error)
|
||||||
|
// Podcasts exclusion (plan §9): generic book surfaces never list
|
||||||
|
// podcast episodes (library_type_name is the denormalized, trigger/
|
||||||
|
// fetcher-maintained discriminator; NULL rows predate it and stay).
|
||||||
ListMediaItems(ctx context.Context, arg ListMediaItemsParams) ([]ListMediaItemsRow, error)
|
ListMediaItems(ctx context.Context, arg ListMediaItemsParams) ([]ListMediaItemsRow, error)
|
||||||
ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryRow, error)
|
ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryRow, error)
|
||||||
ListMediaItemsByLibraryIncludingArchived(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryIncludingArchivedRow, error)
|
ListMediaItemsByLibraryIncludingArchived(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryIncludingArchivedRow, error)
|
||||||
@@ -548,18 +653,35 @@ type Querier interface {
|
|||||||
// List media items that have no stored SHA-256 (imported before hashing existed)
|
// List media items that have no stored SHA-256 (imported before hashing existed)
|
||||||
ListMediaItemsMissingHash(ctx context.Context) ([]MediaItems, error)
|
ListMediaItemsMissingHash(ctx context.Context) ([]MediaItems, error)
|
||||||
ListMediaItemsSorted(ctx context.Context, arg ListMediaItemsSortedParams) ([]ListMediaItemsSortedRow, error)
|
ListMediaItemsSorted(ctx context.Context, arg ListMediaItemsSortedParams) ([]ListMediaItemsSortedRow, error)
|
||||||
|
// The podcasts page: the acting user's subscriptions with feed metadata
|
||||||
|
// and the user-owned subscription fields. Facet values derive in Go.
|
||||||
|
ListMyPodcastFeeds(ctx context.Context, userID pgtype.UUID) ([]ListMyPodcastFeedsRow, error)
|
||||||
ListPendingHashConflicts(ctx context.Context) ([]ListPendingHashConflictsRow, error)
|
ListPendingHashConflicts(ctx context.Context) ([]ListPendingHashConflictsRow, error)
|
||||||
// DeviceID NULL = every device's pending items (the queue processor's
|
// DeviceID NULL = every device's pending items (the queue processor's
|
||||||
// enumerator passes NULL; a bare `device_id = NULL` never matches, which
|
// enumerator passes NULL; a bare `device_id = NULL` never matches, which
|
||||||
// silently disabled the offline flush until 2026-10-17).
|
// silently disabled the offline flush until 2026-10-17).
|
||||||
ListPendingSyncQueueItems(ctx context.Context, arg ListPendingSyncQueueItemsParams) ([]SyncQueue, error)
|
ListPendingSyncQueueItems(ctx context.Context, arg ListPendingSyncQueueItemsParams) ([]SyncQueue, error)
|
||||||
|
// Feed detail / sequence source. RLS scopes to subscriber-or-admin;
|
||||||
|
// ordering: native position, then pub date, then guid (stable across
|
||||||
|
// re-fetches for historical rows whose position went stale).
|
||||||
|
ListPodcastEpisodes(ctx context.Context, sourceFeedID pgtype.UUID) ([]MediaItems, error)
|
||||||
|
// Poller worklist (admin identity): every feed with its validators and
|
||||||
|
// the back-off flag (is_complete feeds poll rarely, never disappear).
|
||||||
|
ListPodcastFeedsForPolling(ctx context.Context) ([]PodcastFeeds, error)
|
||||||
// LEFT JOIN (Phase 11): row-less import failures have NO media_items
|
// LEFT JOIN (Phase 11): row-less import failures have NO media_items
|
||||||
// row — an inner join would silently drop the ledger's newest kind.
|
// row — an inner join would silently drop the ledger's newest kind.
|
||||||
// Item-side projections COALESCE to keep them non-nullable strings;
|
// Item-side projections COALESCE to keep them non-nullable strings;
|
||||||
// the display file path falls back to the issue's own for row-less rows.
|
// the display file path falls back to the issue's own for row-less rows.
|
||||||
ListProcessingIssuesByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListProcessingIssuesByLibraryRow, error)
|
ListProcessingIssuesByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListProcessingIssuesByLibraryRow, error)
|
||||||
|
// Fair-share candidate scan (§0 item 4): the download pool claims in
|
||||||
|
// TICKET order (download_queue_pos = SEQUENCE nextval at enqueue — the
|
||||||
|
// enqueue loop's ordering IS the download order, item 5). The worker
|
||||||
|
// groups these per feed and prefers the least-recently-served feed.
|
||||||
|
ListQueuedPodcastEpisodes(ctx context.Context) ([]ListQueuedPodcastEpisodesRow, error)
|
||||||
ListSyncConflictsByMediaItem(ctx context.Context, arg ListSyncConflictsByMediaItemParams) ([]SyncConflicts, error)
|
ListSyncConflictsByMediaItem(ctx context.Context, arg ListSyncConflictsByMediaItemParams) ([]SyncConflicts, error)
|
||||||
ListSyncConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListSyncConflictsByUserRow, error)
|
ListSyncConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListSyncConflictsByUserRow, error)
|
||||||
|
// Download All + policy union worklist.
|
||||||
|
ListUndownloadedEpisodes(ctx context.Context, sourceFeedID pgtype.UUID) ([]MediaItems, error)
|
||||||
// List unresolved unlinked books with pagination
|
// List unresolved unlinked books with pagination
|
||||||
ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnresolvedUnlinkedBooksParams) ([]ListUnresolvedUnlinkedBooksRow, error)
|
ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnresolvedUnlinkedBooksParams) ([]ListUnresolvedUnlinkedBooksRow, error)
|
||||||
// Page list: unresolved queue rows for the caller's own devices (ignored
|
// Page list: unresolved queue rows for the caller's own devices (ignored
|
||||||
@@ -568,6 +690,17 @@ type Querier interface {
|
|||||||
ListUsers(ctx context.Context) ([]ListUsersRow, error)
|
ListUsers(ctx context.Context) ([]ListUsersRow, error)
|
||||||
// First consecutive scan that cannot find the file on disk.
|
// First consecutive scan that cannot find the file on disk.
|
||||||
MarkMediaItemMissing(ctx context.Context, id pgtype.UUID) error
|
MarkMediaItemMissing(ctx context.Context, id pgtype.UUID) error
|
||||||
|
// SetPodcastEpisodeDownloading RETIRED (§0 item 4): the pair
|
||||||
|
// GetNextQueuedEpisode + SetPodcastEpisodeDownloading raced under
|
||||||
|
// concurrency; ClaimQueuedPodcastEpisode does both atomically.
|
||||||
|
// SUCCESS-only write (invariant 7): the file is on disk and probed.
|
||||||
|
// ffprobe facts (real duration via audio_metadata, precise size/mime)
|
||||||
|
// reconcile the feed's declared values.
|
||||||
|
MarkPodcastEpisodeDownloaded(ctx context.Context, arg MarkPodcastEpisodeDownloadedParams) (MediaItems, error)
|
||||||
|
// Failure/rejection write: state + reason + whether a human already
|
||||||
|
// intervened (manual_retry resets the auto-retry budget). Existing
|
||||||
|
// metadata/file facts are never cleared.
|
||||||
|
MarkPodcastEpisodeFailed(ctx context.Context, arg MarkPodcastEpisodeFailedParams) error
|
||||||
// Content reappeared at a new location while the old path vanished: treat as
|
// Content reappeared at a new location while the old path vanished: treat as
|
||||||
// a move. Repoints the row (and refreshes file-level fields) so reading
|
// a move. Repoints the row (and refreshes file-level fields) so reading
|
||||||
// history follows the book; archive/missing state is cleared separately.
|
// history follows the book; archive/missing state is cleared separately.
|
||||||
@@ -602,6 +735,17 @@ type Querier interface {
|
|||||||
ReorderCollectionItems(ctx context.Context, arg ReorderCollectionItemsParams) error
|
ReorderCollectionItems(ctx context.Context, arg ReorderCollectionItemsParams) error
|
||||||
// Re-parent all child rows of p_source onto p_target (defined in schema.sql)
|
// Re-parent all child rows of p_source onto p_target (defined in schema.sql)
|
||||||
ReparentMediaItemChildren(ctx context.Context, arg ReparentMediaItemChildrenParams) error
|
ReparentMediaItemChildren(ctx context.Context, arg ReparentMediaItemChildrenParams) error
|
||||||
|
// Admin file deletion (§0 item 8): ONE downloaded episode's disk file
|
||||||
|
// was removed — reset the row to pending and clear the download-derived
|
||||||
|
// facts. file_path is deliberately KEPT: it is the fetch-maintained
|
||||||
|
// layout (written at fetch time, not download time), so a re-download
|
||||||
|
// lands on the same path. The poll upsert never touches download_state,
|
||||||
|
// so a deleted file stays deleted (invariant 2: polling never deletes;
|
||||||
|
// admins delete knowingly).
|
||||||
|
ResetPodcastEpisodeFileDeleted(ctx context.Context, id pgtype.UUID) error
|
||||||
|
// Same reset for EVERY downloaded episode of a feed (per-feed
|
||||||
|
// "Delete files" action on the admin view).
|
||||||
|
ResetPodcastFeedFilesDeleted(ctx context.Context, sourceFeedID pgtype.UUID) (int64, error)
|
||||||
ResetSystemCollectionMetadata(ctx context.Context, arg ResetSystemCollectionMetadataParams) error
|
ResetSystemCollectionMetadata(ctx context.Context, arg ResetSystemCollectionMetadataParams) error
|
||||||
ResolveHashConflict(ctx context.Context, arg ResolveHashConflictParams) error
|
ResolveHashConflict(ctx context.Context, arg ResolveHashConflictParams) error
|
||||||
// Ledger self-heal (faceted plan Phase 11): a scan that processes the
|
// Ledger self-heal (faceted plan Phase 11): a scan that processes the
|
||||||
@@ -626,19 +770,44 @@ type Querier interface {
|
|||||||
RevokeOpdsToken(ctx context.Context, token string) error
|
RevokeOpdsToken(ctx context.Context, token string) error
|
||||||
RevokeRefreshToken(ctx context.Context, token pgtype.UUID) error
|
RevokeRefreshToken(ctx context.Context, token pgtype.UUID) error
|
||||||
SearchAuthorValues(ctx context.Context, arg SearchAuthorValuesParams) ([]SearchAuthorValuesRow, error)
|
SearchAuthorValues(ctx context.Context, arg SearchAuthorValuesParams) ([]SearchAuthorValuesRow, error)
|
||||||
|
// Episode picker scope (plan item 2.3): YOUR subscriptions' episodes.
|
||||||
|
// The podcasts exclusion in SearchMediaItemsUnified stays (books
|
||||||
|
// surfaces never list podcasts — plan §9); THIS query is the episode
|
||||||
|
// picker's dedicated scope. Visibility is the engine's job (D20): the
|
||||||
|
// media_items policy drops episodes from unsubscribed feeds and the
|
||||||
|
// podcast_feeds join is governed the same way — non-subscribers see
|
||||||
|
// nothing here without any Go-side clause.
|
||||||
|
SearchEpisodesForPicker(ctx context.Context, q_ string) ([]SearchEpisodesForPickerRow, error)
|
||||||
SearchGenreValues(ctx context.Context, arg SearchGenreValuesParams) ([]SearchGenreValuesRow, error)
|
SearchGenreValues(ctx context.Context, arg SearchGenreValuesParams) ([]SearchGenreValuesRow, error)
|
||||||
SearchLanguageValues(ctx context.Context, arg SearchLanguageValuesParams) ([]SearchLanguageValuesRow, error)
|
SearchLanguageValues(ctx context.Context, arg SearchLanguageValuesParams) ([]SearchLanguageValuesRow, error)
|
||||||
// Search Media Items queries
|
// Search Media Items queries
|
||||||
SearchMediaItems(ctx context.Context, arg SearchMediaItemsParams) ([]SearchMediaItemsRow, error)
|
SearchMediaItems(ctx context.Context, arg SearchMediaItemsParams) ([]SearchMediaItemsRow, error)
|
||||||
SearchMediaItemsUnified(ctx context.Context, arg SearchMediaItemsUnifiedParams) ([]SearchMediaItemsUnifiedRow, error)
|
SearchMediaItemsUnified(ctx context.Context, arg SearchMediaItemsUnifiedParams) ([]SearchMediaItemsUnifiedRow, error)
|
||||||
|
// Subscription-scoped episode search (D14): title + description cover
|
||||||
|
// un-downloaded AND historical rows (fetcher persists metadata-only).
|
||||||
|
// RLS already restricts to the caller's subscriptions — the explicit
|
||||||
|
// source filter keeps scan items out of podcast search.
|
||||||
|
SearchMyPodcastEpisodes(ctx context.Context, searchQuery string) ([]MediaItems, error)
|
||||||
|
// Feed-title AND subscription-display-name search (D14) + the facet
|
||||||
|
// fields the podcasts page filters on.
|
||||||
|
SearchMyPodcastFeeds(ctx context.Context, arg SearchMyPodcastFeedsParams) ([]SearchMyPodcastFeedsRow, error)
|
||||||
SearchNarratorValues(ctx context.Context, arg SearchNarratorValuesParams) ([]SearchNarratorValuesRow, error)
|
SearchNarratorValues(ctx context.Context, arg SearchNarratorValuesParams) ([]SearchNarratorValuesRow, error)
|
||||||
SearchSeriesValues(ctx context.Context, arg SearchSeriesValuesParams) ([]SearchSeriesValuesRow, error)
|
SearchSeriesValues(ctx context.Context, arg SearchSeriesValuesParams) ([]SearchSeriesValuesRow, error)
|
||||||
SearchTagsValues(ctx context.Context, arg SearchTagsValuesParams) ([]SearchTagsValuesRow, error)
|
SearchTagsValues(ctx context.Context, arg SearchTagsValuesParams) ([]SearchTagsValuesRow, error)
|
||||||
SetDeviceSyncEnabled(ctx context.Context, arg SetDeviceSyncEnabledParams) (Devices, error)
|
SetDeviceSyncEnabled(ctx context.Context, arg SetDeviceSyncEnabledParams) (Devices, error)
|
||||||
// Library Visibility queries
|
// Library Visibility queries
|
||||||
SetLibraryVisibility(ctx context.Context, arg SetLibraryVisibilityParams) (LibraryVisibility, error)
|
SetLibraryVisibility(ctx context.Context, arg SetLibraryVisibilityParams) (LibraryVisibility, error)
|
||||||
|
// Enqueue: pending|failed -> queued, stamped with the NEXT QUEUE TICKET
|
||||||
|
// (§0 items 4/5) so claim order = enqueue order (the effective
|
||||||
|
// listening order the enqueue loop applied). Attempts increment at
|
||||||
|
// enqueue so a crashed worker still counts the try.
|
||||||
|
SetPodcastEpisodeDownloadQueued(ctx context.Context, id pgtype.UUID) (int64, error)
|
||||||
|
// Manual retry (same endpoint as download): reset to pending so the
|
||||||
|
// worker re-claims it; the reason text clears with the fresh attempt.
|
||||||
|
SetPodcastEpisodePending(ctx context.Context, id pgtype.UUID) error
|
||||||
// Set system config
|
// Set system config
|
||||||
SetSystemConfig(ctx context.Context, arg SetSystemConfigParams) (SystemConfig, error)
|
SetSystemConfig(ctx context.Context, arg SetSystemConfigParams) (SystemConfig, error)
|
||||||
|
SubscribePodcastFeed(ctx context.Context, arg SubscribePodcastFeedParams) (PodcastSubscriptions, error)
|
||||||
SyncLibraryTypeExtensions(ctx context.Context, arg SyncLibraryTypeExtensionsParams) error
|
SyncLibraryTypeExtensions(ctx context.Context, arg SyncLibraryTypeExtensionsParams) error
|
||||||
TombstoneMediaBookmarkByDedupKey(ctx context.Context, arg TombstoneMediaBookmarkByDedupKeyParams) error
|
TombstoneMediaBookmarkByDedupKey(ctx context.Context, arg TombstoneMediaBookmarkByDedupKeyParams) error
|
||||||
TombstoneMediaBookmarkByID(ctx context.Context, arg TombstoneMediaBookmarkByIDParams) error
|
TombstoneMediaBookmarkByID(ctx context.Context, arg TombstoneMediaBookmarkByIDParams) error
|
||||||
@@ -651,11 +820,12 @@ type Querier interface {
|
|||||||
UnignoreUnlinkedBook(ctx context.Context, id pgtype.UUID) error
|
UnignoreUnlinkedBook(ctx context.Context, id pgtype.UUID) error
|
||||||
// Update collection
|
// Update collection
|
||||||
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (Collections, error)
|
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (Collections, error)
|
||||||
// Replace ONLY the auto_assign_rules document (rule CRUD: read-modify-write
|
|
||||||
// of the JSONB without touching metadata stale clients did not send).
|
|
||||||
UpdateCollectionRulesJSON(ctx context.Context, arg UpdateCollectionRulesJSONParams) (Collections, error)
|
|
||||||
// A2c §2.4: provenance + playlist flags on an existing collection without
|
// A2c §2.4: provenance + playlist flags on an existing collection without
|
||||||
// touching the rest of the row.
|
// touching the rest of the row.
|
||||||
|
// (Historical bug, fixed 2026-10-30: this wrote `updated_at = NOW()` but
|
||||||
|
// collections has no updated_at column — the scanner's series-import
|
||||||
|
// call has been failing silently, its error discarded. Fixed by
|
||||||
|
// dropping the phantom column write.)
|
||||||
UpdateCollectionViewSettings(ctx context.Context, arg UpdateCollectionViewSettingsParams) error
|
UpdateCollectionViewSettings(ctx context.Context, arg UpdateCollectionViewSettingsParams) error
|
||||||
UpdateDashboardPreferences(ctx context.Context, arg UpdateDashboardPreferencesParams) (UserDashboardPreferences, error)
|
UpdateDashboardPreferences(ctx context.Context, arg UpdateDashboardPreferencesParams) (UserDashboardPreferences, error)
|
||||||
UpdateDevice(ctx context.Context, arg UpdateDeviceParams) (Devices, error)
|
UpdateDevice(ctx context.Context, arg UpdateDeviceParams) (Devices, error)
|
||||||
@@ -703,6 +873,20 @@ type Querier interface {
|
|||||||
UpdateMediaNoteForSync(ctx context.Context, arg UpdateMediaNoteForSyncParams) (MediaNotes, error)
|
UpdateMediaNoteForSync(ctx context.Context, arg UpdateMediaNoteForSyncParams) (MediaNotes, error)
|
||||||
UpdateMediaRating(ctx context.Context, arg UpdateMediaRatingParams) (MediaRatings, error)
|
UpdateMediaRating(ctx context.Context, arg UpdateMediaRatingParams) (MediaRatings, error)
|
||||||
UpdatePassword(ctx context.Context, arg UpdatePasswordParams) error
|
UpdatePassword(ctx context.Context, arg UpdatePasswordParams) error
|
||||||
|
UpdatePodcastFeedArtwork(ctx context.Context, arg UpdatePodcastFeedArtworkParams) error
|
||||||
|
// Post-fetch bookkeeping: validators for conditional GETs, the fetch
|
||||||
|
// timestamp, and the feed's own last-build marker.
|
||||||
|
UpdatePodcastFeedFetch(ctx context.Context, arg UpdatePodcastFeedFetchParams) (PodcastFeeds, error)
|
||||||
|
// Fetcher-only full metadata refresh (parse result). podcast_guid is
|
||||||
|
// set once a feed declares it; new_feed_url handling is a dedicated
|
||||||
|
// query below.
|
||||||
|
UpdatePodcastFeedMetadata(ctx context.Context, arg UpdatePodcastFeedMetadataParams) (PodcastFeeds, error)
|
||||||
|
// itunes:new-feed-url (or a permanent HTTP redirect chain): the feed has
|
||||||
|
// MOVED. Rewrite feed_url so future polls hit the new location; the row
|
||||||
|
// (and every subscription/episode) stays put.
|
||||||
|
UpdatePodcastFeedRedirect(ctx context.Context, arg UpdatePodcastFeedRedirectParams) error
|
||||||
|
// User-owned fields only (D9): display name + auto-download policy.
|
||||||
|
UpdatePodcastSubscription(ctx context.Context, arg UpdatePodcastSubscriptionParams) (PodcastSubscriptions, error)
|
||||||
UpdateReadingProgress(ctx context.Context, arg UpdateReadingProgressParams) (ReadingProgress, error)
|
UpdateReadingProgress(ctx context.Context, arg UpdateReadingProgressParams) (ReadingProgress, error)
|
||||||
UpdateReadingSpeed(ctx context.Context, arg UpdateReadingSpeedParams) (ReadingSpeed, error)
|
UpdateReadingSpeed(ctx context.Context, arg UpdateReadingSpeedParams) (ReadingSpeed, error)
|
||||||
UpdateSavedFilter(ctx context.Context, arg UpdateSavedFilterParams) (SavedFilters, error)
|
UpdateSavedFilter(ctx context.Context, arg UpdateSavedFilterParams) (SavedFilters, error)
|
||||||
@@ -710,6 +894,7 @@ type Querier interface {
|
|||||||
UpdateSystemSetting(ctx context.Context, arg UpdateSystemSettingParams) error
|
UpdateSystemSetting(ctx context.Context, arg UpdateSystemSettingParams) error
|
||||||
// Update universal progress
|
// Update universal progress
|
||||||
UpdateUniversalProgress(ctx context.Context, arg UpdateUniversalProgressParams) (ReadingProgress, error)
|
UpdateUniversalProgress(ctx context.Context, arg UpdateUniversalProgressParams) (ReadingProgress, error)
|
||||||
|
UpdateUserAudioResumeRewind(ctx context.Context, arg UpdateUserAudioResumeRewindParams) error
|
||||||
UpdateUserDefaultAudioSpeed(ctx context.Context, arg UpdateUserDefaultAudioSpeedParams) error
|
UpdateUserDefaultAudioSpeed(ctx context.Context, arg UpdateUserDefaultAudioSpeedParams) error
|
||||||
UpdateUserKeepScreenAwake(ctx context.Context, arg UpdateUserKeepScreenAwakeParams) error
|
UpdateUserKeepScreenAwake(ctx context.Context, arg UpdateUserKeepScreenAwakeParams) error
|
||||||
UpdateUserMaxDevices(ctx context.Context, arg UpdateUserMaxDevicesParams) (Users, error)
|
UpdateUserMaxDevices(ctx context.Context, arg UpdateUserMaxDevicesParams) (Users, error)
|
||||||
@@ -728,11 +913,23 @@ type Querier interface {
|
|||||||
// content identical), and after a manifest-gated re-extract.
|
// content identical), and after a manifest-gated re-extract.
|
||||||
UpsertMediaScanState(ctx context.Context, arg UpsertMediaScanStateParams) error
|
UpsertMediaScanState(ctx context.Context, arg UpsertMediaScanStateParams) error
|
||||||
UpsertPanelData(ctx context.Context, arg UpsertPanelDataParams) (PanelData, error)
|
UpsertPanelData(ctx context.Context, arg UpsertPanelDataParams) (PanelData, error)
|
||||||
|
// ============================================================================
|
||||||
|
// Podcast episodes — media_items rows with source='podcast_episode'
|
||||||
|
// ============================================================================
|
||||||
|
// Fetcher-only upsert keyed on the partial unique index
|
||||||
|
// (source_feed_id, episode_guid). DO UPDATE refreshes FETCH-OWNED
|
||||||
|
// metadata only. Write-after-success guard (AGENTS.md gotcha #7):
|
||||||
|
// download state/error/attempts are NEVER touched here, and the
|
||||||
|
// downloaded file's facts (path/size/mime/cover) survive renumbering —
|
||||||
|
// only un-downloaded rows follow the re-derived file_path.
|
||||||
|
// authors_search mirrors authors (trigram search column).
|
||||||
|
UpsertPodcastEpisode(ctx context.Context, arg UpsertPodcastEpisodeParams) (MediaItems, error)
|
||||||
UpsertReaderSettings(ctx context.Context, arg UpsertReaderSettingsParams) (ReaderSettings, error)
|
UpsertReaderSettings(ctx context.Context, arg UpsertReaderSettingsParams) (ReaderSettings, error)
|
||||||
UpsertSystemSetting(ctx context.Context, arg UpsertSystemSettingParams) (SystemSettings, error)
|
UpsertSystemSetting(ctx context.Context, arg UpsertSystemSettingParams) (SystemSettings, error)
|
||||||
// Ingest: insert or refresh the queue row for a device file that could not
|
// Ingest: insert or refresh the queue row for a device file that could not
|
||||||
// be identified. One row per (device, content_id); re-syncs bump last_seen.
|
// be identified. One row per (device, content_id); re-syncs bump last_seen.
|
||||||
UpsertUnlinkedBook(ctx context.Context, arg UpsertUnlinkedBookParams) (UnlinkedBooks, error)
|
UpsertUnlinkedBook(ctx context.Context, arg UpsertUnlinkedBookParams) (UnlinkedBooks, error)
|
||||||
|
UpsertUserSettingByKey(ctx context.Context, arg UpsertUserSettingByKeyParams) error
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ Querier = (*Queries)(nil)
|
var _ Querier = (*Queries)(nil)
|
||||||
+4040
-882
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -178,6 +178,16 @@ func verifyFunctions(ctx context.Context, db *pgxpool.Pool, expectedFunctions []
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// schemaMigrationIdentity is the synthetic identity the schema
|
||||||
|
// migration transaction declares for RLS (plan appendix A11). The
|
||||||
|
// schema's dedup DO block SELECTs/DELETEs media_items, and with the
|
||||||
|
// visibility policies live (FORCE applies to the owner) that SELECT
|
||||||
|
// needs app.user_id set even in this maintenance tx. The identity has
|
||||||
|
// NO library_visibility rows, so the policy grants it full visibility
|
||||||
|
// — appropriate for the server's own schema pass, and unreachable by
|
||||||
|
// requests (this tx only ever runs at startup, on the raw pool).
|
||||||
|
const schemaMigrationIdentity = "00000000-0000-0000-0000-000000000001"
|
||||||
|
|
||||||
// executeSchema runs the entire schema.sql in a single transaction
|
// executeSchema runs the entire schema.sql in a single transaction
|
||||||
func executeSchema(ctx context.Context, db *pgxpool.Pool) error {
|
func executeSchema(ctx context.Context, db *pgxpool.Pool) error {
|
||||||
tx, err := db.Begin(ctx)
|
tx, err := db.Begin(ctx)
|
||||||
@@ -186,6 +196,12 @@ func executeSchema(ctx context.Context, db *pgxpool.Pool) error {
|
|||||||
}
|
}
|
||||||
defer tx.Rollback(ctx)
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
// Declare the migration identity BEFORE the file executes (A11):
|
||||||
|
// policy'd tables are queried by schema.sql itself on re-runs.
|
||||||
|
if _, err := tx.Exec(ctx, `SELECT set_config('app.user_id', $1, true)`, schemaMigrationIdentity); err != nil {
|
||||||
|
return fmt.Errorf("schema execution failed (declare migration identity): %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
_, err = tx.Exec(ctx, SchemaFile)
|
_, err = tx.Exec(ctx, SchemaFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("HINT: Run manually to debug: psql -h localhost -U postgres -d bookhoard -f database/schema/schema.sql")
|
log.Printf("HINT: Run manually to debug: psql -h localhost -U postgres -d bookhoard -f database/schema/schema.sql")
|
||||||
|
|||||||
Loaded 100 of 305 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user