Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
8c97f59453 | ||
|
|
f6a73ab5aa |
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"bookhoard/internal/database"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// unlinkedBooksFixture is one user + device + two queue rows. The regular
|
||||||
|
// user's cleanup cascades: users → devices → unlinked_books.
|
||||||
|
type unlinkedBooksFixture struct {
|
||||||
|
setup *TestServerSetup
|
||||||
|
owner UserTestData
|
||||||
|
ownerTok string
|
||||||
|
deviceID pgtype.UUID
|
||||||
|
contentA string
|
||||||
|
contentB string
|
||||||
|
rowA pgtype.UUID
|
||||||
|
rowB pgtype.UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupUnlinkedBooksFixture(t *testing.T) *unlinkedBooksFixture {
|
||||||
|
f := &unlinkedBooksFixture{setup: setupTestServer(t), contentA: "test-content-aaa", contentB: "test-content-bbb"}
|
||||||
|
f.owner = createRegularUserOnce(t, f.setup.DB)
|
||||||
|
f.ownerTok = loginUserWithCredentials(t, f.setup.Server, f.owner.Email, f.owner.Password)
|
||||||
|
|
||||||
|
device, err := f.setup.DB.CreateDevice(context.Background(), database.CreateDeviceParams{
|
||||||
|
UserID: pgtype.UUID{Bytes: [16]byte(f.owner.ID), Valid: true},
|
||||||
|
DeviceName: "test-unlinked-reader",
|
||||||
|
DeviceType: "koreader",
|
||||||
|
DeviceIdentifier: "test-unlinked-" + uuid.New().String()[:8],
|
||||||
|
AuthToken: "dev_test_" + uuid.New().String(),
|
||||||
|
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
|
||||||
|
AutoSync: pgtype.Bool{Bool: true, Valid: true},
|
||||||
|
SyncFrequencyMinutes: pgtype.Int4{Int32: 5, Valid: true},
|
||||||
|
DeviceMetadata: []byte("{}"),
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "device for unlinked-books tests")
|
||||||
|
f.deviceID = device.ID
|
||||||
|
|
||||||
|
f.rowA = f.seedRow(t, f.contentA, "Test Book A")
|
||||||
|
f.rowB = f.seedRow(t, f.contentB, "Test Book B")
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedRow inserts one queue row for the fixture device.
|
||||||
|
func (f *unlinkedBooksFixture) seedRow(t *testing.T, contentID, title string) pgtype.UUID {
|
||||||
|
row, err := f.setup.DB.CreateUnlinkedBook(context.Background(), database.CreateUnlinkedBookParams{
|
||||||
|
DeviceID: f.deviceID,
|
||||||
|
ContentID: contentID,
|
||||||
|
Title: pgtype.Text{String: title, Valid: true},
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "seed unlinked_books row")
|
||||||
|
return row.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// doAction fires an htmx-style page action as the given user.
|
||||||
|
func (f *unlinkedBooksFixture) doAction(t *testing.T, token, method, path string) *httptest.ResponseRecorder {
|
||||||
|
req := httptest.NewRequest(method, path, nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
req.Header.Set("Cookie", "token="+token)
|
||||||
|
req.Header.Set("HX-Request", "true")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
f.setup.Server.Config.Handler.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *unlinkedBooksFixture) unresolvedCount(t *testing.T) int {
|
||||||
|
rows, err := f.setup.DB.ListUnresolvedUnlinkedBooksForUser(context.Background(),
|
||||||
|
pgtype.UUID{Bytes: [16]byte(f.owner.ID), Valid: true})
|
||||||
|
require.NoError(t, err)
|
||||||
|
return len(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *unlinkedBooksFixture) ignoredIDs(t *testing.T) []uuid.UUID {
|
||||||
|
rows, err := f.setup.DB.ListIgnoredUnlinkedBooksForUser(context.Background(),
|
||||||
|
pgtype.UUID{Bytes: [16]byte(f.owner.ID), Valid: true})
|
||||||
|
require.NoError(t, err)
|
||||||
|
ids := make([]uuid.UUID, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
ids = append(ids, uuid.UUID(r.ID.Bytes))
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnlinkedBooksIgnoreLifecycle pins the soft-dismiss contract: hidden
|
||||||
|
// from the page list, badge count, and per-device API; preserved across
|
||||||
|
// the sync upsert; reversible via unignore. Hard delete is covered too.
|
||||||
|
func TestUnlinkedBooksIgnoreLifecycle(t *testing.T) {
|
||||||
|
f := setupUnlinkedBooksFixture(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
pathA := "/unlinked-books/" + uuid.UUID(f.rowA.Bytes).String()
|
||||||
|
pathB := "/unlinked-books/" + uuid.UUID(f.rowB.Bytes).String()
|
||||||
|
|
||||||
|
t.Run("ignore hides the row everywhere", func(t *testing.T) {
|
||||||
|
rec := f.doAction(t, f.ownerTok, "POST", pathA+"/ignore")
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
|
||||||
|
// The htmx swap re-renders the whole section partial.
|
||||||
|
assert.Contains(t, rec.Body.String(), `id="unlinked-section"`)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, f.unresolvedCount(t), "row B remains queued")
|
||||||
|
ignored := f.ignoredIDs(t)
|
||||||
|
require.Len(t, ignored, 1)
|
||||||
|
assert.Equal(t, uuid.UUID(f.rowA.Bytes), ignored[0])
|
||||||
|
|
||||||
|
count, err := f.setup.DB.CountUnresolvedUnlinkedBooksForUser(ctx, pgtype.UUID{Bytes: [16]byte(f.owner.ID), Valid: true})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, int64(1), count, "dashboard badge must still count row B only")
|
||||||
|
|
||||||
|
perDevice, err := f.setup.DB.GetUnlinkedBooksByDevice(ctx, f.deviceID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, perDevice, 1, "per-device API must hide the ignored row")
|
||||||
|
deviceCount, err := f.setup.DB.CountUnlinkedBooks(ctx, f.deviceID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, int64(1), deviceCount)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("device sync refresh preserves the ignore", func(t *testing.T) {
|
||||||
|
// The exact query the KOReader sync path runs on every re-sync.
|
||||||
|
_, err := f.setup.DB.UpsertUnlinkedBook(ctx, database.UpsertUnlinkedBookParams{
|
||||||
|
DeviceID: f.deviceID,
|
||||||
|
ContentID: f.contentA,
|
||||||
|
Title: pgtype.Text{String: "Test Book A (restored metadata)", Valid: true},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
row, err := f.setup.DB.GetUnlinkedBookByID(ctx, f.rowA)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, row.IgnoredAt.Valid, "upsert must never clear ignored_at")
|
||||||
|
assert.Equal(t, "Test Book A (restored metadata)", row.Title.String, "upsert still refreshes metadata")
|
||||||
|
assert.Equal(t, 1, f.unresolvedCount(t), "ignored row must not re-enter the queue")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unignore restores the row to the queue", func(t *testing.T) {
|
||||||
|
rec := f.doAction(t, f.ownerTok, "POST", pathA+"/unignore")
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
|
||||||
|
|
||||||
|
assert.Equal(t, 2, f.unresolvedCount(t))
|
||||||
|
assert.Empty(t, f.ignoredIDs(t))
|
||||||
|
row, err := f.setup.DB.GetUnlinkedBookByID(ctx, f.rowA)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, row.IgnoredAt.Valid)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("delete removes the row outright", func(t *testing.T) {
|
||||||
|
rec := f.doAction(t, f.ownerTok, "DELETE", pathB)
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
|
||||||
|
|
||||||
|
_, err := f.setup.DB.GetUnlinkedBookByID(ctx, f.rowB)
|
||||||
|
assert.Error(t, err, "hard-deleted row must be gone")
|
||||||
|
assert.Equal(t, 1, f.unresolvedCount(t))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unknown and malformed ids are rejected", func(t *testing.T) {
|
||||||
|
rec := f.doAction(t, f.ownerTok, "DELETE", "/unlinked-books/"+uuid.New().String())
|
||||||
|
assert.Equal(t, http.StatusNotFound, rec.Code)
|
||||||
|
rec = f.doAction(t, f.ownerTok, "POST", "/unlinked-books/not-a-uuid/ignore")
|
||||||
|
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnlinkedBooksOwnership pins row → device → user authorization:
|
||||||
|
// another user's queue rows are untouchable, ignored or deleted.
|
||||||
|
func TestUnlinkedBooksOwnership(t *testing.T) {
|
||||||
|
f := setupUnlinkedBooksFixture(t)
|
||||||
|
|
||||||
|
stranger := createRegularUserOnce(t, f.setup.DB)
|
||||||
|
strangerTok := loginUserWithCredentials(t, f.setup.Server, stranger.Email, stranger.Password)
|
||||||
|
pathA := "/unlinked-books/" + uuid.UUID(f.rowA.Bytes).String()
|
||||||
|
|
||||||
|
rec := f.doAction(t, strangerTok, "POST", pathA+"/ignore")
|
||||||
|
assert.Equal(t, http.StatusForbidden, rec.Code)
|
||||||
|
row, err := f.setup.DB.GetUnlinkedBookByID(context.Background(), f.rowA)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, row.IgnoredAt.Valid, "foreign ignore must not land")
|
||||||
|
|
||||||
|
rec = f.doAction(t, strangerTok, "DELETE", pathA)
|
||||||
|
assert.Equal(t, http.StatusForbidden, rec.Code)
|
||||||
|
_, err = f.setup.DB.GetUnlinkedBookByID(context.Background(), f.rowA)
|
||||||
|
assert.NoError(t, err, "foreign delete must not land")
|
||||||
|
}
|
||||||
+439
-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(),
|
||||||
@@ -1496,6 +1530,12 @@ CREATE TABLE IF NOT EXISTS unlinked_books (
|
|||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Soft-dismiss for unlinked_books: ignored rows stay on the device's sync
|
||||||
|
-- queue (UpsertUnlinkedBook keeps refreshing their file metadata, and must
|
||||||
|
-- never clear this column) but are hidden from the page list, badge count,
|
||||||
|
-- and per-device API. Reversible via UnignoreUnlinkedBook.
|
||||||
|
ALTER TABLE unlinked_books ADD COLUMN IF NOT EXISTS ignored_at TIMESTAMP WITH TIME ZONE;
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_unlinked_books_device ON unlinked_books(device_id);
|
CREATE INDEX IF NOT EXISTS idx_unlinked_books_device ON unlinked_books(device_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_unlinked_books_content_id ON unlinked_books(content_id);
|
CREATE INDEX IF NOT EXISTS idx_unlinked_books_content_id ON unlinked_books(content_id);
|
||||||
-- One queue row per (device, document): the sync ingest upserts on this pair.
|
-- One queue row per (device, document): the sync ingest upserts on this pair.
|
||||||
@@ -1609,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)
|
||||||
@@ -1863,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
|
||||||
|
|
||||||
|
|||||||
+87
-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"`
|
||||||
@@ -562,6 +617,7 @@ type UnlinkedBooks struct {
|
|||||||
ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"`
|
ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"`
|
||||||
LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"`
|
LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"`
|
||||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||||
|
IgnoredAt pgtype.Timestamptz `db:"ignored_at" json:"ignored_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserDashboardPreferences struct {
|
type UserDashboardPreferences struct {
|
||||||
@@ -584,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"`
|
||||||
}
|
}
|
||||||
+228
-19
@@ -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,9 +60,23 @@ 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)
|
||||||
// Count unlinked books for a device
|
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)
|
||||||
CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error)
|
CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error)
|
||||||
// Banner count: unresolved queue rows across the caller's own devices.
|
// Banner count: unresolved, non-ignored queue rows across the caller's own
|
||||||
|
// devices. Ignored rows must not nag from the dashboard badge.
|
||||||
CountUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) (int64, error)
|
CountUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) (int64, error)
|
||||||
CountUserDevices(ctx context.Context, userID pgtype.UUID) (int64, error)
|
CountUserDevices(ctx context.Context, userID pgtype.UUID) (int64, error)
|
||||||
CreateAutoResolvedSyncConflict(ctx context.Context, arg CreateAutoResolvedSyncConflictParams) (SyncConflicts, error)
|
CreateAutoResolvedSyncConflict(ctx context.Context, arg CreateAutoResolvedSyncConflictParams) (SyncConflicts, error)
|
||||||
@@ -110,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
|
||||||
@@ -154,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)
|
||||||
@@ -164,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.
|
||||||
@@ -213,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).
|
||||||
@@ -274,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)
|
||||||
@@ -397,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.
|
||||||
@@ -456,7 +550,9 @@ type Querier interface {
|
|||||||
GetUnlinkedBookByContentId(ctx context.Context, arg GetUnlinkedBookByContentIdParams) (UnlinkedBooks, error)
|
GetUnlinkedBookByContentId(ctx context.Context, arg GetUnlinkedBookByContentIdParams) (UnlinkedBooks, error)
|
||||||
// Get unlinked book by ID
|
// Get unlinked book by ID
|
||||||
GetUnlinkedBookByID(ctx context.Context, id pgtype.UUID) (UnlinkedBooks, error)
|
GetUnlinkedBookByID(ctx context.Context, id pgtype.UUID) (UnlinkedBooks, error)
|
||||||
// Get unlinked books for a device
|
// Fetch one row together with its device's owner for authorization.
|
||||||
|
GetUnlinkedBookWithOwner(ctx context.Context, id pgtype.UUID) (GetUnlinkedBookWithOwnerRow, error)
|
||||||
|
// Get unlinked books for a device (ignored rows stay hidden)
|
||||||
GetUnlinkedBooksByDevice(ctx context.Context, deviceID pgtype.UUID) ([]GetUnlinkedBooksByDeviceRow, error)
|
GetUnlinkedBooksByDevice(ctx context.Context, deviceID pgtype.UUID) ([]GetUnlinkedBooksByDeviceRow, error)
|
||||||
GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, error)
|
GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, error)
|
||||||
GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error)
|
GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error)
|
||||||
@@ -495,15 +591,18 @@ 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
|
||||||
|
// device's user_id before calling this).
|
||||||
|
IgnoreUnlinkedBook(ctx context.Context, id pgtype.UUID) error
|
||||||
IncrementSyncQueueAttempts(ctx context.Context, arg IncrementSyncQueueAttemptsParams) (SyncQueue, error)
|
IncrementSyncQueueAttempts(ctx context.Context, arg IncrementSyncQueueAttemptsParams) (SyncQueue, error)
|
||||||
// Check if book is in collection
|
// Check if book is in collection
|
||||||
IsBookInCollection(ctx context.Context, arg IsBookInCollectionParams) (bool, error)
|
IsBookInCollection(ctx context.Context, arg IsBookInCollectionParams) (bool, error)
|
||||||
@@ -511,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)
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -523,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
|
||||||
@@ -530,7 +638,13 @@ type Querier interface {
|
|||||||
// archived row's reading data into its active twin instead of waiting for a
|
// archived row's reading data into its active twin instead of waiting for a
|
||||||
// file that will never return.
|
// file that will never return.
|
||||||
ListHiddenMediaItems(ctx context.Context) ([]ListHiddenMediaItemsRow, error)
|
ListHiddenMediaItems(ctx context.Context) ([]ListHiddenMediaItemsRow, error)
|
||||||
|
// Page list, ignored bucket: rows the user dismissed, kept for the
|
||||||
|
// collapsible "Ignored" section so a misclick is recoverable.
|
||||||
|
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)
|
||||||
@@ -539,25 +653,54 @@ 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.
|
// Page list: unresolved queue rows for the caller's own devices (ignored
|
||||||
|
// rows are listed by ListIgnoredUnlinkedBooksForUser instead).
|
||||||
ListUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) ([]ListUnresolvedUnlinkedBooksForUserRow, error)
|
ListUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) ([]ListUnresolvedUnlinkedBooksForUserRow, error)
|
||||||
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.
|
||||||
@@ -592,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
|
||||||
@@ -616,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
|
||||||
@@ -637,13 +816,16 @@ type Querier interface {
|
|||||||
TombstoneMediaNoteByDedupKey(ctx context.Context, arg TombstoneMediaNoteByDedupKeyParams) error
|
TombstoneMediaNoteByDedupKey(ctx context.Context, arg TombstoneMediaNoteByDedupKeyParams) error
|
||||||
TombstoneMediaNoteByID(ctx context.Context, arg TombstoneMediaNoteByIDParams) error
|
TombstoneMediaNoteByID(ctx context.Context, arg TombstoneMediaNoteByIDParams) error
|
||||||
TombstoneSystemCollection(ctx context.Context, id pgtype.UUID) error
|
TombstoneSystemCollection(ctx context.Context, id pgtype.UUID) error
|
||||||
|
// Undo a soft-dismiss.
|
||||||
|
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)
|
||||||
@@ -691,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)
|
||||||
@@ -698,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)
|
||||||
@@ -716,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)
|
||||||
+4211
-899
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
Loaded 100 of 306 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user