Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
No files matched your search
@@ -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/
|
||||||
@@ -145,6 +145,17 @@ every user sees every library unless an admin hides one) is enforced by
|
|||||||
new pages/controls go into `tests/ui/COVERAGE.md`). Item-scoped
|
new pages/controls go into `tests/ui/COVERAGE.md`). Item-scoped
|
||||||
gates may answer 404 as well as 403 (row invisible vs Go-level
|
gates may answer 404 as well as 403 (row invisible vs Go-level
|
||||||
check — contract_pins 2026-10-29).
|
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; **2026-10-29 enforcement moved to RLS** — the
|
leaked hidden libraries; **2026-10-29 enforcement moved to RLS** — the
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
@@ -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,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);
|
||||||
|
});
|
||||||
+20
-3
@@ -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())
|
||||||
@@ -168,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, scopedDB, 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
|
||||||
@@ -237,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,
|
||||||
|
|||||||
@@ -659,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")
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -459,6 +459,8 @@ 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",
|
"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 {
|
||||||
@@ -498,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) {
|
||||||
@@ -562,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()
|
||||||
@@ -752,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")
|
||||||
|
}
|
||||||
@@ -295,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() })
|
||||||
@@ -346,12 +455,17 @@ func TestFilterUserRelativeFields(t *testing.T) {
|
|||||||
aID, wickID, aID, tideID)
|
aID, wickID, aID, tideID)
|
||||||
|
|
||||||
var collID pgtype.UUID
|
var collID pgtype.UUID
|
||||||
err := setup.DBPool.QueryRow(context.Background(),
|
// 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() {
|
||||||
seedExec(t, setup.DBPool,
|
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)
|
||||||
|
|
||||||
titlesFor := func(token string, qf string) []string {
|
titlesFor := func(token string, qf string) []string {
|
||||||
@@ -433,7 +547,6 @@ 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(seedCtx(), database.CreateMediaItemParams{
|
||||||
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
||||||
Title: "Sug Dune",
|
Title: "Sug Dune",
|
||||||
@@ -537,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.
|
||||||
@@ -561,16 +684,21 @@ 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(context.Background(),
|
// 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(context.Background(), `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)
|
||||||
seedExec(t, setup.DBPool,
|
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)
|
||||||
|
|
||||||
status, results = suggest("collection", " ", "")
|
status, results = suggest("collection", " ", "")
|
||||||
|
|||||||
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -329,6 +329,22 @@ func seedCtx() context.Context {
|
|||||||
return rls.WithUser(context.Background(), rls.SeedIdentity)
|
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
|
// seedExec runs a raw statement against the pool under the seed
|
||||||
// identity. The bare pool bypasses ScopedDB (nothing reads the ctx for
|
// identity. The bare pool bypasses ScopedDB (nothing reads the ctx for
|
||||||
// the GUC), so governed tables need the identity declared manually
|
// the GUC), so governed tables need the identity declared manually
|
||||||
@@ -356,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
|
||||||
@@ -596,10 +617,13 @@ 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)
|
||||||
@@ -610,6 +634,14 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
|||||||
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()
|
||||||
|
|
||||||
@@ -672,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)
|
||||||
@@ -758,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -836,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+348
-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
|
||||||
@@ -99,7 +100,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
|
||||||
@@ -1336,7 +1341,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',
|
||||||
@@ -1352,12 +1359,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(),
|
||||||
@@ -1623,12 +1641,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)
|
||||||
@@ -1878,6 +1903,137 @@ 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)
|
-- RLS: PER-USER LIBRARY VISIBILITY (ONE SOURCE OF TRUTH)
|
||||||
-- Plan: ~/.opencode/plan/server-rls-library-visibility.md REV 2.
|
-- Plan: ~/.opencode/plan/server-rls-library-visibility.md REV 2.
|
||||||
@@ -1912,6 +2068,13 @@ ALTER TABLE media_items FORCE ROW LEVEL SECURITY;
|
|||||||
-- semantics — row absent -> visible; row true -> visible; row false
|
-- semantics — row absent -> visible; row true -> visible; row false
|
||||||
-- -> hidden. No new index needed: library_visibility's
|
-- -> hidden. No new index needed: library_visibility's
|
||||||
-- UNIQUE(user_id, library_id) constraint already indexes the join.
|
-- 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
|
CREATE POLICY p_media_items_visible ON media_items
|
||||||
FOR SELECT
|
FOR SELECT
|
||||||
USING (
|
USING (
|
||||||
@@ -1922,6 +2085,19 @@ CREATE POLICY p_media_items_visible ON media_items
|
|||||||
AND lv.library_id = media_items.library_id
|
AND lv.library_id = media_items.library_id
|
||||||
AND lv.is_visible = false
|
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
|
-- WRITE policies: PERMISSIVE STAND-INS for the deferred scoped write
|
||||||
@@ -1942,7 +2118,173 @@ CREATE POLICY p_media_items_update ON media_items
|
|||||||
CREATE POLICY p_media_items_delete ON media_items
|
CREATE POLICY p_media_items_delete ON media_items
|
||||||
FOR DELETE USING (true);
|
FOR DELETE USING (true);
|
||||||
|
|
||||||
-- Other tables: NONE. collection_items reaches media only via INNER
|
-- Other tables: collection_items reaches media only via INNER
|
||||||
-- JOIN media_items (the join drops filtered rows). collections
|
-- JOIN media_items (the join drops filtered rows). collections
|
||||||
-- metadata (book_count) is a separate counts question — plan A13.
|
-- 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:
|
||||||
|
|||||||
@@ -443,3 +443,280 @@ Both answers are gated: 403 (Go-level explicit-library checks remain on
|
|||||||
listing endpoints) and 404 (row-level invisibility — no existence leak).
|
listing endpoints) and 404 (row-level invisibility — no existence leak).
|
||||||
Pins asserting 403 on item-scoped content endpoints widen to accept
|
Pins asserting 403 on item-scoped content endpoints widen to accept
|
||||||
{403, 404}. Listing/scoped endpoints keep the strict 403 contract.
|
{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.
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
+71
-16
@@ -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"`
|
||||||
|
|||||||
+183
-10
@@ -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,11 +60,19 @@ type Querier interface {
|
|||||||
// collections page); same live filter as GetCollectionItems, plus the
|
// collections page); same live filter as GetCollectionItems, plus the
|
||||||
// future-hybrid excluded exclusion and an optional library scope.
|
// future-hybrid excluded exclusion and an optional library scope.
|
||||||
CountCollectionLiveItems(ctx context.Context, arg CountCollectionLiveItemsParams) (int32, error)
|
CountCollectionLiveItems(ctx context.Context, arg CountCollectionLiveItemsParams) (int32, error)
|
||||||
|
CountDownloadedEpisodes(ctx context.Context, sourceFeedID pgtype.UUID) (int64, error)
|
||||||
// Sibling-book count for sidecar naming (MetadataWriter): a dir-scoped
|
// Sibling-book count for sidecar naming (MetadataWriter): a dir-scoped
|
||||||
// metadata.opf may only be written when NO other non-archived item
|
// metadata.opf may only be written when NO other non-archived item
|
||||||
// shares the directory; otherwise the sidecar goes to
|
// shares the directory; otherwise the sidecar goes to
|
||||||
// <basename>.opf so one book's metadata can never poison dir-mates.
|
// <basename>.opf so one book's metadata can never poison dir-mates.
|
||||||
CountOtherMediaItemsInDir(ctx context.Context, arg CountOtherMediaItemsInDirParams) (int32, error)
|
CountOtherMediaItemsInDir(ctx context.Context, arg CountOtherMediaItemsInDirParams) (int32, error)
|
||||||
|
CountPodcastEpisodesForFeed(ctx context.Context, sourceFeedID pgtype.UUID) (int64, error)
|
||||||
|
CountPodcastFeedSubscribers(ctx context.Context, feedID pgtype.UUID) (int64, error)
|
||||||
|
// Sidebar/page gate (D13): >=1 podcasts-type library the user can see
|
||||||
|
// (COALESCE(is_visible,true) semantics — mirrors GetUserVisibleLibraries).
|
||||||
|
CountPodcastLibrariesForUser(ctx context.Context, userID pgtype.UUID) (int64, error)
|
||||||
|
CountQueuedEpisodes(ctx context.Context) (int64, error)
|
||||||
|
CountUndownloadedEpisodes(ctx context.Context, sourceFeedID pgtype.UUID) (int64, error)
|
||||||
// Count unlinked books for a device (ignored rows stay hidden)
|
// Count unlinked books for a device (ignored rows stay hidden)
|
||||||
CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error)
|
CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error)
|
||||||
// Banner count: unresolved, non-ignored queue rows across the caller's own
|
// Banner count: unresolved, non-ignored queue rows across the caller's own
|
||||||
@@ -116,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
|
||||||
@@ -160,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)
|
||||||
@@ -170,6 +206,7 @@ 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
|
// Device deletion (deregister on logout): detach the device's reading
|
||||||
// history instead of deleting it — these rows are real captured stats
|
// history instead of deleting it — these rows are real captured stats
|
||||||
@@ -225,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).
|
||||||
@@ -421,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.
|
||||||
@@ -521,14 +591,14 @@ type Querier interface {
|
|||||||
// END instant (the push time; NULL falls back to created_at) — the same
|
// END instant (the push time; NULL falls back to created_at) — the same
|
||||||
// value the capture stamps every write with.
|
// value the capture stamps every write with.
|
||||||
GetUserReadingSessionsRange(ctx context.Context, arg GetUserReadingSessionsRangeParams) ([]GetUserReadingSessionsRangeRow, error)
|
GetUserReadingSessionsRange(ctx context.Context, arg GetUserReadingSessionsRangeParams) ([]GetUserReadingSessionsRangeRow, error)
|
||||||
|
// ============================================================================
|
||||||
|
// GENERIC reader_settings KV (podcast order override, plan D5: key
|
||||||
|
// podcast_order:<feed_id>). The pre-existing reader_settings queries are
|
||||||
|
// hard-keyed to 'reader_settings'; these two are the generic form.
|
||||||
|
// ============================================================================
|
||||||
|
GetUserSettingByKey(ctx context.Context, arg GetUserSettingByKeyParams) ([]byte, error)
|
||||||
GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]GetUserVisibleLibrariesRow, error)
|
GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]GetUserVisibleLibrariesRow, error)
|
||||||
GetVisibleLibraryMediaCounts(ctx context.Context, userID pgtype.UUID) ([]GetVisibleLibraryMediaCountsRow, error)
|
GetVisibleLibraryMediaCounts(ctx context.Context, userID pgtype.UUID) ([]GetVisibleLibraryMediaCountsRow, error)
|
||||||
// Smart ("dynamic") collections: rules are evaluated LIVE at render, so
|
|
||||||
// membership comes from the candidate book set instead of collection_items.
|
|
||||||
// The set is scoped to the requesting user's visible libraries (family
|
|
||||||
// lens enforced at the source) and live items only — the same filters the
|
|
||||||
// static collection paths apply.
|
|
||||||
GetVisibleLiveMediaForRules(ctx context.Context, arg GetVisibleLiveMediaForRulesParams) ([]MediaItems, error)
|
|
||||||
HasRecentConflictResolution(ctx context.Context, arg HasRecentConflictResolutionParams) (bool, error)
|
HasRecentConflictResolution(ctx context.Context, arg HasRecentConflictResolutionParams) (bool, error)
|
||||||
// Soft-dismiss one row (ownership is checked by the caller against the
|
// Soft-dismiss one row (ownership is checked by the caller against the
|
||||||
// device's user_id before calling this).
|
// device's user_id before calling this).
|
||||||
@@ -540,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)
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -552,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
|
||||||
@@ -563,6 +642,9 @@ type Querier interface {
|
|||||||
// collapsible "Ignored" section so a misclick is recoverable.
|
// collapsible "Ignored" section so a misclick is recoverable.
|
||||||
ListIgnoredUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) ([]ListIgnoredUnlinkedBooksForUserRow, error)
|
ListIgnoredUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) ([]ListIgnoredUnlinkedBooksForUserRow, error)
|
||||||
ListLibraries(ctx context.Context) ([]ListLibrariesRow, error)
|
ListLibraries(ctx context.Context) ([]ListLibrariesRow, error)
|
||||||
|
// Podcasts exclusion (plan §9): generic book surfaces never list
|
||||||
|
// podcast episodes (library_type_name is the denormalized, trigger/
|
||||||
|
// fetcher-maintained discriminator; NULL rows predate it and stay).
|
||||||
ListMediaItems(ctx context.Context, arg ListMediaItemsParams) ([]ListMediaItemsRow, error)
|
ListMediaItems(ctx context.Context, arg ListMediaItemsParams) ([]ListMediaItemsRow, error)
|
||||||
ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryRow, error)
|
ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryRow, error)
|
||||||
ListMediaItemsByLibraryIncludingArchived(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryIncludingArchivedRow, error)
|
ListMediaItemsByLibraryIncludingArchived(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryIncludingArchivedRow, error)
|
||||||
@@ -571,18 +653,35 @@ type Querier interface {
|
|||||||
// List media items that have no stored SHA-256 (imported before hashing existed)
|
// List media items that have no stored SHA-256 (imported before hashing existed)
|
||||||
ListMediaItemsMissingHash(ctx context.Context) ([]MediaItems, error)
|
ListMediaItemsMissingHash(ctx context.Context) ([]MediaItems, error)
|
||||||
ListMediaItemsSorted(ctx context.Context, arg ListMediaItemsSortedParams) ([]ListMediaItemsSortedRow, error)
|
ListMediaItemsSorted(ctx context.Context, arg ListMediaItemsSortedParams) ([]ListMediaItemsSortedRow, error)
|
||||||
|
// The podcasts page: the acting user's subscriptions with feed metadata
|
||||||
|
// and the user-owned subscription fields. Facet values derive in Go.
|
||||||
|
ListMyPodcastFeeds(ctx context.Context, userID pgtype.UUID) ([]ListMyPodcastFeedsRow, error)
|
||||||
ListPendingHashConflicts(ctx context.Context) ([]ListPendingHashConflictsRow, error)
|
ListPendingHashConflicts(ctx context.Context) ([]ListPendingHashConflictsRow, error)
|
||||||
// DeviceID NULL = every device's pending items (the queue processor's
|
// DeviceID NULL = every device's pending items (the queue processor's
|
||||||
// enumerator passes NULL; a bare `device_id = NULL` never matches, which
|
// enumerator passes NULL; a bare `device_id = NULL` never matches, which
|
||||||
// silently disabled the offline flush until 2026-10-17).
|
// silently disabled the offline flush until 2026-10-17).
|
||||||
ListPendingSyncQueueItems(ctx context.Context, arg ListPendingSyncQueueItemsParams) ([]SyncQueue, error)
|
ListPendingSyncQueueItems(ctx context.Context, arg ListPendingSyncQueueItemsParams) ([]SyncQueue, error)
|
||||||
|
// Feed detail / sequence source. RLS scopes to subscriber-or-admin;
|
||||||
|
// ordering: native position, then pub date, then guid (stable across
|
||||||
|
// re-fetches for historical rows whose position went stale).
|
||||||
|
ListPodcastEpisodes(ctx context.Context, sourceFeedID pgtype.UUID) ([]MediaItems, error)
|
||||||
|
// Poller worklist (admin identity): every feed with its validators and
|
||||||
|
// the back-off flag (is_complete feeds poll rarely, never disappear).
|
||||||
|
ListPodcastFeedsForPolling(ctx context.Context) ([]PodcastFeeds, error)
|
||||||
// LEFT JOIN (Phase 11): row-less import failures have NO media_items
|
// LEFT JOIN (Phase 11): row-less import failures have NO media_items
|
||||||
// row — an inner join would silently drop the ledger's newest kind.
|
// row — an inner join would silently drop the ledger's newest kind.
|
||||||
// Item-side projections COALESCE to keep them non-nullable strings;
|
// Item-side projections COALESCE to keep them non-nullable strings;
|
||||||
// the display file path falls back to the issue's own for row-less rows.
|
// the display file path falls back to the issue's own for row-less rows.
|
||||||
ListProcessingIssuesByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListProcessingIssuesByLibraryRow, error)
|
ListProcessingIssuesByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListProcessingIssuesByLibraryRow, error)
|
||||||
|
// Fair-share candidate scan (§0 item 4): the download pool claims in
|
||||||
|
// TICKET order (download_queue_pos = SEQUENCE nextval at enqueue — the
|
||||||
|
// enqueue loop's ordering IS the download order, item 5). The worker
|
||||||
|
// groups these per feed and prefers the least-recently-served feed.
|
||||||
|
ListQueuedPodcastEpisodes(ctx context.Context) ([]ListQueuedPodcastEpisodesRow, error)
|
||||||
ListSyncConflictsByMediaItem(ctx context.Context, arg ListSyncConflictsByMediaItemParams) ([]SyncConflicts, error)
|
ListSyncConflictsByMediaItem(ctx context.Context, arg ListSyncConflictsByMediaItemParams) ([]SyncConflicts, error)
|
||||||
ListSyncConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListSyncConflictsByUserRow, error)
|
ListSyncConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListSyncConflictsByUserRow, error)
|
||||||
|
// Download All + policy union worklist.
|
||||||
|
ListUndownloadedEpisodes(ctx context.Context, sourceFeedID pgtype.UUID) ([]MediaItems, error)
|
||||||
// List unresolved unlinked books with pagination
|
// List unresolved unlinked books with pagination
|
||||||
ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnresolvedUnlinkedBooksParams) ([]ListUnresolvedUnlinkedBooksRow, error)
|
ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnresolvedUnlinkedBooksParams) ([]ListUnresolvedUnlinkedBooksRow, error)
|
||||||
// Page list: unresolved queue rows for the caller's own devices (ignored
|
// Page list: unresolved queue rows for the caller's own devices (ignored
|
||||||
@@ -591,6 +690,17 @@ type Querier interface {
|
|||||||
ListUsers(ctx context.Context) ([]ListUsersRow, error)
|
ListUsers(ctx context.Context) ([]ListUsersRow, error)
|
||||||
// First consecutive scan that cannot find the file on disk.
|
// First consecutive scan that cannot find the file on disk.
|
||||||
MarkMediaItemMissing(ctx context.Context, id pgtype.UUID) error
|
MarkMediaItemMissing(ctx context.Context, id pgtype.UUID) error
|
||||||
|
// SetPodcastEpisodeDownloading RETIRED (§0 item 4): the pair
|
||||||
|
// GetNextQueuedEpisode + SetPodcastEpisodeDownloading raced under
|
||||||
|
// concurrency; ClaimQueuedPodcastEpisode does both atomically.
|
||||||
|
// SUCCESS-only write (invariant 7): the file is on disk and probed.
|
||||||
|
// ffprobe facts (real duration via audio_metadata, precise size/mime)
|
||||||
|
// reconcile the feed's declared values.
|
||||||
|
MarkPodcastEpisodeDownloaded(ctx context.Context, arg MarkPodcastEpisodeDownloadedParams) (MediaItems, error)
|
||||||
|
// Failure/rejection write: state + reason + whether a human already
|
||||||
|
// intervened (manual_retry resets the auto-retry budget). Existing
|
||||||
|
// metadata/file facts are never cleared.
|
||||||
|
MarkPodcastEpisodeFailed(ctx context.Context, arg MarkPodcastEpisodeFailedParams) error
|
||||||
// Content reappeared at a new location while the old path vanished: treat as
|
// Content reappeared at a new location while the old path vanished: treat as
|
||||||
// a move. Repoints the row (and refreshes file-level fields) so reading
|
// a move. Repoints the row (and refreshes file-level fields) so reading
|
||||||
// history follows the book; archive/missing state is cleared separately.
|
// history follows the book; archive/missing state is cleared separately.
|
||||||
@@ -625,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
|
||||||
@@ -649,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
|
||||||
@@ -674,11 +820,12 @@ type Querier interface {
|
|||||||
UnignoreUnlinkedBook(ctx context.Context, id pgtype.UUID) error
|
UnignoreUnlinkedBook(ctx context.Context, id pgtype.UUID) error
|
||||||
// Update collection
|
// Update collection
|
||||||
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (Collections, error)
|
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (Collections, error)
|
||||||
// Replace ONLY the auto_assign_rules document (rule CRUD: read-modify-write
|
|
||||||
// of the JSONB without touching metadata stale clients did not send).
|
|
||||||
UpdateCollectionRulesJSON(ctx context.Context, arg UpdateCollectionRulesJSONParams) (Collections, error)
|
|
||||||
// A2c §2.4: provenance + playlist flags on an existing collection without
|
// A2c §2.4: provenance + playlist flags on an existing collection without
|
||||||
// touching the rest of the row.
|
// touching the rest of the row.
|
||||||
|
// (Historical bug, fixed 2026-10-30: this wrote `updated_at = NOW()` but
|
||||||
|
// collections has no updated_at column — the scanner's series-import
|
||||||
|
// call has been failing silently, its error discarded. Fixed by
|
||||||
|
// dropping the phantom column write.)
|
||||||
UpdateCollectionViewSettings(ctx context.Context, arg UpdateCollectionViewSettingsParams) error
|
UpdateCollectionViewSettings(ctx context.Context, arg UpdateCollectionViewSettingsParams) error
|
||||||
UpdateDashboardPreferences(ctx context.Context, arg UpdateDashboardPreferencesParams) (UserDashboardPreferences, error)
|
UpdateDashboardPreferences(ctx context.Context, arg UpdateDashboardPreferencesParams) (UserDashboardPreferences, error)
|
||||||
UpdateDevice(ctx context.Context, arg UpdateDeviceParams) (Devices, error)
|
UpdateDevice(ctx context.Context, arg UpdateDeviceParams) (Devices, error)
|
||||||
@@ -726,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)
|
||||||
@@ -751,11 +912,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)
|
||||||
+3833
-848
File diff suppressed because it is too large.
Load diff
@@ -97,7 +97,9 @@ DELETE FROM libraries WHERE id = $1;
|
|||||||
|
|
||||||
-- Library Folders queries
|
-- Library Folders queries
|
||||||
-- name: AddLibraryFolder :one
|
-- name: AddLibraryFolder :one
|
||||||
INSERT INTO library_folders (library_id, folder_path) VALUES ($1, $2) RETURNING *;
|
-- purpose: 'scan' (FS-walker root, default) | 'podcast' (download root;
|
||||||
|
-- scanner-skipped, written by the podcast download worker instead).
|
||||||
|
INSERT INTO library_folders (library_id, folder_path, purpose) VALUES ($1, $2, $3) RETURNING *;
|
||||||
|
|
||||||
-- name: GetLibraryFolders :many
|
-- name: GetLibraryFolders :many
|
||||||
SELECT * FROM library_folders WHERE library_id = $1 ORDER BY created_at;
|
SELECT * FROM library_folders WHERE library_id = $1 ORDER BY created_at;
|
||||||
@@ -178,12 +180,16 @@ SELECT * FROM media_items WHERE library_id = $1 AND file_path = $2;
|
|||||||
SELECT * FROM media_items WHERE id = $1;
|
SELECT * FROM media_items WHERE id = $1;
|
||||||
|
|
||||||
-- name: ListMediaItems :many
|
-- name: ListMediaItems :many
|
||||||
|
-- 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).
|
||||||
SELECT mi.*, l.name as library_name, lt.name as library_type_name
|
SELECT mi.*, l.name as library_name, lt.name as library_type_name
|
||||||
FROM media_items mi
|
FROM media_items mi
|
||||||
JOIN libraries l ON mi.library_id = l.id
|
JOIN libraries l ON mi.library_id = l.id
|
||||||
JOIN library_types lt ON l.library_type_id = lt.id
|
JOIN library_types lt ON l.library_type_id = lt.id
|
||||||
WHERE mi.archived_at IS NULL
|
WHERE mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts')
|
||||||
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
||||||
ORDER BY mi.created_at DESC LIMIT $1 OFFSET $2;
|
ORDER BY mi.created_at DESC LIMIT $1 OFFSET $2;
|
||||||
|
|
||||||
@@ -212,6 +218,7 @@ JOIN libraries l ON mi.library_id = l.id
|
|||||||
JOIN library_types lt ON l.library_type_id = lt.id
|
JOIN library_types lt ON l.library_type_id = lt.id
|
||||||
WHERE mi.archived_at IS NULL
|
WHERE mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND mi.library_id = sqlc.narg('library_id')
|
AND mi.library_id = sqlc.narg('library_id')
|
||||||
ORDER BY
|
ORDER BY
|
||||||
CASE
|
CASE
|
||||||
@@ -658,6 +665,7 @@ LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.na
|
|||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
AND (
|
AND (
|
||||||
mi.title ILIKE sqlc.narg('search_pattern') OR
|
mi.title ILIKE sqlc.narg('search_pattern') OR
|
||||||
@@ -748,6 +756,7 @@ LEFT JOIN reading_progress rp ON rp.media_item_id = mi.id AND rp.user_id = sqlc.
|
|||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND (sqlc.narg('library_id')::uuid IS NULL
|
AND (sqlc.narg('library_id')::uuid IS NULL
|
||||||
OR mi.library_id = sqlc.narg('library_id')::uuid)
|
OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
-- Fuzzy author filter — per-element against the authors[] array (A2c
|
-- Fuzzy author filter — per-element against the authors[] array (A2c
|
||||||
@@ -923,6 +932,26 @@ ORDER BY
|
|||||||
mi.title ASC
|
mi.title ASC
|
||||||
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
|
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
|
||||||
|
|
||||||
|
-- name: SearchEpisodesForPicker :many
|
||||||
|
-- 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.
|
||||||
|
SELECT mi.id, mi.title, mi.author, mi.cover_image_path, mi.library_id,
|
||||||
|
mi.format_group, mi.download_state,
|
||||||
|
pf.title AS feed_title
|
||||||
|
FROM media_items mi
|
||||||
|
JOIN podcast_feeds pf ON mi.source_feed_id = pf.id
|
||||||
|
WHERE mi.source = 'podcast_episode'
|
||||||
|
AND mi.archived_at IS NULL
|
||||||
|
AND mi.missing_scan_count = 0
|
||||||
|
AND (sqlc.arg('q')::text = '' OR mi.title ILIKE '%' || sqlc.arg('q')::text || '%')
|
||||||
|
ORDER BY pf.title ASC, mi.feed_position ASC NULLS LAST
|
||||||
|
LIMIT 60;
|
||||||
|
|
||||||
-- name: SearchAuthorValues :many
|
-- name: SearchAuthorValues :many
|
||||||
SELECT
|
SELECT
|
||||||
mi.author as value,
|
mi.author as value,
|
||||||
@@ -932,6 +961,7 @@ FROM media_items mi
|
|||||||
JOIN libraries l ON mi.library_id = l.id
|
JOIN libraries l ON mi.library_id = l.id
|
||||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
@@ -951,6 +981,7 @@ FROM media_items mi
|
|||||||
JOIN libraries l ON mi.library_id = l.id
|
JOIN libraries l ON mi.library_id = l.id
|
||||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
@@ -971,6 +1002,7 @@ JOIN libraries l ON mi.library_id = l.id
|
|||||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||||
CROSS JOIN LATERAL unnest(mi.tags_search) AS tag
|
CROSS JOIN LATERAL unnest(mi.tags_search) AS tag
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
AND tag IS NOT NULL
|
AND tag IS NOT NULL
|
||||||
AND word_similarity(sqlc.narg('search_query'), tag) > 0.3
|
AND word_similarity(sqlc.narg('search_query'), tag) > 0.3
|
||||||
@@ -988,6 +1020,7 @@ JOIN libraries l ON mi.library_id = l.id
|
|||||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||||
CROSS JOIN LATERAL unnest(mi.narrators) AS narrator
|
CROSS JOIN LATERAL unnest(mi.narrators) AS narrator
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
AND narrator IS NOT NULL
|
AND narrator IS NOT NULL
|
||||||
AND word_similarity(sqlc.narg('search_query'), narrator) > 0.3
|
AND word_similarity(sqlc.narg('search_query'), narrator) > 0.3
|
||||||
@@ -1004,6 +1037,7 @@ FROM media_items mi
|
|||||||
JOIN libraries l ON mi.library_id = l.id
|
JOIN libraries l ON mi.library_id = l.id
|
||||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
@@ -1023,6 +1057,7 @@ FROM media_items mi
|
|||||||
JOIN libraries l ON mi.library_id = l.id
|
JOIN libraries l ON mi.library_id = l.id
|
||||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
@@ -1061,6 +1096,7 @@ JOIN libraries l ON mi.library_id = l.id
|
|||||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||||
CROSS JOIN LATERAL unnest(mi.authors) AS a
|
CROSS JOIN LATERAL unnest(mi.authors) AS a
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
@@ -1129,6 +1165,7 @@ JOIN libraries l ON mi.library_id = l.id
|
|||||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||||
CROSS JOIN LATERAL unnest(mi.publishers) AS p
|
CROSS JOIN LATERAL unnest(mi.publishers) AS p
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
@@ -1196,6 +1233,7 @@ JOIN libraries l ON mi.library_id = l.id
|
|||||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||||
CROSS JOIN LATERAL unnest(mi.narrators) AS n
|
CROSS JOIN LATERAL unnest(mi.narrators) AS n
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
@@ -1265,6 +1303,7 @@ JOIN libraries l ON mi.library_id = l.id
|
|||||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||||
CROSS JOIN LATERAL unnest(mi.tags) AS t
|
CROSS JOIN LATERAL unnest(mi.tags) AS t
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
@@ -1333,6 +1372,7 @@ FROM media_items mi
|
|||||||
JOIN libraries l ON mi.library_id = l.id
|
JOIN libraries l ON mi.library_id = l.id
|
||||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
@@ -1404,6 +1444,7 @@ FROM media_items mi
|
|||||||
JOIN libraries l ON mi.library_id = l.id
|
JOIN libraries l ON mi.library_id = l.id
|
||||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
@@ -1475,6 +1516,7 @@ FROM media_items mi
|
|||||||
JOIN libraries l ON mi.library_id = l.id
|
JOIN libraries l ON mi.library_id = l.id
|
||||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
@@ -1546,6 +1588,7 @@ FROM media_items mi
|
|||||||
JOIN libraries l ON mi.library_id = l.id
|
JOIN libraries l ON mi.library_id = l.id
|
||||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
@@ -1613,6 +1656,7 @@ FROM media_items mi
|
|||||||
JOIN libraries l ON mi.library_id = l.id
|
JOIN libraries l ON mi.library_id = l.id
|
||||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
@@ -1680,6 +1724,7 @@ FROM media_items mi
|
|||||||
JOIN libraries l ON mi.library_id = l.id
|
JOIN libraries l ON mi.library_id = l.id
|
||||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
@@ -1747,6 +1792,7 @@ FROM media_items mi
|
|||||||
JOIN libraries l ON mi.library_id = l.id
|
JOIN libraries l ON mi.library_id = l.id
|
||||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
@@ -1818,6 +1864,7 @@ FROM media_items mi
|
|||||||
JOIN libraries l ON mi.library_id = l.id
|
JOIN libraries l ON mi.library_id = l.id
|
||||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
@@ -1889,6 +1936,7 @@ FROM media_items mi
|
|||||||
JOIN libraries l ON mi.library_id = l.id
|
JOIN libraries l ON mi.library_id = l.id
|
||||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
@@ -1960,6 +2008,7 @@ FROM media_items mi
|
|||||||
JOIN libraries l ON mi.library_id = l.id
|
JOIN libraries l ON mi.library_id = l.id
|
||||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
@@ -2604,6 +2653,7 @@ SELECT
|
|||||||
FROM media_items mi
|
FROM media_items mi
|
||||||
JOIN reading_history rh ON rh.media_item_id = mi.id
|
JOIN reading_history rh ON rh.media_item_id = mi.id
|
||||||
WHERE rh.user_id = $1
|
WHERE rh.user_id = $1
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
GROUP BY mi.id, mi.title, mi.author
|
GROUP BY mi.id, mi.title, mi.author
|
||||||
ORDER BY read_count DESC
|
ORDER BY read_count DESC
|
||||||
LIMIT $2;
|
LIMIT $2;
|
||||||
@@ -2666,6 +2716,7 @@ SELECT
|
|||||||
FROM reading_history rh
|
FROM reading_history rh
|
||||||
JOIN media_items mi ON mi.id = rh.media_item_id
|
JOIN media_items mi ON mi.id = rh.media_item_id
|
||||||
WHERE rh.user_id = sqlc.arg('user_id')
|
WHERE rh.user_id = sqlc.arg('user_id')
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND COALESCE(rh.reading_session_end, rh.created_at) >= sqlc.arg('start_time')
|
AND COALESCE(rh.reading_session_end, rh.created_at) >= sqlc.arg('start_time')
|
||||||
AND COALESCE(rh.reading_session_end, rh.created_at) <= sqlc.arg('end_time')
|
AND COALESCE(rh.reading_session_end, rh.created_at) <= sqlc.arg('end_time')
|
||||||
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
||||||
@@ -3438,14 +3489,18 @@ DELETE FROM device_file_aliases WHERE id = $1;
|
|||||||
|
|
||||||
-- Create collection
|
-- Create collection
|
||||||
-- name: CreateCollection :one
|
-- name: CreateCollection :one
|
||||||
INSERT INTO collections (user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard)
|
INSERT INTO collections (user_id, name, description, color, icon, view_settings, show_on_dashboard, filter_tree)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- 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.
|
||||||
-- name: UpdateCollectionViewSettings :exec
|
-- name: UpdateCollectionViewSettings :exec
|
||||||
UPDATE collections SET view_settings = $2, updated_at = NOW() WHERE id = $1;
|
-- (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.)
|
||||||
|
UPDATE collections SET view_settings = $2 WHERE id = $1;
|
||||||
|
|
||||||
-- Get collection
|
-- Get collection
|
||||||
-- name: GetCollection :one
|
-- name: GetCollection :one
|
||||||
@@ -3473,16 +3528,11 @@ SET
|
|||||||
description = $3,
|
description = $3,
|
||||||
color = $4,
|
color = $4,
|
||||||
icon = $5,
|
icon = $5,
|
||||||
auto_assign_rules = $6,
|
view_settings = $6,
|
||||||
view_settings = $7
|
filter_tree = $7
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- Replace ONLY the auto_assign_rules document (rule CRUD: read-modify-write
|
|
||||||
-- of the JSONB without touching metadata stale clients did not send).
|
|
||||||
-- name: UpdateCollectionRulesJSON :one
|
|
||||||
UPDATE collections SET auto_assign_rules = $2 WHERE id = $1 RETURNING *;
|
|
||||||
|
|
||||||
-- Delete collection
|
-- Delete collection
|
||||||
-- name: DeleteCollection :exec
|
-- name: DeleteCollection :exec
|
||||||
DELETE FROM collections WHERE id = $1;
|
DELETE FROM collections WHERE id = $1;
|
||||||
@@ -3545,6 +3595,42 @@ WHERE ci.collection_id = $1
|
|||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
ORDER BY ci.position ASC NULLS LAST, ci.added_at ASC;
|
ORDER BY ci.position ASC NULLS LAST, ci.added_at ASC;
|
||||||
|
|
||||||
|
-- name: GetCollectionItemsDetailed :many
|
||||||
|
-- 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).
|
||||||
|
SELECT ci.media_item_id, ci.position, ci.added_at,
|
||||||
|
(mi.id IS NOT NULL)::bool AS visible,
|
||||||
|
mi.title, mi.author, mi.cover_image_path, mi.format_group,
|
||||||
|
mi.source, mi.download_state,
|
||||||
|
pf.title AS feed_title
|
||||||
|
FROM collection_items ci
|
||||||
|
LEFT JOIN media_items mi ON ci.media_item_id = mi.id
|
||||||
|
LEFT JOIN podcast_feeds pf ON mi.source_feed_id = pf.id
|
||||||
|
WHERE ci.collection_id = $1
|
||||||
|
AND (mi.id IS NULL OR (mi.archived_at IS NULL AND mi.missing_scan_count = 0))
|
||||||
|
ORDER BY ci.position ASC NULLS LAST, ci.added_at ASC;
|
||||||
|
|
||||||
|
-- name: GetCollectionEpisodesOrdered :many
|
||||||
|
-- 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).
|
||||||
|
SELECT mi.id, mi.title, mi.feed_position, mi.date_published
|
||||||
|
FROM collection_items ci
|
||||||
|
JOIN media_items mi ON ci.media_item_id = mi.id
|
||||||
|
WHERE ci.collection_id = $1
|
||||||
|
AND mi.source = 'podcast_episode'
|
||||||
|
AND mi.download_state = 'downloaded'
|
||||||
|
AND mi.archived_at IS NULL
|
||||||
|
AND mi.missing_scan_count = 0
|
||||||
|
ORDER BY ci.position ASC NULLS LAST, ci.added_at ASC;
|
||||||
|
|
||||||
-- Get collections for book
|
-- Get collections for book
|
||||||
-- name: GetCollectionsForBook :many
|
-- name: GetCollectionsForBook :many
|
||||||
SELECT c.*
|
SELECT c.*
|
||||||
@@ -3936,8 +4022,8 @@ RETURNING *;
|
|||||||
DELETE FROM collection_items WHERE collection_id = $1;
|
DELETE FROM collection_items WHERE collection_id = $1;
|
||||||
|
|
||||||
-- name: CreateSystemCollection :one
|
-- name: CreateSystemCollection :one
|
||||||
INSERT INTO collections (user_id, name, description, icon, color, show_on_dashboard, query_type, priority, is_system_collection, auto_assign_rules)
|
INSERT INTO collections (user_id, name, description, icon, color, show_on_dashboard, query_type, priority, is_system_collection)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true, 'null'::jsonb)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true)
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: ResetSystemCollectionMetadata :exec
|
-- name: ResetSystemCollectionMetadata :exec
|
||||||
@@ -3962,6 +4048,7 @@ INNER JOIN (
|
|||||||
ORDER BY media_item_id, last_read_at DESC
|
ORDER BY media_item_id, last_read_at DESC
|
||||||
) rp ON rp.media_item_id = mi.id
|
) rp ON rp.media_item_id = mi.id
|
||||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
ORDER BY rp.last_read_at DESC
|
ORDER BY rp.last_read_at DESC
|
||||||
@@ -3970,6 +4057,7 @@ LIMIT sqlc.narg('limit');
|
|||||||
-- name: GetRecentlyAddedItems :many
|
-- name: GetRecentlyAddedItems :many
|
||||||
SELECT mi.* FROM media_items mi
|
SELECT mi.* FROM media_items mi
|
||||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
ORDER BY mi.imported_at DESC NULLS LAST, mi.created_at DESC
|
ORDER BY mi.imported_at DESC NULLS LAST, mi.created_at DESC
|
||||||
@@ -3985,6 +4073,7 @@ INNER JOIN (
|
|||||||
ORDER BY media_item_id, last_read_at DESC
|
ORDER BY media_item_id, last_read_at DESC
|
||||||
) rp ON rp.media_item_id = mi.id
|
) rp ON rp.media_item_id = mi.id
|
||||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
ORDER BY rp.last_read_at DESC
|
ORDER BY rp.last_read_at DESC
|
||||||
@@ -3993,6 +4082,7 @@ LIMIT sqlc.narg('limit');
|
|||||||
-- name: GetNotStartedItems :many
|
-- name: GetNotStartedItems :many
|
||||||
SELECT mi.* FROM media_items mi
|
SELECT mi.* FROM media_items mi
|
||||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
@@ -4049,19 +4139,6 @@ LIMIT sqlc.narg('limit');
|
|||||||
-- name: CountCollectionItems :one
|
-- name: CountCollectionItems :one
|
||||||
SELECT COUNT(*) FROM collection_items WHERE collection_id = $1;
|
SELECT COUNT(*) FROM collection_items WHERE collection_id = $1;
|
||||||
|
|
||||||
-- 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.
|
|
||||||
-- name: GetVisibleLiveMediaForRules :many
|
|
||||||
SELECT mi.* FROM media_items mi
|
|
||||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
|
||||||
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
|
||||||
AND mi.archived_at IS NULL
|
|
||||||
AND mi.missing_scan_count = 0
|
|
||||||
ORDER BY mi.title ASC;
|
|
||||||
|
|
||||||
-- Live item count for a STATIC collection (rule-free cards on the
|
-- Live item count for a STATIC collection (rule-free cards on the
|
||||||
-- 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.
|
||||||
@@ -4079,6 +4156,7 @@ SELECT mi.* FROM media_items mi
|
|||||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9: collections picker)
|
||||||
ORDER BY mi.created_at DESC;
|
ORDER BY mi.created_at DESC;
|
||||||
|
|
||||||
-- name: GetDistinctSeries :many
|
-- name: GetDistinctSeries :many
|
||||||
@@ -4111,6 +4189,7 @@ WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_
|
|||||||
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
||||||
AND archived_at IS NULL
|
AND archived_at IS NULL
|
||||||
AND missing_scan_count = 0
|
AND missing_scan_count = 0
|
||||||
|
AND (library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9: series pages)
|
||||||
AND series IS NOT NULL AND series != ''
|
AND series IS NOT NULL AND series != ''
|
||||||
-- Multi-field search (series page): the query matches the series
|
-- Multi-field search (series page): the query matches the series
|
||||||
-- NAME, any book's AUTHOR, or any book's TITLE in the group. It is
|
-- NAME, any book's AUTHOR, or any book's TITLE in the group. It is
|
||||||
@@ -4171,6 +4250,7 @@ SELECT COUNT(*)::int FROM (
|
|||||||
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
||||||
AND archived_at IS NULL
|
AND archived_at IS NULL
|
||||||
AND missing_scan_count = 0
|
AND missing_scan_count = 0
|
||||||
|
AND (library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9: series pages)
|
||||||
AND series IS NOT NULL AND series != ''
|
AND series IS NOT NULL AND series != ''
|
||||||
AND (sqlc.narg('series_query')::text IS NULL OR
|
AND (sqlc.narg('series_query')::text IS NULL OR
|
||||||
series ILIKE '%' || sqlc.narg('series_query')::text || '%'
|
series ILIKE '%' || sqlc.narg('series_query')::text || '%'
|
||||||
@@ -4590,3 +4670,481 @@ SET file_size = $2,
|
|||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, abridged, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence;
|
RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, abridged, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 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.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- name: CreatePodcastFeed :one
|
||||||
|
-- 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.
|
||||||
|
INSERT INTO podcast_feeds (library_id, feed_url, title, created_by_admin_id)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (library_id, feed_url) DO UPDATE SET updated_at = NOW()
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: GetPodcastFeed :one
|
||||||
|
SELECT * FROM podcast_feeds WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: ListMyPodcastFeeds :many
|
||||||
|
-- The podcasts page: the acting user's subscriptions with feed metadata
|
||||||
|
-- and the user-owned subscription fields. Facet values derive in Go.
|
||||||
|
SELECT f.*,
|
||||||
|
s.display_name,
|
||||||
|
s.auto_download,
|
||||||
|
s.auto_download_count,
|
||||||
|
s.created_at AS subscribed_at
|
||||||
|
FROM podcast_subscriptions s
|
||||||
|
JOIN podcast_feeds f ON f.id = s.feed_id
|
||||||
|
WHERE s.user_id = $1
|
||||||
|
ORDER BY COALESCE(s.display_name, f.title) ASC;
|
||||||
|
|
||||||
|
-- name: GetPodcastSubscription :one
|
||||||
|
SELECT * FROM podcast_subscriptions WHERE user_id = $1 AND feed_id = $2;
|
||||||
|
|
||||||
|
-- name: SubscribePodcastFeed :one
|
||||||
|
INSERT INTO podcast_subscriptions (user_id, feed_id)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (user_id, feed_id) DO NOTHING
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: UpdatePodcastSubscription :one
|
||||||
|
-- User-owned fields only (D9): display name + auto-download policy.
|
||||||
|
UPDATE podcast_subscriptions
|
||||||
|
SET display_name = $3,
|
||||||
|
auto_download = $4,
|
||||||
|
auto_download_count = $5
|
||||||
|
WHERE user_id = $1 AND feed_id = $2
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: DeletePodcastSubscription :execrows
|
||||||
|
-- Unsubscribe NEVER deletes feed/episode/file state (D6).
|
||||||
|
DELETE FROM podcast_subscriptions WHERE user_id = $1 AND feed_id = $2;
|
||||||
|
|
||||||
|
-- name: ListFeedSubscribers :many
|
||||||
|
-- Download-worker policy union (D6): most aggressive policy wins for
|
||||||
|
-- what arrives. Runs under an admin identity (background job).
|
||||||
|
SELECT user_id, auto_download, auto_download_count
|
||||||
|
FROM podcast_subscriptions
|
||||||
|
WHERE feed_id = $1;
|
||||||
|
|
||||||
|
-- name: CountPodcastFeedSubscribers :one
|
||||||
|
SELECT COUNT(*) FROM podcast_subscriptions WHERE feed_id = $1;
|
||||||
|
|
||||||
|
-- name: ListAllPodcastFeedsAdmin :many
|
||||||
|
-- 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.
|
||||||
|
SELECT f.id, f.title, f.host, f.last_fetched_at, f.library_id,
|
||||||
|
l.name AS library_name,
|
||||||
|
(SELECT COUNT(*) FROM media_items m
|
||||||
|
WHERE m.source_feed_id = f.id AND m.source = 'podcast_episode') AS episode_total,
|
||||||
|
(SELECT COUNT(*) FROM media_items m
|
||||||
|
WHERE m.source_feed_id = f.id AND m.source = 'podcast_episode'
|
||||||
|
AND m.download_state = 'downloaded') AS episode_downloaded,
|
||||||
|
(SELECT COALESCE(SUM(m.file_size), 0)::BIGINT FROM media_items m
|
||||||
|
WHERE m.source_feed_id = f.id AND m.source = 'podcast_episode'
|
||||||
|
AND m.download_state = 'downloaded') AS disk_bytes,
|
||||||
|
(SELECT COUNT(*) FROM podcast_subscriptions s
|
||||||
|
WHERE s.feed_id = f.id) AS subscriber_count,
|
||||||
|
(SELECT COALESCE(string_agg(u.username, ', ' ORDER BY u.username), '')::TEXT
|
||||||
|
FROM podcast_subscriptions s
|
||||||
|
JOIN users u ON u.id = s.user_id
|
||||||
|
WHERE s.feed_id = f.id) AS subscriber_names
|
||||||
|
FROM podcast_feeds f
|
||||||
|
JOIN libraries l ON l.id = f.library_id
|
||||||
|
ORDER BY 9 DESC, f.title ASC;
|
||||||
|
|
||||||
|
-- name: ResetPodcastEpisodeFileDeleted :exec
|
||||||
|
-- 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).
|
||||||
|
UPDATE media_items
|
||||||
|
SET download_state = 'pending',
|
||||||
|
download_error = NULL,
|
||||||
|
download_attempts = 0,
|
||||||
|
file_size = NULL,
|
||||||
|
mime_type = NULL,
|
||||||
|
cover_image_path = NULL,
|
||||||
|
audio_metadata = NULL,
|
||||||
|
chapter_count = NULL,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1 AND source = 'podcast_episode' AND download_state = 'downloaded';
|
||||||
|
|
||||||
|
-- name: ResetPodcastFeedFilesDeleted :execrows
|
||||||
|
-- Same reset for EVERY downloaded episode of a feed (per-feed
|
||||||
|
-- "Delete files" action on the admin view).
|
||||||
|
UPDATE media_items
|
||||||
|
SET download_state = 'pending',
|
||||||
|
download_error = NULL,
|
||||||
|
download_attempts = 0,
|
||||||
|
file_size = NULL,
|
||||||
|
mime_type = NULL,
|
||||||
|
cover_image_path = NULL,
|
||||||
|
audio_metadata = NULL,
|
||||||
|
chapter_count = NULL,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE source_feed_id = $1 AND source = 'podcast_episode'
|
||||||
|
AND download_state = 'downloaded';
|
||||||
|
|
||||||
|
-- name: ListPodcastFeedsForPolling :many
|
||||||
|
-- Poller worklist (admin identity): every feed with its validators and
|
||||||
|
-- the back-off flag (is_complete feeds poll rarely, never disappear).
|
||||||
|
SELECT * FROM podcast_feeds ORDER BY last_fetched_at ASC NULLS FIRST;
|
||||||
|
|
||||||
|
-- name: UpdatePodcastFeedFetch :one
|
||||||
|
-- Post-fetch bookkeeping: validators for conditional GETs, the fetch
|
||||||
|
-- timestamp, and the feed's own last-build marker.
|
||||||
|
UPDATE podcast_feeds
|
||||||
|
SET etag = $2,
|
||||||
|
last_modified = $3,
|
||||||
|
last_fetched_at = NOW(),
|
||||||
|
last_build_date = $4,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: UpdatePodcastFeedMetadata :one
|
||||||
|
-- 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.
|
||||||
|
UPDATE podcast_feeds
|
||||||
|
SET title = $2,
|
||||||
|
description = $3,
|
||||||
|
host = $4,
|
||||||
|
publisher = $5,
|
||||||
|
owner_name = $6,
|
||||||
|
owner_email = $7,
|
||||||
|
language = $8,
|
||||||
|
tags = $9,
|
||||||
|
explicit = $10,
|
||||||
|
feed_type = $11,
|
||||||
|
medium = $12,
|
||||||
|
is_complete = $13,
|
||||||
|
podcast_guid = COALESCE($14, podcast_guid),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: UpdatePodcastFeedArtwork :exec
|
||||||
|
UPDATE podcast_feeds SET artwork_path = $2, updated_at = NOW() WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: UpdatePodcastFeedRedirect :exec
|
||||||
|
-- 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.
|
||||||
|
UPDATE podcast_feeds
|
||||||
|
SET feed_url = $2,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: GetPodcastFeedByGuid :one
|
||||||
|
-- URL-migration dedupe: same canonical guid within the library reuses
|
||||||
|
-- the existing row.
|
||||||
|
SELECT * FROM podcast_feeds
|
||||||
|
WHERE library_id = $1 AND podcast_guid = $2
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- name: DeletePodcastFeed :execrows
|
||||||
|
-- Admin-only removal (handler checks role); episodes cascade via
|
||||||
|
-- media_items.source_feed_id ON DELETE CASCADE.
|
||||||
|
DELETE FROM podcast_feeds WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: CountPodcastLibrariesForUser :one
|
||||||
|
-- Sidebar/page gate (D13): >=1 podcasts-type library the user can see
|
||||||
|
-- (COALESCE(is_visible,true) semantics — mirrors GetUserVisibleLibraries).
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM libraries l
|
||||||
|
JOIN library_types lt ON lt.id = l.library_type_id AND lt.name = 'podcasts'
|
||||||
|
LEFT JOIN library_visibility lv ON lv.library_id = l.id AND lv.user_id = $1
|
||||||
|
WHERE COALESCE(lv.is_visible, true) = true;
|
||||||
|
|
||||||
|
-- name: GetPodcastsLibraryForUser :many
|
||||||
|
-- The podcasts library/ies visible to the user (add-feed target list;
|
||||||
|
-- a self-hosted instance typically has exactly one).
|
||||||
|
SELECT l.id, l.name
|
||||||
|
FROM libraries l
|
||||||
|
JOIN library_types lt ON lt.id = l.library_type_id AND lt.name = 'podcasts'
|
||||||
|
LEFT JOIN library_visibility lv ON lv.library_id = l.id AND lv.user_id = $1
|
||||||
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
ORDER BY l.created_at ASC;
|
||||||
|
|
||||||
|
-- name: GetPodcastLibraryFolder :many
|
||||||
|
-- The download root (purpose='podcast') of a podcasts library.
|
||||||
|
SELECT lf.*
|
||||||
|
FROM library_folders lf
|
||||||
|
WHERE lf.library_id = $1 AND lf.purpose = 'podcast';
|
||||||
|
|
||||||
|
-- name: CreatePodcastLibraryFolder :one
|
||||||
|
INSERT INTO library_folders (library_id, folder_path, purpose)
|
||||||
|
VALUES ($1, $2, 'podcast')
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- Podcast episodes — media_items rows with source='podcast_episode'
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- name: UpsertPodcastEpisode :one
|
||||||
|
-- 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).
|
||||||
|
INSERT INTO media_items (
|
||||||
|
library_id, title, subtitle, description, authors, authors_search,
|
||||||
|
file_path, file_size, mime_type, cover_image_path, explicit,
|
||||||
|
date_published, language, tags, genre,
|
||||||
|
format_group, format_mimetype, is_reflowable, library_type_name,
|
||||||
|
source, source_feed_id, episode_guid, feed_position,
|
||||||
|
season_number, episode_number, enclosure_url,
|
||||||
|
declared_duration_seconds, transcript_url, chapters_url,
|
||||||
|
hosts, guests
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, $4, $5, $6,
|
||||||
|
$7, $8, $9, $10, $11,
|
||||||
|
$12, $13, $14, $15,
|
||||||
|
'audiobook', $9, FALSE, 'podcasts',
|
||||||
|
'podcast_episode', $16, $17, $18,
|
||||||
|
$19, $20, $21,
|
||||||
|
$22, $23, $24,
|
||||||
|
$25, $26
|
||||||
|
)
|
||||||
|
ON CONFLICT (source_feed_id, episode_guid) WHERE source_feed_id IS NOT NULL DO UPDATE SET
|
||||||
|
title = EXCLUDED.title,
|
||||||
|
subtitle = EXCLUDED.subtitle,
|
||||||
|
description = EXCLUDED.description,
|
||||||
|
authors = EXCLUDED.authors,
|
||||||
|
authors_search = EXCLUDED.authors_search,
|
||||||
|
explicit = EXCLUDED.explicit,
|
||||||
|
date_published = EXCLUDED.date_published,
|
||||||
|
language = EXCLUDED.language,
|
||||||
|
tags = EXCLUDED.tags,
|
||||||
|
genre = EXCLUDED.genre,
|
||||||
|
season_number = EXCLUDED.season_number,
|
||||||
|
episode_number = EXCLUDED.episode_number,
|
||||||
|
enclosure_url = EXCLUDED.enclosure_url,
|
||||||
|
declared_duration_seconds = EXCLUDED.declared_duration_seconds,
|
||||||
|
transcript_url = EXCLUDED.transcript_url,
|
||||||
|
chapters_url = EXCLUDED.chapters_url,
|
||||||
|
hosts = EXCLUDED.hosts,
|
||||||
|
guests = EXCLUDED.guests,
|
||||||
|
feed_position = EXCLUDED.feed_position,
|
||||||
|
cover_image_path = CASE WHEN media_items.download_state = 'downloaded' THEN media_items.cover_image_path ELSE EXCLUDED.cover_image_path END,
|
||||||
|
file_path = CASE WHEN media_items.download_state = 'downloaded' THEN media_items.file_path ELSE EXCLUDED.file_path END,
|
||||||
|
file_size = CASE WHEN media_items.download_state = 'downloaded' THEN media_items.file_size ELSE EXCLUDED.file_size END,
|
||||||
|
mime_type = CASE WHEN media_items.download_state = 'downloaded' THEN media_items.mime_type ELSE EXCLUDED.mime_type END,
|
||||||
|
updated_at = NOW()
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: ListPodcastEpisodes :many
|
||||||
|
-- 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).
|
||||||
|
SELECT * FROM media_items
|
||||||
|
WHERE source_feed_id = $1 AND source = 'podcast_episode'
|
||||||
|
ORDER BY feed_position ASC NULLS LAST, date_published ASC NULLS LAST, episode_guid ASC;
|
||||||
|
|
||||||
|
-- name: GetPodcastEpisode :one
|
||||||
|
SELECT * FROM media_items
|
||||||
|
WHERE id = $1 AND source = 'podcast_episode';
|
||||||
|
|
||||||
|
-- name: SetPodcastEpisodeDownloadQueued :execrows
|
||||||
|
-- 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.
|
||||||
|
UPDATE media_items
|
||||||
|
SET download_state = 'queued',
|
||||||
|
download_queue_pos = nextval('podcast_queue_seq'),
|
||||||
|
download_error = NULL,
|
||||||
|
download_attempts = download_attempts + 1,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
AND source = 'podcast_episode'
|
||||||
|
AND download_state IN ('pending', 'failed');
|
||||||
|
|
||||||
|
-- SetPodcastEpisodeDownloading RETIRED (§0 item 4): the pair
|
||||||
|
-- GetNextQueuedEpisode + SetPodcastEpisodeDownloading raced under
|
||||||
|
-- concurrency; ClaimQueuedPodcastEpisode does both atomically.
|
||||||
|
|
||||||
|
-- name: MarkPodcastEpisodeDownloaded :one
|
||||||
|
-- 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.
|
||||||
|
UPDATE media_items
|
||||||
|
SET download_state = 'downloaded',
|
||||||
|
download_error = NULL,
|
||||||
|
file_path = $2,
|
||||||
|
file_size = $3,
|
||||||
|
mime_type = $4,
|
||||||
|
cover_image_path = COALESCE($5, cover_image_path),
|
||||||
|
audio_metadata = $6,
|
||||||
|
chapter_count = $7,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1 AND source = 'podcast_episode'
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: MarkPodcastEpisodeFailed :exec
|
||||||
|
-- Failure/rejection write: state + reason + whether a human already
|
||||||
|
-- intervened (manual_retry resets the auto-retry budget). Existing
|
||||||
|
-- metadata/file facts are never cleared.
|
||||||
|
UPDATE media_items
|
||||||
|
SET download_state = 'failed',
|
||||||
|
download_error = $2,
|
||||||
|
download_attempts = CASE WHEN $3::boolean THEN 0 ELSE download_attempts END,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1 AND source = 'podcast_episode';
|
||||||
|
|
||||||
|
-- name: SetPodcastEpisodePending :exec
|
||||||
|
-- Manual retry (same endpoint as download): reset to pending so the
|
||||||
|
-- worker re-claims it; the reason text clears with the fresh attempt.
|
||||||
|
UPDATE media_items
|
||||||
|
SET download_state = 'pending',
|
||||||
|
download_error = NULL,
|
||||||
|
download_attempts = 0,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1 AND source = 'podcast_episode';
|
||||||
|
|
||||||
|
-- name: ListUndownloadedEpisodes :many
|
||||||
|
-- Download All + policy union worklist.
|
||||||
|
SELECT * FROM media_items
|
||||||
|
WHERE source_feed_id = $1
|
||||||
|
AND source = 'podcast_episode'
|
||||||
|
AND download_state <> 'downloaded'
|
||||||
|
ORDER BY feed_position ASC NULLS LAST, date_published ASC NULLS LAST, episode_guid ASC;
|
||||||
|
|
||||||
|
-- name: CountUndownloadedEpisodes :one
|
||||||
|
SELECT COUNT(*) FROM media_items
|
||||||
|
WHERE source_feed_id = $1
|
||||||
|
AND source = 'podcast_episode'
|
||||||
|
AND download_state <> 'downloaded';
|
||||||
|
|
||||||
|
-- name: CountDownloadedEpisodes :one
|
||||||
|
SELECT COUNT(*) FROM media_items
|
||||||
|
WHERE source_feed_id = $1
|
||||||
|
AND source = 'podcast_episode'
|
||||||
|
AND download_state = 'downloaded';
|
||||||
|
|
||||||
|
-- name: SearchMyPodcastEpisodes :many
|
||||||
|
-- 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.
|
||||||
|
SELECT * FROM media_items
|
||||||
|
WHERE source = 'podcast_episode'
|
||||||
|
AND (sqlc.arg('search_query')::text = '' OR title ILIKE '%' || sqlc.arg('search_query')::text || '%' OR description ILIKE '%' || sqlc.arg('search_query')::text || '%')
|
||||||
|
ORDER BY feed_position ASC NULLS LAST, date_published ASC NULLS LAST
|
||||||
|
LIMIT 50;
|
||||||
|
|
||||||
|
-- name: SearchMyPodcastFeeds :many
|
||||||
|
-- Feed-title AND subscription-display-name search (D14) + the facet
|
||||||
|
-- fields the podcasts page filters on.
|
||||||
|
SELECT f.*,
|
||||||
|
s.display_name,
|
||||||
|
s.auto_download,
|
||||||
|
s.auto_download_count,
|
||||||
|
s.created_at AS subscribed_at
|
||||||
|
FROM podcast_subscriptions s
|
||||||
|
JOIN podcast_feeds f ON f.id = s.feed_id
|
||||||
|
WHERE s.user_id = sqlc.arg('user_id')
|
||||||
|
AND (sqlc.arg('search_query')::text = '' OR f.title ILIKE '%' || sqlc.arg('search_query')::text || '%'
|
||||||
|
OR COALESCE(s.display_name, '') ILIKE '%' || sqlc.arg('search_query')::text || '%'
|
||||||
|
OR COALESCE(f.host, '') ILIKE '%' || sqlc.arg('search_query')::text || '%'
|
||||||
|
OR COALESCE(f.publisher, '') ILIKE '%' || sqlc.arg('search_query')::text || '%')
|
||||||
|
ORDER BY COALESCE(s.display_name, f.title) ASC;
|
||||||
|
|
||||||
|
-- name: ListQueuedPodcastEpisodes :many
|
||||||
|
-- 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.
|
||||||
|
SELECT id, source_feed_id FROM media_items
|
||||||
|
WHERE source = 'podcast_episode' AND download_state = 'queued'
|
||||||
|
ORDER BY download_queue_pos ASC NULLS LAST;
|
||||||
|
|
||||||
|
-- name: ClaimQueuedPodcastEpisode :one
|
||||||
|
-- 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.
|
||||||
|
UPDATE media_items
|
||||||
|
SET download_state = 'downloading', updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
AND source = 'podcast_episode'
|
||||||
|
AND download_state = 'queued'
|
||||||
|
RETURNING media_items.*;
|
||||||
|
|
||||||
|
-- name: CountQueuedEpisodes :one
|
||||||
|
SELECT COUNT(*) FROM media_items
|
||||||
|
WHERE source = 'podcast_episode' AND download_state IN ('queued', 'downloading');
|
||||||
|
|
||||||
|
-- name: CountPodcastEpisodesForFeed :one
|
||||||
|
SELECT COUNT(*) FROM media_items
|
||||||
|
WHERE source_feed_id = $1 AND source = 'podcast_episode';
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 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.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- name: GetUserSettingByKey :one
|
||||||
|
SELECT setting_value FROM reader_settings
|
||||||
|
WHERE user_id = $1 AND setting_key = $2;
|
||||||
|
|
||||||
|
-- name: UpsertUserSettingByKey :exec
|
||||||
|
INSERT INTO reader_settings (user_id, setting_key, setting_value)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (user_id, setting_key)
|
||||||
|
DO UPDATE SET setting_value = EXCLUDED.setting_value, updated_at = NOW();
|
||||||
|
|
||||||
|
-- name: DeleteUserSettingByKey :exec
|
||||||
|
DELETE FROM reader_settings WHERE user_id = $1 AND setting_key = $2;
|
||||||
|
|
||||||
|
-- name: GetPodcastEpisodeProgress :many
|
||||||
|
-- Per-user progress for a feed's episodes (row badges + the Phase-2
|
||||||
|
-- continue fields). reading_progress is UNIQUE per (media_item, user).
|
||||||
|
SELECT rp.media_item_id,
|
||||||
|
rp.percentage,
|
||||||
|
rp.last_read_at
|
||||||
|
FROM reading_progress rp
|
||||||
|
JOIN media_items mi ON mi.id = rp.media_item_id
|
||||||
|
WHERE rp.user_id = $1
|
||||||
|
AND mi.source_feed_id = $2
|
||||||
|
AND mi.source = 'podcast_episode';
|
||||||
|
|
||||||
|
-- name: GetPodcastContinueForUser :many
|
||||||
|
-- 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.
|
||||||
|
SELECT mi.source_feed_id,
|
||||||
|
mi.id,
|
||||||
|
mi.title,
|
||||||
|
mi.feed_position,
|
||||||
|
rp.percentage,
|
||||||
|
rp.last_read_at
|
||||||
|
FROM reading_progress rp
|
||||||
|
JOIN media_items mi ON mi.id = rp.media_item_id
|
||||||
|
WHERE rp.user_id = $1
|
||||||
|
AND mi.source = 'podcast_episode'
|
||||||
|
AND rp.percentage > 0
|
||||||
|
AND rp.percentage < 0.995
|
||||||
|
ORDER BY rp.last_read_at DESC;
|
||||||
@@ -25,6 +25,7 @@ import (
|
|||||||
// SettingType enumerates the value types stored in system_settings.setting_type.
|
// SettingType enumerates the value types stored in system_settings.setting_type.
|
||||||
const (
|
const (
|
||||||
SettingTypeInt = "int"
|
SettingTypeInt = "int"
|
||||||
|
SettingTypeFloat = "float"
|
||||||
SettingTypeBool = "bool"
|
SettingTypeBool = "bool"
|
||||||
SettingTypeString = "string"
|
SettingTypeString = "string"
|
||||||
SettingTypeStringList = "string_list"
|
SettingTypeStringList = "string_list"
|
||||||
@@ -82,6 +83,9 @@ var SettingDefaults = []SettingDefault{
|
|||||||
{Key: "sync_queue_batch_size", Value: "50", Type: SettingTypeInt, Min: "1", Max: "10000", RequiresRestart: true, Category: "sync", Group: "Sync Queue", Description: "Maximum items processed per sync queue flush"},
|
{Key: "sync_queue_batch_size", Value: "50", Type: SettingTypeInt, Min: "1", Max: "10000", RequiresRestart: true, Category: "sync", Group: "Sync Queue", Description: "Maximum items processed per sync queue flush"},
|
||||||
{Key: "worker_pool_size", Value: "3", Type: SettingTypeInt, Min: "1", Max: "100", RequiresRestart: true, Category: "performance", Group: "Worker Pool", Description: "Number of background worker goroutines"},
|
{Key: "worker_pool_size", Value: "3", Type: SettingTypeInt, Min: "1", Max: "100", RequiresRestart: true, Category: "performance", Group: "Worker Pool", Description: "Number of background worker goroutines"},
|
||||||
{Key: "worker_queue_cap", Value: "100", Type: SettingTypeInt, Min: "1", Max: "10000", RequiresRestart: true, Category: "performance", Group: "Worker Pool", Description: "Background worker job queue capacity"},
|
{Key: "worker_queue_cap", Value: "100", Type: SettingTypeInt, Min: "1", Max: "10000", RequiresRestart: true, Category: "performance", Group: "Worker Pool", Description: "Background worker job queue capacity"},
|
||||||
|
{Key: "podcast_poll_interval_seconds", Value: "3600", Type: SettingTypeInt, Min: "300", Max: "604800", Category: "podcasts", Group: "Podcasts", Description: "How often to poll podcast feeds for new episodes (seconds; complete feeds back off)"},
|
||||||
|
{Key: "podcast_download_concurrency", Value: "4", Type: SettingTypeInt, Min: "1", Max: "16", Category: "podcasts", Group: "Podcasts", Description: "Podcast episode downloads in flight server-wide (the connection is the limit; fair-share spreads them across feeds)"},
|
||||||
|
{Key: "podcast_download_speed_mbps", Value: "0", Type: SettingTypeFloat, Min: "0", Max: "10000", Category: "podcasts", Group: "Podcasts", Description: "Shared bandwidth cap for podcast downloads in megabits/second (0 = unlimited)"},
|
||||||
}
|
}
|
||||||
|
|
||||||
// defaultBy indexes SettingDefaults by key for O(1) lookup.
|
// defaultBy indexes SettingDefaults by key for O(1) lookup.
|
||||||
@@ -172,6 +176,26 @@ func (r *SettingsRegistry) getInt(key string) int {
|
|||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *SettingsRegistry) getFloat(key string) float64 {
|
||||||
|
d := defaultBy[key]
|
||||||
|
v := r.raw(key)
|
||||||
|
f, err := strconv.ParseFloat(v, 64)
|
||||||
|
if err != nil {
|
||||||
|
f, _ = strconv.ParseFloat(d.Value, 64)
|
||||||
|
}
|
||||||
|
if d.Min != "" {
|
||||||
|
if mn, err := strconv.ParseFloat(d.Min, 64); err == nil && f < mn {
|
||||||
|
f = mn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if d.Max != "" {
|
||||||
|
if mx, err := strconv.ParseFloat(d.Max, 64); err == nil && f > mx {
|
||||||
|
f = mx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
func (r *SettingsRegistry) getBool(key string) bool {
|
func (r *SettingsRegistry) getBool(key string) bool {
|
||||||
v := r.raw(key)
|
v := r.raw(key)
|
||||||
b, err := strconv.ParseBool(v)
|
b, err := strconv.ParseBool(v)
|
||||||
@@ -188,6 +212,24 @@ func (r *SettingsRegistry) ScanPollInterval() time.Duration {
|
|||||||
return time.Duration(r.getInt("scan_poll_interval_seconds")) * time.Second
|
return time.Duration(r.getInt("scan_poll_interval_seconds")) * time.Second
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PodcastPollInterval is how often the poller checks feeds, as a
|
||||||
|
// duration (live setting; the poller re-reads it every cycle — writes
|
||||||
|
// go through the settings API path, AGENTS.md gotcha #2).
|
||||||
|
func (r *SettingsRegistry) PodcastPollInterval() time.Duration {
|
||||||
|
return time.Duration(r.getInt("podcast_poll_interval_seconds")) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
// PodcastDownloadConcurrency: episode downloads in flight server-wide
|
||||||
|
// (the connection is the limit; fair-share spreads them across feeds).
|
||||||
|
func (r *SettingsRegistry) PodcastDownloadConcurrency() int {
|
||||||
|
return r.getInt("podcast_download_concurrency")
|
||||||
|
}
|
||||||
|
|
||||||
|
// PodcastDownloadSpeedMbps: shared bandwidth cap (0 = unlimited).
|
||||||
|
func (r *SettingsRegistry) PodcastDownloadSpeedMbps() float64 {
|
||||||
|
return r.getFloat("podcast_download_speed_mbps")
|
||||||
|
}
|
||||||
|
|
||||||
// AutoScanEnabled reports whether auto-scanning is on.
|
// AutoScanEnabled reports whether auto-scanning is on.
|
||||||
func (r *SettingsRegistry) AutoScanEnabled() bool { return r.getBool("auto_scan_enabled") }
|
func (r *SettingsRegistry) AutoScanEnabled() bool { return r.getBool("auto_scan_enabled") }
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,21 @@ func TestSettingDefaults(t *testing.T) {
|
|||||||
if _, err := strconv.ParseBool(d.Value); err != nil {
|
if _, err := strconv.ParseBool(d.Value); err != nil {
|
||||||
t.Errorf("bool setting %s default %q is not a bool", d.Key, d.Value)
|
t.Errorf("bool setting %s default %q is not a bool", d.Key, d.Value)
|
||||||
}
|
}
|
||||||
|
case SettingTypeFloat:
|
||||||
|
f, err := strconv.ParseFloat(d.Value, 64)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("float setting %s default %q is not a float", d.Key, d.Value)
|
||||||
|
}
|
||||||
|
if d.Min != "" {
|
||||||
|
if mn, err := strconv.ParseFloat(d.Min, 64); err == nil && f < mn {
|
||||||
|
t.Errorf("float setting %s default %q below min %q", d.Key, d.Value, d.Min)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if d.Max != "" {
|
||||||
|
if mx, err := strconv.ParseFloat(d.Max, 64); err == nil && f > mx {
|
||||||
|
t.Errorf("float setting %s default %q above max %q", d.Key, d.Value, d.Max)
|
||||||
|
}
|
||||||
|
}
|
||||||
case SettingTypeString:
|
case SettingTypeString:
|
||||||
if d.Value == "" {
|
if d.Value == "" {
|
||||||
t.Errorf("string setting %s has empty default", d.Key)
|
t.Errorf("string setting %s has empty default", d.Key)
|
||||||
|
|||||||
+8
-25
@@ -49,7 +49,7 @@ var Schemas = map[string]Schema{
|
|||||||
Response: json.RawMessage(`{
|
Response: json.RawMessage(`{
|
||||||
"id": "", "user_id": "", "name": "", "description": "",
|
"id": "", "user_id": "", "name": "", "description": "",
|
||||||
"color": "", "icon": "", "collection_type": "",
|
"color": "", "icon": "", "collection_type": "",
|
||||||
"auto_assign_rules": null, "view_settings": {},
|
"view_settings": {},
|
||||||
"book_count": 0, "created_at": ""
|
"book_count": 0, "created_at": ""
|
||||||
}`),
|
}`),
|
||||||
},
|
},
|
||||||
@@ -62,7 +62,7 @@ var Schemas = map[string]Schema{
|
|||||||
"GET /api/collections/:id": {
|
"GET /api/collections/:id": {
|
||||||
Response: json.RawMessage(`{
|
Response: json.RawMessage(`{
|
||||||
"id": "", "user_id": "", "name": "", "description": "",
|
"id": "", "user_id": "", "name": "", "description": "",
|
||||||
"color": "", "icon": "", "auto_assign_rules": null,
|
"color": "", "icon": "",
|
||||||
"view_settings": {}, "books": [], "book_count": 0,
|
"view_settings": {}, "books": [], "book_count": 0,
|
||||||
"is_smart": false, "up_next": [], "created_at": ""
|
"is_smart": false, "up_next": [], "created_at": ""
|
||||||
}`),
|
}`),
|
||||||
@@ -71,7 +71,7 @@ var Schemas = map[string]Schema{
|
|||||||
Request: handlers.UpdateCollectionRequest{},
|
Request: handlers.UpdateCollectionRequest{},
|
||||||
Response: json.RawMessage(`{
|
Response: json.RawMessage(`{
|
||||||
"id": "", "user_id": "", "name": "", "description": "",
|
"id": "", "user_id": "", "name": "", "description": "",
|
||||||
"color": "", "icon": "", "auto_assign_rules": null,
|
"color": "", "icon": "",
|
||||||
"view_settings": {}, "created_at": ""
|
"view_settings": {}, "created_at": ""
|
||||||
}`),
|
}`),
|
||||||
},
|
},
|
||||||
@@ -601,15 +601,14 @@ var Schemas = map[string]Schema{
|
|||||||
Request: json.RawMessage(`{"item_ids": []}`),
|
Request: json.RawMessage(`{"item_ids": []}`),
|
||||||
Response: json.RawMessage(`{"status": "reordered"}`),
|
Response: json.RawMessage(`{"status": "reordered"}`),
|
||||||
},
|
},
|
||||||
"PUT /api/collections/:id/rules/:ruleId": {
|
"PUT /api/collections/:id/view-settings": {
|
||||||
// Handler is SetRuleEnabled: toggle only, request {enabled required},
|
// Merges chain_order into the collection's view_settings JSONB
|
||||||
// response {collection_id, is_smart}.
|
// (playlist toggles share the doc and are preserved).
|
||||||
Request: json.RawMessage(`{"enabled": false}`),
|
Request: json.RawMessage(`{"chain_order": "newest"}`),
|
||||||
Response: json.RawMessage(`{
|
Response: json.RawMessage(`{
|
||||||
"collection_id": "", "is_smart": false
|
"id": "", "chain_order": "", "view_settings": {}
|
||||||
}`),
|
}`),
|
||||||
},
|
},
|
||||||
|
|
||||||
// --- Beam batch 7: scanner ---
|
// --- Beam batch 7: scanner ---
|
||||||
"POST /api/scanner/scan": {
|
"POST /api/scanner/scan": {
|
||||||
Request: handlers.ScanLibraryRequest{},
|
Request: handlers.ScanLibraryRequest{},
|
||||||
@@ -835,22 +834,6 @@ var Schemas = map[string]Schema{
|
|||||||
"DELETE /api/devices/:id/file-aliases/:aliasId": {
|
"DELETE /api/devices/:id/file-aliases/:aliasId": {
|
||||||
Response: json.RawMessage(`{"message": ""}`),
|
Response: json.RawMessage(`{"message": ""}`),
|
||||||
},
|
},
|
||||||
"POST /api/collections/:id/rules": {
|
|
||||||
// First rule converts a static collection to smart.
|
|
||||||
Request: handlers.AddRuleRequest{},
|
|
||||||
Response: json.RawMessage(`{
|
|
||||||
"collection_id": "", "rule": {}, "is_smart": false
|
|
||||||
}`),
|
|
||||||
},
|
|
||||||
"DELETE /api/collections/:id/rules/:ruleId": {
|
|
||||||
Response: json.RawMessage(`{
|
|
||||||
"collection_id": "", "is_smart": false
|
|
||||||
}`),
|
|
||||||
},
|
|
||||||
"POST /api/collections/test-rules": {
|
|
||||||
Request: handlers.TestRulesRequest{},
|
|
||||||
Response: json.RawMessage(`{"matches": [], "total": 0}`),
|
|
||||||
},
|
|
||||||
"POST /api/collections/:id/books": {
|
"POST /api/collections/:id/books": {
|
||||||
// Bulk add: {operations: [{collection_id, book_ids:[...]}]}.
|
// Bulk add: {operations: [{collection_id, book_ids:[...]}]}.
|
||||||
Request: json.RawMessage(`{
|
Request: json.RawMessage(`{
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
"bookhoard/internal/middleware"
|
"bookhoard/internal/middleware"
|
||||||
|
"bookhoard/internal/rls"
|
||||||
"bookhoard/internal/setupstatus"
|
"bookhoard/internal/setupstatus"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -1145,6 +1146,11 @@ func (h *AuthHandler) generateJWT(userID string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) CreateDefaultCollectionsForUser(ctx context.Context, userID pgtype.UUID) error {
|
func (h *AuthHandler) CreateDefaultCollectionsForUser(ctx context.Context, userID pgtype.UUID) error {
|
||||||
|
// Registration runs BEFORE the JWT exists — the request context
|
||||||
|
// carries no identity, and the collections RLS insert policy
|
||||||
|
// (2026-10-31) would reject the five system-collection seeds.
|
||||||
|
// Declare the new user as the actor: owner == actor, policy passes.
|
||||||
|
ctx = rls.WithUser(ctx, userID)
|
||||||
defaultCollections := []struct {
|
defaultCollections := []struct {
|
||||||
Name string
|
Name string
|
||||||
Description string
|
Description string
|
||||||
|
|||||||
@@ -43,6 +43,21 @@ type BookInfo struct {
|
|||||||
// it empty rather than pay a join).
|
// it empty rather than pay a join).
|
||||||
ViewerPercentage *float64 `json:"viewer_percentage"`
|
ViewerPercentage *float64 `json:"viewer_percentage"`
|
||||||
LibraryName string `json:"library_name"`
|
LibraryName string `json:"library_name"`
|
||||||
|
// Episode-collections extras (plan item 2.3): populated only by the
|
||||||
|
// collection-detail adapter. IsEpisode renders the episode-row
|
||||||
|
// variant in the grid; FeedTitle disambiguates same-named episodes;
|
||||||
|
// DownloadState gates the play affordance. Hidden=true means the
|
||||||
|
// LEFT JOIN missed under RLS (unsubscribed episode, or a
|
||||||
|
// family-lens hidden book) — the row renders as an "unavailable"
|
||||||
|
// hint instead of silently vanishing. Adapters that don't load
|
||||||
|
// visibility leave Hidden false (cards stay fully rendered). The
|
||||||
|
// four keys are NOT omitempty: every BookInfo surface emits the
|
||||||
|
// same stable key set (the viewer_percentage precedent —
|
||||||
|
// contract-pin widening, docs/contract_pins.md 2026-10-30).
|
||||||
|
IsEpisode bool `json:"is_episode"`
|
||||||
|
FeedTitle string `json:"feed_title"`
|
||||||
|
DownloadState string `json:"download_state"`
|
||||||
|
Hidden bool `json:"hidden"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// LibraryTypeMap builds libraryID→library-type-name for badge resolution
|
// LibraryTypeMap builds libraryID→library-type-name for badge resolution
|
||||||
@@ -151,8 +166,7 @@ func BookCardsFromCollectionDashRows(rows []database.GetCollectionItemsForDashbo
|
|||||||
ID: uuid.UUID(mi.ID.Bytes),
|
ID: uuid.UUID(mi.ID.Bytes),
|
||||||
Title: mi.Title,
|
Title: mi.Title,
|
||||||
Author: utils.PgTextString(mi.Author),
|
Author: utils.PgTextString(mi.Author),
|
||||||
LibraryID: mi.LibraryID,
|
LibraryID: mi.LibraryID, CoverPath: mi.CoverImagePath,
|
||||||
CoverPath: mi.CoverImagePath,
|
|
||||||
FormatGroup: mi.FormatGroup,
|
FormatGroup: mi.FormatGroup,
|
||||||
LibraryType: libTypes[uuid.UUID(mi.LibraryID.Bytes).String()],
|
LibraryType: libTypes[uuid.UUID(mi.LibraryID.Bytes).String()],
|
||||||
}))
|
}))
|
||||||
@@ -180,6 +194,39 @@ func BookCardsFromCollectionItemRows(rows []database.GetCollectionItemsRow, libT
|
|||||||
return cards
|
return cards
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BookCardsFromCollectionDetailed builds cards from the
|
||||||
|
// GetCollectionItemsDetailed rows (plan item 2.3): the collection's
|
||||||
|
// full membership with per-row visibility. RLS-invisible rows
|
||||||
|
// (unsubscribed episode, family-lens hidden book) become Hidden cards
|
||||||
|
// — the grid renders them as "unavailable" hint rows; episode rows
|
||||||
|
// carry feed title + download state for the play affordance. Unknown
|
||||||
|
// library types resolve no badge; hidden rows need none anyway.
|
||||||
|
func BookCardsFromCollectionDetailed(rows []database.GetCollectionItemsDetailedRow, libTypes map[string]string) []BookInfo {
|
||||||
|
cards := make([]BookInfo, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
if !r.Visible {
|
||||||
|
cards = append(cards, BookInfo{
|
||||||
|
MediaItemID: uuid.UUID(r.MediaItemID.Bytes).String(),
|
||||||
|
Hidden: true,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
isEpisode := r.Source.String == "podcast_episode"
|
||||||
|
card := buildBookCard(cardSource{
|
||||||
|
ID: uuid.UUID(r.MediaItemID.Bytes),
|
||||||
|
Title: utils.PgTextString(r.Title),
|
||||||
|
Author: utils.PgTextString(r.Author),
|
||||||
|
CoverPath: r.CoverImagePath,
|
||||||
|
FormatGroup: r.FormatGroup.String,
|
||||||
|
})
|
||||||
|
card.IsEpisode = isEpisode
|
||||||
|
card.FeedTitle = r.FeedTitle.String
|
||||||
|
card.DownloadState = r.DownloadState.String
|
||||||
|
cards = append(cards, card)
|
||||||
|
}
|
||||||
|
return cards
|
||||||
|
}
|
||||||
|
|
||||||
// BookCardsFromUnified builds cards from search-unified rows. Those
|
// BookCardsFromUnified builds cards from search-unified rows. Those
|
||||||
// rows carry the library type name as a SQL column, so this adapter
|
// rows carry the library type name as a SQL column, so this adapter
|
||||||
// needs no LibraryTypeMap query at all — and the viewer's own
|
// needs no LibraryTypeMap query at all — and the viewer's own
|
||||||
|
|||||||
+373
-347
@@ -9,7 +9,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -26,32 +25,128 @@ type CollectionHandler struct {
|
|||||||
connManager *wsync.ConnectionManager
|
connManager *wsync.ConnectionManager
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCollectionHandler(db *database.Queries, libraryService *services.LibraryService, connManager *wsync.ConnectionManager) *CollectionHandler {
|
func NewCollectionHandler(db *database.Queries, libraryService *services.LibraryService, connManager *wsync.ConnectionManager, search ...*services.SearchService) *CollectionHandler {
|
||||||
return &CollectionHandler{
|
return &CollectionHandler{
|
||||||
db: db,
|
db: db,
|
||||||
collectionService: services.NewCollectionService(db),
|
collectionService: services.NewCollectionService(db, search...),
|
||||||
libraryService: libraryService,
|
libraryService: libraryService,
|
||||||
connManager: connManager,
|
connManager: connManager,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateCollectionRequest struct {
|
type CreateCollectionRequest struct {
|
||||||
Name string `form:"name" json:"name" validate:"required"`
|
Name string `form:"name" json:"name" validate:"required"`
|
||||||
Description string `form:"description" json:"description"`
|
Description string `form:"description" json:"description"`
|
||||||
Color string `form:"color" json:"color"`
|
Color string `form:"color" json:"color"`
|
||||||
Icon string `form:"icon" json:"icon"`
|
Icon string `form:"icon" json:"icon"`
|
||||||
CollectionType string `form:"collection_type" json:"collection_type"` // "static" (default) or "smart" (rule-based)
|
CollectionType string `form:"collection_type" json:"collection_type"` // "static" (default) or "smart"
|
||||||
AutoAssignRules []services.Rule `form:"auto_assign_rules" json:"auto_assign_rules"`
|
// Custom-section builder payload (adapted 2026-10-31): the page
|
||||||
|
// posts the legacy rules vocabulary — rules as a JSON-encoded array
|
||||||
|
// (a string), a match_type select, hand-picked book ids, and the
|
||||||
|
// dashboard pin. The server converts rules → filter tree at write
|
||||||
|
// time and attaches the manual picks; storage stays tree-only.
|
||||||
|
AutoAssignRules json.RawMessage `json:"auto_assign_rules"`
|
||||||
|
MatchType string `json:"match_type"`
|
||||||
|
ManualBookIDs []string `json:"manual_book_ids"`
|
||||||
|
LibraryID string `json:"library_id"` // selection context only — not stored
|
||||||
|
ShowOnDashboard bool `json:"show_on_dashboard"`
|
||||||
ViewSettings map[string]interface{} `form:"view_settings" json:"view_settings"`
|
ViewSettings map[string]interface{} `form:"view_settings" json:"view_settings"`
|
||||||
|
// The unified filter tree (item 3.2): when present the collection's
|
||||||
|
// smart membership runs through the qf engine. INVALID trees are a
|
||||||
|
// 400 (ParseFilterTree at the handler); a tree supersedes
|
||||||
|
// auto_assign_rules. Persisted as-is (any). RawMessage for
|
||||||
|
// round-trip safety. JSON bodies bind FilterTree; FORM bodies
|
||||||
|
// (the create modal's urlencoded hx-post) bind FilterTreeForm —
|
||||||
|
// echo's binder cannot set an any-typed field from a form string
|
||||||
|
// (the create-modal seed POST 400'd "invalid request" on it,
|
||||||
|
// 2026-10-31).
|
||||||
|
FilterTree any `json:"filter_tree"`
|
||||||
|
// FilterTreeForm mirrors filter_tree for application/x-www-form-
|
||||||
|
// urlencoded submissions: the raw JSON as a string, validated by
|
||||||
|
// the same parseRequestFilterTree path.
|
||||||
|
FilterTreeForm string `form:"filter_tree"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseBuilderRules decodes the custom-section builder's rules payload:
|
||||||
|
// the page posts JSON.stringify(rules) — a JSON string CONTAINING the
|
||||||
|
// array (it has done so since the page was written, 2026-02-19) — while
|
||||||
|
// JSON clients may post the array directly. Null/empty = no rules.
|
||||||
|
func parseBuilderRules(raw json.RawMessage) ([]services.Rule, error) {
|
||||||
|
if len(raw) == 0 || string(raw) == "null" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var rules []services.Rule
|
||||||
|
if raw[0] == '"' {
|
||||||
|
var encoded string
|
||||||
|
if err := json.Unmarshal(raw, &encoded); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(encoded) == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return rules, json.Unmarshal([]byte(encoded), &rules)
|
||||||
|
}
|
||||||
|
return rules, json.Unmarshal(raw, &rules)
|
||||||
|
}
|
||||||
|
|
||||||
|
// treeFilterJSON returns the request's filter tree as JSON storage
|
||||||
|
// bytes (nil when none): object payloads marshal; strings (a legacy
|
||||||
|
// double-encoded client, the custom-section builder's known bug class)
|
||||||
|
// are written verbatim after object-attempt.
|
||||||
|
func treeFilterJSON(payload any) ([]byte, error) {
|
||||||
|
switch v := payload.(type) {
|
||||||
|
case nil:
|
||||||
|
return nil, nil
|
||||||
|
case map[string]interface{}:
|
||||||
|
b, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
case string:
|
||||||
|
return []byte(v), nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("filter_tree must be an object")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseRequestFilterTree validates the request's tree against the
|
||||||
|
// engine grammar (ParseFilterTree) so an invalid tree is a 400, never
|
||||||
|
// a stored 500 at render time.
|
||||||
|
func parseRequestFilterTree(payload any) ([]byte, error) {
|
||||||
|
b, err := treeFilterJSON(payload)
|
||||||
|
if err != nil || b == nil {
|
||||||
|
return b, err
|
||||||
|
}
|
||||||
|
if _, err := services.ParseFilterTree(string(b)); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid filter_tree: %w", err)
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveTreePayload — the tree payload from either transport: JSON
|
||||||
|
// bodies carry the object in FilterTree, form bodies (the create
|
||||||
|
// modal's urlencoded hx-post) the raw JSON string in FilterTreeForm.
|
||||||
|
func resolveTreePayload(jsonTree any, formTree string) any {
|
||||||
|
if formTree != "" {
|
||||||
|
return formTree
|
||||||
|
}
|
||||||
|
return jsonTree
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateCollectionRequest struct {
|
type UpdateCollectionRequest struct {
|
||||||
Name string `form:"name" json:"name" validate:"required"`
|
Name string `form:"name" json:"name" validate:"required"`
|
||||||
Description string `form:"description" json:"description"`
|
// The unified filter tree (item 3.2): when present the collection's
|
||||||
Color string `form:"color" json:"color"`
|
// smart membership runs through the qf engine. INVALID trees are a
|
||||||
Icon string `form:"icon" json:"icon"`
|
// 400 (ParseFilterTree at the handler). JSON bodies bind FilterTree; form bodies bind
|
||||||
AutoAssignRules []services.Rule `form:"auto_assign_rules" json:"auto_assign_rules"`
|
// FilterTreeForm (echo's binder cannot set an any-typed field from
|
||||||
ViewSettings map[string]interface{} `form:"view_settings" json:"view_settings"`
|
// a form string — see CreateCollectionRequest).
|
||||||
|
FilterTree any `json:"filter_tree"`
|
||||||
|
FilterTreeForm string `form:"filter_tree"`
|
||||||
|
Description string `form:"description" json:"description"`
|
||||||
|
Color string `form:"color" json:"color"`
|
||||||
|
Icon string `form:"icon" json:"icon"`
|
||||||
|
ViewSettings map[string]interface{} `form:"view_settings" json:"view_settings"`
|
||||||
// A2c §3.3: playlist toggles — merged into view_settings server-side
|
// A2c §3.3: playlist toggles — merged into view_settings server-side
|
||||||
// (nil = untouched). []string because the form posts a hidden "false"
|
// (nil = untouched). []string because the form posts a hidden "false"
|
||||||
// sibling next to the checkbox; the LAST value wins. JSON clients may
|
// sibling next to the checkbox; the LAST value wins. JSON clients may
|
||||||
@@ -89,15 +184,14 @@ type DeviceMappingSummary struct {
|
|||||||
// CollectionSummary is one entry in GET /api/collections. Package-level so
|
// CollectionSummary is one entry in GET /api/collections. Package-level so
|
||||||
// the docs schema registry (internal/docs) can pin the response shape.
|
// the docs schema registry (internal/docs) can pin the response shape.
|
||||||
type CollectionSummary struct {
|
type CollectionSummary struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Color string `json:"color"`
|
Color string `json:"color"`
|
||||||
Icon string `json:"icon"`
|
Icon string `json:"icon"`
|
||||||
AutoAssignRules json.RawMessage `json:"auto_assign_rules"`
|
CreatedAt string `json:"created_at"`
|
||||||
CreatedAt string `json:"created_at"`
|
BookCount int `json:"book_count"`
|
||||||
BookCount int `json:"book_count"`
|
IsSmart bool `json:"is_smart"`
|
||||||
IsSmart bool `json:"is_smart"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// BookCollectionSummary is one entry in GET /api/collections/:id/books —
|
// BookCollectionSummary is one entry in GET /api/collections/:id/books —
|
||||||
@@ -138,6 +232,55 @@ func (h *CollectionHandler) CreateCollection(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Custom-section builder adapter: convert the posted rules to a
|
||||||
|
// filter tree at WRITE time (the save-fix — the page's payload had
|
||||||
|
// never once bound successfully before this).
|
||||||
|
builderRules, rulesErr := parseBuilderRules(req.AutoAssignRules)
|
||||||
|
if rulesErr != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "auto_assign_rules: " + rulesErr.Error()})
|
||||||
|
}
|
||||||
|
matchMode := req.MatchType
|
||||||
|
if matchMode != services.RuleMatchAll && matchMode != services.RuleMatchAny {
|
||||||
|
matchMode = services.RuleMatchAny
|
||||||
|
}
|
||||||
|
treeJSON, treeErr := parseRequestFilterTree(resolveTreePayload(req.FilterTree, req.FilterTreeForm))
|
||||||
|
if treeErr != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": treeErr.Error()})
|
||||||
|
}
|
||||||
|
if treeJSON == nil && len(builderRules) > 0 {
|
||||||
|
tree, cerr := services.RulesToFilterTree(builderRules, matchMode)
|
||||||
|
if cerr != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "section rule: " + cerr.Error()})
|
||||||
|
}
|
||||||
|
b, merr := json.Marshal(tree)
|
||||||
|
if merr != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": merr.Error()})
|
||||||
|
}
|
||||||
|
// The strict-parse guarantee holds for converted trees too: an
|
||||||
|
// invalid tree is a 400 at write, never a stored 500 at render.
|
||||||
|
if _, verr := services.ParseFilterTree(string(b)); verr != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "section rule: " + verr.Error()})
|
||||||
|
}
|
||||||
|
treeJSON = b
|
||||||
|
}
|
||||||
|
// New podcast smart collections are BORN with the reversed listening
|
||||||
|
// default (2026-11-01 user request): seed chain_order into
|
||||||
|
// view_settings at creation — the dropdown shows "Feed order
|
||||||
|
// (reversed)" selected and the chain/page machinery reads the stored
|
||||||
|
// value exactly as if picked. Episodes-scope trees only (books have
|
||||||
|
// no episode chain); merge semantics never clobber a value the
|
||||||
|
// request itself sent; existing collections are untouched.
|
||||||
|
if treeJSON != nil {
|
||||||
|
if t, perr := services.ParseFilterTree(string(treeJSON)); perr == nil &&
|
||||||
|
t.FilterScope() == services.ScopeEpisodes {
|
||||||
|
if req.ViewSettings == nil {
|
||||||
|
req.ViewSettings = map[string]interface{}{}
|
||||||
|
}
|
||||||
|
if _, exists := req.ViewSettings["chain_order"]; !exists {
|
||||||
|
req.ViewSettings["chain_order"] = "reverse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
collection, err := h.collectionService.CreateCollection(
|
collection, err := h.collectionService.CreateCollection(
|
||||||
c.Request().Context(),
|
c.Request().Context(),
|
||||||
userUUID,
|
userUUID,
|
||||||
@@ -145,8 +288,9 @@ func (h *CollectionHandler) CreateCollection(c *echo.Context) error {
|
|||||||
req.Description,
|
req.Description,
|
||||||
req.Color,
|
req.Color,
|
||||||
req.Icon,
|
req.Icon,
|
||||||
req.AutoAssignRules,
|
|
||||||
req.ViewSettings,
|
req.ViewSettings,
|
||||||
|
req.ShowOnDashboard,
|
||||||
|
treeJSON,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, services.ErrCollectionNameTaken) {
|
if errors.Is(err, services.ErrCollectionNameTaken) {
|
||||||
@@ -155,20 +299,30 @@ func (h *CollectionHandler) CreateCollection(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Manual picks: a section = rules + hand-picked books. Attach after
|
||||||
|
// create; unparsable ids are skipped (same leniency as AddBooks).
|
||||||
|
for _, bookIDStr := range req.ManualBookIDs {
|
||||||
|
bookID, perr := uuid.Parse(bookIDStr)
|
||||||
|
if perr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_ = h.collectionService.AddBookToCollection(c.Request().Context(),
|
||||||
|
uuid.UUID(collection.ID.Bytes), bookID, userUUID)
|
||||||
|
}
|
||||||
|
|
||||||
bookCount := int32(0)
|
bookCount := int32(0)
|
||||||
isSmart := req.CollectionType == "smart" || len(req.AutoAssignRules) > 0
|
isSmart := req.CollectionType == "smart" || treeJSON != nil
|
||||||
response := map[string]interface{}{
|
response := map[string]interface{}{
|
||||||
"id": uuid.UUID(collection.ID.Bytes).String(),
|
"id": uuid.UUID(collection.ID.Bytes).String(),
|
||||||
"user_id": uuid.UUID(collection.UserID.Bytes).String(),
|
"user_id": uuid.UUID(collection.UserID.Bytes).String(),
|
||||||
"name": collection.Name,
|
"name": collection.Name,
|
||||||
"description": utils.PgTextString(collection.Description),
|
"description": utils.PgTextString(collection.Description),
|
||||||
"color": utils.PgTextString(collection.Color),
|
"color": utils.PgTextString(collection.Color),
|
||||||
"icon": utils.PgTextString(collection.Icon),
|
"icon": utils.PgTextString(collection.Icon),
|
||||||
"collection_type": map[bool]string{true: "smart", false: "static"}[isSmart],
|
"collection_type": map[bool]string{true: "smart", false: "static"}[isSmart],
|
||||||
"auto_assign_rules": collection.AutoAssignRules,
|
"view_settings": collection.ViewSettings,
|
||||||
"view_settings": collection.ViewSettings,
|
"book_count": bookCount,
|
||||||
"book_count": bookCount,
|
"created_at": collection.CreatedAt.Time.String(),
|
||||||
"created_at": collection.CreatedAt.Time.String(),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add redirect header for HTMX requests
|
// Add redirect header for HTMX requests
|
||||||
@@ -197,7 +351,7 @@ func (h *CollectionHandler) GetCollections(c *echo.Context) error {
|
|||||||
|
|
||||||
response := make([]CollectionSummary, 0, len(collections))
|
response := make([]CollectionSummary, 0, len(collections))
|
||||||
for _, col := range collections {
|
for _, col := range collections {
|
||||||
isSmart := h.collectionService.IsSmartCollection(col.AutoAssignRules)
|
isSmart := services.IsCollectionSmart(col)
|
||||||
if !includeAuto && isSmart {
|
if !includeAuto && isSmart {
|
||||||
// Legacy default for API clients (the Android app pre-dates
|
// Legacy default for API clients (the Android app pre-dates
|
||||||
// smart collections): rule-driven collections stay opt-in so
|
// smart collections): rule-driven collections stay opt-in so
|
||||||
@@ -213,12 +367,12 @@ func (h *CollectionHandler) GetCollections(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if isSmart {
|
if isSmart {
|
||||||
rules := h.collectionService.ParseRules(col.AutoAssignRules)
|
|
||||||
visibleIDs, visErr := h.userVisibleLibraryIDs(c)
|
visibleIDs, visErr := h.userVisibleLibraryIDs(c)
|
||||||
if visErr == nil {
|
if visErr == nil {
|
||||||
matches, evalErr := h.collectionService.EvaluateSmartCollection(c.Request().Context(), rules, services.SmartEvalOptions{
|
matches, evalErr := h.collectionService.EvaluateSmartMembership(c.Request().Context(), col, services.SmartEvalOptions{
|
||||||
LibraryID: libUUID,
|
LibraryID: libUUID,
|
||||||
VisibleIDs: visibleIDs,
|
VisibleIDs: visibleIDs,
|
||||||
|
ViewerUserID: MustGetAuthenticatedUser(c).ID,
|
||||||
})
|
})
|
||||||
if evalErr == nil {
|
if evalErr == nil {
|
||||||
bookCount = len(matches)
|
bookCount = len(matches)
|
||||||
@@ -235,15 +389,14 @@ func (h *CollectionHandler) GetCollections(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
response = append(response, CollectionSummary{
|
response = append(response, CollectionSummary{
|
||||||
ID: col.ID.Bytes,
|
ID: col.ID.Bytes,
|
||||||
Name: col.Name,
|
Name: col.Name,
|
||||||
Description: utils.PgTextString(col.Description),
|
Description: utils.PgTextString(col.Description),
|
||||||
Color: utils.PgTextString(col.Color),
|
Color: utils.PgTextString(col.Color),
|
||||||
Icon: utils.PgTextString(col.Icon),
|
Icon: utils.PgTextString(col.Icon),
|
||||||
AutoAssignRules: col.AutoAssignRules,
|
CreatedAt: col.CreatedAt.Time.String(),
|
||||||
CreatedAt: col.CreatedAt.Time.String(),
|
BookCount: bookCount,
|
||||||
BookCount: bookCount,
|
IsSmart: isSmart,
|
||||||
IsSmart: isSmart,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,17 +439,17 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error {
|
|||||||
libUUID = pgtype.UUID{Bytes: parsed, Valid: true}
|
libUUID = pgtype.UUID{Bytes: parsed, Valid: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
isSmart := h.collectionService.IsSmartCollection(collection.AutoAssignRules)
|
isSmart := services.IsCollectionSmart(collection)
|
||||||
libTypes := LibraryTypeMap(c.Request().Context(), h.db)
|
libTypes := LibraryTypeMap(c.Request().Context(), h.db)
|
||||||
if isSmart {
|
if isSmart {
|
||||||
rules := h.collectionService.ParseRules(collection.AutoAssignRules)
|
|
||||||
visibleIDs, err := h.userVisibleLibraryIDs(c)
|
visibleIDs, err := h.userVisibleLibraryIDs(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
matches, evalErr := h.collectionService.EvaluateSmartCollection(c.Request().Context(), rules, services.SmartEvalOptions{
|
matches, evalErr := h.collectionService.EvaluateSmartMembership(c.Request().Context(), collection, services.SmartEvalOptions{
|
||||||
LibraryID: libUUID,
|
LibraryID: libUUID,
|
||||||
VisibleIDs: visibleIDs,
|
VisibleIDs: visibleIDs,
|
||||||
|
ViewerUserID: MustGetAuthenticatedUser(c).ID,
|
||||||
})
|
})
|
||||||
if evalErr != nil {
|
if evalErr != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": evalErr.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": evalErr.Error()})
|
||||||
@@ -305,7 +458,7 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error {
|
|||||||
} else if collection.IsSystemCollection.Bool && collection.QueryType.Valid && collection.QueryType.String != "" && collection.QueryType.String != "filter" {
|
} else if collection.IsSystemCollection.Bool && collection.QueryType.Valid && collection.QueryType.String != "" && collection.QueryType.String != "filter" {
|
||||||
user := c.Get("user").(database.Users)
|
user := c.Get("user").(database.Users)
|
||||||
userUUID := uuid.UUID(user.ID.Bytes)
|
userUUID := uuid.UUID(user.ID.Bytes)
|
||||||
dashboardSvc := services.NewDashboardService(h.db)
|
dashboardSvc := services.NewDashboardService(h.db, h.collectionService.Search())
|
||||||
sections, secErr := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libUUID, 1000, []string{}, []string{})
|
sections, secErr := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libUUID, 1000, []string{}, []string{})
|
||||||
if secErr != nil {
|
if secErr != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": secErr.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": secErr.Error()})
|
||||||
@@ -403,19 +556,19 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
"id": uuid.UUID(collection.ID.Bytes).String(),
|
"id": uuid.UUID(collection.ID.Bytes).String(),
|
||||||
"user_id": uuid.UUID(collection.UserID.Bytes).String(),
|
"user_id": uuid.UUID(collection.UserID.Bytes).String(),
|
||||||
"name": collection.Name,
|
"name": collection.Name,
|
||||||
"description": utils.PgTextString(collection.Description),
|
"description": utils.PgTextString(collection.Description),
|
||||||
"color": utils.PgTextString(collection.Color),
|
"color": utils.PgTextString(collection.Color),
|
||||||
"icon": utils.PgTextString(collection.Icon),
|
"icon": utils.PgTextString(collection.Icon),
|
||||||
"auto_assign_rules": json.RawMessage(collection.AutoAssignRules),
|
"view_settings": viewSettings,
|
||||||
"view_settings": viewSettings,
|
"filter_tree": treeEnvelope(collection.FilterTree),
|
||||||
"books": bookList,
|
"books": bookList,
|
||||||
"book_count": len(bookList),
|
"book_count": len(bookList),
|
||||||
"is_smart": isSmart,
|
"is_smart": isSmart,
|
||||||
"up_next": upNext,
|
"up_next": upNext,
|
||||||
"created_at": collection.CreatedAt.Time.String(),
|
"created_at": collection.CreatedAt.Time.String(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,6 +603,31 @@ func (h *CollectionHandler) UpdateCollection(c *echo.Context) error {
|
|||||||
req.ViewSettings["show_up_next"] = *v
|
req.ViewSettings["show_up_next"] = *v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
treeJSON, treeErr := parseRequestFilterTree(resolveTreePayload(req.FilterTree, req.FilterTreeForm))
|
||||||
|
if treeErr != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": treeErr.Error()})
|
||||||
|
}
|
||||||
|
// Applying a tree to a collection with NO curated listening order
|
||||||
|
// seeds the reversed default (2026-11-01 — the creation seed's twin):
|
||||||
|
// a static collection becoming smart lands with the same default a
|
||||||
|
// born-smart one gets. Triple-guarded: the client's explicit
|
||||||
|
// chain_order wins, a STORED chain_order is never clobbered, and
|
||||||
|
// only episodes-scope trees seed (books have no episode chain).
|
||||||
|
if treeJSON != nil && (req.ViewSettings == nil || req.ViewSettings["chain_order"] == nil) {
|
||||||
|
if t, perr := services.ParseFilterTree(string(treeJSON)); perr == nil &&
|
||||||
|
t.FilterScope() == services.ScopeEpisodes {
|
||||||
|
if existing, gerr := h.collectionService.GetCollection(c.Request().Context(), collectionID); gerr == nil {
|
||||||
|
var stored map[string]interface{}
|
||||||
|
_ = json.Unmarshal(existing.ViewSettings, &stored)
|
||||||
|
if stored == nil || stored["chain_order"] == nil {
|
||||||
|
if req.ViewSettings == nil {
|
||||||
|
req.ViewSettings = map[string]interface{}{}
|
||||||
|
}
|
||||||
|
req.ViewSettings["chain_order"] = "reverse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
collection, err := h.collectionService.UpdateCollection(
|
collection, err := h.collectionService.UpdateCollection(
|
||||||
c.Request().Context(),
|
c.Request().Context(),
|
||||||
collectionID,
|
collectionID,
|
||||||
@@ -457,23 +635,28 @@ func (h *CollectionHandler) UpdateCollection(c *echo.Context) error {
|
|||||||
req.Description,
|
req.Description,
|
||||||
req.Color,
|
req.Color,
|
||||||
req.Icon,
|
req.Icon,
|
||||||
req.AutoAssignRules,
|
|
||||||
req.ViewSettings,
|
req.ViewSettings,
|
||||||
|
treeJSON,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
// Collections RLS: another user's collection is INVISIBLE —
|
||||||
|
// the update touched no rows. Answer 404, not a 500 that
|
||||||
|
// reads like a bug (2026-10-31 collections RLS).
|
||||||
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "collection not found"})
|
||||||
|
}
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
response := map[string]interface{}{
|
response := map[string]interface{}{
|
||||||
"id": uuid.UUID(collection.ID.Bytes).String(),
|
"id": uuid.UUID(collection.ID.Bytes).String(),
|
||||||
"user_id": uuid.UUID(collection.UserID.Bytes).String(),
|
"user_id": uuid.UUID(collection.UserID.Bytes).String(),
|
||||||
"name": collection.Name,
|
"name": collection.Name,
|
||||||
"description": utils.PgTextString(collection.Description),
|
"description": utils.PgTextString(collection.Description),
|
||||||
"color": utils.PgTextString(collection.Color),
|
"color": utils.PgTextString(collection.Color),
|
||||||
"icon": utils.PgTextString(collection.Icon),
|
"icon": utils.PgTextString(collection.Icon),
|
||||||
"auto_assign_rules": json.RawMessage(collection.AutoAssignRules),
|
"view_settings": json.RawMessage(collection.ViewSettings),
|
||||||
"view_settings": json.RawMessage(collection.ViewSettings),
|
"created_at": collection.CreatedAt.Time.String(),
|
||||||
"created_at": collection.CreatedAt.Time.String(),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add redirect header for HTMX requests
|
// Add redirect header for HTMX requests
|
||||||
@@ -484,6 +667,99 @@ func (h *CollectionHandler) UpdateCollection(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusCreated, response)
|
return c.JSON(http.StatusCreated, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidChainOrders — the collection chain's listening-order vocabulary
|
||||||
|
// (plan §10 R2: a collection's playback order is CURATED on the
|
||||||
|
// collection, not a player-side knob). Mirrors OrderEpisodes' orders;
|
||||||
|
// "" means native (the pre-chain-order default).
|
||||||
|
var ValidChainOrders = map[string]bool{"": true, "native": true, "newest": true, "oldest": true, "reverse": true}
|
||||||
|
|
||||||
|
// CollectionChainOrder resolves a collection row's curated chain order
|
||||||
|
// from its view_settings JSONB — "" (native) when absent/unknown. The
|
||||||
|
// episode chain producer routes OrderEpisodes through this; the
|
||||||
|
// view-settings PUT writes the key (SetCollectionChainOrder).
|
||||||
|
func CollectionChainOrder(viewSettings []byte) string {
|
||||||
|
if len(viewSettings) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var vs map[string]interface{}
|
||||||
|
if json.Unmarshal(viewSettings, &vs) != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if s, ok := vs["chain_order"].(string); ok && ValidChainOrders[s] {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCollectionChainOrder — PUT /api/collections/:id/view-settings with
|
||||||
|
// {"chain_order": "..."}: merges the key into the collection's stored
|
||||||
|
// view_settings (the playlist toggles share that JSONB — a blind write
|
||||||
|
// would clobber them). The episode chain producer
|
||||||
|
// (buildSmartCollectionSequenceContext) honors it.
|
||||||
|
func (h *CollectionHandler) SetCollectionChainOrder(c *echo.Context) error {
|
||||||
|
collectionID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
ChainOrder string `json:"chain_order"`
|
||||||
|
}
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||||
|
}
|
||||||
|
if !ValidChainOrders[req.ChainOrder] {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||||
|
"error": "chain_order must be one of: native, newest, oldest, reverse"})
|
||||||
|
}
|
||||||
|
|
||||||
|
collection, err := h.collectionService.GetCollection(c.Request().Context(), collectionID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "collection not found"})
|
||||||
|
}
|
||||||
|
// Same owner gate as the sibling config mutation (ReorderItems) —
|
||||||
|
// my endpoint writes the row, so it must not let a non-owner flip
|
||||||
|
// someone else's chain order.
|
||||||
|
if u, ok := c.Get("user").(database.Users); ok {
|
||||||
|
owns, err := h.collectionService.UserOwnsCollection(c.Request().Context(), collectionID, uuid.UUID(u.ID.Bytes))
|
||||||
|
if err != nil || !owns {
|
||||||
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "only the collection owner can change it"})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "no user in context"})
|
||||||
|
}
|
||||||
|
|
||||||
|
settings := map[string]interface{}{}
|
||||||
|
if len(collection.ViewSettings) > 0 {
|
||||||
|
if uerr := json.Unmarshal(collection.ViewSettings, &settings); uerr != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "corrupt view_settings"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if req.ChainOrder == "" || req.ChainOrder == "native" {
|
||||||
|
// Both spellings mean the default — drop the key entirely so the
|
||||||
|
// stored doc stays clean.
|
||||||
|
delete(settings, "chain_order")
|
||||||
|
} else {
|
||||||
|
settings["chain_order"] = req.ChainOrder
|
||||||
|
}
|
||||||
|
blob, err := json.Marshal(settings)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
if err := h.db.UpdateCollectionViewSettings(c.Request().Context(), database.UpdateCollectionViewSettingsParams{
|
||||||
|
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
||||||
|
ViewSettings: blob,
|
||||||
|
}); err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
"id": uuid.UUID(collection.ID.Bytes).String(),
|
||||||
|
"chain_order": req.ChainOrder,
|
||||||
|
"view_settings": settings,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *CollectionHandler) DeleteCollection(c *echo.Context) error {
|
func (h *CollectionHandler) DeleteCollection(c *echo.Context) error {
|
||||||
collectionID, err := uuid.Parse(c.Param("id"))
|
collectionID, err := uuid.Parse(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -851,278 +1127,25 @@ func (h *CollectionHandler) rejectSmartMutation(c *echo.Context, collectionID uu
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusNotFound, "collection not found")
|
return echo.NewHTTPError(http.StatusNotFound, "collection not found")
|
||||||
}
|
}
|
||||||
if h.collectionService.IsSmartCollection(collection.AutoAssignRules) {
|
if services.IsCollectionSmart(collection) {
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, "smart collections are rule-based — membership is managed by the collection's rules, not by hand")
|
return echo.NewHTTPError(http.StatusBadRequest, "smart collections are tree-based — membership is managed by the collection's filter tree, not by hand")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type AddRuleRequest struct {
|
// treeEnvelope renders the stored filter tree for the envelope: nil
|
||||||
Field string `json:"field" validate:"required"`
|
// (static collections) serializes as null, trees as the object.
|
||||||
Operator string `json:"operator" validate:"required"`
|
func treeEnvelope(t []byte) any {
|
||||||
Value string `json:"value" validate:"required"`
|
if len(t) == 0 {
|
||||||
Enabled *bool `json:"enabled"`
|
return nil
|
||||||
Priority int `json:"priority"`
|
}
|
||||||
|
var v interface{}
|
||||||
|
if json.Unmarshal(t, &v) == nil {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddRule handles POST /api/collections/:id/rules — appends one rule to
|
|
||||||
// the ruleset. The first rule converts a static collection to smart.
|
|
||||||
func (h *CollectionHandler) AddRule(c *echo.Context) error {
|
|
||||||
collectionID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
|
||||||
}
|
|
||||||
|
|
||||||
collection, err := h.GetCollectionData(c, collectionID)
|
|
||||||
if err != nil {
|
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "collection not found"})
|
|
||||||
}
|
|
||||||
if collection.IsSystemCollection.Bool {
|
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "system collections cannot have rules"})
|
|
||||||
}
|
|
||||||
|
|
||||||
var req AddRuleRequest
|
|
||||||
if err := c.Bind(&req); err != nil {
|
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
|
||||||
}
|
|
||||||
if err := c.Validate(&req); err != nil {
|
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, rule, err := h.collectionService.AddRule(c.Request().Context(), collectionID, services.Rule{
|
|
||||||
Field: req.Field,
|
|
||||||
Operator: req.Operator,
|
|
||||||
Value: req.Value,
|
|
||||||
Enabled: req.Enabled,
|
|
||||||
Priority: req.Priority,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
var ruleErr *services.RuleValidationError
|
|
||||||
if errors.As(err, &ruleErr) {
|
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
||||||
}
|
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(http.StatusCreated, map[string]interface{}{
|
|
||||||
"collection_id": uuid.UUID(updated.ID.Bytes).String(),
|
|
||||||
"rule": rule,
|
|
||||||
"is_smart": h.collectionService.IsSmartCollection(updated.AutoAssignRules),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateRule handles PUT /api/collections/:id/rules/:ruleId — currently
|
|
||||||
// the enabled toggle; the payload may only carry {"enabled": bool}.
|
|
||||||
func (h *CollectionHandler) UpdateRule(c *echo.Context) error {
|
|
||||||
collectionID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
|
||||||
}
|
|
||||||
ruleID := c.Param("ruleId")
|
|
||||||
if ruleID == "" {
|
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "rule id required"})
|
|
||||||
}
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
Enabled *bool `json:"enabled" validate:"required"`
|
|
||||||
}
|
|
||||||
if err := c.Bind(&req); err != nil {
|
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
|
||||||
}
|
|
||||||
if err := c.Validate(&req); err != nil {
|
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, err := h.collectionService.SetRuleEnabled(c.Request().Context(), collectionID, ruleID, *req.Enabled)
|
|
||||||
if err != nil {
|
|
||||||
if strings.Contains(err.Error(), "rule not found") {
|
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
|
||||||
}
|
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
||||||
"collection_id": uuid.UUID(updated.ID.Bytes).String(),
|
|
||||||
"is_smart": h.collectionService.IsSmartCollection(updated.AutoAssignRules),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoveRule handles DELETE /api/collections/:id/rules/:ruleId.
|
|
||||||
func (h *CollectionHandler) RemoveRule(c *echo.Context) error {
|
|
||||||
collectionID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
|
||||||
}
|
|
||||||
ruleID := c.Param("ruleId")
|
|
||||||
if ruleID == "" {
|
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "rule id required"})
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, err := h.collectionService.RemoveRule(c.Request().Context(), collectionID, ruleID)
|
|
||||||
if err != nil {
|
|
||||||
if strings.Contains(err.Error(), "rule not found") {
|
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
|
||||||
}
|
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
||||||
"collection_id": uuid.UUID(updated.ID.Bytes).String(),
|
|
||||||
"is_smart": h.collectionService.IsSmartCollection(updated.AutoAssignRules),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
type TestRulesRequest struct {
|
|
||||||
Rules []map[string]interface{} `json:"rules" validate:"required"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type BookMatch struct {
|
|
||||||
MediaItemID string `json:"media_item_id"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Author string `json:"author"`
|
|
||||||
CoverImagePath string `json:"cover_image_path"`
|
|
||||||
MatchReason string `json:"match_reason"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *CollectionHandler) TestRules(c *echo.Context) error {
|
|
||||||
var req TestRulesRequest
|
|
||||||
if err := c.Bind(&req); err != nil {
|
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
|
||||||
}
|
|
||||||
if err := c.Validate(&req); err != nil {
|
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Candidates mirror what smart collections actually evaluate against:
|
|
||||||
// the requester's visible libraries and live items only — the preview
|
|
||||||
// must not promise matches the live render will never produce.
|
|
||||||
visibleIDs, err := h.userVisibleLibraryIDs(c)
|
|
||||||
if err != nil {
|
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
||||||
}
|
|
||||||
var visibleArg []pgtype.UUID
|
|
||||||
if len(visibleIDs) > 0 {
|
|
||||||
visibleArg = visibleIDs
|
|
||||||
}
|
|
||||||
mediaItems, err := h.db.GetVisibleLiveMediaForRules(c.Request().Context(), database.GetVisibleLiveMediaForRulesParams{
|
|
||||||
LibraryID: pgtype.UUID{},
|
|
||||||
VisibleLibraryIds: visibleArg,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to load media items"})
|
|
||||||
}
|
|
||||||
|
|
||||||
var matches []BookMatch
|
|
||||||
for _, item := range mediaItems {
|
|
||||||
matchReason := h.checkRulesAgainstBook(item, req.Rules)
|
|
||||||
if matchReason != "" {
|
|
||||||
author := ""
|
|
||||||
if item.Author.Valid {
|
|
||||||
author = item.Author.String
|
|
||||||
}
|
|
||||||
|
|
||||||
matches = append(matches, BookMatch{
|
|
||||||
MediaItemID: uuid.UUID(item.ID.Bytes).String(),
|
|
||||||
Title: item.Title,
|
|
||||||
Author: author,
|
|
||||||
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
|
|
||||||
MatchReason: matchReason,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
||||||
"matches": matches,
|
|
||||||
"total": len(matches),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *CollectionHandler) checkRulesAgainstBook(item database.MediaItems, rules []map[string]interface{}) string {
|
|
||||||
for _, rule := range rules {
|
|
||||||
field, _ := rule["field"].(string)
|
|
||||||
operator, _ := rule["operator"].(string)
|
|
||||||
value, _ := rule["value"].(string)
|
|
||||||
|
|
||||||
if h.evaluateRule(item, field, operator, value) {
|
|
||||||
return fmt.Sprintf("Matched rule: %s %s %s", field, operator, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *CollectionHandler) evaluateRule(item database.MediaItems, field, operator, value string) bool {
|
|
||||||
var itemValue string
|
|
||||||
|
|
||||||
switch field {
|
|
||||||
case "genre":
|
|
||||||
if item.Genre.Valid {
|
|
||||||
itemValue = item.Genre.String
|
|
||||||
}
|
|
||||||
case "series":
|
|
||||||
if item.Series.Valid {
|
|
||||||
itemValue = item.Series.String
|
|
||||||
}
|
|
||||||
case "author":
|
|
||||||
if item.Author.Valid {
|
|
||||||
itemValue = item.Author.String
|
|
||||||
}
|
|
||||||
case "language":
|
|
||||||
if item.Language.Valid {
|
|
||||||
itemValue = item.Language.String
|
|
||||||
}
|
|
||||||
case "publisher":
|
|
||||||
if item.Publisher.Valid {
|
|
||||||
itemValue = item.Publisher.String
|
|
||||||
}
|
|
||||||
case "copyright_year":
|
|
||||||
if item.CopyrightYear.Valid {
|
|
||||||
itemValue = fmt.Sprintf("%d", item.CopyrightYear.Int32)
|
|
||||||
}
|
|
||||||
case "tags":
|
|
||||||
if len(item.Tags) > 0 {
|
|
||||||
itemValue = strings.Join(item.Tags, ", ")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return h.compareValues(itemValue, operator, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *CollectionHandler) compareValues(itemValue, operator, ruleValue string) bool {
|
|
||||||
switch operator {
|
|
||||||
case "equals":
|
|
||||||
return itemValue == ruleValue
|
|
||||||
case "not_equals":
|
|
||||||
return itemValue != ruleValue
|
|
||||||
case "contains":
|
|
||||||
return strings.Contains(strings.ToLower(itemValue), strings.ToLower(ruleValue))
|
|
||||||
case "not_contains":
|
|
||||||
return !strings.Contains(strings.ToLower(itemValue), strings.ToLower(ruleValue))
|
|
||||||
case "starts_with":
|
|
||||||
return strings.HasPrefix(strings.ToLower(itemValue), strings.ToLower(ruleValue))
|
|
||||||
case "ends_with":
|
|
||||||
return strings.HasSuffix(strings.ToLower(itemValue), strings.ToLower(ruleValue))
|
|
||||||
case "greater_than":
|
|
||||||
itemNum, err1 := strconv.Atoi(itemValue)
|
|
||||||
ruleNum, err2 := strconv.Atoi(ruleValue)
|
|
||||||
if err1 != nil || err2 != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return itemNum > ruleNum
|
|
||||||
case "less_than":
|
|
||||||
itemNum, err1 := strconv.Atoi(itemValue)
|
|
||||||
ruleNum, err2 := strconv.Atoi(ruleValue)
|
|
||||||
if err1 != nil || err2 != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return itemNum < ruleNum
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bulk Operations
|
|
||||||
|
|
||||||
// POST /api/collections/bulk-add-books
|
|
||||||
// Bulk add books to multiple collections
|
|
||||||
func (h *CollectionHandler) HandleBulkAddBooks(c *echo.Context) error {
|
func (h *CollectionHandler) HandleBulkAddBooks(c *echo.Context) error {
|
||||||
user := c.Get("user").(database.Users)
|
user := c.Get("user").(database.Users)
|
||||||
userUUID := uuid.UUID(user.ID.Bytes)
|
userUUID := uuid.UUID(user.ID.Bytes)
|
||||||
@@ -1268,9 +1291,12 @@ func (h *CollectionHandler) PreviewCollection(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load library items"})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load library items"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Book-scope preview (the rules page remains books-first until the
|
||||||
|
// filter-unification pass); no viewer progress loaded here, so
|
||||||
|
// played/unplayed rules preview at the no-progress default.
|
||||||
var matchedItems []database.MediaItems
|
var matchedItems []database.MediaItems
|
||||||
for _, item := range allItems {
|
for _, item := range allItems {
|
||||||
evaluations := h.collectionService.EvaluateRules(item, req.Rules)
|
evaluations := h.collectionService.EvaluateRules(item, 0, req.Rules)
|
||||||
for _, eval := range evaluations {
|
for _, eval := range evaluations {
|
||||||
if eval.Matches {
|
if eval.Matches {
|
||||||
matchedItems = append(matchedItems, item)
|
matchedItems = append(matchedItems, item)
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// The collection chain's curated order (plan §10 R2): the resolver
|
||||||
|
// reads view_settings.chain_order, tolerates absent/corrupt docs, and
|
||||||
|
// refuses unknown orders (they mean native).
|
||||||
|
func TestCollectionChainOrder(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
vs string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"absent doc", "", ""},
|
||||||
|
{"empty object", `{}`, ""},
|
||||||
|
{"reverse", `{"chain_order":"reverse"}`, "reverse"},
|
||||||
|
{"newest", `{"chain_order":"newest"}`, "newest"},
|
||||||
|
{"merged with toggles", `{"hide_finished":true,"chain_order":"oldest"}`, "oldest"},
|
||||||
|
{"unknown order means native", `{"chain_order":"shuffle"}`, ""},
|
||||||
|
{"non-string means native", `{"chain_order":3}`, ""},
|
||||||
|
{"corrupt doc means native", `not json`, ""},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := CollectionChainOrder([]byte(tc.vs)); got != tc.want {
|
||||||
|
t.Fatalf("CollectionChainOrder(%s) = %q, want %q", tc.vs, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,415 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"bookhoard/internal/database"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Test collection rule matching with various operators
|
|
||||||
|
|
||||||
func TestEvaluateRule_Equals_StringMatch(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
item := database.MediaItems{
|
|
||||||
Genre: pgtype.Text{String: "Science Fiction", Valid: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
result := handler.evaluateRule(item, "genre", "equals", "Science Fiction")
|
|
||||||
assert.True(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvaluateRule_Equals_NoMatch(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
item := database.MediaItems{
|
|
||||||
Genre: pgtype.Text{String: "Fantasy", Valid: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
result := handler.evaluateRule(item, "genre", "equals", "Science Fiction")
|
|
||||||
assert.False(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvaluateRule_NotEquals_Matches(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
item := database.MediaItems{
|
|
||||||
Genre: pgtype.Text{String: "Fantasy", Valid: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
result := handler.evaluateRule(item, "genre", "not_equals", "Science Fiction")
|
|
||||||
assert.True(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvaluateRule_NotEquals_NoMatch(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
item := database.MediaItems{
|
|
||||||
Genre: pgtype.Text{String: "Science Fiction", Valid: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
result := handler.evaluateRule(item, "genre", "not_equals", "Science Fiction")
|
|
||||||
assert.False(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvaluateRule_Contains_Matches(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
item := database.MediaItems{
|
|
||||||
Author: pgtype.Text{String: "Isaac Asimov", Valid: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
result := handler.evaluateRule(item, "author", "contains", "asimov")
|
|
||||||
assert.True(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvaluateRule_Contains_NoMatch(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
item := database.MediaItems{
|
|
||||||
Author: pgtype.Text{String: "Isaac Asimov", Valid: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
result := handler.evaluateRule(item, "author", "contains", "clarke")
|
|
||||||
assert.False(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvaluateRule_NotContains_Matches(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
item := database.MediaItems{
|
|
||||||
Author: pgtype.Text{String: "Isaac Asimov", Valid: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
result := handler.evaluateRule(item, "author", "not_contains", "clarke")
|
|
||||||
assert.True(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvaluateRule_StartsWith_Matches(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
item := database.MediaItems{
|
|
||||||
Series: pgtype.Text{String: "Foundation", Valid: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
result := handler.evaluateRule(item, "series", "starts_with", "Foun")
|
|
||||||
assert.True(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvaluateRule_StartsWith_NoMatch(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
item := database.MediaItems{
|
|
||||||
Series: pgtype.Text{String: "Foundation", Valid: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
result := handler.evaluateRule(item, "series", "starts_with", "Robots")
|
|
||||||
assert.False(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvaluateRule_EndsWith_Matches(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
item := database.MediaItems{
|
|
||||||
Series: pgtype.Text{String: "Foundation", Valid: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
result := handler.evaluateRule(item, "series", "ends_with", "tion")
|
|
||||||
assert.True(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvaluateRule_GreaterThan_Matches(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
item := database.MediaItems{
|
|
||||||
CopyrightYear: pgtype.Int4{Int32: 2021, Valid: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
result := handler.evaluateRule(item, "copyright_year", "greater_than", "2000")
|
|
||||||
assert.True(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvaluateRule_GreaterThan_NoMatch(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
item := database.MediaItems{
|
|
||||||
CopyrightYear: pgtype.Int4{Int32: 1999, Valid: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
result := handler.evaluateRule(item, "copyright_year", "greater_than", "2000")
|
|
||||||
assert.False(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvaluateRule_LessThan_Matches(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
item := database.MediaItems{
|
|
||||||
CopyrightYear: pgtype.Int4{Int32: 1999, Valid: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
result := handler.evaluateRule(item, "copyright_year", "less_than", "2000")
|
|
||||||
assert.True(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvaluateRule_LessThan_NoMatch(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
item := database.MediaItems{
|
|
||||||
CopyrightYear: pgtype.Int4{Int32: 2021, Valid: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
result := handler.evaluateRule(item, "copyright_year", "less_than", "2000")
|
|
||||||
assert.False(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvaluateRule_NullField(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
item := database.MediaItems{
|
|
||||||
Genre: pgtype.Text{Valid: false}, // NULL
|
|
||||||
}
|
|
||||||
|
|
||||||
result := handler.evaluateRule(item, "genre", "equals", "Science Fiction")
|
|
||||||
assert.False(t, result, "Should return false for NULL fields")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompareValues_CaseInsensitive(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
|
|
||||||
result := handler.compareValues("Science Fiction", "contains", "science fiction")
|
|
||||||
assert.True(t, result, "Contains should be case-insensitive")
|
|
||||||
|
|
||||||
result = handler.compareValues("Science Fiction", "contains", "SCIENCE FICTION")
|
|
||||||
assert.True(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompareValues_EmptyString(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
|
|
||||||
result := handler.compareValues("", "contains", "test")
|
|
||||||
assert.False(t, result, "Empty string should not contain anything")
|
|
||||||
|
|
||||||
result = handler.compareValues("test", "contains", "")
|
|
||||||
assert.True(t, result, "Non-empty string contains empty string")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompareValues_NumericEdgeCases(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
|
|
||||||
result := handler.compareValues("0", "greater_than", "-1")
|
|
||||||
assert.True(t, result)
|
|
||||||
|
|
||||||
result = handler.compareValues("9999", "less_than", "10000")
|
|
||||||
assert.True(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test rule checking with multiple rules
|
|
||||||
|
|
||||||
func TestCheckRulesAgainstBook_MatchesFirstRule(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
item := database.MediaItems{
|
|
||||||
Genre: pgtype.Text{String: "Science Fiction", Valid: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
rules := []map[string]interface{}{
|
|
||||||
{
|
|
||||||
"field": "genre",
|
|
||||||
"operator": "equals",
|
|
||||||
"value": "Science Fiction",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"field": "author",
|
|
||||||
"operator": "contains",
|
|
||||||
"value": "Asimov",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
reason := handler.checkRulesAgainstBook(item, rules)
|
|
||||||
assert.NotEmpty(t, reason, "Should match at least one rule")
|
|
||||||
assert.Contains(t, reason, "genre")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckRulesAgainstBook_MatchesSecondRule(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
item := database.MediaItems{
|
|
||||||
Genre: pgtype.Text{String: "Fantasy", Valid: true},
|
|
||||||
Author: pgtype.Text{String: "Isaac Asimov", Valid: true},
|
|
||||||
CopyrightYear: pgtype.Int4{Int32: 2021, Valid: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
rules := []map[string]interface{}{
|
|
||||||
{
|
|
||||||
"field": "genre",
|
|
||||||
"operator": "equals",
|
|
||||||
"value": "Science Fiction",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"field": "author",
|
|
||||||
"operator": "contains",
|
|
||||||
"value": "Asimov",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
reason := handler.checkRulesAgainstBook(item, rules)
|
|
||||||
assert.NotEmpty(t, reason, "Should match second rule")
|
|
||||||
assert.Contains(t, reason, "author")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckRulesAgainstBook_NoMatches(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
item := database.MediaItems{
|
|
||||||
Genre: pgtype.Text{String: "Mystery", Valid: true},
|
|
||||||
Author: pgtype.Text{String: "Agatha Christie", Valid: true},
|
|
||||||
CopyrightYear: pgtype.Int4{Int32: 1950, Valid: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
rules := []map[string]interface{}{
|
|
||||||
{
|
|
||||||
"field": "genre",
|
|
||||||
"operator": "equals",
|
|
||||||
"value": "Science Fiction",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"field": "author",
|
|
||||||
"operator": "contains",
|
|
||||||
"value": "Asimov",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
reason := handler.checkRulesAgainstBook(item, rules)
|
|
||||||
assert.Empty(t, reason, "Should not match any rules")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckRulesAgainstBook_EmptyRules(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
item := database.MediaItems{
|
|
||||||
Genre: pgtype.Text{String: "Science Fiction", Valid: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
rules := []map[string]interface{}{}
|
|
||||||
|
|
||||||
reason := handler.checkRulesAgainstBook(item, rules)
|
|
||||||
assert.Empty(t, reason, "Empty rules should return empty reason")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test complex rule scenarios
|
|
||||||
|
|
||||||
func TestComplexRule_MultipleConditions(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
|
|
||||||
// Book: "Foundation" by Isaac Asimov, Sci-Fi, 1951
|
|
||||||
item := database.MediaItems{
|
|
||||||
Title: "Foundation",
|
|
||||||
Author: pgtype.Text{String: "Isaac Asimov", Valid: true},
|
|
||||||
Genre: pgtype.Text{String: "Science Fiction", Valid: true},
|
|
||||||
CopyrightYear: pgtype.Int4{Int32: 1951, Valid: true},
|
|
||||||
Series: pgtype.Text{String: "Foundation", Valid: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
field string
|
|
||||||
operator string
|
|
||||||
value string
|
|
||||||
expected bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Match genre exactly",
|
|
||||||
field: "genre",
|
|
||||||
operator: "equals",
|
|
||||||
value: "Science Fiction",
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Match author substring",
|
|
||||||
field: "author",
|
|
||||||
operator: "contains",
|
|
||||||
value: "asimov",
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Match year greater than",
|
|
||||||
field: "copyright_year",
|
|
||||||
operator: "greater_than",
|
|
||||||
value: "1950",
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Match year less than",
|
|
||||||
field: "copyright_year",
|
|
||||||
operator: "less_than",
|
|
||||||
value: "2000",
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Match series starts with",
|
|
||||||
field: "series",
|
|
||||||
operator: "starts_with",
|
|
||||||
value: "Found",
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Match series ends with",
|
|
||||||
field: "series",
|
|
||||||
operator: "ends_with",
|
|
||||||
value: "tion",
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "No match for genre",
|
|
||||||
field: "genre",
|
|
||||||
operator: "equals",
|
|
||||||
value: "Fantasy",
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "No match for author",
|
|
||||||
field: "author",
|
|
||||||
operator: "contains",
|
|
||||||
value: "Tolkien",
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
result := handler.evaluateRule(item, tt.field, tt.operator, tt.value)
|
|
||||||
assert.Equal(t, tt.expected, result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test edge cases and error handling
|
|
||||||
|
|
||||||
func TestEvaluateRule_InvalidOperator(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
item := database.MediaItems{
|
|
||||||
Genre: pgtype.Text{String: "Science Fiction", Valid: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
result := handler.evaluateRule(item, "genre", "invalid_operator", "Science Fiction")
|
|
||||||
assert.False(t, result, "Invalid operator should return false")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvaluateRule_NonExistentField(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
item := database.MediaItems{
|
|
||||||
Genre: pgtype.Text{String: "Science Fiction", Valid: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
result := handler.evaluateRule(item, "nonexistent_field", "equals", "value")
|
|
||||||
assert.False(t, result, "Non-existent field should return false")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompareValues_InvalidOperator(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
|
|
||||||
result := handler.compareValues("test", "invalid_op", "value")
|
|
||||||
assert.False(t, result, "Invalid comparison operator should return false")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompareValues_NumericConversion(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
|
|
||||||
// Valid numeric strings
|
|
||||||
result := handler.compareValues("100", "greater_than", "99")
|
|
||||||
assert.True(t, result)
|
|
||||||
|
|
||||||
result = handler.compareValues("99", "less_than", "100")
|
|
||||||
assert.True(t, result)
|
|
||||||
|
|
||||||
// Invalid numeric strings (should return false)
|
|
||||||
result = handler.compareValues("abc", "greater_than", "100")
|
|
||||||
assert.False(t, result, "Non-numeric string should not compare as greater than")
|
|
||||||
|
|
||||||
result = handler.compareValues("100", "greater_than", "xyz")
|
|
||||||
assert.False(t, result, "Non-numeric value should not allow comparison")
|
|
||||||
}
|
|
||||||
@@ -3,95 +3,12 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"bookhoard/internal/database"
|
|
||||||
"bookhoard/internal/utils"
|
"bookhoard/internal/utils"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCompareValues_Equals(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
|
|
||||||
result := handler.compareValues("Science Fiction", "equals", "Science Fiction")
|
|
||||||
assert.True(t, result)
|
|
||||||
|
|
||||||
result = handler.compareValues("Science Fiction", "equals", "Fantasy")
|
|
||||||
assert.False(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompareValues_Contains(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
|
|
||||||
result := handler.compareValues("Isaac Asimov", "contains", "asimov")
|
|
||||||
assert.True(t, result)
|
|
||||||
|
|
||||||
result = handler.compareValues("Isaac Asimov", "contains", "clarke")
|
|
||||||
assert.False(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompareValues_GreaterThan(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
|
|
||||||
result := handler.compareValues("2021", "greater_than", "2000")
|
|
||||||
assert.True(t, result)
|
|
||||||
|
|
||||||
result = handler.compareValues("1999", "greater_than", "2000")
|
|
||||||
assert.False(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompareValues_NotEquals(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
|
|
||||||
result := handler.compareValues("Science Fiction", "not_equals", "Fantasy")
|
|
||||||
assert.True(t, result)
|
|
||||||
|
|
||||||
result = handler.compareValues("Science Fiction", "not_equals", "Science Fiction")
|
|
||||||
assert.False(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvaluateRule_Genre(t *testing.T) {
|
|
||||||
item := database.MediaItems{
|
|
||||||
Genre: pgtype.Text{String: "Science Fiction", Valid: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
|
|
||||||
result := handler.evaluateRule(item, "genre", "equals", "Science Fiction")
|
|
||||||
assert.True(t, result)
|
|
||||||
|
|
||||||
result = handler.evaluateRule(item, "genre", "equals", "Fantasy")
|
|
||||||
assert.False(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvaluateRule_Author(t *testing.T) {
|
|
||||||
item := database.MediaItems{
|
|
||||||
Author: pgtype.Text{String: "Isaac Asimov", Valid: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
|
|
||||||
result := handler.evaluateRule(item, "author", "contains", "asimov")
|
|
||||||
assert.True(t, result)
|
|
||||||
|
|
||||||
result = handler.evaluateRule(item, "author", "contains", "clarke")
|
|
||||||
assert.False(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvaluateRule_CopyrightYear(t *testing.T) {
|
|
||||||
item := database.MediaItems{
|
|
||||||
CopyrightYear: pgtype.Int4{Int32: 2021, Valid: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
|
|
||||||
result := handler.evaluateRule(item, "copyright_year", "greater_than", "2000")
|
|
||||||
assert.True(t, result)
|
|
||||||
|
|
||||||
result = handler.evaluateRule(item, "copyright_year", "less_than", "2000")
|
|
||||||
assert.False(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTextToString(t *testing.T) {
|
func TestTextToString(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ type DashboardHandler struct {
|
|||||||
dashboardService *services.DashboardService
|
dashboardService *services.DashboardService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDashboardHandler(db *database.Queries) *DashboardHandler {
|
func NewDashboardHandler(db *database.Queries, search ...*services.SearchService) *DashboardHandler {
|
||||||
return &DashboardHandler{
|
return &DashboardHandler{
|
||||||
db: db,
|
db: db,
|
||||||
dashboardService: services.NewDashboardService(db),
|
dashboardService: services.NewDashboardService(db, search...),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -243,6 +243,36 @@ func NewMediaHandler(db *database.Queries, raw database.DBTX, libraryService *se
|
|||||||
|
|
||||||
// ExecuteSearch performs search and returns results with count
|
// ExecuteSearch performs search and returns results with count
|
||||||
// Public wrapper for shared search logic used by both JSON and HTML endpoints
|
// Public wrapper for shared search logic used by both JSON and HTML endpoints
|
||||||
|
// SearchService exposes the shared search engine for SSR surfaces
|
||||||
|
// that render a filter tree (the collection editor and bookshelf pages
|
||||||
|
// resolve id labels through it — 2026-11-01).
|
||||||
|
func (h *MediaHandler) SearchService() *services.SearchService { return h.searchService }
|
||||||
|
|
||||||
|
// ResolveFilterLabels — GET /api/filter/labels?field=X&ids=a,b,c names
|
||||||
|
// id-kind filter values straight from their source tables: the client
|
||||||
|
// path that never touches a server render (applying a SAVED filter
|
||||||
|
// from a cold bookshelf) would otherwise fall back to the suggest
|
||||||
|
// facet, whose rows are media-constrained and label only the ids whose
|
||||||
|
// media matches the tree's sibling rules (2026-11-01 feed repro).
|
||||||
|
// Visibility/RLS ride the request identity, same as the SSR embed.
|
||||||
|
func (h *MediaHandler) ResolveFilterLabels(c *echo.Context) error {
|
||||||
|
user := c.Get("user").(database.Users)
|
||||||
|
field := c.QueryParam("field")
|
||||||
|
if !services.IsIDValuedField(field) {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "field is not an id-valued filter field"})
|
||||||
|
}
|
||||||
|
ids := services.ParseUUIDValues(strings.Split(c.QueryParam("ids"), ","))
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "ids required"})
|
||||||
|
}
|
||||||
|
if len(ids) > services.MaxResolveIDs {
|
||||||
|
ids = ids[:services.MaxResolveIDs]
|
||||||
|
}
|
||||||
|
labels := h.SearchService().ResolveFieldLabels(
|
||||||
|
c.Request().Context(), user.ID, field, ids)
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{"labels": labels})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *MediaHandler) ExecuteSearch(ctx context.Context, params services.SearchParams) ([]database.SearchMediaItemsUnifiedRow, int, error) {
|
func (h *MediaHandler) ExecuteSearch(ctx context.Context, params services.SearchParams) ([]database.SearchMediaItemsUnifiedRow, int, error) {
|
||||||
return h.searchService.ExecuteSearch(ctx, params)
|
return h.searchService.ExecuteSearch(ctx, params)
|
||||||
}
|
}
|
||||||
@@ -1542,6 +1572,13 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "media item not found"})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "media item not found"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Edit segregation (plan D10, task 1.8): podcast episodes are
|
||||||
|
// fetcher-owned (D9 — the sole writer); the generic edit box would
|
||||||
|
// clobber feed metadata that the next refresh overwrites anyway.
|
||||||
|
if existing.Source == "podcast_episode" {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "podcast episodes are managed by their feed and cannot be edited here"})
|
||||||
|
}
|
||||||
|
|
||||||
coverPath := existing.CoverImagePath.String
|
coverPath := existing.CoverImagePath.String
|
||||||
coverUploaded := false
|
coverUploaded := false
|
||||||
|
|
||||||
@@ -1874,6 +1911,17 @@ func (mh *MediaHandler) DeleteMediaItem(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Invariant 2 (podcast plan §10, absolute form): episode rows are
|
||||||
|
// removed ONLY when the whole feed is removed — the feed must match
|
||||||
|
// its XML until then. Same segregation as the edit path (D9/D10):
|
||||||
|
// episodes are fetcher-owned; delete FILES via the podcast surfaces
|
||||||
|
// (feed page / admin podcast view) instead.
|
||||||
|
if media.Source == "podcast_episode" {
|
||||||
|
return c.JSON(http.StatusConflict, map[string]string{
|
||||||
|
"error": "podcast episodes are removed only by removing the whole feed — delete the episode's files instead (feed page or admin podcast view)",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Storage-down guard (see HandleBulkDelete): refuse while the library
|
// Storage-down guard (see HandleBulkDelete): refuse while the library
|
||||||
// storage is unreachable so the cascade can't destroy annotations for
|
// storage is unreachable so the cascade can't destroy annotations for
|
||||||
// a file that will re-import on the next scan.
|
// a file that will re-import on the next scan.
|
||||||
@@ -2541,6 +2589,13 @@ func ParseMediaSearchParams(c searchParamSource) services.SearchParams {
|
|||||||
tree, err := services.ParseFilterTree(qf)
|
tree, err := services.ParseFilterTree(qf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
params.FilterParseErr = err.Error()
|
params.FilterParseErr = err.Error()
|
||||||
|
} else if len(tree.Children) == 0 {
|
||||||
|
// A qf with no rules is not a browse query: the deep-link
|
||||||
|
// contract answers 400 (pinned). The empty root gained
|
||||||
|
// meaning only as a STORED collection seed — where the
|
||||||
|
// composers define it as matching nothing. As a query it
|
||||||
|
// would silently mean "everything", so reject at the wire.
|
||||||
|
params.FilterParseErr = "qf tree has no rules"
|
||||||
} else {
|
} else {
|
||||||
params.FilterTree = tree
|
params.FilterTree = tree
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
"bookhoard/internal/opds"
|
"bookhoard/internal/opds"
|
||||||
@@ -18,6 +17,24 @@ import (
|
|||||||
"github.com/labstack/echo/v5"
|
"github.com/labstack/echo/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// opdsVisibleLibraries is GetUserVisibleLibraries minus podcasts-type
|
||||||
|
// libraries (plan D16): feeds are not OPDS inventory day one — they
|
||||||
|
// surface via the /podcasts endpoints only.
|
||||||
|
func opdsVisibleLibraries(ctx context.Context, q *database.Queries, userID pgtype.UUID) ([]database.GetUserVisibleLibrariesRow, error) {
|
||||||
|
libs, err := q.GetUserVisibleLibraries(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]database.GetUserVisibleLibrariesRow, 0, len(libs))
|
||||||
|
for _, lib := range libs {
|
||||||
|
if lib.TypeName == services.LibraryTypePodcasts {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, lib)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
type OPDSHandler struct {
|
type OPDSHandler struct {
|
||||||
db *database.Queries
|
db *database.Queries
|
||||||
libraryService *services.LibraryService
|
libraryService *services.LibraryService
|
||||||
@@ -209,7 +226,7 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
|
|||||||
|
|
||||||
// Get user's visible libraries
|
// Get user's visible libraries
|
||||||
userID := device.UserID.Bytes
|
userID := device.UserID.Bytes
|
||||||
libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
|
libraries, err := opdsVisibleLibraries(c.Request().Context(), h.db, pgtype.UUID{Bytes: userID, Valid: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries"))
|
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries"))
|
||||||
}
|
}
|
||||||
@@ -318,7 +335,7 @@ func (h *OPDSHandler) serveAllBooksCatalog(c *echo.Context) error {
|
|||||||
|
|
||||||
// Get user's visible libraries
|
// Get user's visible libraries
|
||||||
userID := device.UserID.Bytes
|
userID := device.UserID.Bytes
|
||||||
libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
|
libraries, err := opdsVisibleLibraries(c.Request().Context(), h.db, pgtype.UUID{Bytes: userID, Valid: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries"))
|
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries"))
|
||||||
}
|
}
|
||||||
@@ -570,7 +587,7 @@ func (h *OPDSHandler) GetLibraryCatalog(c *echo.Context) error {
|
|||||||
|
|
||||||
// The library must be visible to the device owner.
|
// The library must be visible to the device owner.
|
||||||
userID := device.UserID.Bytes
|
userID := device.UserID.Bytes
|
||||||
libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
|
libraries, err := opdsVisibleLibraries(c.Request().Context(), h.db, pgtype.UUID{Bytes: userID, Valid: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries"))
|
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries"))
|
||||||
}
|
}
|
||||||
@@ -677,7 +694,7 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
|
|||||||
|
|
||||||
// Get user's visible libraries
|
// Get user's visible libraries
|
||||||
userID := device.UserID.Bytes
|
userID := device.UserID.Bytes
|
||||||
libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
|
libraries, err := opdsVisibleLibraries(c.Request().Context(), h.db, pgtype.UUID{Bytes: userID, Valid: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries"))
|
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries"))
|
||||||
}
|
}
|
||||||
@@ -818,7 +835,7 @@ func (h *OPDSHandler) DownloadBook(c *echo.Context) error {
|
|||||||
|
|
||||||
// Get user's visible libraries
|
// Get user's visible libraries
|
||||||
userID := device.UserID.Bytes
|
userID := device.UserID.Bytes
|
||||||
libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
|
libraries, err := opdsVisibleLibraries(c.Request().Context(), h.db, pgtype.UUID{Bytes: userID, Valid: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get libraries"})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get libraries"})
|
||||||
}
|
}
|
||||||
@@ -974,7 +991,7 @@ func (h *OPDSHandler) GetCoverImage(c *echo.Context) error {
|
|||||||
|
|
||||||
// Get user's visible libraries
|
// Get user's visible libraries
|
||||||
userID := device.UserID.Bytes
|
userID := device.UserID.Bytes
|
||||||
libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
|
libraries, err := opdsVisibleLibraries(c.Request().Context(), h.db, pgtype.UUID{Bytes: userID, Valid: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get libraries"})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get libraries"})
|
||||||
}
|
}
|
||||||
@@ -1115,7 +1132,7 @@ func (h *OPDSHandler) ListFormats(c *echo.Context) error {
|
|||||||
|
|
||||||
// Get user's visible libraries
|
// Get user's visible libraries
|
||||||
userID := device.UserID.Bytes
|
userID := device.UserID.Bytes
|
||||||
libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
|
libraries, err := opdsVisibleLibraries(c.Request().Context(), h.db, pgtype.UUID{Bytes: userID, Valid: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get libraries"})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get libraries"})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,758 @@
|
|||||||
|
// Podcast handlers (plan §8): JSON API over the RLS-governed podcast
|
||||||
|
// surfaces. Handlers parse requests and call services; visibility is
|
||||||
|
// the engine's job (D20) — Go checks only explicit-argument
|
||||||
|
// authorization (target-library rules, admin role) and subscription
|
||||||
|
// lookups (podcast_subscriptions is ungoverned BY DESIGN: it IS the
|
||||||
|
// read policy's lookup table).
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"bookhoard/internal/database"
|
||||||
|
"bookhoard/internal/services"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/labstack/echo/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PodcastHandler struct {
|
||||||
|
db *database.Queries
|
||||||
|
podcasts *services.PodcastService
|
||||||
|
worker *services.PodcastDownloadWorker
|
||||||
|
|
||||||
|
// episodeListRenderer renders the refreshed episode-list partial
|
||||||
|
// for htmx action responses (download/refresh/order/policy).
|
||||||
|
// Injected by the router at wiring time — the view assembly lives
|
||||||
|
// with the other templ view code (handlers must not import
|
||||||
|
// templates: templates imports handlers for MediaDetail). When nil
|
||||||
|
// (tests, API-only), htmx callers fall back to JSON + HX-Trigger.
|
||||||
|
episodeListRenderer EpisodeListRenderer
|
||||||
|
}
|
||||||
|
|
||||||
|
// EpisodeListRenderer renders the post-action episode list for an htmx
|
||||||
|
// request. includePolicySelect additionally out-of-band-swaps the
|
||||||
|
// auto-download select (Download All flips the caller's policy — the
|
||||||
|
// dropdown must reflect it immediately, issue #3);
|
||||||
|
// includeOrderSelect likewise rides along for the order endpoint (the
|
||||||
|
// "Reverse feed order" button flips to reverse — the dropdown must
|
||||||
|
// mirror the server state, §0 item 3).
|
||||||
|
type EpisodeListRenderer func(c *echo.Context, feedID string, includePolicySelect, includeOrderSelect bool) error
|
||||||
|
|
||||||
|
// SetEpisodeListRenderer wires the renderer (router-only; product wiring).
|
||||||
|
func (h *PodcastHandler) SetEpisodeListRenderer(r EpisodeListRenderer) {
|
||||||
|
h.episodeListRenderer = r
|
||||||
|
}
|
||||||
|
|
||||||
|
// hxEpisodeList answers an htmx action with the refreshed partial when
|
||||||
|
// a renderer is wired; otherwise falls back to 204 + HX-Trigger.
|
||||||
|
func (h *PodcastHandler) hxEpisodeList(c *echo.Context, feedID string, includePolicySelect, includeOrderSelect bool) error {
|
||||||
|
if h.episodeListRenderer != nil {
|
||||||
|
return h.episodeListRenderer(c, feedID, includePolicySelect, includeOrderSelect)
|
||||||
|
}
|
||||||
|
return hxAccepted(c, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminDeleteFeedFiles POST /api/podcasts/feeds/:id/delete-files —
|
||||||
|
// admin-only (§0 item 8): delete every downloaded episode file of the
|
||||||
|
// feed; rows reset to pending. HX answers HX-Refresh (the admin table
|
||||||
|
// re-renders with fresh aggregates).
|
||||||
|
func (h *PodcastHandler) AdminDeleteFeedFiles(c *echo.Context) error {
|
||||||
|
user := MustGetAuthenticatedUser(c)
|
||||||
|
if user.Role != "admin" {
|
||||||
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin only"})
|
||||||
|
}
|
||||||
|
feedID, err := parseUUID(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid feed id"})
|
||||||
|
}
|
||||||
|
// media_items writes run under the first-admin identity (invariant
|
||||||
|
// 12 — same contract as the fetcher/worker).
|
||||||
|
actx, err := h.worker.AdminCtx(c.Request().Context())
|
||||||
|
if err != nil {
|
||||||
|
return podcastErrorResponse(c, err)
|
||||||
|
}
|
||||||
|
reset, err := h.podcasts.AdminDeleteFeedFiles(actx, feedID)
|
||||||
|
if err != nil {
|
||||||
|
return podcastErrorResponse(c, err)
|
||||||
|
}
|
||||||
|
log.Printf("[PODCAST] admin: deleted files for %d episode(s) of feed %s (user %s)", reset, feedID, user.Username)
|
||||||
|
if isHXRequest(c) {
|
||||||
|
c.Response().Header().Set("HX-Refresh", "true")
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{"reset_episodes": reset})
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{"reset_episodes": reset})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminDeleteEpisodeFile POST /api/podcasts/episodes/:id/delete-file —
|
||||||
|
// admin-only (§0 item 8): ONE episode's file deleted, row reset to
|
||||||
|
// pending. HX answers with the refreshed episode list.
|
||||||
|
func (h *PodcastHandler) AdminDeleteEpisodeFile(c *echo.Context) error {
|
||||||
|
user := MustGetAuthenticatedUser(c)
|
||||||
|
if user.Role != "admin" {
|
||||||
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin only"})
|
||||||
|
}
|
||||||
|
episodeID, err := parseUUID(c.Param("eid"))
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid episode id"})
|
||||||
|
}
|
||||||
|
actx, err := h.worker.AdminCtx(c.Request().Context())
|
||||||
|
if err != nil {
|
||||||
|
return podcastErrorResponse(c, err)
|
||||||
|
}
|
||||||
|
if err := h.podcasts.AdminDeleteEpisodeFile(actx, episodeID); err != nil {
|
||||||
|
return podcastErrorResponse(c, err)
|
||||||
|
}
|
||||||
|
log.Printf("[PODCAST] admin: deleted file of episode %s (user %s)", episodeID, user.Username)
|
||||||
|
if isHXRequest(c) {
|
||||||
|
// Re-render the episode list: the row needs its fresh state. The
|
||||||
|
// episode's feed id comes from the row we just reset.
|
||||||
|
if ep, err := h.db.GetPodcastEpisode(actx, episodeID); err == nil && ep.SourceFeedID.Valid {
|
||||||
|
return h.hxEpisodeList(c, ep.SourceFeedID.String(), false, false)
|
||||||
|
}
|
||||||
|
return hxAccepted(c, "")
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, map[string]string{"message": "file deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminDeleteEpisodeFilesBulk POST /api/podcasts/feeds/:id/episodes/delete-files —
|
||||||
|
// admin-only (§0 item 8): multi-select file deletion. Body carries the
|
||||||
|
// CHECKED boxes' values (form-encoded, repeated episode-file-select
|
||||||
|
// keys — hx-include gathers them; parsed directly, never bound: the
|
||||||
|
// repeated-key wire is another echo-bind gotcha). Each id is validated
|
||||||
|
// to belong to THIS feed; rows reset to pending, files removed.
|
||||||
|
func (h *PodcastHandler) AdminDeleteEpisodeFilesBulk(c *echo.Context) error {
|
||||||
|
user := MustGetAuthenticatedUser(c)
|
||||||
|
if user.Role != "admin" {
|
||||||
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin only"})
|
||||||
|
}
|
||||||
|
feedID, err := parseUUID(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid feed id"})
|
||||||
|
}
|
||||||
|
if err := c.Request().ParseForm(); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||||
|
}
|
||||||
|
rawIDs := c.Request().PostForm["episode-file-select"]
|
||||||
|
if len(rawIDs) == 0 {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "no episodes selected"})
|
||||||
|
}
|
||||||
|
actx, err := h.worker.AdminCtx(c.Request().Context())
|
||||||
|
if err != nil {
|
||||||
|
return podcastErrorResponse(c, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
deleted := 0
|
||||||
|
for _, raw := range rawIDs {
|
||||||
|
episodeID, perr := parseUUID(raw)
|
||||||
|
if perr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// The checkbox list only renders this feed's rows, but the wire
|
||||||
|
// is user-controlled: a forged id for a foreign feed's episode
|
||||||
|
// is refused (feed-match guard), not silently applied.
|
||||||
|
ep, gerr := h.db.GetPodcastEpisode(actx, episodeID)
|
||||||
|
if gerr != nil || ep.SourceFeedID != feedID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if derr := h.podcasts.AdminDeleteEpisodeFile(actx, episodeID); derr == nil {
|
||||||
|
deleted++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Printf("[PODCAST] admin: bulk-deleted files for %d/%d episode(s) of feed %s (user %s)",
|
||||||
|
deleted, len(rawIDs), feedID, user.Username)
|
||||||
|
if isHXRequest(c) {
|
||||||
|
return h.hxEpisodeList(c, feedID.String(), false, false)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{"deleted": deleted})
|
||||||
|
}
|
||||||
|
|
||||||
|
// flipSubscriptionToAll implements issue #3: Download All (button or
|
||||||
|
// add-feed checkbox) sets the CALLER's subscription policy to 'all' —
|
||||||
|
// per-user, never global (files are shared D6, policies are not). The
|
||||||
|
// dropdown reflects it via the renderer's oob swap; the user can turn
|
||||||
|
// it down afterwards.
|
||||||
|
func (h *PodcastHandler) flipSubscriptionToAll(ctx context.Context, userID, feedID pgtype.UUID) {
|
||||||
|
sub, err := h.db.GetPodcastSubscription(ctx, database.GetPodcastSubscriptionParams{
|
||||||
|
UserID: userID, FeedID: feedID,
|
||||||
|
})
|
||||||
|
if err != nil || sub.AutoDownload == "all" {
|
||||||
|
return // not subscribed (admin pass-through) or already all
|
||||||
|
}
|
||||||
|
_, _ = h.db.UpdatePodcastSubscription(ctx, database.UpdatePodcastSubscriptionParams{
|
||||||
|
UserID: userID,
|
||||||
|
FeedID: feedID,
|
||||||
|
DisplayName: sub.DisplayName,
|
||||||
|
AutoDownload: "all",
|
||||||
|
AutoDownloadCount: pgtype.Int4{}, // all needs no count
|
||||||
|
})
|
||||||
|
log.Printf("[PODCAST] flip: user %s → policy 'all' for feed %s (Download All / add checkbox)", userID, feedID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPodcastHandler(db *database.Queries, svc *services.PodcastService, worker *services.PodcastDownloadWorker) *PodcastHandler {
|
||||||
|
return &PodcastHandler{db: db, podcasts: svc, worker: worker}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- request/response shapes ----
|
||||||
|
|
||||||
|
type AddPodcastFeedRequest struct {
|
||||||
|
LibraryID string `json:"library_id" form:"library_id" validate:"required"`
|
||||||
|
URL string `json:"url" form:"url" validate:"required"`
|
||||||
|
DownloadAll FlexBool `json:"download_all" form:"download_all"`
|
||||||
|
// DownloadOrder: the initial listening order for THIS feed, applied
|
||||||
|
// only with DownloadAll (§0 item 5c) — it both seeds the queue in
|
||||||
|
// listening order and sets the caller's order override. ""/"feed" =
|
||||||
|
// no override row.
|
||||||
|
DownloadOrder string `json:"download_order,omitempty" form:"download_order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlexBool binds checkbox-style booleans on every wire. echo bool-binds
|
||||||
|
// via strconv.ParseBool, which accepts "true"/"1" but REJECTS the
|
||||||
|
// browser-native checkbox value "on" — and a bind failure is fatal for
|
||||||
|
// the WHOLE request (the add modal would 400 "invalid request" on any
|
||||||
|
// form variant that posts "on"). §0 item 2 (2026-10-30).
|
||||||
|
//
|
||||||
|
// A checkbox only contributes its field when CHECKED, so for form
|
||||||
|
// posts presence really is the signal — JSON's native bool binding
|
||||||
|
// ("false"→false) is unaffected. Absence on either wire = false.
|
||||||
|
type FlexBool bool
|
||||||
|
|
||||||
|
func (b *FlexBool) UnmarshalParam(v string) error {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||||
|
case "false", "0", "off", "n", "no":
|
||||||
|
*b = false
|
||||||
|
default:
|
||||||
|
*b = true // "true", "on", "yes", "checked", "1" — or any truthy variant
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdatePodcastSubscriptionRequest struct {
|
||||||
|
DisplayName *string `json:"display_name" form:"display_name"`
|
||||||
|
AutoDownload *string `json:"auto_download" form:"auto_download" validate:"omitempty,oneof=off all newest"`
|
||||||
|
AutoDownloadCount *int `json:"auto_download_count" form:"auto_download_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DownloadEpisodesRequest struct {
|
||||||
|
EpisodeIDs []string `json:"episode_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// hxAccepted answers an htmx request with 204 + the podcast:changed
|
||||||
|
// trigger — the episode-list partial re-fetches itself (its hx-trigger
|
||||||
|
// listens for the event; polling covers download progress). JSON
|
||||||
|
// callers keep the JSON body. HX-Redirect/HX-Trigger headers are the
|
||||||
|
// sanctioned Go-handler response channels (CSP discipline).
|
||||||
|
func hxAccepted(c *echo.Context, redirect string) error {
|
||||||
|
if redirect != "" {
|
||||||
|
c.Response().Header().Set("HX-Redirect", redirect)
|
||||||
|
return c.NoContent(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
c.Response().Header().Set("HX-Trigger", "podcast:changed")
|
||||||
|
return c.NoContent(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isHXRequest(c *echo.Context) bool {
|
||||||
|
return c.Request().Header.Get("HX-Request") == "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
// podcastErrorResponse maps service errors (incl. PodcastError statuses)
|
||||||
|
// onto HTTP responses.
|
||||||
|
func podcastErrorResponse(c *echo.Context, err error) error {
|
||||||
|
if pe, ok := err.(*services.PodcastError); ok {
|
||||||
|
return c.JSON(pe.Status, map[string]string{"error": pe.Message})
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
// podcastFeedSummaries is the list payload: subscription + feed metadata
|
||||||
|
// + the facet values the /podcasts page filters on (computed here —
|
||||||
|
// feed counts are tens, not thousands; no facet SQL needed).
|
||||||
|
type podcastFeedSummary struct {
|
||||||
|
Feed
|
||||||
|
SubscribedAt string `json:"subscribed_at"`
|
||||||
|
EpisodeTotal int `json:"episode_total"`
|
||||||
|
DownloadedN int `json:"episode_downloaded"`
|
||||||
|
UndownloadedN int `json:"episode_undownloaded"`
|
||||||
|
NewEpisodes bool `json:"new_episodes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddFeed POST /api/podcasts/feeds
|
||||||
|
func (h *PodcastHandler) AddFeed(c *echo.Context) error {
|
||||||
|
user := MustGetAuthenticatedUser(c)
|
||||||
|
var req AddPodcastFeedRequest
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||||
|
}
|
||||||
|
if err := c.Validate(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
libraryID, err := parseUUID(req.LibraryID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library id"})
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := h.podcasts.AddFeed(c.Request().Context(), user.ID, user.Role == "admin", libraryID, req.URL)
|
||||||
|
if err != nil {
|
||||||
|
return podcastErrorResponse(c, err)
|
||||||
|
}
|
||||||
|
if req.DownloadAll && res.Feed != nil {
|
||||||
|
// §0 item 5c: the add modal's order dropdown is the feed's
|
||||||
|
// INITIAL listening order — it IS the download order (the queue
|
||||||
|
// seeds in that order below). 'feed'/'native' = no override row.
|
||||||
|
order := "native"
|
||||||
|
switch req.DownloadOrder {
|
||||||
|
case "reverse", "oldest", "newest":
|
||||||
|
order = req.DownloadOrder
|
||||||
|
payload, _ := json.Marshal(map[string]string{"order": order})
|
||||||
|
if err := h.db.UpsertUserSettingByKey(c.Request().Context(), database.UpsertUserSettingByKeyParams{
|
||||||
|
UserID: user.ID, SettingKey: podcastOrderSettingKey(res.Feed.ID.String()), SettingValue: payload,
|
||||||
|
}); err != nil {
|
||||||
|
// subscription + queue stand; the dropdown just won't
|
||||||
|
// preselect it (surfaced on the next order set).
|
||||||
|
_ = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := h.worker.QueueFeedDownloadsAs(c.Request().Context(), user.ID, res.Feed.ID); err != nil {
|
||||||
|
// subscription stands; download-all failure surfaces via the
|
||||||
|
// episode list states.
|
||||||
|
}
|
||||||
|
// Issue #3: the checkbox means "everything, now AND going
|
||||||
|
// forward" — reflect it in the caller's subscription policy.
|
||||||
|
h.flipSubscriptionToAll(c.Request().Context(), user.ID, res.Feed.ID)
|
||||||
|
}
|
||||||
|
if isHXRequest(c) {
|
||||||
|
// Post-redirect-get: land on the new feed's page. Fetch errors
|
||||||
|
// (subscription stands) surface via the flash query param.
|
||||||
|
if res.FeedError() != "" {
|
||||||
|
return hxAccepted(c, "/podcasts/"+uuidString(res.Feed.ID)+"?fetch_error=1")
|
||||||
|
}
|
||||||
|
return hxAccepted(c, "/podcasts/"+uuidString(res.Feed.ID))
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusCreated, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListFeeds GET /api/podcasts/feeds — the caller's subscriptions.
|
||||||
|
func (h *PodcastHandler) ListFeeds(c *echo.Context) error {
|
||||||
|
user := MustGetAuthenticatedUser(c)
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
|
||||||
|
feeds, err := h.db.ListMyPodcastFeeds(ctx, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return podcastErrorResponse(c, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]podcastFeedSummary, 0, len(feeds))
|
||||||
|
for i := range feeds {
|
||||||
|
row := &feeds[i]
|
||||||
|
total, _ := h.db.CountPodcastEpisodesForFeed(ctx, row.ID)
|
||||||
|
downloaded, _ := h.db.CountDownloadedEpisodes(ctx, row.ID)
|
||||||
|
undownloaded, _ := h.db.CountUndownloadedEpisodes(ctx, row.ID)
|
||||||
|
out = append(out, podcastFeedSummary{
|
||||||
|
Feed: Feed{
|
||||||
|
ID: uuidString(row.ID), LibraryID: uuidString(row.LibraryID),
|
||||||
|
FeedUrl: row.FeedUrl, PodcastGuid: row.PodcastGuid.String,
|
||||||
|
Title: row.Title, Description: row.Description.String,
|
||||||
|
Host: row.Host.String, Publisher: row.Publisher.String,
|
||||||
|
Language: row.Language.String, Tags: row.Tags,
|
||||||
|
Explicit: row.Explicit, ArtworkPath: row.ArtworkPath.String,
|
||||||
|
FeedType: row.FeedType.String, Medium: row.Medium.String,
|
||||||
|
IsComplete: row.IsComplete, LastFetchedAt: timeString(row.LastFetchedAt),
|
||||||
|
},
|
||||||
|
SubscribedAt: timeString(row.SubscribedAt),
|
||||||
|
EpisodeTotal: int(total),
|
||||||
|
DownloadedN: int(downloaded),
|
||||||
|
UndownloadedN: int(undownloaded),
|
||||||
|
// "New episodes" (D18): the feed released something the user
|
||||||
|
// has not pulled — undownloaded rows exist beyond the last
|
||||||
|
// downloaded position.
|
||||||
|
NewEpisodes: undownloaded > 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
"feeds": out,
|
||||||
|
"facets": facetValuesFor(out),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFeed GET /api/podcasts/feeds/:id — detail: metadata, episodes with
|
||||||
|
// download state, gap summary. RLS answers 404 for non-subscribers
|
||||||
|
// (existence leak) — no Go-level visibility plumbing.
|
||||||
|
func (h *PodcastHandler) GetFeed(c *echo.Context) error {
|
||||||
|
user := MustGetAuthenticatedUser(c)
|
||||||
|
feedID, err := parseUUID(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid feed id"})
|
||||||
|
}
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
|
||||||
|
feed, err := h.db.GetPodcastFeed(ctx, feedID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "feed not found"})
|
||||||
|
}
|
||||||
|
sub, subFound := h.db.GetPodcastSubscription(ctx, database.GetPodcastSubscriptionParams{UserID: user.ID, FeedID: feedID})
|
||||||
|
eps, err := h.db.ListPodcastEpisodes(ctx, feedID)
|
||||||
|
if err != nil {
|
||||||
|
return podcastErrorResponse(c, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
episodeViews := make([]PodcastEpisodeView, 0, len(eps))
|
||||||
|
for i := range eps {
|
||||||
|
episodeViews = append(episodeViews, episodeView(&eps[i]))
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
"feed": feedWithSubscription(&feed, sub, subFound == nil),
|
||||||
|
"episodes": episodeViews,
|
||||||
|
"gap_count": countGaps(episodeViews),
|
||||||
|
"undownloaded": len(episodeViews) - countDownloaded(episodeViews),
|
||||||
|
"has_library_access": true, // RLS already gated the row; kept for client convenience
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSubscription PUT /api/podcasts/feeds/:id/subscription — the
|
||||||
|
// only user-writable podcast state (D9): display name + auto-download.
|
||||||
|
func (h *PodcastHandler) UpdateSubscription(c *echo.Context) error {
|
||||||
|
user := MustGetAuthenticatedUser(c)
|
||||||
|
feedID, err := parseUUID(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid feed id"})
|
||||||
|
}
|
||||||
|
var req UpdatePodcastSubscriptionRequest
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
sub, err := h.db.GetPodcastSubscription(ctx, database.GetPodcastSubscriptionParams{UserID: user.ID, FeedID: feedID})
|
||||||
|
if err != nil {
|
||||||
|
// Not subscribed (or feed invisible): 404 semantics.
|
||||||
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "not subscribed"})
|
||||||
|
}
|
||||||
|
|
||||||
|
displayName := sub.DisplayName
|
||||||
|
if req.DisplayName != nil {
|
||||||
|
displayName = pgtypeText(*req.DisplayName)
|
||||||
|
}
|
||||||
|
autoDownload := sub.AutoDownload
|
||||||
|
if req.AutoDownload != nil {
|
||||||
|
autoDownload = *req.AutoDownload
|
||||||
|
}
|
||||||
|
count := sub.AutoDownloadCount
|
||||||
|
if req.AutoDownloadCount != nil {
|
||||||
|
if *req.AutoDownloadCount < 0 || *req.AutoDownloadCount > 100 {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "auto_download_count out of range"})
|
||||||
|
}
|
||||||
|
count = pgtype.Int4{Int32: int32(*req.AutoDownloadCount), Valid: *req.AutoDownloadCount > 0}
|
||||||
|
}
|
||||||
|
if autoDownload == "newest" && !count.Valid {
|
||||||
|
count = pgtype.Int4{Int32: 3, Valid: true} // sane default for newest-N
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := h.db.UpdatePodcastSubscription(ctx, database.UpdatePodcastSubscriptionParams{
|
||||||
|
UserID: user.ID,
|
||||||
|
FeedID: feedID,
|
||||||
|
DisplayName: displayName,
|
||||||
|
AutoDownload: autoDownload,
|
||||||
|
AutoDownloadCount: count,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return podcastErrorResponse(c, err)
|
||||||
|
}
|
||||||
|
if isHXRequest(c) {
|
||||||
|
// Policy changes feed the auto-download union on the next poll.
|
||||||
|
_, _ = h.worker.ApplyAutoPolicies(ctx, feedID)
|
||||||
|
return h.hxEpisodeList(c, feedID.String(), true, false)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteSubscription DELETE /api/podcasts/feeds/:id/subscription —
|
||||||
|
// unsubscribe NEVER deletes files/episodes (D6; last unsubscribe
|
||||||
|
// retains them so re-subscribing must not re-download).
|
||||||
|
func (h *PodcastHandler) DeleteSubscription(c *echo.Context) error {
|
||||||
|
user := MustGetAuthenticatedUser(c)
|
||||||
|
feedID, err := parseUUID(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid feed id"})
|
||||||
|
}
|
||||||
|
rows, err := h.db.DeletePodcastSubscription(c.Request().Context(), database.DeletePodcastSubscriptionParams{UserID: user.ID, FeedID: feedID})
|
||||||
|
if err != nil {
|
||||||
|
return podcastErrorResponse(c, err)
|
||||||
|
}
|
||||||
|
if rows == 0 {
|
||||||
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "not subscribed"})
|
||||||
|
}
|
||||||
|
if isHXRequest(c) {
|
||||||
|
return hxAccepted(c, "/podcasts")
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, map[string]string{"message": "unsubscribed (files retained)"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshFeed POST /api/podcasts/feeds/:id/refresh — manual poll.
|
||||||
|
func (h *PodcastHandler) RefreshFeed(c *echo.Context) error {
|
||||||
|
user := MustGetAuthenticatedUser(c)
|
||||||
|
_ = user
|
||||||
|
feedID, err := parseUUID(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid feed id"})
|
||||||
|
}
|
||||||
|
if err := h.podcasts.RefreshFeed(c.Request().Context(), feedID); err != nil {
|
||||||
|
return podcastErrorResponse(c, err)
|
||||||
|
}
|
||||||
|
if isHXRequest(c) {
|
||||||
|
return h.hxEpisodeList(c, feedID.String(), false, false)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, map[string]string{"message": "refreshed"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadEpisodes POST /api/podcasts/feeds/:id/episodes/:eid/download
|
||||||
|
// (+ bulk via episode_ids[]; Download All = all non-downloaded). Retry
|
||||||
|
// is the same endpoint (idempotent state machine, plan §7).
|
||||||
|
func (h *PodcastHandler) DownloadEpisodes(c *echo.Context) error {
|
||||||
|
user := MustGetAuthenticatedUser(c)
|
||||||
|
_ = user
|
||||||
|
feedID, err := parseUUID(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid feed id"})
|
||||||
|
}
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
|
||||||
|
// The feed must be in the caller's scope; RLS answers 404.
|
||||||
|
if _, err := h.db.GetPodcastFeed(ctx, feedID); err != nil {
|
||||||
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "feed not found"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bulk form?
|
||||||
|
var bulk DownloadEpisodesRequest
|
||||||
|
_ = c.Bind(&bulk)
|
||||||
|
if len(bulk.EpisodeIDs) > 0 {
|
||||||
|
queued := 0
|
||||||
|
for _, raw := range bulk.EpisodeIDs {
|
||||||
|
epID, err := parseUUID(raw)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := h.worker.QueueEpisode(ctx, epID, feedID); err == nil {
|
||||||
|
queued++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusAccepted, map[string]interface{}{"queued": queued})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single-episode path (:eid).
|
||||||
|
eidRaw := c.Param("eid")
|
||||||
|
if eidRaw == "all" {
|
||||||
|
// Download All affordance: exactly the non-downloaded set (pin).
|
||||||
|
// Queues in the CALLER's listening order (§0 item 5b).
|
||||||
|
queued, err := h.worker.QueueFeedDownloadsAs(ctx, user.ID, feedID)
|
||||||
|
if err != nil {
|
||||||
|
return podcastErrorResponse(c, err)
|
||||||
|
}
|
||||||
|
if isHXRequest(c) {
|
||||||
|
// Download All flips the caller's policy to all (issue #3)
|
||||||
|
// — the oob select keeps the dropdown honest.
|
||||||
|
h.flipSubscriptionToAll(ctx, user.ID, feedID)
|
||||||
|
return h.hxEpisodeList(c, feedID.String(), true, false)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusAccepted, map[string]interface{}{"queued": queued})
|
||||||
|
}
|
||||||
|
epID, err := parseUUID(eidRaw)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid episode id"})
|
||||||
|
}
|
||||||
|
if err := h.worker.QueueEpisode(ctx, epID, feedID); err != nil {
|
||||||
|
return podcastErrorResponse(c, err)
|
||||||
|
}
|
||||||
|
if isHXRequest(c) {
|
||||||
|
return h.hxEpisodeList(c, feedID.String(), false, false)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusAccepted, map[string]string{"message": "queued"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search GET /api/podcasts/search?q= — subscription-scoped feed +
|
||||||
|
// episode search (D14): covers un-downloaded and historical episodes
|
||||||
|
// (rows are never deleted when a feed shrinks).
|
||||||
|
func (h *PodcastHandler) Search(c *echo.Context) error {
|
||||||
|
user := MustGetAuthenticatedUser(c)
|
||||||
|
q := strings.TrimSpace(c.QueryParam("q"))
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
|
||||||
|
feeds, err := h.db.SearchMyPodcastFeeds(ctx, database.SearchMyPodcastFeedsParams{UserID: user.ID, SearchQuery: q})
|
||||||
|
if err != nil {
|
||||||
|
return podcastErrorResponse(c, err)
|
||||||
|
}
|
||||||
|
eps, err := h.db.SearchMyPodcastEpisodes(ctx, q)
|
||||||
|
if err != nil {
|
||||||
|
return podcastErrorResponse(c, err)
|
||||||
|
}
|
||||||
|
episodeViews := make([]PodcastEpisodeView, 0, len(eps))
|
||||||
|
for i := range eps {
|
||||||
|
episodeViews = append(episodeViews, episodeView(&eps[i]))
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{"feeds": feeds, "episodes": episodeViews})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteFeed DELETE /api/podcasts/feeds/:id — admin-only removal;
|
||||||
|
// optional ?files=false keeps the episode files on disk.
|
||||||
|
func (h *PodcastHandler) DeleteFeed(c *echo.Context) error {
|
||||||
|
user := MustGetAuthenticatedUser(c)
|
||||||
|
if user.Role != "admin" {
|
||||||
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin only"})
|
||||||
|
}
|
||||||
|
feedID, err := parseUUID(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid feed id"})
|
||||||
|
}
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
if _, err := h.db.GetPodcastFeed(ctx, feedID); err != nil {
|
||||||
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "feed not found"})
|
||||||
|
}
|
||||||
|
|
||||||
|
keepFiles := c.QueryParam("files") == "false"
|
||||||
|
if !keepFiles {
|
||||||
|
// File deletion is best-effort before the row cascade; episodes'
|
||||||
|
// file_path values are relative to the download root.
|
||||||
|
if eps, err := h.db.ListPodcastEpisodes(ctx, feedID); err == nil {
|
||||||
|
if feed, err := h.db.GetPodcastFeed(ctx, feedID); err == nil {
|
||||||
|
h.podcasts.DeleteFeedFiles(ctx, &feed, eps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rows, err := h.db.DeletePodcastFeed(ctx, feedID); err != nil {
|
||||||
|
return podcastErrorResponse(c, err)
|
||||||
|
} else if rows == 0 {
|
||||||
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "feed not found"})
|
||||||
|
}
|
||||||
|
if isHXRequest(c) {
|
||||||
|
return hxAccepted(c, "/podcasts")
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, map[string]string{"message": "feed removed"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Sequence (plan §8 / Phase 2: D5 order override, D7 chaining) ----
|
||||||
|
|
||||||
|
// podcastOrderSettingKey is the per-user, per-feed order override key
|
||||||
|
// in the reader_settings KV (D5).
|
||||||
|
func podcastOrderSettingKey(feedID string) string {
|
||||||
|
return "podcast_order:" + feedID
|
||||||
|
}
|
||||||
|
|
||||||
|
// podcastOrderValid native (feed position) | reverse (position DESC,
|
||||||
|
// no dates) | oldest | newest.
|
||||||
|
func podcastOrderValid(o string) bool {
|
||||||
|
return services.ValidPodcastEpisodeOrder(o)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFeedOrder reads the caller's effective order for a feed
|
||||||
|
// (default: native — feed-native order is the D5 default).
|
||||||
|
func (h *PodcastHandler) GetFeedOrder(c *echo.Context, feedID string) string {
|
||||||
|
user := MustGetAuthenticatedUser(c)
|
||||||
|
row, err := h.db.GetUserSettingByKey(c.Request().Context(), database.GetUserSettingByKeyParams{
|
||||||
|
UserID: user.ID,
|
||||||
|
SettingKey: podcastOrderSettingKey(feedID),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "native"
|
||||||
|
}
|
||||||
|
var v struct {
|
||||||
|
Order string `json:"order"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(row, &v); err != nil || !podcastOrderValid(v.Order) {
|
||||||
|
return "native"
|
||||||
|
}
|
||||||
|
return v.Order
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetFeedOrder PUT /api/podcasts/feeds/:id/order — per-user override.
|
||||||
|
// Deleting the override (order=native) removes the KV row so the
|
||||||
|
// default and the explicit choice are indistinguishable.
|
||||||
|
func (h *PodcastHandler) SetFeedOrder(c *echo.Context) error {
|
||||||
|
user := MustGetAuthenticatedUser(c)
|
||||||
|
feedID, err := parseUUID(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid feed id"})
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Order string `json:"order" form:"order" validate:"required,oneof=native reverse oldest newest"`
|
||||||
|
}
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||||
|
}
|
||||||
|
if err := c.Validate(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
if _, err := h.db.GetPodcastFeed(ctx, feedID); err != nil {
|
||||||
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "feed not found"})
|
||||||
|
}
|
||||||
|
key := podcastOrderSettingKey(feedID.String())
|
||||||
|
if req.Order == "native" {
|
||||||
|
_ = h.db.DeleteUserSettingByKey(ctx, database.DeleteUserSettingByKeyParams{
|
||||||
|
UserID: user.ID, SettingKey: key,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
payload, _ := json.Marshal(map[string]string{"order": req.Order})
|
||||||
|
if err := h.db.UpsertUserSettingByKey(ctx, database.UpsertUserSettingByKeyParams{
|
||||||
|
UserID: user.ID, SettingKey: key, SettingValue: payload,
|
||||||
|
}); err != nil {
|
||||||
|
return podcastErrorResponse(c, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isHXRequest(c) {
|
||||||
|
return h.hxEpisodeList(c, feedID.String(), false, true)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, map[string]string{"order": req.Order})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFeedSequence GET /api/podcasts/feeds/:id/sequence — the effective
|
||||||
|
// listening order (override applied, feed-native fallback), DOWNLOADED
|
||||||
|
// episodes only, plus the gap list (D7/D8). This payload powers both
|
||||||
|
// the web player's chain mode and (later, §15) the media3 queue.
|
||||||
|
func (h *PodcastHandler) GetFeedSequence(c *echo.Context) error {
|
||||||
|
feedID, err := parseUUID(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid feed id"})
|
||||||
|
}
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
|
||||||
|
order := h.GetFeedOrder(c, feedID.String())
|
||||||
|
eps, err := h.db.ListPodcastEpisodes(ctx, feedID)
|
||||||
|
if err != nil {
|
||||||
|
return podcastErrorResponse(c, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
views := make([]PodcastEpisodeView, 0, len(eps))
|
||||||
|
for i := range eps {
|
||||||
|
views = append(views, episodeView(&eps[i]))
|
||||||
|
}
|
||||||
|
// Effective listening order (order consolidation §0 item 5a).
|
||||||
|
services.OrderEpisodes(views, order,
|
||||||
|
func(v PodcastEpisodeView) int32 { return int32(v.FeedPosition) },
|
||||||
|
func(v PodcastEpisodeView) string { return v.Date },
|
||||||
|
)
|
||||||
|
|
||||||
|
sequence := make([]PodcastEpisodeView, 0, len(views))
|
||||||
|
gaps := make([]PodcastEpisodeView, 0)
|
||||||
|
for _, v := range views {
|
||||||
|
if v.Downloaded {
|
||||||
|
sequence = append(sequence, v)
|
||||||
|
} else {
|
||||||
|
gaps = append(gaps, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
"feed_id": feedID.String(),
|
||||||
|
"order": order,
|
||||||
|
"episodes": sequence,
|
||||||
|
"gaps": gaps,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortSlice: sort.Slice with a controller-friendly wrapper.
|
||||||
|
func sortSlice[T any](s []T, less func(a, b T) bool) {
|
||||||
|
sort.Slice(s, func(i, j int) bool { return less(s[i], s[j]) })
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
// Podcast view types + mappers shared by the JSON API and the SSR
|
||||||
|
// partials (plan §8/§9). One shape per concept so the htmx partials and
|
||||||
|
// the API can never drift.
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"bookhoard/internal/database"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Feed is the API-facing feed shape (subscription fields attached when
|
||||||
|
// the caller has one).
|
||||||
|
type Feed struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
LibraryID string `json:"library_id"`
|
||||||
|
FeedUrl string `json:"feed_url"`
|
||||||
|
PodcastGuid string `json:"podcast_guid,omitempty"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Host string `json:"host,omitempty"`
|
||||||
|
Publisher string `json:"publisher,omitempty"`
|
||||||
|
Language string `json:"language,omitempty"`
|
||||||
|
Tags []string `json:"tags,omitempty"`
|
||||||
|
Explicit bool `json:"explicit"`
|
||||||
|
ArtworkPath string `json:"artwork_path,omitempty"`
|
||||||
|
FeedType string `json:"feed_type,omitempty"`
|
||||||
|
Medium string `json:"medium,omitempty"`
|
||||||
|
IsComplete bool `json:"is_complete"`
|
||||||
|
LastFetchedAt string `json:"last_fetched_at,omitempty"`
|
||||||
|
|
||||||
|
// Subscription-owned (rendered when present).
|
||||||
|
DisplayName string `json:"display_name,omitempty"`
|
||||||
|
AutoDownload string `json:"auto_download,omitempty"`
|
||||||
|
AutoDownloadCount int `json:"auto_download_count,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PodcastEpisodeView is the episode-list row: media facts + the
|
||||||
|
// download lifecycle the list renders inline (D8/D19).
|
||||||
|
type PodcastEpisodeView struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
FeedID string `json:"feed_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Subtitle string `json:"subtitle,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Authors []string `json:"authors,omitempty"`
|
||||||
|
Hosts []string `json:"hosts,omitempty"`
|
||||||
|
Guests []string `json:"guests,omitempty"`
|
||||||
|
Date string `json:"date_published,omitempty"`
|
||||||
|
FeedPosition int `json:"feed_position"`
|
||||||
|
Season int `json:"season_number,omitempty"`
|
||||||
|
Episode int `json:"episode_number,omitempty"`
|
||||||
|
Duration int `json:"declared_duration_seconds,omitempty"`
|
||||||
|
MimeType string `json:"mime_type,omitempty"`
|
||||||
|
CoverImage string `json:"cover_image_path,omitempty"`
|
||||||
|
EnclosureURL string `json:"enclosure_url,omitempty"`
|
||||||
|
|
||||||
|
// Download lifecycle.
|
||||||
|
DownloadState string `json:"download_state"`
|
||||||
|
DownloadError string `json:"download_error,omitempty"`
|
||||||
|
DownloadAttempts int `json:"download_attempts"`
|
||||||
|
Downloaded bool `json:"downloaded"`
|
||||||
|
FileSize int64 `json:"file_size,omitempty"`
|
||||||
|
|
||||||
|
// Progress (from the shared reading_progress machinery).
|
||||||
|
ProgressPercent float64 `json:"progress_percent,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// episodeView maps a media_items row (podcast episode) onto the view.
|
||||||
|
func episodeView(ep *database.MediaItems) PodcastEpisodeView {
|
||||||
|
v := PodcastEpisodeView{
|
||||||
|
ID: uuidString(ep.ID),
|
||||||
|
FeedID: uuidString(ep.SourceFeedID),
|
||||||
|
Title: ep.Title,
|
||||||
|
Subtitle: ep.Subtitle.String,
|
||||||
|
Description: ep.Description.String,
|
||||||
|
Authors: ep.Authors,
|
||||||
|
Hosts: ep.Hosts,
|
||||||
|
Guests: ep.Guests,
|
||||||
|
FeedPosition: int(ep.FeedPosition.Int32),
|
||||||
|
Season: int(ep.SeasonNumber.Int32),
|
||||||
|
Episode: int(ep.EpisodeNumber.Int32),
|
||||||
|
Duration: int(ep.DeclaredDurationSeconds.Int32),
|
||||||
|
MimeType: ep.MimeType.String,
|
||||||
|
CoverImage: ep.CoverImagePath.String,
|
||||||
|
EnclosureURL: ep.EnclosureUrl.String,
|
||||||
|
|
||||||
|
DownloadState: ep.DownloadState,
|
||||||
|
DownloadError: ep.DownloadError.String,
|
||||||
|
DownloadAttempts: int(ep.DownloadAttempts),
|
||||||
|
Downloaded: ep.DownloadState == "downloaded",
|
||||||
|
FileSize: ep.FileSize.Int64,
|
||||||
|
}
|
||||||
|
if ep.DatePublished.Valid {
|
||||||
|
v.Date = ep.DatePublished.Time.Format("2006-01-02")
|
||||||
|
}
|
||||||
|
if !ep.FileSize.Valid {
|
||||||
|
v.FileSize = 0
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// feedWithSubscription merges the feed row + the caller's subscription
|
||||||
|
// (empty sub → bare feed).
|
||||||
|
func feedWithSubscription(feed *database.PodcastFeeds, sub database.PodcastSubscriptions, subFound bool) Feed {
|
||||||
|
f := Feed{
|
||||||
|
ID: uuidString(feed.ID),
|
||||||
|
LibraryID: uuidString(feed.LibraryID),
|
||||||
|
FeedUrl: feed.FeedUrl,
|
||||||
|
PodcastGuid: feed.PodcastGuid.String,
|
||||||
|
Title: feed.Title,
|
||||||
|
Description: feed.Description.String,
|
||||||
|
Host: feed.Host.String,
|
||||||
|
Publisher: feed.Publisher.String,
|
||||||
|
Language: feed.Language.String,
|
||||||
|
Tags: feed.Tags,
|
||||||
|
Explicit: feed.Explicit,
|
||||||
|
ArtworkPath: feed.ArtworkPath.String,
|
||||||
|
FeedType: feed.FeedType.String,
|
||||||
|
Medium: feed.Medium.String,
|
||||||
|
IsComplete: feed.IsComplete,
|
||||||
|
LastFetchedAt: timeString(feed.LastFetchedAt),
|
||||||
|
}
|
||||||
|
if subFound {
|
||||||
|
f.DisplayName = sub.DisplayName.String
|
||||||
|
f.AutoDownload = sub.AutoDownload
|
||||||
|
f.AutoDownloadCount = int(sub.AutoDownloadCount.Int32)
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// countGaps counts un-downloaded episodes that have a downloaded
|
||||||
|
// episode AFTER them in list order — the D8 "middle episodes missing"
|
||||||
|
// definition that drives the gap banner.
|
||||||
|
func countGaps(eps []PodcastEpisodeView) int {
|
||||||
|
seenDownloaded := false
|
||||||
|
gaps := 0
|
||||||
|
for i := len(eps) - 1; i >= 0; i-- {
|
||||||
|
if eps[i].Downloaded {
|
||||||
|
seenDownloaded = true
|
||||||
|
} else if seenDownloaded {
|
||||||
|
gaps++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return gaps
|
||||||
|
}
|
||||||
|
|
||||||
|
func countDownloaded(eps []PodcastEpisodeView) int {
|
||||||
|
n := 0
|
||||||
|
for _, e := range eps {
|
||||||
|
if e.Downloaded {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// facetValuesFor derives the /podcasts facet bar values (D18) from the
|
||||||
|
// subscription list — feed counts are small; no facet SQL.
|
||||||
|
func facetValuesFor(feeds []podcastFeedSummary) map[string][]string {
|
||||||
|
hosts := map[string]bool{}
|
||||||
|
tags := map[string]bool{}
|
||||||
|
langs := map[string]bool{}
|
||||||
|
media := map[string]bool{}
|
||||||
|
for _, f := range feeds {
|
||||||
|
if f.Host != "" {
|
||||||
|
hosts[f.Host] = true
|
||||||
|
}
|
||||||
|
for _, t := range f.Tags {
|
||||||
|
tags[t] = true
|
||||||
|
}
|
||||||
|
if f.Language != "" {
|
||||||
|
langs[f.Language] = true
|
||||||
|
}
|
||||||
|
if f.Medium != "" {
|
||||||
|
media[f.Medium] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map[string][]string{
|
||||||
|
"hosts": sortedKeys(hosts),
|
||||||
|
"tags": sortedKeys(tags),
|
||||||
|
"language": sortedKeys(langs),
|
||||||
|
"medium": sortedKeys(media),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedKeys(set map[string]bool) []string {
|
||||||
|
out := make([]string, 0, len(set))
|
||||||
|
for k := range set {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// uuidString renders a pgtype.UUID (empty string when NULL).
|
||||||
|
func uuidString(id pgtype.UUID) string {
|
||||||
|
if !id.Valid {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return uuid.UUID(id.Bytes).String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// timeString renders a timestamp (RFC3339; empty when NULL).
|
||||||
|
func timeString(t pgtype.Timestamptz) string {
|
||||||
|
if !t.Valid {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return t.Time.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pgtypeText (handlers-local) — service has its own.
|
||||||
|
func pgtypeText(s string) pgtype.Text {
|
||||||
|
if s == "" {
|
||||||
|
return pgtype.Text{}
|
||||||
|
}
|
||||||
|
return pgtype.Text{String: s, Valid: true}
|
||||||
|
}
|
||||||
@@ -74,6 +74,23 @@ func (h *AuthHandler) RefreshAccessToken(c *echo.Context) error {
|
|||||||
|
|
||||||
// Logout handles POST /api/auth/logout
|
// Logout handles POST /api/auth/logout
|
||||||
func (h *AuthHandler) Logout(c *echo.Context) error {
|
func (h *AuthHandler) Logout(c *echo.Context) error {
|
||||||
|
// Clear the HttpOnly session cookie — the SSR pages authenticate
|
||||||
|
// with it, and JavaScript cannot clear an HttpOnly cookie by
|
||||||
|
// design. Without this, "logout" wiped localStorage but left the
|
||||||
|
// cookie session alive for up to session_duration_seconds (the
|
||||||
|
// fake-signout bug: users roamed every cookie-authed page). The
|
||||||
|
// cookie is cleared BEFORE anything else — even a malformed body
|
||||||
|
// must sign the browser out.
|
||||||
|
c.SetCookie(&http.Cookie{
|
||||||
|
Name: "token",
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: secure == "true",
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
MaxAge: -1, // delete
|
||||||
|
})
|
||||||
|
|
||||||
var req RefreshTokenRequest
|
var req RefreshTokenRequest
|
||||||
if err := c.Bind(&req); err != nil {
|
if err := c.Bind(&req); err != nil {
|
||||||
return c.JSON(http.StatusOK, map[string]string{"message": "logged out successfully"})
|
return c.JSON(http.StatusOK, map[string]string{"message": "logged out successfully"})
|
||||||
|
|||||||
@@ -85,6 +85,11 @@ func (h *Handler) ScanLibrary(c *echo.Context) error {
|
|||||||
for _, folder := range libraryFolders {
|
for _, folder := range libraryFolders {
|
||||||
if folder.FolderPath == req.FolderPath {
|
if folder.FolderPath == req.FolderPath {
|
||||||
registered = true
|
registered = true
|
||||||
|
// Podcast download roots are feed-managed (invariant
|
||||||
|
// 4): never scanner-ingestible, even scoped.
|
||||||
|
if folder.Purpose == services.FolderPurposePodcast {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "podcast download roots are feed-managed and cannot be scanned"})
|
||||||
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -178,6 +178,21 @@ func validateSettingValue(def database.SettingDefault, value string) error {
|
|||||||
if _, err := strconv.ParseBool(value); err != nil {
|
if _, err := strconv.ParseBool(value); err != nil {
|
||||||
return fmt.Errorf("value must be true or false")
|
return fmt.Errorf("value must be true or false")
|
||||||
}
|
}
|
||||||
|
case database.SettingTypeFloat:
|
||||||
|
f, err := strconv.ParseFloat(value, 64)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("value must be a number")
|
||||||
|
}
|
||||||
|
if def.Min != "" {
|
||||||
|
if mn, err := strconv.ParseFloat(def.Min, 64); err == nil && f < mn {
|
||||||
|
return fmt.Errorf("value must be >= %s", def.Min)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if def.Max != "" {
|
||||||
|
if mx, err := strconv.ParseFloat(def.Max, 64); err == nil && f > mx {
|
||||||
|
return fmt.Errorf("value must be <= %s", def.Max)
|
||||||
|
}
|
||||||
|
}
|
||||||
case database.SettingTypeString:
|
case database.SettingTypeString:
|
||||||
if value == "" {
|
if value == "" {
|
||||||
return fmt.Errorf("value must not be empty")
|
return fmt.Errorf("value must not be empty")
|
||||||
|
|||||||
@@ -17,17 +17,16 @@ func registerCollectionsRoutes(cfg *Config) {
|
|||||||
collections.GET("/:id/books", cfg.CollectionHandler.GetBookCollections)
|
collections.GET("/:id/books", cfg.CollectionHandler.GetBookCollections)
|
||||||
collections.POST("/:id/books", cfg.CollectionHandler.AddBooks)
|
collections.POST("/:id/books", cfg.CollectionHandler.AddBooks)
|
||||||
collections.PUT("/:id/items/order", cfg.CollectionHandler.ReorderItems)
|
collections.PUT("/:id/items/order", cfg.CollectionHandler.ReorderItems)
|
||||||
|
// The chain's listening order lives ON the collection (plan §10 R2):
|
||||||
|
// merges into view_settings; the episode chain producer honors it.
|
||||||
|
collections.PUT("/:id/view-settings", cfg.CollectionHandler.SetCollectionChainOrder)
|
||||||
collections.DELETE("/:id/books/:bookId", cfg.CollectionHandler.RemoveBook)
|
collections.DELETE("/:id/books/:bookId", cfg.CollectionHandler.RemoveBook)
|
||||||
collections.POST("/:id/books/bulk-remove", cfg.CollectionHandler.BulkRemoveBooks)
|
collections.POST("/:id/books/bulk-remove", cfg.CollectionHandler.BulkRemoveBooks)
|
||||||
collections.POST("/bulk-add-books", cfg.CollectionHandler.HandleBulkAddBooks)
|
collections.POST("/bulk-add-books", cfg.CollectionHandler.HandleBulkAddBooks)
|
||||||
collections.POST("/test-rules", cfg.CollectionHandler.TestRules)
|
|
||||||
collections.POST("/preview", cfg.CollectionHandler.PreviewCollection)
|
collections.POST("/preview", cfg.CollectionHandler.PreviewCollection)
|
||||||
|
|
||||||
// Smart collection rule CRUD (the rules builder persists through
|
// Smart collection rule CRUD (the rules builder persists through
|
||||||
// these; type follows rules presence — first rule = smart).
|
// these; type follows rules presence — first rule = smart).
|
||||||
collections.POST("/:id/rules", cfg.CollectionHandler.AddRule)
|
|
||||||
collections.PUT("/:id/rules/:ruleId", cfg.CollectionHandler.UpdateRule)
|
|
||||||
collections.DELETE("/:id/rules/:ruleId", cfg.CollectionHandler.RemoveRule)
|
|
||||||
|
|
||||||
// Device shelf mapping routes
|
// Device shelf mapping routes
|
||||||
deviceCollections := protected.Group("/devices/:id/collections")
|
deviceCollections := protected.Group("/devices/:id/collections")
|
||||||
|
|||||||
+110
-10
@@ -14,6 +14,7 @@ import (
|
|||||||
|
|
||||||
"bookhoard/internal/config"
|
"bookhoard/internal/config"
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
|
"bookhoard/internal/rls"
|
||||||
"bookhoard/internal/handlers"
|
"bookhoard/internal/handlers"
|
||||||
"bookhoard/internal/services"
|
"bookhoard/internal/services"
|
||||||
"bookhoard/internal/utils"
|
"bookhoard/internal/utils"
|
||||||
@@ -408,9 +409,19 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SSR id-label resolution for a URL-carried qf tree: the drawer's
|
||||||
|
// value cells show NAMES from first paint instead of raw uuids
|
||||||
|
// (2026-11-01 — same story as the collection editor above).
|
||||||
|
labelsJSON := "{}"
|
||||||
|
if qfRaw := c.QueryParam("qf"); qfRaw != "" {
|
||||||
|
if t, perr := services.ParseFilterTree(qfRaw); perr == nil {
|
||||||
|
labelsJSON = resolveTreeLabelsJSON(c, cfg, user.ID, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
bookInfoList = handlers.MarkActiveConflicts(c.Request().Context(), cfg.Queries, pgtype.UUID{Bytes: userUUID, Valid: true}, bookInfoList)
|
bookInfoList = handlers.MarkActiveConflicts(c.Request().Context(), cfg.Queries, pgtype.UUID{Bytes: userUUID, Valid: true}, bookInfoList)
|
||||||
err = templates.BookShelf(user, libData, libraryID, errorMsg, savedFilters, bookInfoList, limit, offset, totalCount).Render(c.Request().Context(), &buf)
|
err = templates.BookShelf(user, libData, libraryID, errorMsg, savedFilters, bookInfoList, limit, offset, totalCount, labelsJSON).Render(c.Request().Context(), &buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -561,13 +572,17 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
colData := make([]templates.CollectionData, len(collections))
|
colData := make([]templates.CollectionData, len(collections))
|
||||||
colService := cfg.CollectionHandler.CollectionService()
|
colService := cfg.CollectionHandler.CollectionService()
|
||||||
for i, col := range collections {
|
for i, col := range collections {
|
||||||
isSmart := colService.IsSmartCollection(col.AutoAssignRules)
|
isSmart := services.IsCollectionSmart(col)
|
||||||
bookCount := 0
|
bookCount := 0
|
||||||
if isSmart {
|
if isSmart {
|
||||||
matches, evalErr := colService.EvaluateSmartCollection(c.Request().Context(),
|
matches, evalErr := colService.EvaluateSmartMembership(c.Request().Context(),
|
||||||
colService.ParseRules(col.AutoAssignRules), services.SmartEvalOptions{
|
col, services.SmartEvalOptions{
|
||||||
LibraryID: libScope,
|
LibraryID: libScope,
|
||||||
VisibleIDs: visibleIDs,
|
VisibleIDs: visibleIDs,
|
||||||
|
ViewerUserID: func() pgtype.UUID {
|
||||||
|
uid, _ := uuid.Parse(user.ID)
|
||||||
|
return uuidToPGType(uid)
|
||||||
|
}(),
|
||||||
})
|
})
|
||||||
if evalErr == nil {
|
if evalErr == nil {
|
||||||
bookCount = len(matches)
|
bookCount = len(matches)
|
||||||
@@ -695,6 +710,35 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
return renderErrorPage(c, "System collections cannot have rules", "not_allowed")
|
return renderErrorPage(c, "System collections cannot have rules", "not_allowed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The editor's vocabulary + prefill (item 3.4): the unified
|
||||||
|
// builder in the collection's scope. A stored tree renders
|
||||||
|
// as-is. (The legacy rules-array preload retired 2026-10-31
|
||||||
|
// with the rules-array storage — the tree is the only form.)
|
||||||
|
scope := "books"
|
||||||
|
treeJSON := "{}"
|
||||||
|
var parsedTree *services.FilterTree
|
||||||
|
if len(collection.FilterTree) > 0 {
|
||||||
|
if t, perr := services.ParseFilterTree(string(collection.FilterTree)); perr == nil {
|
||||||
|
scope = t.FilterScope()
|
||||||
|
if b, merr := json.Marshal(t); merr == nil {
|
||||||
|
treeJSON = string(b)
|
||||||
|
}
|
||||||
|
parsedTree = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fieldsPayload, merr := json.Marshal(services.FilterFieldsPayload(scope))
|
||||||
|
if merr != nil {
|
||||||
|
fieldsPayload = []byte("[]")
|
||||||
|
}
|
||||||
|
fieldsJSON := string(fieldsPayload)
|
||||||
|
|
||||||
|
// SSR id-label resolution: the stored tree arrives as bare ids;
|
||||||
|
// embed the id→name map so value cells render NAMES at first
|
||||||
|
// paint (2026-11-01: the client's pick-time cache dies with the
|
||||||
|
// page, and the quiet suggest fetch can't label ids whose media
|
||||||
|
// doesn't match the tree's sibling rules — raw uuids on refresh).
|
||||||
|
labelsJSON := resolveTreeLabelsJSON(c, cfg, user.ID, parsedTree)
|
||||||
|
|
||||||
colData := templates.CollectionData{
|
colData := templates.CollectionData{
|
||||||
ID: collectionID,
|
ID: collectionID,
|
||||||
Name: collection.Name,
|
Name: collection.Name,
|
||||||
@@ -702,11 +746,11 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
Color: utils.PgTextString(collection.Color),
|
Color: utils.PgTextString(collection.Color),
|
||||||
Icon: utils.PgTextString(collection.Icon),
|
Icon: utils.PgTextString(collection.Icon),
|
||||||
IsSystem: collection.IsSystemCollection.Bool,
|
IsSystem: collection.IsSystemCollection.Bool,
|
||||||
IsSmart: cfg.CollectionHandler.CollectionService().IsSmartCollection(collection.AutoAssignRules),
|
IsSmart: services.IsCollectionSmart(collection),
|
||||||
}
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
err = templates.CollectionRules(user, colData).Render(c.Request().Context(), &buf)
|
err = templates.CollectionRules(user, colData, fieldsJSON, treeJSON, scope, labelsJSON).Render(c.Request().Context(), &buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1507,8 +1551,7 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
|
|
||||||
// Admin hash conflicts page: content-duplicate groups flagged during hash
|
// Admin hash conflicts page: content-duplicate groups flagged during hash
|
||||||
// backfill or rescan, resolved by keeping all copies or merging into one.
|
// backfill or rescan, resolved by keeping all copies or merging into one.
|
||||||
frontendProtected.GET("/admin/hash-conflicts", handlers.AdminMiddleware(func(c *echo.Context) error {
|
frontendProtected.GET("/admin/hash-conflicts", handlers.AdminMiddleware(func(c *echo.Context) error { user, err := getTemplateUserWithTheme(c, cfg)
|
||||||
user, err := getTemplateUserWithTheme(c, cfg)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return renderErrorPage(c, "Error loading user", "user_load_error")
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
||||||
}
|
}
|
||||||
@@ -1562,6 +1605,62 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
return c.HTML(http.StatusOK, buf.String())
|
return c.HTML(http.StatusOK, buf.String())
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// Admin all-podcasts view (§0 item 8): every feed from every user with
|
||||||
|
// maintenance aggregates (disk usage first). The aggregate read runs
|
||||||
|
// under the FIRST-ADMIN identity — the RLS admin escape (invariant
|
||||||
|
// 12, poller/fetcher contract) — so admins see feeds from libraries
|
||||||
|
// hidden even from them.
|
||||||
|
frontendProtected.GET("/admin/podcasts", handlers.AdminMiddleware(func(c *echo.Context) error {
|
||||||
|
user, err := getTemplateUserWithTheme(c, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
||||||
|
}
|
||||||
|
|
||||||
|
adminUUID, err := cfg.Queries.GetFirstAdmin(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
return renderErrorPage(c, "Error loading podcasts", "podcasts_load_error")
|
||||||
|
}
|
||||||
|
// cfg.Queries is ScopedDB-backed: the context identity is all the
|
||||||
|
// aggregate read needs.
|
||||||
|
actx := rls.WithUser(c.Request().Context(), adminUUID)
|
||||||
|
|
||||||
|
rows, err := cfg.Queries.ListAllPodcastFeedsAdmin(actx)
|
||||||
|
if err != nil {
|
||||||
|
return renderErrorPage(c, "Error loading podcasts", "podcasts_load_error")
|
||||||
|
}
|
||||||
|
|
||||||
|
feeds := make([]templates.AdminPodcastFeedData, 0, len(rows))
|
||||||
|
var totalBytes int64
|
||||||
|
totalDownloaded, totalEpisodes := 0, 0
|
||||||
|
for _, r := range rows {
|
||||||
|
lastFetch := "never"
|
||||||
|
if r.LastFetchedAt.Valid {
|
||||||
|
lastFetch = r.LastFetchedAt.Time.In(utils.UserLocation(user.Timezone)).Format("Jan 2, 2006 15:04")
|
||||||
|
}
|
||||||
|
feeds = append(feeds, templates.AdminPodcastFeedData{
|
||||||
|
ID: uuid.UUID(r.ID.Bytes).String(),
|
||||||
|
Title: r.Title,
|
||||||
|
Host: r.Host.String,
|
||||||
|
LibraryName: r.LibraryName,
|
||||||
|
Subscribers: int(r.SubscriberCount),
|
||||||
|
SubscriberNames: r.SubscriberNames,
|
||||||
|
EpisodeTotal: int(r.EpisodeTotal),
|
||||||
|
EpisodeDownloaded: int(r.EpisodeDownloaded),
|
||||||
|
DiskBytes: r.DiskBytes,
|
||||||
|
LastFetched: lastFetch,
|
||||||
|
})
|
||||||
|
totalBytes += r.DiskBytes
|
||||||
|
totalEpisodes += int(r.EpisodeTotal)
|
||||||
|
totalDownloaded += int(r.EpisodeDownloaded)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := templates.AdminPodcasts(user, feeds, len(feeds), totalDownloaded, totalEpisodes, totalBytes).Render(c.Request().Context(), &buf); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.HTML(http.StatusOK, buf.String())
|
||||||
|
}))
|
||||||
|
|
||||||
// Admin users page
|
// Admin users page
|
||||||
frontendProtected.GET("/admin/users", handlers.AdminMiddleware(func(c *echo.Context) error {
|
frontendProtected.GET("/admin/users", handlers.AdminMiddleware(func(c *echo.Context) error {
|
||||||
user, err := getTemplateUserWithTheme(c, cfg)
|
user, err := getTemplateUserWithTheme(c, cfg)
|
||||||
@@ -1984,6 +2083,7 @@ func (cfg *Config) GetHealth(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
"status": "healthy",
|
"status": "healthy",
|
||||||
"version": version.Version,
|
"version": version.Version,
|
||||||
|
"commit": version.Commit,
|
||||||
"database": "connected",
|
"database": "connected",
|
||||||
"scan": map[string]interface{}{
|
"scan": map[string]interface{}{
|
||||||
"scan_in_progress": cfg.Worker.HasActiveScans(),
|
"scan_in_progress": cfg.Worker.HasActiveScans(),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package router
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -55,6 +56,14 @@ func getTemplateUserWithTheme(c *echo.Context, cfg *Config) (templates.User, err
|
|||||||
token = cookie.Value
|
token = cookie.Value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sidebar /podcasts gate (plan D13): >=1 visible podcasts library.
|
||||||
|
// One indexed COUNT per page render — the same class of per-request
|
||||||
|
// lookup the live-user guard already does.
|
||||||
|
showPodcasts := false
|
||||||
|
if n, err := cfg.Queries.CountPodcastLibrariesForUser(c.Request().Context(), uuidToPGType(userUUID)); err == nil && n > 0 {
|
||||||
|
showPodcasts = true
|
||||||
|
}
|
||||||
|
|
||||||
return templates.User{
|
return templates.User{
|
||||||
ID: userID,
|
ID: userID,
|
||||||
Email: userEmail,
|
Email: userEmail,
|
||||||
@@ -68,6 +77,7 @@ func getTemplateUserWithTheme(c *echo.Context, cfg *Config) (templates.User, err
|
|||||||
CreatedAt: userDB.CreatedAt.Time,
|
CreatedAt: userDB.CreatedAt.Time,
|
||||||
DefaultAudioSpeed: float64(userDB.DefaultAudioSpeed),
|
DefaultAudioSpeed: float64(userDB.DefaultAudioSpeed),
|
||||||
KeepScreenAwake: userDB.KeepScreenAwake,
|
KeepScreenAwake: userDB.KeepScreenAwake,
|
||||||
|
ShowPodcastsNav: showPodcasts,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,6 +271,15 @@ func visibleLibraryIDsFor(c *echo.Context, cfg *Config) (map[string]bool, []pgty
|
|||||||
// collectionDataFromRow builds the template collection payload shared by
|
// collectionDataFromRow builds the template collection payload shared by
|
||||||
// the collection detail page and its books-grid partial.
|
// the collection detail page and its books-grid partial.
|
||||||
func collectionDataFromRow(collection database.Collections, collectionID string, isSmart bool, bookCount int) templates.CollectionData {
|
func collectionDataFromRow(collection database.Collections, collectionID string, isSmart bool, bookCount int) templates.CollectionData {
|
||||||
|
chainOrder := ""
|
||||||
|
if len(collection.ViewSettings) > 0 {
|
||||||
|
var vs map[string]interface{}
|
||||||
|
if json.Unmarshal(collection.ViewSettings, &vs) == nil {
|
||||||
|
if s, ok := vs["chain_order"].(string); ok {
|
||||||
|
chainOrder = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return templates.CollectionData{
|
return templates.CollectionData{
|
||||||
ID: collectionID,
|
ID: collectionID,
|
||||||
Name: collection.Name,
|
Name: collection.Name,
|
||||||
@@ -269,6 +288,7 @@ func collectionDataFromRow(collection database.Collections, collectionID string,
|
|||||||
Icon: collection.Icon.String,
|
Icon: collection.Icon.String,
|
||||||
IsSystem: collection.IsSystemCollection.Bool,
|
IsSystem: collection.IsSystemCollection.Bool,
|
||||||
IsSmart: isSmart,
|
IsSmart: isSmart,
|
||||||
|
ChainOrder: chainOrder,
|
||||||
BookCount: bookCount,
|
BookCount: bookCount,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -277,25 +297,72 @@ func collectionDataFromRow(collection database.Collections, collectionID string,
|
|||||||
// detail page and the books-grid partial: smart evaluation, a dashboard
|
// detail page and the books-grid partial: smart evaluation, a dashboard
|
||||||
// query section, or manual membership. Callers resolve the scope:
|
// query section, or manual membership. Callers resolve the scope:
|
||||||
// libUUID/libScope/isAll/scoped per their own resolution rules.
|
// libUUID/libScope/isAll/scoped per their own resolution rules.
|
||||||
|
// resolveTreeLabelsJSON — SSR id-label resolution for a page-rendered
|
||||||
|
// filter tree: walks the tree's id-kind rules, resolves uuids to display
|
||||||
|
// names through the search engine (RLS/visibility ride the request
|
||||||
|
// identity), and returns the JSON map embedded into the builder mount.
|
||||||
|
// Best-effort: any failure yields "{}" and the page renders with the
|
||||||
|
// client's fallbacks intact (2026-11-01, filter_labels.go for the WHY).
|
||||||
|
func resolveTreeLabelsJSON(c *echo.Context, cfg *Config, userID string, tree *services.FilterTree) string {
|
||||||
|
if tree == nil || len(tree.Children) == 0 {
|
||||||
|
return "{}"
|
||||||
|
}
|
||||||
|
userUUID, err := uuid.Parse(userID)
|
||||||
|
if err != nil {
|
||||||
|
return "{}"
|
||||||
|
}
|
||||||
|
labels := cfg.MediaHandler.SearchService().FilterTreeLabels(
|
||||||
|
c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true}, tree)
|
||||||
|
if len(labels) == 0 {
|
||||||
|
return "{}"
|
||||||
|
}
|
||||||
|
b, merr := json.Marshal(labels)
|
||||||
|
if merr != nil {
|
||||||
|
return "{}"
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
func (cfg *Config) collectionBooksForScope(c *echo.Context, userUUID uuid.UUID, collUUID pgtype.UUID, collection database.Collections, collectionID string, libUUID pgtype.UUID, libScope pgtype.UUID, scoped bool, visibleIDs []pgtype.UUID) (books []handlers.BookInfo, isSmart bool, errCode string) {
|
func (cfg *Config) collectionBooksForScope(c *echo.Context, userUUID uuid.UUID, collUUID pgtype.UUID, collection database.Collections, collectionID string, libUUID pgtype.UUID, libScope pgtype.UUID, scoped bool, visibleIDs []pgtype.UUID) (books []handlers.BookInfo, isSmart bool, errCode string) {
|
||||||
colService := cfg.CollectionHandler.CollectionService()
|
colService := cfg.CollectionHandler.CollectionService()
|
||||||
isSmart = colService.IsSmartCollection(collection.AutoAssignRules)
|
isSmart = services.IsCollectionSmart(collection)
|
||||||
libTypes := handlers.LibraryTypeMap(c.Request().Context(), cfg.Queries)
|
libTypes := handlers.LibraryTypeMap(c.Request().Context(), cfg.Queries)
|
||||||
|
|
||||||
if isSmart {
|
if isSmart {
|
||||||
// Smart (dynamic) collection: membership is evaluated live —
|
// Smart (dynamic) collection: membership is evaluated live —
|
||||||
// visible libraries, live items only, any enabled rule matches.
|
// visible libraries, live items only, rules applied under the
|
||||||
matches, evalErr := colService.EvaluateSmartCollection(c.Request().Context(),
|
// collection's match mode (item 2.4: any|all), with the
|
||||||
colService.ParseRules(collection.AutoAssignRules), services.SmartEvalOptions{
|
// viewer's progress feeding played/unplayed rules.
|
||||||
LibraryID: libScope,
|
matches, evalErr := colService.EvaluateSmartMembership(c.Request().Context(),
|
||||||
VisibleIDs: visibleIDs,
|
collection, services.SmartEvalOptions{
|
||||||
|
LibraryID: libScope,
|
||||||
|
VisibleIDs: visibleIDs,
|
||||||
|
ViewerUserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||||
})
|
})
|
||||||
if evalErr != nil {
|
if evalErr != nil {
|
||||||
return nil, isSmart, "books_load_error"
|
return nil, isSmart, "books_load_error"
|
||||||
}
|
}
|
||||||
|
// The visible order IS the play order: the curated listening
|
||||||
|
// order (view_settings.chain_order, §10 R2) re-orders the
|
||||||
|
// episode cards exactly as the sequence producer will play
|
||||||
|
// them, so the select's effect is visible and Play launches
|
||||||
|
// from the card the user sees first (2026-11-01 user report:
|
||||||
|
// the select changed nothing on the page).
|
||||||
|
services.OrderEpisodeMedia(matches, handlers.CollectionChainOrder(collection.ViewSettings))
|
||||||
books = handlers.BookCardsFromMediaItems(matches, libTypes)
|
books = handlers.BookCardsFromMediaItems(matches, libTypes)
|
||||||
|
// Episode-awareness for smart matches (item 2.5): episode cards
|
||||||
|
// carry the play affordance (chain via ?queue=collection:).
|
||||||
|
// BookCardsFromMediaItems preserves match order, so a parallel
|
||||||
|
// walk is enough; feed titles stay empty here (batch lookup is
|
||||||
|
// polish, not acceptance).
|
||||||
|
for i := range books {
|
||||||
|
if i < len(matches) && matches[i].Source == "podcast_episode" {
|
||||||
|
books[i].IsEpisode = true
|
||||||
|
books[i].DownloadState = matches[i].DownloadState
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if collection.IsSystemCollection.Bool && collection.QueryType.Valid && collection.QueryType.String != "" && collection.QueryType.String != "filter" {
|
} else if collection.IsSystemCollection.Bool && collection.QueryType.Valid && collection.QueryType.String != "" && collection.QueryType.String != "filter" {
|
||||||
dashboardSvc := services.NewDashboardService(cfg.Queries)
|
dashboardSvc := services.NewDashboardService(cfg.Queries, cfg.CollectionHandler.CollectionService().Search())
|
||||||
sections, secErr := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libUUID, 1000, []string{}, []string{})
|
sections, secErr := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libUUID, 1000, []string{}, []string{})
|
||||||
if secErr != nil {
|
if secErr != nil {
|
||||||
return nil, isSmart, "books_load_error"
|
return nil, isSmart, "books_load_error"
|
||||||
@@ -322,11 +389,17 @@ func (cfg *Config) collectionBooksForScope(c *echo.Context, userUUID uuid.UUID,
|
|||||||
books = handlers.BookCardsFromCollectionDashRows(collItems, libTypes)
|
books = handlers.BookCardsFromCollectionDashRows(collItems, libTypes)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
collItems, collErr := cfg.Queries.GetCollectionItems(c.Request().Context(), collUUID)
|
// Episode collections (plan item 2.3): the detailed query
|
||||||
|
// keeps RLS-invisible rows as Hidden cards (unsubscribed
|
||||||
|
// episodes render an "unavailable" hint) and tags episode
|
||||||
|
// rows with feed title + download state. Same position
|
||||||
|
// ordering; the legacy INNER-JOIN shape remains on the
|
||||||
|
// library-scoped branch above.
|
||||||
|
collItems, collErr := cfg.Queries.GetCollectionItemsDetailed(c.Request().Context(), collUUID)
|
||||||
if collErr != nil {
|
if collErr != nil {
|
||||||
collItems = nil
|
collItems = nil
|
||||||
}
|
}
|
||||||
books = handlers.BookCardsFromCollectionItemRows(collItems, libTypes)
|
books = handlers.BookCardsFromCollectionDetailed(collItems, libTypes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return books, isSmart, ""
|
return books, isSmart, ""
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
// Podcast routes (plan §8). All under /api/podcasts with the JWT
|
||||||
|
// middleware; scoping is RLS's job (D20) — handlers add no visibility
|
||||||
|
// plumbing.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"bookhoard/internal/handlers"
|
||||||
|
"bookhoard/templates"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func registerPodcastRoutes(cfg *Config) {
|
||||||
|
g := cfg.Echo.Group("/api/podcasts", createJWTMiddleware(cfg))
|
||||||
|
|
||||||
|
// Episode-list renderer (issue #1): htmx actions answer with the
|
||||||
|
// refreshed partial directly — no event indirection to miss. The
|
||||||
|
// view assembly lives here (templates) while the action logic lives
|
||||||
|
// in the handler; this closure is the sanctioned seam.
|
||||||
|
cfg.PodcastHandler.SetEpisodeListRenderer(func(c *echo.Context, feedID string, includePolicySelect, includeOrderSelect bool) error {
|
||||||
|
user, err := getTemplateUserWithTheme(c, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
feedUUID, err := parsePodcastUUID(feedID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := buildPodcastDetailData(c, cfg, user, feedUUID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
render := templates.PodcastEpisodeList
|
||||||
|
if templates.HasActiveDownloads(data.Episodes) {
|
||||||
|
render = templates.PodcastEpisodeListPolling
|
||||||
|
}
|
||||||
|
if err := render(*data).Render(c.Request().Context(), &buf); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if includePolicySelect {
|
||||||
|
// Issue #3: out-of-band swap of the auto-download select —
|
||||||
|
// the dropdown reflects the flipped policy immediately.
|
||||||
|
if err := templates.PodcastAutoDownloadSelectOOB(data.Feed).Render(c.Request().Context(), &buf); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if includeOrderSelect {
|
||||||
|
// §0 item 3: out-of-band swap of the order select — the
|
||||||
|
// "Reverse feed order" button flips to reverse; the
|
||||||
|
// dropdown mirrors the server state immediately.
|
||||||
|
if err := templates.PodcastOrderSelectOOB(data.Feed.ID, data.Order).Render(c.Request().Context(), &buf); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c.HTML(http.StatusOK, buf.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
g.POST("/feeds", cfg.PodcastHandler.AddFeed)
|
||||||
|
g.GET("/feeds", cfg.PodcastHandler.ListFeeds)
|
||||||
|
g.GET("/search", cfg.PodcastHandler.Search)
|
||||||
|
|
||||||
|
// :id-scoped (RLS answers 404 for feeds outside the caller's scope).
|
||||||
|
feeds := g.Group("/feeds/:id")
|
||||||
|
feeds.GET("", cfg.PodcastHandler.GetFeed)
|
||||||
|
feeds.GET("/sequence", cfg.PodcastHandler.GetFeedSequence)
|
||||||
|
feeds.PUT("/order", cfg.PodcastHandler.SetFeedOrder)
|
||||||
|
feeds.PUT("/subscription", cfg.PodcastHandler.UpdateSubscription)
|
||||||
|
feeds.DELETE("/subscription", cfg.PodcastHandler.DeleteSubscription)
|
||||||
|
feeds.POST("/refresh", cfg.PodcastHandler.RefreshFeed)
|
||||||
|
feeds.POST("/episodes/:eid/download", cfg.PodcastHandler.DownloadEpisodes)
|
||||||
|
feeds.DELETE("", cfg.PodcastHandler.DeleteFeed) // admin-only (handler checks role)
|
||||||
|
|
||||||
|
// Admin maintenance (§0 item 8): file deletion frees the disk and
|
||||||
|
// resets rows to pending. Handler checks admin role.
|
||||||
|
feeds.POST("/delete-files", handlers.AdminMiddleware(cfg.PodcastHandler.AdminDeleteFeedFiles))
|
||||||
|
feeds.POST("/episodes/delete-files", handlers.AdminMiddleware(cfg.PodcastHandler.AdminDeleteEpisodeFilesBulk))
|
||||||
|
feeds.POST("/episodes/:eid/delete-file", handlers.AdminMiddleware(cfg.PodcastHandler.AdminDeleteEpisodeFile))
|
||||||
|
}
|
||||||
@@ -0,0 +1,481 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
// Podcast SSR pages (plan task 1.9): /podcasts (subscriptions grid +
|
||||||
|
// add-feed wizard) and /podcasts/:id (feed detail with the inline
|
||||||
|
// download lifecycle). Rendering ownership: ALL content is
|
||||||
|
// templ-rendered server data; htmx swaps partials; the sanctioned JS is
|
||||||
|
// the modal toggle + event delegation in web/src/podcasts.ts.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bookhoard/internal/database"
|
||||||
|
"bookhoard/internal/handlers"
|
||||||
|
"bookhoard/internal/services"
|
||||||
|
"bookhoard/internal/utils"
|
||||||
|
"bookhoard/templates"
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/labstack/echo/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func registerPodcastPages(cfg *Config) {
|
||||||
|
pages := cfg.Echo.Group("", createJWTMiddleware(cfg))
|
||||||
|
|
||||||
|
pages.GET("/podcasts", func(c *echo.Context) error {
|
||||||
|
user, err := getTemplateUserWithTheme(c, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
||||||
|
}
|
||||||
|
data, err := buildPodcastsPageData(c, cfg)
|
||||||
|
if err != nil {
|
||||||
|
data = &templates.PodcastsPageData{ErrorMsg: "Could not load subscriptions"}
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := templates.PodcastsPage(user, *data, "/podcasts").Render(c.Request().Context(), &buf); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.HTML(http.StatusOK, buf.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
pages.GET("/podcasts/:id", func(c *echo.Context) error {
|
||||||
|
user, err := getTemplateUserWithTheme(c, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
||||||
|
}
|
||||||
|
fid, ferr := parsePodcastUUID(c.Param("id"))
|
||||||
|
if ferr != nil {
|
||||||
|
return c.NoContent(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
data, err := buildPodcastDetailData(c, cfg, user, fid)
|
||||||
|
if err != nil {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
_ = templates.ErrorPage("Feed not found", "not_found").Render(c.Request().Context(), &buf)
|
||||||
|
return c.HTML(http.StatusNotFound, buf.String())
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := templates.PodcastDetailPage(user, *data).Render(c.Request().Context(), &buf); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.HTML(http.StatusOK, buf.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
// Episode list partial — the htmx refresh unit (poll while
|
||||||
|
// downloads are active; podcast:changed events swap it too).
|
||||||
|
pages.GET("/podcasts/:id/episodes", func(c *echo.Context) error {
|
||||||
|
user, err := getTemplateUserWithTheme(c, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
||||||
|
}
|
||||||
|
fid, ferr := parsePodcastUUID(c.Param("id"))
|
||||||
|
if ferr != nil {
|
||||||
|
return c.NoContent(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
data, err := buildPodcastDetailData(c, cfg, user, fid)
|
||||||
|
if err != nil {
|
||||||
|
return c.NoContent(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if templates.HasActiveDownloads(data.Episodes) {
|
||||||
|
err = templates.PodcastEpisodeListPolling(*data).Render(c.Request().Context(), &buf)
|
||||||
|
} else {
|
||||||
|
err = templates.PodcastEpisodeList(*data).Render(c.Request().Context(), &buf)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.HTML(http.StatusOK, buf.String())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildPodcastsPageData assembles the /podcasts page: subscription
|
||||||
|
// cards, facet values, server-side filters (q/host/tag/language).
|
||||||
|
func buildPodcastsPageData(c *echo.Context, cfg *Config) (*templates.PodcastsPageData, error) {
|
||||||
|
user := handlers.MustGetAuthenticatedUser(c)
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
|
||||||
|
libs, err := cfg.Queries.GetPodcastsLibraryForUser(ctx, user.ID)
|
||||||
|
data := &templates.PodcastsPageData{HasLibrary: len(libs) > 0}
|
||||||
|
if err != nil || len(libs) == 0 {
|
||||||
|
return data, err
|
||||||
|
}
|
||||||
|
for _, lib := range libs {
|
||||||
|
data.Libraries = append(data.Libraries, templates.LibraryData{
|
||||||
|
ID: uuid.UUID(lib.ID.Bytes).String(),
|
||||||
|
Name: lib.Name,
|
||||||
|
TypeName: "podcasts",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
feeds, err := cfg.Queries.ListMyPodcastFeeds(ctx, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return data, err
|
||||||
|
}
|
||||||
|
// Continue aggregation (plan 2.5): first in-progress episode per
|
||||||
|
// feed (the query is already most-recent-first).
|
||||||
|
continueByFeed := make(map[string]*database.GetPodcastContinueForUserRow)
|
||||||
|
if continueRows, err := cfg.Queries.GetPodcastContinueForUser(ctx, user.ID); err == nil {
|
||||||
|
for i := range continueRows {
|
||||||
|
row := &continueRows[i]
|
||||||
|
key := uuid.UUID(row.SourceFeedID.Bytes).String()
|
||||||
|
if _, ok := continueByFeed[key]; !ok {
|
||||||
|
continueByFeed[key] = row
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range feeds {
|
||||||
|
row := &feeds[i]
|
||||||
|
total, _ := cfg.Queries.CountPodcastEpisodesForFeed(ctx, row.ID)
|
||||||
|
downloaded, _ := cfg.Queries.CountDownloadedEpisodes(ctx, row.ID)
|
||||||
|
data.Feeds = append(data.Feeds, templates.PodcastFeedCard{
|
||||||
|
ID: uuid.UUID(row.ID.Bytes).String(),
|
||||||
|
LibraryID: uuid.UUID(row.LibraryID.Bytes).String(),
|
||||||
|
Title: row.Title,
|
||||||
|
DisplayName: row.DisplayName.String,
|
||||||
|
Description: firstLine(row.Description.String, 220),
|
||||||
|
Host: row.Host.String,
|
||||||
|
Publisher: row.Publisher.String,
|
||||||
|
Language: row.Language.String,
|
||||||
|
Tags: row.Tags,
|
||||||
|
Explicit: row.Explicit,
|
||||||
|
ArtworkPath: row.ArtworkPath.String,
|
||||||
|
FeedType: row.FeedType.String,
|
||||||
|
Medium: row.Medium.String,
|
||||||
|
IsComplete: row.IsComplete,
|
||||||
|
FeedURL: row.FeedUrl,
|
||||||
|
LastFetched: pgTimeDisplay(row.LastFetchedAt, utils.PgTextString(user.Timezone)),
|
||||||
|
SubscribedAt: pgTimeDisplay(row.SubscribedAt, utils.PgTextString(user.Timezone)),
|
||||||
|
EpisodeTotal: int(total),
|
||||||
|
DownloadedN: int(downloaded),
|
||||||
|
UndownloadedN: int(total) - int(downloaded),
|
||||||
|
NewEpisodes: total-downloaded > 0,
|
||||||
|
AutoDownload: row.AutoDownload,
|
||||||
|
AutoDownloadCount: int(row.AutoDownloadCount.Int32),
|
||||||
|
Subscribed: true,
|
||||||
|
})
|
||||||
|
if cont := continueByFeed[uuid.UUID(row.ID.Bytes).String()]; cont != nil {
|
||||||
|
card := &data.Feeds[len(data.Feeds)-1]
|
||||||
|
card.HasContinue = true
|
||||||
|
card.ContinueTitle = cont.Title
|
||||||
|
card.ContinueEpisode = int(cont.FeedPosition.Int32)
|
||||||
|
card.ContinuePercent = cont.Percentage.Float64
|
||||||
|
card.ContinueLastRead = cont.LastReadAt.Time
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Facet values from the unfiltered subscription list (D18).
|
||||||
|
allData := *data
|
||||||
|
data.Facets = facetValuesFromCards(allData.Feeds)
|
||||||
|
|
||||||
|
// Server-side filters.
|
||||||
|
q := strings.ToLower(strings.TrimSpace(c.QueryParam("q")))
|
||||||
|
host := c.QueryParam("host")
|
||||||
|
tag := c.QueryParam("tag")
|
||||||
|
lang := c.QueryParam("language")
|
||||||
|
|
||||||
|
// Episode-level matches (issue #8 / D14): a query also hits episode
|
||||||
|
// titles + descriptions — the feed surfaces with a match count.
|
||||||
|
// SearchMyPodcastEpisodes is subscription-scoped via RLS (the
|
||||||
|
// caller's identity). Only consult it when a query is present.
|
||||||
|
matchesByFeed := make(map[string]int)
|
||||||
|
if q != "" {
|
||||||
|
if eps, err := cfg.Queries.SearchMyPodcastEpisodes(ctx, q); err == nil {
|
||||||
|
for i := range eps {
|
||||||
|
key := uuid.UUID(eps[i].SourceFeedID.Bytes).String()
|
||||||
|
matchesByFeed[key]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := data.Feeds[:0:0]
|
||||||
|
for _, f := range data.Feeds {
|
||||||
|
if q != "" {
|
||||||
|
hay := strings.ToLower(f.Title + " " + f.DisplayName + " " + f.Host + " " + f.Publisher + " " + f.Description)
|
||||||
|
matched := matchesByFeed[f.ID]
|
||||||
|
if !strings.Contains(hay, q) && matched == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if host != "" && f.Host != host {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if lang != "" && !strings.EqualFold(f.Language, lang) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if tag != "" {
|
||||||
|
found := false
|
||||||
|
for _, t := range f.Tags {
|
||||||
|
if strings.EqualFold(t, tag) {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filtered = append(filtered, f)
|
||||||
|
}
|
||||||
|
if q != "" {
|
||||||
|
for i := range filtered {
|
||||||
|
filtered[i].EpisodeMatches = matchesByFeed[filtered[i].ID]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data.Feeds = filtered
|
||||||
|
data.Query = q
|
||||||
|
data.ContinueFeeds = templates.PodcastContinueFeeds(data.Feeds)
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildPodcastDetailData assembles the feed-detail page + episode list
|
||||||
|
// partial. RLS scopes the lookup (non-subscribers get sqlc.ErrNoRows →
|
||||||
|
// 404 semantics — no Go visibility plumbing, D20).
|
||||||
|
func buildPodcastDetailData(c *echo.Context, cfg *Config, user templates.User, feedID pgtype.UUID) (*templates.PodcastDetailData, error) {
|
||||||
|
actor := handlers.MustGetAuthenticatedUser(c)
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
|
||||||
|
feed, err := cfg.Queries.GetPodcastFeed(ctx, feedID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sub, _ := cfg.Queries.GetPodcastSubscription(ctx, database.GetPodcastSubscriptionParams{
|
||||||
|
UserID: actor.ID, FeedID: feedID,
|
||||||
|
})
|
||||||
|
eps, err := cfg.Queries.ListPodcastEpisodes(ctx, feedID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
progressRows, _ := cfg.Queries.GetPodcastEpisodeProgress(ctx, database.GetPodcastEpisodeProgressParams{
|
||||||
|
UserID: actor.ID, SourceFeedID: feedID,
|
||||||
|
})
|
||||||
|
progressByItem := make(map[string]struct {
|
||||||
|
Pct float64
|
||||||
|
LastRead pgtype.Timestamptz
|
||||||
|
}, len(progressRows))
|
||||||
|
for _, p := range progressRows {
|
||||||
|
progressByItem[uuid.UUID(p.MediaItemID.Bytes).String()] = struct {
|
||||||
|
Pct float64
|
||||||
|
LastRead pgtype.Timestamptz
|
||||||
|
}{p.Percentage.Float64, p.LastReadAt}
|
||||||
|
}
|
||||||
|
|
||||||
|
total, _ := cfg.Queries.CountPodcastEpisodesForFeed(ctx, feedID)
|
||||||
|
downloaded, _ := cfg.Queries.CountDownloadedEpisodes(ctx, feedID)
|
||||||
|
subscriberCount, _ := cfg.Queries.CountPodcastFeedSubscribers(ctx, feedID)
|
||||||
|
|
||||||
|
card := templates.PodcastFeedCard{
|
||||||
|
ID: uuid.UUID(feed.ID.Bytes).String(),
|
||||||
|
LibraryID: uuid.UUID(feed.LibraryID.Bytes).String(),
|
||||||
|
Title: feed.Title,
|
||||||
|
DisplayName: sub.DisplayName.String,
|
||||||
|
Description: feed.Description.String,
|
||||||
|
Host: feed.Host.String,
|
||||||
|
Publisher: feed.Publisher.String,
|
||||||
|
Language: feed.Language.String,
|
||||||
|
Tags: feed.Tags,
|
||||||
|
Explicit: feed.Explicit,
|
||||||
|
ArtworkPath: feed.ArtworkPath.String,
|
||||||
|
FeedType: feed.FeedType.String,
|
||||||
|
Medium: feed.Medium.String,
|
||||||
|
IsComplete: feed.IsComplete,
|
||||||
|
FeedURL: feed.FeedUrl,
|
||||||
|
LastFetched: pgTimeDisplay(feed.LastFetchedAt, user.Timezone),
|
||||||
|
EpisodeTotal: int(total),
|
||||||
|
DownloadedN: int(downloaded),
|
||||||
|
UndownloadedN: int(total) - int(downloaded),
|
||||||
|
NewEpisodes: total-downloaded > 0,
|
||||||
|
AutoDownload: sub.AutoDownload,
|
||||||
|
AutoDownloadCount: int(sub.AutoDownloadCount.Int32),
|
||||||
|
Subscribed: sub.ID.Valid,
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := make([]templates.PodcastEpisodeRow, 0, len(eps))
|
||||||
|
gaps := 0
|
||||||
|
seenDownloadedFrom := false // walk reversed: gap = undownloaded with a downloaded LATER in order
|
||||||
|
for i := len(eps) - 1; i >= 0; i-- {
|
||||||
|
ep := &eps[i]
|
||||||
|
downloadedEp := ep.DownloadState == "downloaded"
|
||||||
|
if !downloadedEp && seenDownloadedFrom {
|
||||||
|
gaps++
|
||||||
|
}
|
||||||
|
if downloadedEp {
|
||||||
|
seenDownloadedFrom = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Forward pass for the actual rows (native order — the list keeps
|
||||||
|
// feed order; the ORDER selector drives the sequence endpoint, and
|
||||||
|
// the order control re-renders this list via the override applied
|
||||||
|
// below).
|
||||||
|
order := podcastOrderForFeed(cfg, c, feedID.String())
|
||||||
|
for i := range eps {
|
||||||
|
ep := &eps[i]
|
||||||
|
rowID := uuid.UUID(ep.ID.Bytes).String()
|
||||||
|
p := progressByItem[rowID]
|
||||||
|
dateDisplay := ""
|
||||||
|
dateSort := ""
|
||||||
|
if ep.DatePublished.Valid {
|
||||||
|
dateSort = ep.DatePublished.Time.Format("2006-01-02")
|
||||||
|
dateDisplay = ep.DatePublished.Time.In(utils.UserLocation(user.Timezone)).Format("Jan 2, 2006")
|
||||||
|
}
|
||||||
|
rows = append(rows, templates.PodcastEpisodeRow{
|
||||||
|
ID: uuid.UUID(ep.ID.Bytes).String(),
|
||||||
|
FeedID: uuid.UUID(ep.SourceFeedID.Bytes).String(),
|
||||||
|
Title: ep.Title,
|
||||||
|
Description: firstLine(ep.Description.String, 200),
|
||||||
|
Date: dateDisplay,
|
||||||
|
SortDate: dateSort,
|
||||||
|
Position: int(ep.FeedPosition.Int32),
|
||||||
|
Duration: templates.FormatEpisodeDuration(int(ep.DeclaredDurationSeconds.Int32)),
|
||||||
|
Size: templates.FormatEpisodeSize(ep.FileSize.Int64),
|
||||||
|
PlayURL: "/readers/" + uuid.UUID(ep.ID.Bytes).String(),
|
||||||
|
ListenPct: p.Pct,
|
||||||
|
Downloaded: ep.DownloadState == "downloaded",
|
||||||
|
DownloadState: ep.DownloadState,
|
||||||
|
DownloadError: ep.DownloadError.String,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Order override reshapes the LIST too (the sequence endpoint is the
|
||||||
|
// strict downloaded-only view; the list shows everything in the
|
||||||
|
// chosen order so pending episodes stay visible). Consolidated sort
|
||||||
|
// (§0 item 5a) — SortDate is the lexical pub-date key so the
|
||||||
|
// tiebreak is chronological even in display-tz date strings.
|
||||||
|
services.OrderEpisodes(rows, order,
|
||||||
|
func(r templates.PodcastEpisodeRow) int32 { return int32(r.Position) },
|
||||||
|
func(r templates.PodcastEpisodeRow) string { return r.SortDate },
|
||||||
|
)
|
||||||
|
|
||||||
|
detail := &templates.PodcastDetailData{
|
||||||
|
Feed: card,
|
||||||
|
Episodes: rows,
|
||||||
|
GapCount: gaps,
|
||||||
|
Order: order,
|
||||||
|
IsAdmin: actor.Role == "admin",
|
||||||
|
IsSerial: feed.FeedType.String == "serial" || feed.Medium.String == "audiobook",
|
||||||
|
SubscriberCount: int(subscriberCount),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live byte progress for in-flight downloads (issue #1): read the
|
||||||
|
// worker's snapshot; rows not actively downloading render states.
|
||||||
|
if cfg.PodcastWorker != nil {
|
||||||
|
for i := range detail.Episodes {
|
||||||
|
row := &detail.Episodes[i]
|
||||||
|
if row.DownloadState != "downloading" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if recv, tot, ok := cfg.PodcastWorker.EpisodeProgress(pgtype.UUID{Bytes: uuid.MustParse(row.ID), Valid: true}); ok {
|
||||||
|
row.ProgressKnown = true
|
||||||
|
row.ProgressReceived = recv
|
||||||
|
row.ProgressTotal = tot
|
||||||
|
if tot > 0 {
|
||||||
|
row.ProgressPct = float64(recv) / float64(tot) * 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return detail, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// podcastOrderForFeed reads the caller's order override (D5).
|
||||||
|
//
|
||||||
|
// The UNSET default is "reverse" (2026-11-01 user request): the platform
|
||||||
|
// is audiobook-first and feed document order runs newest-first, so
|
||||||
|
// native starts a book at its LAST chapter. This is the same per-feed
|
||||||
|
// setting the Order dropdown shows — page list, player chain, and
|
||||||
|
// download queue all resolve through it. Stored overrides are untouched:
|
||||||
|
// anyone who picked an order keeps exactly what they picked.
|
||||||
|
func podcastOrderForFeed(cfg *Config, c *echo.Context, feedID string) string {
|
||||||
|
user := handlers.MustGetAuthenticatedUser(c)
|
||||||
|
row, err := cfg.Queries.GetUserSettingByKey(c.Request().Context(), database.GetUserSettingByKeyParams{
|
||||||
|
UserID: user.ID,
|
||||||
|
SettingKey: "podcast_order:" + feedID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "reverse"
|
||||||
|
}
|
||||||
|
var v struct {
|
||||||
|
Order string `json:"order"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(row, &v); err != nil {
|
||||||
|
return "reverse"
|
||||||
|
}
|
||||||
|
if !services.ValidPodcastEpisodeOrder(v.Order) {
|
||||||
|
return "reverse"
|
||||||
|
}
|
||||||
|
return v.Order
|
||||||
|
}
|
||||||
|
|
||||||
|
// facetValuesFromCards derives the filter bar values.
|
||||||
|
// facetValuesFromCards derives the filter-bar values with feed counts,
|
||||||
|
// sorted by frequency DESC then alphabetically — the top of each group
|
||||||
|
// is what a user with hundreds of feeds actually filters by.
|
||||||
|
func facetValuesFromCards(feeds []templates.PodcastFeedCard) map[string][]templates.FacetValue {
|
||||||
|
hosts, tags, langs := map[string]int{}, map[string]int{}, map[string]int{}
|
||||||
|
for _, f := range feeds {
|
||||||
|
if f.Host != "" {
|
||||||
|
hosts[f.Host]++
|
||||||
|
}
|
||||||
|
for _, t := range f.Tags {
|
||||||
|
tags[t]++
|
||||||
|
}
|
||||||
|
if f.Language != "" {
|
||||||
|
langs[f.Language]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map[string][]templates.FacetValue{
|
||||||
|
"hosts": sortedFacet(hosts),
|
||||||
|
"tags": sortedFacet(tags),
|
||||||
|
"language": sortedFacet(langs),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortedFacet: count DESC, then value ASC (stable, scannable).
|
||||||
|
func sortedFacet(set map[string]int) []templates.FacetValue {
|
||||||
|
out := make([]templates.FacetValue, 0, len(set))
|
||||||
|
for k, n := range set {
|
||||||
|
out = append(out, templates.FacetValue{Value: k, Count: n})
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
if out[i].Count != out[j].Count {
|
||||||
|
return out[i].Count > out[j].Count
|
||||||
|
}
|
||||||
|
return out[i].Value < out[j].Value
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstLine(s string, max int) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if i := strings.IndexAny(s, "\n\r"); i > 0 {
|
||||||
|
s = s[:i]
|
||||||
|
}
|
||||||
|
if len(s) > max {
|
||||||
|
s = s[:max] + "…"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func pgTimeDisplay(t pgtype.Timestamptz, tz string) string {
|
||||||
|
if !t.Valid {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return utils.FormatTimeInTimezone(t.Time, tz)
|
||||||
|
}
|
||||||
|
|
||||||
|
func itoaPad(n int) string {
|
||||||
|
return fmt.Sprintf("%06d", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePodcastUUID parses an id path param (handlers.parseUUID is
|
||||||
|
// package-private).
|
||||||
|
func parsePodcastUUID(s string) (pgtype.UUID, error) {
|
||||||
|
id, err := uuid.Parse(strings.TrimSpace(s))
|
||||||
|
if err != nil {
|
||||||
|
return pgtype.UUID{}, err
|
||||||
|
}
|
||||||
|
return pgtype.UUID{Bytes: id, Valid: true}, nil
|
||||||
|
}
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
package router
|
package router
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bookhoard/internal/database"
|
||||||
"bookhoard/internal/handlers"
|
"bookhoard/internal/handlers"
|
||||||
"bookhoard/internal/services"
|
"bookhoard/internal/services"
|
||||||
"bookhoard/internal/sync"
|
"bookhoard/internal/sync"
|
||||||
"bookhoard/internal/utils"
|
"bookhoard/internal/utils"
|
||||||
"bookhoard/templates"
|
"bookhoard/templates"
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
@@ -102,6 +105,18 @@ func registerReaderRoutes(cfg *Config) {
|
|||||||
TreatAsComic: handlers.ShouldTreatAsComic(libraryWithType.TypeName, mediaItem.FormatGroup),
|
TreatAsComic: handlers.ShouldTreatAsComic(libraryWithType.TypeName, mediaItem.FormatGroup),
|
||||||
KeepScreenAwake: user.KeepScreenAwake,
|
KeepScreenAwake: user.KeepScreenAwake,
|
||||||
}
|
}
|
||||||
|
// Podcast chaining (plan D7): episodes render the audio reader
|
||||||
|
// with sequence context — the neighboring DOWNLOADED episodes in
|
||||||
|
// the caller's effective order (override applied). RLS already
|
||||||
|
// scoped the episode read; the subscription predicate covers the
|
||||||
|
// neighbors the same way. ?single=1 escapes chaining (the "play
|
||||||
|
// single episode" affordance).
|
||||||
|
if mediaItem.Source == "podcast_episode" && mediaItem.SourceFeedID.Valid && c.QueryParam("single") != "1" {
|
||||||
|
userPG := uuidToPGType(userUUID)
|
||||||
|
if seq := buildPodcastSequenceContext(c, cfg, &mediaItem, userPG, libraryWithType.TypeName); seq != nil {
|
||||||
|
metadata.PodcastSequence = seq
|
||||||
|
}
|
||||||
|
}
|
||||||
// Render template
|
// Render template
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
// Audiobooks get the audio player page — the foliate document
|
// Audiobooks get the audio player page — the foliate document
|
||||||
@@ -144,3 +159,276 @@ func registerReaderRoutes(cfg *Config) {
|
|||||||
reader.GET("/settings", cfg.ReaderHandler.GetSettings)
|
reader.GET("/settings", cfg.ReaderHandler.GetSettings)
|
||||||
reader.PUT("/settings", cfg.ReaderHandler.UpdateSettings)
|
reader.PUT("/settings", cfg.ReaderHandler.UpdateSettings)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildPodcastSequenceContext computes the episode chain payload (plan
|
||||||
|
// D7): effective order — the D5 override applied, feed-native fallback —
|
||||||
|
// DOWNLOADED episodes only (gaps never enter the queue), the current
|
||||||
|
// episode's index, and prev/next reader URLs. RLS scopes the episode
|
||||||
|
// reads; non-subscribers never reach this path in the first place.
|
||||||
|
func buildPodcastSequenceContext(c *echo.Context, cfg *Config, item *database.MediaItems, userID pgtype.UUID, libraryType string) *templates.PodcastSequenceContext {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
// Queue override (plan item 2.3): ?queue=collection:<id> plays the
|
||||||
|
// episode inside an Up Next collection chain. Absent/malformed/
|
||||||
|
// not-owned ids fall back to the feed queue — the reader page must
|
||||||
|
// never hard-fail on a stale link.
|
||||||
|
if q := c.QueryParam("queue"); strings.HasPrefix(q, "collection:") {
|
||||||
|
if collID, err := uuid.Parse(strings.TrimPrefix(q, "collection:")); err == nil {
|
||||||
|
if seq := buildCollectionSequenceContext(c, cfg, item, userID, collID); seq != nil {
|
||||||
|
return seq
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
eps, err := cfg.Queries.ListPodcastEpisodes(ctx, item.SourceFeedID)
|
||||||
|
if err != nil || len(eps) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
feed, err := cfg.Queries.GetPodcastFeed(ctx, item.SourceFeedID)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Effective order override (D5): reader_settings KV. UNSET defaults
|
||||||
|
// to "reverse" (2026-11-01: audiobook-first — native document order
|
||||||
|
// starts a book at its last chapter); the same default the podcast
|
||||||
|
// page's Order dropdown shows. Stored overrides are untouched.
|
||||||
|
order := "reverse"
|
||||||
|
if row, err := cfg.Queries.GetUserSettingByKey(ctx, database.GetUserSettingByKeyParams{
|
||||||
|
UserID: userID,
|
||||||
|
SettingKey: "podcast_order:" + uuid.UUID(item.SourceFeedID.Bytes).String(),
|
||||||
|
}); err == nil {
|
||||||
|
var v struct {
|
||||||
|
Order string `json:"order"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(row, &v) == nil && services.ValidPodcastEpisodeOrder(v.Order) {
|
||||||
|
order = v.Order
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type seqEp struct {
|
||||||
|
id string
|
||||||
|
title string
|
||||||
|
date string
|
||||||
|
pos int32
|
||||||
|
}
|
||||||
|
ordered := make([]seqEp, 0, len(eps))
|
||||||
|
currentID := uuid.UUID(item.ID.Bytes).String()
|
||||||
|
for i := range eps {
|
||||||
|
ep := eps[i]
|
||||||
|
if ep.DownloadState != "downloaded" {
|
||||||
|
continue // un-downloaded episodes are skipped, not tracked (invariant 6)
|
||||||
|
}
|
||||||
|
date := ""
|
||||||
|
if ep.DatePublished.Valid {
|
||||||
|
date = ep.DatePublished.Time.Format("2006-01-02")
|
||||||
|
}
|
||||||
|
ordered = append(ordered, seqEp{
|
||||||
|
id: uuid.UUID(ep.ID.Bytes).String(),
|
||||||
|
title: ep.Title,
|
||||||
|
date: date,
|
||||||
|
pos: ep.FeedPosition.Int32,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Effective listening order (order consolidation §0 item 5a).
|
||||||
|
services.OrderEpisodes(ordered, order,
|
||||||
|
func(e seqEp) int32 { return e.pos },
|
||||||
|
func(e seqEp) string { return e.date },
|
||||||
|
)
|
||||||
|
|
||||||
|
idx := -1
|
||||||
|
for i, ep := range ordered {
|
||||||
|
if ep.id == currentID {
|
||||||
|
idx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if idx < 0 {
|
||||||
|
return nil // current episode not downloaded (or not in sequence)
|
||||||
|
}
|
||||||
|
|
||||||
|
seq := &templates.PodcastSequenceContext{
|
||||||
|
FeedURL: "/podcasts/" + uuid.UUID(item.SourceFeedID.Bytes).String(),
|
||||||
|
FeedTitle: feed.Title,
|
||||||
|
// R2: this producer is the feed queue. The player's order
|
||||||
|
// control renders only for this source.
|
||||||
|
Source: "feed",
|
||||||
|
FeedID: uuid.UUID(item.SourceFeedID.Bytes).String(),
|
||||||
|
Order: order,
|
||||||
|
EpisodeIndex: idx + 1,
|
||||||
|
IndexTotal: len(ordered),
|
||||||
|
}
|
||||||
|
if idx > 0 {
|
||||||
|
seq.PrevURL = "/readers/" + ordered[idx-1].id
|
||||||
|
}
|
||||||
|
if idx < len(ordered)-1 {
|
||||||
|
seq.NextURL = "/readers/" + ordered[idx+1].id
|
||||||
|
seq.NextTitle = ordered[idx+1].title
|
||||||
|
}
|
||||||
|
return seq
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildCollectionSequenceContext is the COLLECTION queue producer (plan
|
||||||
|
// item 2.3, §10 R2: same context shape, different owner). The queue is
|
||||||
|
// the collection's ordered downloaded episodes — the collection's
|
||||||
|
// order IS content (R1), so no feed-order override applies here and
|
||||||
|
// the player's order control stays hidden (source=collection).
|
||||||
|
// Ownership gates the chain: collections are user-scoped app state
|
||||||
|
// (no RLS on the table), and a foreign or deleted collection id falls
|
||||||
|
// back to the feed queue. Prev/next URLs carry ?queue=collection:<id>
|
||||||
|
// so the chain survives hops; backToDetail lands on the collection.
|
||||||
|
func buildCollectionSequenceContext(c *echo.Context, cfg *Config, item *database.MediaItems, userID pgtype.UUID, collID uuid.UUID) *templates.PodcastSequenceContext {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
owns, err := cfg.CollectionHandler.CollectionService().UserOwnsCollection(ctx, collID, uuid.UUID(userID.Bytes))
|
||||||
|
if err != nil || !owns {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
coll, err := cfg.Queries.GetCollection(ctx, pgtype.UUID{Bytes: collID, Valid: true})
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Smart collections compute their membership live (R1: computed
|
||||||
|
// order, never written back) — evaluate the rules for THIS viewer
|
||||||
|
// and chain the downloaded episode matches in native feed order
|
||||||
|
// (R3: every listening order routes through OrderEpisodes).
|
||||||
|
if services.IsCollectionSmart(coll) {
|
||||||
|
return buildSmartCollectionSequenceContext(c, cfg, item, userID, coll)
|
||||||
|
}
|
||||||
|
// Downloaded episodes in collection order (SQL orders; RLS scopes
|
||||||
|
// the join — unsubscribed feeds drop out and the chain skips them).
|
||||||
|
eps, err := cfg.Queries.GetCollectionEpisodesOrdered(ctx, pgtype.UUID{Bytes: collID, Valid: true})
|
||||||
|
if err != nil || len(eps) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// The curated listening order applies to static episode playlists
|
||||||
|
// too (§10 R2): native/"" keeps the manual position order (the SQL
|
||||||
|
// order above); reverse flips it; oldest/newest re-key on
|
||||||
|
// publication date with feed position as the tiebreak.
|
||||||
|
switch order := handlers.CollectionChainOrder(coll.ViewSettings); order {
|
||||||
|
case "reverse":
|
||||||
|
for i, j := 0, len(eps)-1; i < j; i, j = i+1, j-1 {
|
||||||
|
eps[i], eps[j] = eps[j], eps[i]
|
||||||
|
}
|
||||||
|
case "oldest", "newest":
|
||||||
|
services.OrderEpisodes(eps, order,
|
||||||
|
func(e database.GetCollectionEpisodesOrderedRow) int32 { return e.FeedPosition.Int32 },
|
||||||
|
func(e database.GetCollectionEpisodesOrderedRow) string {
|
||||||
|
if e.DatePublished.Valid {
|
||||||
|
return e.DatePublished.Time.Format("2006-01-02")
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
currentID := uuid.UUID(item.ID.Bytes).String()
|
||||||
|
idx := -1
|
||||||
|
for i, ep := range eps {
|
||||||
|
if uuid.UUID(ep.ID.Bytes).String() == currentID {
|
||||||
|
idx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if idx < 0 {
|
||||||
|
return nil // current episode not downloaded (or not in sequence)
|
||||||
|
}
|
||||||
|
|
||||||
|
queueParam := "?queue=collection:" + collID.String()
|
||||||
|
seq := &templates.PodcastSequenceContext{
|
||||||
|
FeedURL: "/collections/" + collID.String(),
|
||||||
|
FeedTitle: coll.Name,
|
||||||
|
Source: "collection",
|
||||||
|
EpisodeIndex: idx + 1,
|
||||||
|
IndexTotal: len(eps),
|
||||||
|
}
|
||||||
|
if idx > 0 {
|
||||||
|
seq.PrevURL = "/readers/" + uuid.UUID(eps[idx-1].ID.Bytes).String() + queueParam
|
||||||
|
}
|
||||||
|
if idx < len(eps)-1 {
|
||||||
|
seq.NextURL = "/readers/" + uuid.UUID(eps[idx+1].ID.Bytes).String() + queueParam
|
||||||
|
seq.NextTitle = eps[idx+1].Title
|
||||||
|
}
|
||||||
|
return seq
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSmartCollectionSequenceContext chains a SMART collection's live
|
||||||
|
// membership (plan item 2.5 acceptance: "Daily Fiction" → Play =
|
||||||
|
// today's episodes back-to-back). The rules evaluate for the requesting
|
||||||
|
// user (mode from the collection; family lens via the visible set);
|
||||||
|
// episode matches that are downloaded chain in native feed order.
|
||||||
|
// Books and un-downloaded matches never enter the queue (invariant 6).
|
||||||
|
func buildSmartCollectionSequenceContext(c *echo.Context, cfg *Config, item *database.MediaItems, userID pgtype.UUID, coll database.Collections) *templates.PodcastSequenceContext {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
colService := cfg.CollectionHandler.CollectionService()
|
||||||
|
visibleIDs, err := cfg.CollectionHandler.UserVisibleLibraryIDs(c)
|
||||||
|
if err != nil {
|
||||||
|
visibleIDs = nil
|
||||||
|
}
|
||||||
|
matches, err := colService.EvaluateSmartMembership(ctx, coll, services.SmartEvalOptions{
|
||||||
|
VisibleIDs: visibleIDs,
|
||||||
|
ViewerUserID: userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type seqEp struct {
|
||||||
|
id string
|
||||||
|
title string
|
||||||
|
date string
|
||||||
|
pos int32
|
||||||
|
}
|
||||||
|
ordered := make([]seqEp, 0, len(matches))
|
||||||
|
currentID := uuid.UUID(item.ID.Bytes).String()
|
||||||
|
for _, m := range matches {
|
||||||
|
if m.Source != "podcast_episode" || m.DownloadState != "downloaded" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
date := ""
|
||||||
|
if m.DatePublished.Valid {
|
||||||
|
date = m.DatePublished.Time.Format("2006-01-02")
|
||||||
|
}
|
||||||
|
ordered = append(ordered, seqEp{
|
||||||
|
id: uuid.UUID(m.ID.Bytes).String(),
|
||||||
|
title: m.Title,
|
||||||
|
date: date,
|
||||||
|
pos: m.FeedPosition.Int32,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(ordered) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Plan §10 R2: the collection's CURATED listening order (stored in
|
||||||
|
// view_settings.chain_order, set from the detail header) — not a
|
||||||
|
// player-side knob. "" / native / unknown all mean feed order.
|
||||||
|
services.OrderEpisodes(ordered, handlers.CollectionChainOrder(coll.ViewSettings),
|
||||||
|
func(e seqEp) int32 { return e.pos },
|
||||||
|
func(e seqEp) string { return e.date },
|
||||||
|
)
|
||||||
|
|
||||||
|
idx := -1
|
||||||
|
for i, ep := range ordered {
|
||||||
|
if ep.id == currentID {
|
||||||
|
idx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if idx < 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
collID := uuid.UUID(coll.ID.Bytes).String()
|
||||||
|
queueParam := "?queue=collection:" + collID
|
||||||
|
seq := &templates.PodcastSequenceContext{
|
||||||
|
FeedURL: "/collections/" + collID,
|
||||||
|
FeedTitle: coll.Name,
|
||||||
|
Source: "collection",
|
||||||
|
EpisodeIndex: idx + 1,
|
||||||
|
IndexTotal: len(ordered),
|
||||||
|
}
|
||||||
|
if idx > 0 {
|
||||||
|
seq.PrevURL = "/readers/" + ordered[idx-1].id + queueParam
|
||||||
|
}
|
||||||
|
if idx < len(ordered)-1 {
|
||||||
|
seq.NextURL = "/readers/" + ordered[idx+1].id + queueParam
|
||||||
|
seq.NextTitle = ordered[idx+1].title
|
||||||
|
}
|
||||||
|
return seq
|
||||||
|
}
|
||||||
@@ -80,6 +80,9 @@ type Config struct {
|
|||||||
SidecarHandler *handlers.SidecarHandler
|
SidecarHandler *handlers.SidecarHandler
|
||||||
ReaderHandler *handlers.ReaderHandler
|
ReaderHandler *handlers.ReaderHandler
|
||||||
LibraryService *services.LibraryService
|
LibraryService *services.LibraryService
|
||||||
|
PodcastHandler *handlers.PodcastHandler
|
||||||
|
PodcastService *services.PodcastService
|
||||||
|
PodcastWorker *services.PodcastDownloadWorker
|
||||||
}
|
}
|
||||||
|
|
||||||
// getBaseURL returns the configured base URL from the database, falling back to
|
// getBaseURL returns the configured base URL from the database, falling back to
|
||||||
@@ -344,12 +347,14 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
|
|||||||
registerConflictRoutes(cfg)
|
registerConflictRoutes(cfg)
|
||||||
registerAnalyticsRoutes(cfg)
|
registerAnalyticsRoutes(cfg)
|
||||||
registerQueueRoutes(cfg)
|
registerQueueRoutes(cfg)
|
||||||
|
registerPodcastRoutes(cfg)
|
||||||
registerJobRoutes(cfg)
|
registerJobRoutes(cfg)
|
||||||
registerFiltersRoutes(cfg)
|
registerFiltersRoutes(cfg)
|
||||||
registerOPDSRoutes(cfg)
|
registerOPDSRoutes(cfg)
|
||||||
registerReaderRoutes(cfg)
|
registerReaderRoutes(cfg)
|
||||||
registerWebSocketRoutes(cfg)
|
registerWebSocketRoutes(cfg)
|
||||||
registerFrontendRoutes(cfg)
|
registerFrontendRoutes(cfg)
|
||||||
|
registerPodcastPages(cfg)
|
||||||
registerDocumentationRoutes(cfg)
|
registerDocumentationRoutes(cfg)
|
||||||
|
|
||||||
// Static bundles are served unhashed (reader.js, index.js), so the
|
// Static bundles are served unhashed (reader.js, index.js), so the
|
||||||
@@ -394,6 +399,14 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
|
|||||||
// Start background tasks (queue processor and connection cleanup)
|
// Start background tasks (queue processor and connection cleanup)
|
||||||
scannerHandler.StartBackgroundTasks()
|
scannerHandler.StartBackgroundTasks()
|
||||||
|
|
||||||
|
// Podcast subsystem background loops: sequential download worker +
|
||||||
|
// feed poller (plan §7). The poller re-reads its interval setting
|
||||||
|
// every tick; both exit with the server context.
|
||||||
|
if cfg.PodcastWorker != nil && cfg.PodcastService != nil {
|
||||||
|
cfg.PodcastWorker.Start(context.Background())
|
||||||
|
services.NewPodcastPoller(cfg.Queries, cfg.PodcastService, cfg.PodcastWorker, cfg.Settings).Start(context.Background())
|
||||||
|
}
|
||||||
|
|
||||||
// Start watch mode for all libraries (after 2 second delay for DB)
|
// Start watch mode for all libraries (after 2 second delay for DB)
|
||||||
go func() {
|
go func() {
|
||||||
time.Sleep(2 * time.Second)
|
time.Sleep(2 * time.Second)
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ func registerSearchRoutes(cfg *Config) {
|
|||||||
// JWT middleware for protected routes
|
// JWT middleware for protected routes
|
||||||
jwtMiddleware := createJWTMiddleware(cfg)
|
jwtMiddleware := createJWTMiddleware(cfg)
|
||||||
protected := e.Group("/api", jwtMiddleware)
|
protected := e.Group("/api", jwtMiddleware)
|
||||||
|
// Filter label resolution (2026-11-01): names id-kind filter values
|
||||||
|
// from their source tables for trees the server didn't render (the
|
||||||
|
// cold saved-filter apply). Visibility/RLS ride the request identity.
|
||||||
|
protected.GET("/filter/labels", cfg.MediaHandler.ResolveFilterLabels)
|
||||||
// Search endpoint - returns JSON or HTML based on HX-Request header
|
// Search endpoint - returns JSON or HTML based on HX-Request header
|
||||||
protected.GET("/media-items/search", func(c *echo.Context) error {
|
protected.GET("/media-items/search", func(c *echo.Context) error {
|
||||||
// Check if HTMX request (expects HTML)
|
// Check if HTMX request (expects HTML)
|
||||||
@@ -41,6 +45,24 @@ func handleSearchHTML(c *echo.Context, cfg *Config) error {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return c.String(http.StatusUnauthorized, "Unauthorized")
|
return c.String(http.StatusUnauthorized, "Unauthorized")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Episode picker scope (plan item 2.3): ?scope=episodes&show_checkbox=true
|
||||||
|
// swaps the book cards for YOUR subscriptions' episodes — visibility
|
||||||
|
// is the RLS policy's job (D20), the Go side adds nothing. The books
|
||||||
|
// branch keeps its podcasts exclusion; this is the only door in.
|
||||||
|
if c.QueryParam("scope") == "episodes" && c.QueryParam("show_checkbox") == "true" {
|
||||||
|
eps, err := cfg.Queries.SearchEpisodesForPicker(c.Request().Context(), c.QueryParam("q"))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Episode picker search error: %v", err)
|
||||||
|
return c.HTML(http.StatusInternalServerError, `<div style="color: red;">Search error</div>`)
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := templates.EpisodePickerGrid(eps).Render(c.Request().Context(), &buf); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.HTML(http.StatusOK, buf.String())
|
||||||
|
}
|
||||||
|
|
||||||
// Extract filters via the SHARED parser (handlers.ParseMediaSearchParams)
|
// Extract filters via the SHARED parser (handlers.ParseMediaSearchParams)
|
||||||
// — the HTMX grid partial and the JSON endpoint can never disagree on
|
// — the HTMX grid partial and the JSON endpoint can never disagree on
|
||||||
// which facet params exist or how they parse. (This branch previously
|
// which facet params exist or how they parse. (This branch previously
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
@@ -30,24 +32,6 @@ type Rule struct {
|
|||||||
Enabled *bool `json:"enabled,omitempty"`
|
Enabled *bool `json:"enabled,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Known rule fields and operators — the rules builder UI and the
|
|
||||||
// persisted JSONB documents must agree with this vocabulary.
|
|
||||||
var ruleFields = map[string]bool{
|
|
||||||
"genre": true, "series": true, "author": true, "language": true,
|
|
||||||
"publisher": true, "copyright_year": true, "tags": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
var ruleOperators = map[string]bool{
|
|
||||||
"equals": true, "not_equals": true, "contains": true, "not_contains": true,
|
|
||||||
"starts_with": true, "ends_with": true, "greater_than": true, "less_than": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// RuleValidationError reports a rule payload the evaluator could never
|
|
||||||
// honor (unknown field/operator, empty value).
|
|
||||||
type RuleValidationError struct{ Msg string }
|
|
||||||
|
|
||||||
func (e *RuleValidationError) Error() string { return e.Msg }
|
|
||||||
|
|
||||||
// RuleEvaluation represents the result of evaluating a rule
|
// RuleEvaluation represents the result of evaluating a rule
|
||||||
type RuleEvaluation struct {
|
type RuleEvaluation struct {
|
||||||
RuleID string `json:"rule_id"`
|
RuleID string `json:"rule_id"`
|
||||||
@@ -58,27 +42,28 @@ type RuleEvaluation struct {
|
|||||||
// CollectionService handles collection management
|
// CollectionService handles collection management
|
||||||
type CollectionService struct {
|
type CollectionService struct {
|
||||||
db *database.Queries
|
db *database.Queries
|
||||||
|
// search powers the unified-engine path (item 3.2): tree-driven
|
||||||
|
// smart collections evaluate via ComposedLiveMedia. Optional —
|
||||||
|
// nil disables tree evaluation (legacy-only services, which keeps
|
||||||
|
// every existing constructor call site valid).
|
||||||
|
search *SearchService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCollectionService creates a new collection service
|
// NewCollectionService creates a new collection service. search is
|
||||||
func NewCollectionService(db *database.Queries) *CollectionService {
|
// optional (variadic for constructor compatibility) — required for
|
||||||
return &CollectionService{
|
// TREE-driven smart collections (item 3.2), ignored otherwise.
|
||||||
db: db,
|
func NewCollectionService(db *database.Queries, search ...*SearchService) *CollectionService {
|
||||||
|
svc := &CollectionService{db: db}
|
||||||
|
if len(search) > 0 {
|
||||||
|
svc.search = search[0]
|
||||||
}
|
}
|
||||||
|
return svc
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateCollection creates a new collection
|
// CreateCollection creates a new collection. filterTree (item 3.2), when
|
||||||
func (s *CollectionService) CreateCollection(ctx context.Context, userID uuid.UUID, name, description, color, icon string, autoAssignRules []Rule, viewSettings map[string]any) (database.Collections, error) {
|
// present, makes it smart — the qf engine is the only membership source
|
||||||
// Convert rules to JSONB ([]byte)
|
// (the legacy rules-array storage retired 2026-10-31).
|
||||||
var rulesJSON []byte
|
func (s *CollectionService) CreateCollection(ctx context.Context, userID uuid.UUID, name, description, color, icon string, viewSettings map[string]any, showOnDashboard bool, filterTree []byte) (database.Collections, error) {
|
||||||
if len(autoAssignRules) > 0 {
|
|
||||||
var err error
|
|
||||||
rulesJSON, err = json.Marshal(autoAssignRules)
|
|
||||||
if err != nil {
|
|
||||||
return database.Collections{}, fmt.Errorf("failed to marshal auto-assign rules: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert view settings to JSONB ([]byte)
|
// Convert view settings to JSONB ([]byte)
|
||||||
var settingsJSON []byte
|
var settingsJSON []byte
|
||||||
if len(viewSettings) > 0 {
|
if len(viewSettings) > 0 {
|
||||||
@@ -95,8 +80,9 @@ func (s *CollectionService) CreateCollection(ctx context.Context, userID uuid.UU
|
|||||||
Description: pgtype.Text{String: description, Valid: description != ""},
|
Description: pgtype.Text{String: description, Valid: description != ""},
|
||||||
Color: pgtype.Text{String: color, Valid: color != ""},
|
Color: pgtype.Text{String: color, Valid: color != ""},
|
||||||
Icon: pgtype.Text{String: icon, Valid: icon != ""},
|
Icon: pgtype.Text{String: icon, Valid: icon != ""},
|
||||||
AutoAssignRules: rulesJSON,
|
|
||||||
ViewSettings: settingsJSON,
|
ViewSettings: settingsJSON,
|
||||||
|
FilterTree: filterTree,
|
||||||
|
ShowOnDashboard: pgtype.Bool{Bool: showOnDashboard, Valid: true},
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -132,14 +118,17 @@ func (s *CollectionService) GetUserCollections(ctx context.Context, userID uuid.
|
|||||||
return collections, nil
|
return collections, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateCollection updates a collection
|
// UpdateCollection rewrites the collection row. filterTree semantics:
|
||||||
func (s *CollectionService) UpdateCollection(ctx context.Context, collectionID uuid.UUID, name, description, color, icon string, autoAssignRules []Rule, viewSettings map[string]any) (database.Collections, error) {
|
// nil = leave the stored tree untouched (forms don't send trees).
|
||||||
|
func (s *CollectionService) UpdateCollection(ctx context.Context, collectionID uuid.UUID, name, description, color, icon string, viewSettings map[string]any, filterTree []byte) (database.Collections, error) {
|
||||||
// A2c §3.3: the edit form carries only the toggles it renders — merge
|
// A2c §3.3: the edit form carries only the toggles it renders — merge
|
||||||
// them OVER the stored view_settings so provenance keys
|
// them OVER the stored view_settings so provenance keys
|
||||||
// (imported_from_series) and anything else on the row survive.
|
// (imported_from_series) and anything else on the row survive.
|
||||||
if len(viewSettings) > 0 {
|
if existing, err := s.db.GetCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true}); err == nil {
|
||||||
existing, err := s.db.GetCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
|
if filterTree == nil {
|
||||||
if err == nil && len(existing.ViewSettings) > 0 {
|
filterTree = existing.FilterTree // nil = untouched (forms don't send trees)
|
||||||
|
}
|
||||||
|
if len(viewSettings) > 0 && len(existing.ViewSettings) > 0 {
|
||||||
var stored map[string]any
|
var stored map[string]any
|
||||||
if json.Unmarshal(existing.ViewSettings, &stored) == nil {
|
if json.Unmarshal(existing.ViewSettings, &stored) == nil {
|
||||||
merged := stored
|
merged := stored
|
||||||
@@ -149,18 +138,18 @@ func (s *CollectionService) UpdateCollection(ctx context.Context, collectionID u
|
|||||||
viewSettings = merged
|
viewSettings = merged
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
// Same convention as filterTree: a NIL view_settings payload
|
||||||
|
// means untouched — keep the stored doc. Without this, an edit
|
||||||
// Convert rules to JSONB ([]byte)
|
// that carries no settings wrote NULL over the row and silently
|
||||||
var rulesJSON []byte
|
// dropped every stored key (chain_order, imported provenance);
|
||||||
if len(autoAssignRules) > 0 {
|
// the 2026-10-31 chain-order test pinned exactly this clobber.
|
||||||
var err error
|
if viewSettings == nil && len(existing.ViewSettings) > 0 {
|
||||||
rulesJSON, err = json.Marshal(autoAssignRules)
|
var stored map[string]any
|
||||||
if err != nil {
|
if json.Unmarshal(existing.ViewSettings, &stored) == nil && len(stored) > 0 {
|
||||||
return database.Collections{}, fmt.Errorf("failed to marshal auto-assign rules: %v", err)
|
viewSettings = stored
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert view settings to JSONB ([]byte)
|
// Convert view settings to JSONB ([]byte)
|
||||||
var settingsJSON []byte
|
var settingsJSON []byte
|
||||||
if len(viewSettings) > 0 {
|
if len(viewSettings) > 0 {
|
||||||
@@ -172,17 +161,17 @@ func (s *CollectionService) UpdateCollection(ctx context.Context, collectionID u
|
|||||||
}
|
}
|
||||||
|
|
||||||
collection, err := s.db.UpdateCollection(ctx, database.UpdateCollectionParams{
|
collection, err := s.db.UpdateCollection(ctx, database.UpdateCollectionParams{
|
||||||
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
||||||
Name: name,
|
Name: name,
|
||||||
Description: pgtype.Text{String: description, Valid: description != ""},
|
Description: pgtype.Text{String: description, Valid: description != ""},
|
||||||
Color: pgtype.Text{String: color, Valid: color != ""},
|
Color: pgtype.Text{String: color, Valid: color != ""},
|
||||||
Icon: pgtype.Text{String: icon, Valid: icon != ""},
|
Icon: pgtype.Text{String: icon, Valid: icon != ""},
|
||||||
AutoAssignRules: rulesJSON,
|
ViewSettings: settingsJSON,
|
||||||
ViewSettings: settingsJSON,
|
FilterTree: filterTree,
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return database.Collections{}, fmt.Errorf("failed to update collection: %v", err)
|
return database.Collections{}, fmt.Errorf("failed to update collection: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return collection, nil
|
return collection, nil
|
||||||
@@ -260,162 +249,7 @@ func (s *CollectionService) GetBookCollections(ctx context.Context, mediaItemID
|
|||||||
}
|
}
|
||||||
|
|
||||||
return collections, nil
|
return collections, nil
|
||||||
}
|
} // EnabledRules filters a ruleset down to the rules that participate in// validateRule checks a rule payload the evaluator will have to honor.// AddRule appends a rule to the collection's ruleset, generating an id// SetRuleEnabled flips a single rule's enabled flag in place.// RemoveRule deletes a single rule from the ruleset. Removing the LAST// SmartEvalOptions scopes a smart collection's live evaluation.
|
||||||
|
|
||||||
// ParseRules decodes a collection's auto_assign_rules JSONB into typed
|
|
||||||
// rules. "null", empty, and malformed docs mean "no rules".
|
|
||||||
func (s *CollectionService) ParseRules(raw []byte) []Rule {
|
|
||||||
if len(raw) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var rules []Rule
|
|
||||||
if err := json.Unmarshal(raw, &rules); err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return rules
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsSmartCollection derives the collection type: a non-empty ruleset
|
|
||||||
// makes it SMART (live-matched), an empty/absent ruleset makes it STATIC
|
|
||||||
// (stored collection_items links).
|
|
||||||
func (s *CollectionService) IsSmartCollection(raw []byte) bool {
|
|
||||||
return len(s.ParseRules(raw)) > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// EnabledRules filters a ruleset down to the rules that participate in
|
|
||||||
// live evaluation (absent enabled = enabled for backward compatibility).
|
|
||||||
func (s *CollectionService) EnabledRules(rules []Rule) []Rule {
|
|
||||||
enabled := make([]Rule, 0, len(rules))
|
|
||||||
for _, rule := range rules {
|
|
||||||
if rule.Enabled == nil || *rule.Enabled {
|
|
||||||
enabled = append(enabled, rule)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return enabled
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateRule checks a rule payload the evaluator will have to honor.
|
|
||||||
func (s *CollectionService) validateRule(rule Rule) error {
|
|
||||||
if !ruleFields[rule.Field] {
|
|
||||||
return &RuleValidationError{Msg: "unknown rule field: " + rule.Field}
|
|
||||||
}
|
|
||||||
if !ruleOperators[rule.Operator] {
|
|
||||||
return &RuleValidationError{Msg: "unknown rule operator: " + rule.Operator}
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(rule.Value) == "" {
|
|
||||||
return &RuleValidationError{Msg: "rule value is required"}
|
|
||||||
}
|
|
||||||
if rule.Priority < 0 || rule.Priority > 10 {
|
|
||||||
return &RuleValidationError{Msg: "priority must be between 0 and 10"}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddRule appends a rule to the collection's ruleset, generating an id
|
|
||||||
// when the caller did not supply one. Adding the first rule CONVERTS a
|
|
||||||
// static collection to smart (type is derived from rules presence).
|
|
||||||
func (s *CollectionService) AddRule(ctx context.Context, collectionID uuid.UUID, rule Rule) (database.Collections, Rule, error) {
|
|
||||||
if err := s.validateRule(rule); err != nil {
|
|
||||||
return database.Collections{}, Rule{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
collection, err := s.GetCollection(ctx, collectionID)
|
|
||||||
if err != nil {
|
|
||||||
return database.Collections{}, Rule{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
rules := s.ParseRules(collection.AutoAssignRules)
|
|
||||||
if rule.ID == "" {
|
|
||||||
rule.ID = uuid.NewString()
|
|
||||||
}
|
|
||||||
rules = append(rules, rule)
|
|
||||||
|
|
||||||
rulesJSON, marshalErr := json.Marshal(rules)
|
|
||||||
if marshalErr != nil {
|
|
||||||
return database.Collections{}, Rule{}, fmt.Errorf("failed to marshal rules: %w", marshalErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, err := s.db.UpdateCollectionRulesJSON(ctx, database.UpdateCollectionRulesJSONParams{
|
|
||||||
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
|
||||||
AutoAssignRules: rulesJSON,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return database.Collections{}, Rule{}, fmt.Errorf("failed to save rules: %w", err)
|
|
||||||
}
|
|
||||||
return updated, rule, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetRuleEnabled flips a single rule's enabled flag in place.
|
|
||||||
func (s *CollectionService) SetRuleEnabled(ctx context.Context, collectionID uuid.UUID, ruleID string, enabled bool) (database.Collections, error) {
|
|
||||||
collection, err := s.GetCollection(ctx, collectionID)
|
|
||||||
if err != nil {
|
|
||||||
return database.Collections{}, err
|
|
||||||
}
|
|
||||||
rules := s.ParseRules(collection.AutoAssignRules)
|
|
||||||
found := false
|
|
||||||
for i := range rules {
|
|
||||||
if rules[i].ID == ruleID {
|
|
||||||
rules[i].Enabled = &enabled
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
return database.Collections{}, fmt.Errorf("rule not found: %s", ruleID)
|
|
||||||
}
|
|
||||||
|
|
||||||
rulesJSON, marshalErr := json.Marshal(rules)
|
|
||||||
if marshalErr != nil {
|
|
||||||
return database.Collections{}, fmt.Errorf("failed to marshal rules: %w", marshalErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, err := s.db.UpdateCollectionRulesJSON(ctx, database.UpdateCollectionRulesJSONParams{
|
|
||||||
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
|
||||||
AutoAssignRules: rulesJSON,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return database.Collections{}, fmt.Errorf("failed to save rules: %w", err)
|
|
||||||
}
|
|
||||||
return updated, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoveRule deletes a single rule from the ruleset. Removing the LAST
|
|
||||||
// rule converts the collection back to static (type is derived).
|
|
||||||
func (s *CollectionService) RemoveRule(ctx context.Context, collectionID uuid.UUID, ruleID string) (database.Collections, error) {
|
|
||||||
collection, err := s.GetCollection(ctx, collectionID)
|
|
||||||
if err != nil {
|
|
||||||
return database.Collections{}, err
|
|
||||||
}
|
|
||||||
rules := s.ParseRules(collection.AutoAssignRules)
|
|
||||||
kept := make([]Rule, 0, len(rules))
|
|
||||||
found := false
|
|
||||||
for _, rule := range rules {
|
|
||||||
if rule.ID == ruleID {
|
|
||||||
found = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
kept = append(kept, rule)
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
return database.Collections{}, fmt.Errorf("rule not found: %s", ruleID)
|
|
||||||
}
|
|
||||||
|
|
||||||
rulesJSON, marshalErr := json.Marshal(kept)
|
|
||||||
if marshalErr != nil {
|
|
||||||
return database.Collections{}, fmt.Errorf("failed to marshal rules: %w", marshalErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, err := s.db.UpdateCollectionRulesJSON(ctx, database.UpdateCollectionRulesJSONParams{
|
|
||||||
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
|
||||||
AutoAssignRules: rulesJSON,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return database.Collections{}, fmt.Errorf("failed to save rules: %w", err)
|
|
||||||
}
|
|
||||||
return updated, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SmartEvalOptions scopes a smart collection's live evaluation.
|
|
||||||
type SmartEvalOptions struct {
|
type SmartEvalOptions struct {
|
||||||
// LibraryID, when valid, restricts matching to one library (the
|
// LibraryID, when valid, restricts matching to one library (the
|
||||||
// detail page's library switcher).
|
// detail page's library switcher).
|
||||||
@@ -423,54 +257,98 @@ type SmartEvalOptions struct {
|
|||||||
// VisibleIDs is the requesting user's visible-library set (family
|
// VisibleIDs is the requesting user's visible-library set (family
|
||||||
// lens). Empty means no visible-library filter.
|
// lens). Empty means no visible-library filter.
|
||||||
VisibleIDs []pgtype.UUID
|
VisibleIDs []pgtype.UUID
|
||||||
|
// ViewerUserID scopes the candidate query's progress join — the
|
||||||
|
// played/unplayed rule is per-user. Zero UUID = no viewer (played
|
||||||
|
// rules then see "unplayed" for everything, honestly).
|
||||||
|
ViewerUserID pgtype.UUID
|
||||||
}
|
}
|
||||||
|
|
||||||
// EvaluateSmartCollection resolves a smart collection's membership LIVE:
|
// IsCollectionSmart is the ONE smart detection: a collection is smart
|
||||||
// every visible live book matched by at least one enabled rule is in.
|
// when it carries the unified filter tree (item 3.2). Every surface
|
||||||
// No stored links are consulted — "truly dynamic," always current.
|
// routes through this. (The legacy rules-array branch retired
|
||||||
func (s *CollectionService) EvaluateSmartCollection(ctx context.Context, rules []Rule, opts SmartEvalOptions) ([]database.MediaItems, error) {
|
// 2026-10-31 — the tree is the only smart storage.)
|
||||||
enabled := s.EnabledRules(rules)
|
func IsCollectionSmart(coll database.Collections) bool {
|
||||||
if len(enabled) == 0 {
|
return len(coll.FilterTree) > 0
|
||||||
return []database.MediaItems{}, nil
|
}
|
||||||
}
|
|
||||||
|
|
||||||
var visibleArg []pgtype.UUID
|
// Search exposes the composed search engine the service was built with
|
||||||
if len(opts.VisibleIDs) > 0 {
|
// (nil when the constructor omitted it) — callers composing secondary
|
||||||
visibleArg = opts.VisibleIDs
|
// services that evaluate trees (the dashboard service) pass it through.
|
||||||
}
|
func (s *CollectionService) Search() *SearchService { return s.search }
|
||||||
items, err := s.db.GetVisibleLiveMediaForRules(ctx, database.GetVisibleLiveMediaForRulesParams{
|
|
||||||
LibraryID: opts.LibraryID,
|
// IsCollectionSmartColl is IsCollectionSmart on the service handle —
|
||||||
VisibleLibraryIds: visibleArg,
|
// the dashboard service composes this service and calls the detector
|
||||||
})
|
// through it (no import cycle, one detection definition).
|
||||||
|
func (s *CollectionService) IsCollectionSmartColl(coll database.Collections) bool {
|
||||||
|
return IsCollectionSmart(coll)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvaluateSmartMembership is THE single smart evaluator (item 3.2):
|
||||||
|
// tree-bearing collections evaluate through the unified filter engine.
|
||||||
|
func (s *CollectionService) EvaluateSmartMembership(ctx context.Context, coll database.Collections, opts SmartEvalOptions) ([]database.MediaItems, error) {
|
||||||
|
return s.evaluateTreeMembership(ctx, coll, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CollectionService) evaluateTreeMembership(ctx context.Context, coll database.Collections, opts SmartEvalOptions) ([]database.MediaItems, error) {
|
||||||
|
tree, err := ParseFilterTree(string(coll.FilterTree))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to load candidate books: %w", err)
|
return nil, fmt.Errorf("collection %s has an invalid filter tree: %v", coll.ID, err)
|
||||||
}
|
}
|
||||||
|
if s.search == nil {
|
||||||
matches := make([]database.MediaItems, 0, len(items))
|
return nil, errors.New("tree collections require the search service (constructor wiring)")
|
||||||
for _, item := range items {
|
|
||||||
if s.mediaItemMatchesAnyRule(item, enabled) {
|
|
||||||
matches = append(matches, item)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return matches, nil
|
return s.search.ComposedLiveMedia(ctx, opts.ViewerUserID, opts.LibraryID, opts.VisibleIDs, tree)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *CollectionService) mediaItemMatchesAnyRule(item database.MediaItems, rules []Rule) bool {
|
// episodeFinished is THE canonical finished definition (same predicate
|
||||||
for _, rule := range rules {
|
// as hide_finished): viewer progress percentage >= 0.999.
|
||||||
if s.mediaItemMatchesRule(item, rule) {
|
func episodeFinished(viewerPercentage float64) bool {
|
||||||
return true
|
return viewerPercentage >= 0.999
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *CollectionService) mediaItemMatchesRule(item database.MediaItems, rule Rule) bool {
|
// FieldMatches is THE single rule evaluator (the rules-builder preview
|
||||||
if rule.Enabled != nil && !*rule.Enabled {
|
// delegates here too — one vocabulary, one semantics). Episode/user-state
|
||||||
|
// fields (feed/played/downloaded/published_within) evaluate directly;
|
||||||
|
// everything else extracts a string and hits the generic comparator.
|
||||||
|
func (s *CollectionService) FieldMatches(item database.MediaItems, viewerPercentage float64, field, operator, value string) bool {
|
||||||
|
switch field {
|
||||||
|
case "feed":
|
||||||
|
if !item.SourceFeedID.Valid {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
feedID := uuid.UUID(item.SourceFeedID.Bytes).String()
|
||||||
|
for _, raw := range strings.Split(value, ",") {
|
||||||
|
if strings.TrimSpace(raw) == feedID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
|
case "played":
|
||||||
|
wantPlayed := value == "true"
|
||||||
|
return episodeFinished(viewerPercentage) == wantPlayed
|
||||||
|
case "downloaded":
|
||||||
|
wantDownloaded := value == "true"
|
||||||
|
return (item.DownloadState == "downloaded") == wantDownloaded
|
||||||
|
case "published_within":
|
||||||
|
if !item.DatePublished.Valid {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
days, err := strconv.Atoi(strings.TrimSpace(value))
|
||||||
|
if err != nil || days < 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
cutoff := time.Now().UTC().AddDate(0, 0, -days)
|
||||||
|
return item.DatePublished.Time.After(cutoff)
|
||||||
}
|
}
|
||||||
|
|
||||||
var fieldValue string
|
var fieldValue string
|
||||||
switch rule.Field {
|
switch field {
|
||||||
|
case "title":
|
||||||
|
fieldValue = item.Title
|
||||||
|
case "description":
|
||||||
|
if item.Description.Valid {
|
||||||
|
fieldValue = item.Description.String
|
||||||
|
}
|
||||||
case "genre":
|
case "genre":
|
||||||
if item.Genre.Valid {
|
if item.Genre.Valid {
|
||||||
fieldValue = item.Genre.String
|
fieldValue = item.Genre.String
|
||||||
@@ -501,11 +379,14 @@ func (s *CollectionService) mediaItemMatchesRule(item database.MediaItems, rule
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.evaluateRule(fieldValue, rule.Operator, rule.Value)
|
return s.evaluateRule(fieldValue, operator, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// EvaluateRules evaluates auto-assign rules for a media item
|
// EvaluateRules evaluates auto-assign rules for a media item — the
|
||||||
func (s *CollectionService) EvaluateRules(mediaItem database.MediaItems, rules []Rule) []RuleEvaluation {
|
// rules-builder preview's per-rule view. viewerPercentage is the
|
||||||
|
// requesting user's progress for this item (0 = none), so played /
|
||||||
|
// unplayed rules preview honestly (plan item 2.4).
|
||||||
|
func (s *CollectionService) EvaluateRules(mediaItem database.MediaItems, viewerPercentage float64, rules []Rule) []RuleEvaluation {
|
||||||
var evaluations []RuleEvaluation
|
var evaluations []RuleEvaluation
|
||||||
|
|
||||||
// Sort rules by priority (higher priority first)
|
// Sort rules by priority (higher priority first)
|
||||||
@@ -527,40 +408,7 @@ func (s *CollectionService) EvaluateRules(mediaItem database.MediaItems, rules [
|
|||||||
}
|
}
|
||||||
eval := RuleEvaluation{
|
eval := RuleEvaluation{
|
||||||
RuleID: rule.ID,
|
RuleID: rule.ID,
|
||||||
Matches: false,
|
Matches: s.FieldMatches(mediaItem, viewerPercentage, rule.Field, rule.Operator, rule.Value),
|
||||||
}
|
|
||||||
|
|
||||||
switch rule.Field {
|
|
||||||
case "genre":
|
|
||||||
if mediaItem.Genre.Valid {
|
|
||||||
eval.Matches = s.evaluateRule(mediaItem.Genre.String, rule.Operator, rule.Value)
|
|
||||||
}
|
|
||||||
case "series":
|
|
||||||
if mediaItem.Series.Valid {
|
|
||||||
eval.Matches = s.evaluateRule(mediaItem.Series.String, rule.Operator, rule.Value)
|
|
||||||
}
|
|
||||||
case "author":
|
|
||||||
if mediaItem.Author.Valid {
|
|
||||||
eval.Matches = s.evaluateRule(mediaItem.Author.String, rule.Operator, rule.Value)
|
|
||||||
}
|
|
||||||
case "language":
|
|
||||||
if mediaItem.Language.Valid {
|
|
||||||
eval.Matches = s.evaluateRule(mediaItem.Language.String, rule.Operator, rule.Value)
|
|
||||||
}
|
|
||||||
case "publisher":
|
|
||||||
if mediaItem.Publisher.Valid {
|
|
||||||
eval.Matches = s.evaluateRule(mediaItem.Publisher.String, rule.Operator, rule.Value)
|
|
||||||
}
|
|
||||||
case "copyright_year":
|
|
||||||
if mediaItem.CopyrightYear.Valid {
|
|
||||||
yearStr := fmt.Sprintf("%d", mediaItem.CopyrightYear.Int32)
|
|
||||||
eval.Matches = s.evaluateRule(yearStr, rule.Operator, rule.Value)
|
|
||||||
}
|
|
||||||
case "tags":
|
|
||||||
if len(mediaItem.Tags) > 0 {
|
|
||||||
tagsStr := strings.Join(mediaItem.Tags, ", ")
|
|
||||||
eval.Matches = s.evaluateRule(tagsStr, rule.Operator, rule.Value)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate confidence based on rule type and priority
|
// Calculate confidence based on rule type and priority
|
||||||
@@ -654,6 +502,121 @@ func compareNumbers(a, b, op string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CreateDeviceShelfMapping creates a shelf mapping for a device
|
// CreateDeviceShelfMapping creates a shelf mapping for a device
|
||||||
|
|
||||||
|
// ── Custom-section-builder adapter (2026-10-31) ──────────────────
|
||||||
|
// The builder posts the legacy rules vocabulary; these convert it to a
|
||||||
|
// qf tree at WRITE time — one-way upgrade, storage stays tree-only.
|
||||||
|
// (The 2026-10-31 retirement deleted these as READ-time conversion;
|
||||||
|
// the builder's tree migration revived them as the single write path.)
|
||||||
|
|
||||||
|
// The builder's match_type select: "any" (OR) | "all" (AND).
|
||||||
|
const (
|
||||||
|
RuleMatchAny = "any"
|
||||||
|
RuleMatchAll = "all"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The builder posts the legacy rules vocabulary; these convert it to a
|
||||||
|
// qf tree at WRITE time — one-way upgrade, storage stays tree-only.
|
||||||
|
// (The 2026-10-31 retirement deleted these as READ-time conversion;
|
||||||
|
// the builder's tree migration revived them as the single write path.)
|
||||||
|
|
||||||
|
// RulesToFilterTree converts a legacy rules array + its match mode
|
||||||
|
// into a qf tree so a collection upgrades to the unified engine at its
|
||||||
|
// first edit (item 3.2): zero-rewrite back-compat at read, one-way
|
||||||
|
// upgrade at write. Episode rules map mechanically (feed→in-list,
|
||||||
|
// played/downloaded→booleans, published_within→published_date
|
||||||
|
// within_days); legacy operators without an engine equivalent
|
||||||
|
// (starts_with/ends_with) fall back to contains — documented, and the
|
||||||
|
// operator labels stay in the rules docs.
|
||||||
|
func RulesToFilterTree(rules []Rule, matchMode string) (*FilterTree, error) {
|
||||||
|
children := make([]FilterChild, 0, len(rules))
|
||||||
|
scope := ""
|
||||||
|
for _, r := range rules {
|
||||||
|
child, err := ruleToFilterChild(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if episodeOnlyFields[r.Field] {
|
||||||
|
scope = ScopeEpisodes
|
||||||
|
}
|
||||||
|
children = append(children, child)
|
||||||
|
}
|
||||||
|
if len(children) == 0 {
|
||||||
|
return nil, fmt.Errorf("no rules to convert")
|
||||||
|
}
|
||||||
|
mode := RuleMatchAny
|
||||||
|
if matchMode == RuleMatchAll {
|
||||||
|
mode = RuleMatchAll
|
||||||
|
}
|
||||||
|
return &FilterTree{Scope: scope, Mode: mode, Children: children}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ruleToFilterChild(r Rule) (FilterChild, error) {
|
||||||
|
child := FilterChild{Field: r.Field}
|
||||||
|
switch r.Field {
|
||||||
|
case "feed":
|
||||||
|
// The rules format stores comma-separated feed IDs in one value.
|
||||||
|
child.Op = OpIn
|
||||||
|
child.Values = splitRuleValues(r.Value)
|
||||||
|
return child, nil
|
||||||
|
case "downloaded":
|
||||||
|
child.Op = isTrueFalse(r.Value == "true")
|
||||||
|
return child, nil
|
||||||
|
case "played":
|
||||||
|
child.Op = isTrueFalse(r.Value == "true")
|
||||||
|
return child, nil
|
||||||
|
case "published_within":
|
||||||
|
child.Field = "published_date"
|
||||||
|
child.Op = OpWithinDays
|
||||||
|
child.Values = []string{r.Value}
|
||||||
|
return child, nil
|
||||||
|
}
|
||||||
|
// Registry fields: legacy operators → engine operators.
|
||||||
|
switch r.Operator {
|
||||||
|
case "equals":
|
||||||
|
child.Op = OpIs
|
||||||
|
case "not_equals":
|
||||||
|
child.Op = OpIsNot
|
||||||
|
case "contains":
|
||||||
|
child.Op = OpContains
|
||||||
|
case "not_contains":
|
||||||
|
child.Op = OpNotContains
|
||||||
|
case "greater_than":
|
||||||
|
child.Op = OpAtLeast
|
||||||
|
case "less_than":
|
||||||
|
child.Op = OpAtMost
|
||||||
|
case "in":
|
||||||
|
child.Op = OpIn
|
||||||
|
child.Values = splitRuleValues(r.Value)
|
||||||
|
case "starts_with", "ends_with":
|
||||||
|
// No engine equivalent — contains is the honest nearest.
|
||||||
|
child.Op = OpContains
|
||||||
|
default:
|
||||||
|
return FilterChild{}, fmt.Errorf("legacy rule operator %q has no filter-engine equivalent", r.Operator)
|
||||||
|
}
|
||||||
|
child.Values = []string{r.Value}
|
||||||
|
return child, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTrueFalse(b bool) string {
|
||||||
|
if b {
|
||||||
|
return OpIsTrue
|
||||||
|
}
|
||||||
|
return OpIsFalse
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitRuleValues(v string) []string {
|
||||||
|
parts := strings.Split(v, ",")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p != "" {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func (s *CollectionService) CreateDeviceShelfMapping(ctx context.Context, collectionID, deviceID uuid.UUID, deviceShelfName, syncDirection string) (database.DeviceShelfMappings, error) {
|
func (s *CollectionService) CreateDeviceShelfMapping(ctx context.Context, collectionID, deviceID uuid.UUID, deviceShelfName, syncDirection string) (database.DeviceShelfMappings, error) {
|
||||||
mapping, err := s.db.CreateDeviceShelfMapping(ctx, database.CreateDeviceShelfMappingParams{
|
mapping, err := s.db.CreateDeviceShelfMapping(ctx, database.CreateDeviceShelfMappingParams{
|
||||||
CollectionID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
CollectionID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ func TestEvaluateRules_Equality(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
evaluations := service.EvaluateRules(mediaItem, rules)
|
evaluations := service.EvaluateRules(mediaItem, 0, rules)
|
||||||
|
|
||||||
if len(evaluations) != 1 {
|
if len(evaluations) != 1 {
|
||||||
t.Fatalf("expected 1 evaluation, got %d", len(evaluations))
|
t.Fatalf("expected 1 evaluation, got %d", len(evaluations))
|
||||||
@@ -56,7 +56,7 @@ func TestEvaluateRules_NoMatch(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
evaluations := service.EvaluateRules(mediaItem, rules)
|
evaluations := service.EvaluateRules(mediaItem, 0, rules)
|
||||||
|
|
||||||
if len(evaluations) != 1 {
|
if len(evaluations) != 1 {
|
||||||
t.Fatalf("expected 1 evaluation, got %d", len(evaluations))
|
t.Fatalf("expected 1 evaluation, got %d", len(evaluations))
|
||||||
@@ -88,7 +88,7 @@ func TestEvaluateRules_Contains(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
evaluations := service.EvaluateRules(mediaItem, rules)
|
evaluations := service.EvaluateRules(mediaItem, 0, rules)
|
||||||
|
|
||||||
if len(evaluations) != 1 {
|
if len(evaluations) != 1 {
|
||||||
t.Fatalf("expected 1 evaluation, got %d", len(evaluations))
|
t.Fatalf("expected 1 evaluation, got %d", len(evaluations))
|
||||||
@@ -128,7 +128,7 @@ func TestEvaluateRules_PriorityOrder(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
evaluations := service.EvaluateRules(mediaItem, rules)
|
evaluations := service.EvaluateRules(mediaItem, 0, rules)
|
||||||
|
|
||||||
if len(evaluations) != 2 {
|
if len(evaluations) != 2 {
|
||||||
t.Fatalf("expected 2 evaluations, got %d", len(evaluations))
|
t.Fatalf("expected 2 evaluations, got %d", len(evaluations))
|
||||||
@@ -164,7 +164,7 @@ func TestEvaluateRules_CopyrightYear(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
evaluations := service.EvaluateRules(mediaItem, rules)
|
evaluations := service.EvaluateRules(mediaItem, 0, rules)
|
||||||
|
|
||||||
if len(evaluations) != 1 {
|
if len(evaluations) != 1 {
|
||||||
t.Fatalf("expected 1 evaluation, got %d", len(evaluations))
|
t.Fatalf("expected 1 evaluation, got %d", len(evaluations))
|
||||||
@@ -190,7 +190,7 @@ func TestEvaluateRules_MissingField(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
evaluations := service.EvaluateRules(mediaItem, rules)
|
evaluations := service.EvaluateRules(mediaItem, 0, rules)
|
||||||
|
|
||||||
if len(evaluations) != 1 {
|
if len(evaluations) != 1 {
|
||||||
t.Fatalf("expected 1 evaluation, got %d", len(evaluations))
|
t.Fatalf("expected 1 evaluation, got %d", len(evaluations))
|
||||||
@@ -218,7 +218,7 @@ func TestEvaluateRules_CaseInsensitive(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
evaluations := service.EvaluateRules(mediaItem, rules)
|
evaluations := service.EvaluateRules(mediaItem, 0, rules)
|
||||||
|
|
||||||
if len(evaluations) != 1 {
|
if len(evaluations) != 1 {
|
||||||
t.Fatalf("expected 1 evaluation, got %d", len(evaluations))
|
t.Fatalf("expected 1 evaluation, got %d", len(evaluations))
|
||||||
@@ -253,7 +253,7 @@ func TestEvaluateRules_PriorityBoost(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
evaluations := service.EvaluateRules(mediaItem, rules)
|
evaluations := service.EvaluateRules(mediaItem, 0, rules)
|
||||||
|
|
||||||
if len(evaluations) != 2 {
|
if len(evaluations) != 2 {
|
||||||
t.Fatalf("expected 2 evaluations, got %d", len(evaluations))
|
t.Fatalf("expected 2 evaluations, got %d", len(evaluations))
|
||||||
@@ -276,3 +276,8 @@ func TestEvaluateRules_PriorityBoost(t *testing.T) {
|
|||||||
t.Errorf("expected higher priority rule to be first, got %s", evaluations[0].RuleID)
|
t.Errorf("expected higher priority rule to be first, got %s", evaluations[0].RuleID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRulesToFilterTree retired 2026-10-31 with the function it tested:
|
||||||
|
// rules-array storage is gone, the filter tree is the only smart
|
||||||
|
// membership. (Tests of deleted behavior are deleted — the asymmetry
|
||||||
|
// rule.)
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
@@ -17,10 +18,10 @@ type DashboardService struct {
|
|||||||
collectionService *CollectionService
|
collectionService *CollectionService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDashboardService(db *database.Queries) *DashboardService {
|
func NewDashboardService(db *database.Queries, search ...*SearchService) *DashboardService {
|
||||||
return &DashboardService{
|
return &DashboardService{
|
||||||
db: db,
|
db: db,
|
||||||
collectionService: NewCollectionService(db),
|
collectionService: NewCollectionService(db, search...),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,52 +294,44 @@ func (s *DashboardService) getUserCollectionItems(ctx context.Context, coll data
|
|||||||
|
|
||||||
// The query filters excluded rows in SQL and embeds the media row,
|
// The query filters excluded rows in SQL and embeds the media row,
|
||||||
// so the items are MediaItems as-is.
|
// so the items are MediaItems as-is.
|
||||||
var manualNonExcluded []database.MediaItems
|
items := make([]database.MediaItems, 0, len(manualItems))
|
||||||
|
seen := make(map[uuid.UUID]bool, len(manualItems))
|
||||||
for _, row := range manualItems {
|
for _, row := range manualItems {
|
||||||
manualNonExcluded = append(manualNonExcluded, row.MediaItems)
|
id := uuid.UUID(row.MediaItems.ID.Bytes)
|
||||||
|
if !seen[id] {
|
||||||
|
items = append(items, row.MediaItems)
|
||||||
|
seen[id] = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var autoItems []database.MediaItems
|
// Smart sections (custom sections are rules + manual picks): the
|
||||||
if len(coll.AutoAssignRules) > 0 {
|
// tree's LIVE membership rides along, scoped to the dashboard's
|
||||||
var rules []Rule
|
// active library and the viewing user (played rules are per-user).
|
||||||
if err := json.Unmarshal(coll.AutoAssignRules, &rules); err == nil && len(rules) > 0 {
|
// Evaluated items dedupe against the stored ones and the section
|
||||||
allLibraryItems, err := s.db.GetLibraryItems(ctx, libraryID)
|
// caps at its limit. (Custom-section builder tree migration,
|
||||||
if err == nil {
|
// 2026-10-31 — the rules-array branch this replaces evaluated the
|
||||||
for _, item := range allLibraryItems {
|
// same way, minus the library scoping.)
|
||||||
alreadyInCollection := false
|
if s.collectionService.IsCollectionSmartColl(coll) {
|
||||||
for _, manualItem := range manualNonExcluded {
|
matches, evalErr := s.collectionService.EvaluateSmartMembership(ctx, coll, SmartEvalOptions{
|
||||||
manualUUID := uuid.UUID(manualItem.ID.Bytes)
|
LibraryID: libraryID,
|
||||||
itemUUID := uuid.UUID(item.ID.Bytes)
|
ViewerUserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||||
if manualUUID == itemUUID {
|
})
|
||||||
alreadyInCollection = true
|
if evalErr != nil {
|
||||||
break
|
log.Printf("dashboard: smart section %s evaluation failed: %v", coll.Name, evalErr)
|
||||||
}
|
}
|
||||||
}
|
for _, m := range matches {
|
||||||
if alreadyInCollection {
|
id := uuid.UUID(m.ID.Bytes)
|
||||||
continue
|
if !seen[id] {
|
||||||
}
|
items = append(items, m)
|
||||||
|
seen[id] = true
|
||||||
evaluations := s.collectionService.EvaluateRules(item, rules)
|
|
||||||
for _, eval := range evaluations {
|
|
||||||
if eval.Matches {
|
|
||||||
autoItems = append(autoItems, item)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var finalItems []database.MediaItems
|
if len(items) > limit {
|
||||||
finalItems = append(finalItems, manualNonExcluded...)
|
items = items[:limit]
|
||||||
finalItems = append(finalItems, autoItems...)
|
|
||||||
|
|
||||||
if len(finalItems) > limit {
|
|
||||||
finalItems = finalItems[:limit]
|
|
||||||
}
|
}
|
||||||
|
return items, nil
|
||||||
return finalItems, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *DashboardService) GetDashboardPreferences(ctx context.Context, userID uuid.UUID, libraryID pgtype.UUID) (database.UserDashboardPreferences, error) {
|
func (s *DashboardService) GetDashboardPreferences(ctx context.Context, userID uuid.UUID, libraryID pgtype.UUID) (database.UserDashboardPreferences, error) {
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════
|
||||||
|
// SSR id-label resolution (2026-11-01): pages that render a filter tree
|
||||||
|
// (the collection editor's stored tree, the bookshelf's URL qf) embed
|
||||||
|
// the id→name map so value cells show NAMES at first paint.
|
||||||
|
//
|
||||||
|
// Why not reuse the suggest facet: its rows are media-constrained
|
||||||
|
// (INNER JOIN media_items + the other rules as a constraint), so an id
|
||||||
|
// whose media doesn't match the tree's sibling rules returns NO row and
|
||||||
|
// its label is lost — live repro: feed-in-3 + title-contains-Chapter
|
||||||
|
// labeled one feed of three. A label is a property of the ENTITY row,
|
||||||
|
// not of what currently matches: resolution reads the source tables
|
||||||
|
// directly, no media join, no tree constraint.
|
||||||
|
//
|
||||||
|
// Scoping rides the request identity through s.raw (the ScopedDB —
|
||||||
|
// every statement declares app.user_id): podcast_feeds and collections
|
||||||
|
// are RLS-governed (an invisible feed/collection resolves to nothing),
|
||||||
|
// libraries mirror the facet's explicit visibility join, and users
|
||||||
|
// (added_by) are plain lookups — the viewer already holds the id from
|
||||||
|
// a legal pick, the resolver only names it.
|
||||||
|
// ════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// MaxResolveIDs bounds one field's resolution query (picks accumulate
|
||||||
|
// per rule; menus cap far below this). The label endpoint truncates to
|
||||||
|
// the same cap.
|
||||||
|
const MaxResolveIDs = 100
|
||||||
|
|
||||||
|
// IsIDValuedField — whether the field's values are entity ids (the
|
||||||
|
// label endpoint's accepted universe: id-kind + a known source).
|
||||||
|
func IsIDValuedField(field string) bool {
|
||||||
|
f, ok := FilterFieldByKey(field)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return valueKindFor(f.Kind, field) == VKId && f.Suggest != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseUUIDValues splits comma-joined picks into uuids, skipping junk.
|
||||||
|
func ParseUUIDValues(values []string) []uuid.UUID {
|
||||||
|
return parseUUIDValues(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilterTreeLabels resolves every id-kind rule value in the tree to its
|
||||||
|
// display name. Best-effort by design: a resolve failure degrades to
|
||||||
|
// fewer labels (cells fall back to raw ids — the pre-fix behavior),
|
||||||
|
// never a failed page render. Returns uuid → display.
|
||||||
|
func (s *SearchService) FilterTreeLabels(ctx context.Context, userID pgtype.UUID, tree *FilterTree) map[string]string {
|
||||||
|
labels := map[string]string{}
|
||||||
|
if tree == nil {
|
||||||
|
return labels
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect ids per field, split comma-joined picks (the builder
|
||||||
|
// stores one comma-joined value per multi-pick), cap the query.
|
||||||
|
byField := map[string][]uuid.UUID{}
|
||||||
|
var walk func(children []FilterChild)
|
||||||
|
walk = func(children []FilterChild) {
|
||||||
|
for _, c := range children {
|
||||||
|
if len(c.Children) > 0 {
|
||||||
|
walk(c.Children)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c.Field == "" || len(c.Values) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
f, ok := FilterFieldByKey(c.Field)
|
||||||
|
if !ok || f.Suggest == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if valueKindFor(f.Kind, c.Field) != VKId {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, id := range parseUUIDValues(c.Values) {
|
||||||
|
if len(byField[c.Field]) >= MaxResolveIDs {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
byField[c.Field] = append(byField[c.Field], id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(tree.Children)
|
||||||
|
|
||||||
|
for field, ids := range byField {
|
||||||
|
for k, v := range s.ResolveFieldLabels(ctx, userID, field, ids) {
|
||||||
|
labels[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return labels
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveFieldLabels names one field's picked ids against its source
|
||||||
|
// table — the shared core of the SSR embed and the label endpoint.
|
||||||
|
// Unknown fields resolve to nothing (the caller validates).
|
||||||
|
func (s *SearchService) ResolveFieldLabels(ctx context.Context, userID pgtype.UUID, field string, ids []uuid.UUID) map[string]string {
|
||||||
|
labels := map[string]string{}
|
||||||
|
f, ok := FilterFieldByKey(field)
|
||||||
|
if !ok {
|
||||||
|
return labels
|
||||||
|
}
|
||||||
|
idArgs := make([]uuid.UUID, len(ids))
|
||||||
|
copy(idArgs, ids)
|
||||||
|
var sqlStr string
|
||||||
|
var args []any
|
||||||
|
switch f.Suggest {
|
||||||
|
case suggestLibrary:
|
||||||
|
// The facet's visibility shape, minus the media join: a
|
||||||
|
// hidden library is invisible even by id (family lens).
|
||||||
|
sqlStr = `SELECT l.id::text, l.name
|
||||||
|
FROM libraries l
|
||||||
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $2
|
||||||
|
WHERE COALESCE(lv.is_visible, true) = true AND l.id = ANY($1)`
|
||||||
|
args = []any{idArgs, userID}
|
||||||
|
case suggestFeed:
|
||||||
|
// RLS-governed (subscription + visibility predicate): an
|
||||||
|
// unsubscribed feed id resolves to nothing.
|
||||||
|
sqlStr = `SELECT id::text, title FROM podcast_feeds WHERE id = ANY($1)`
|
||||||
|
args = []any{idArgs}
|
||||||
|
case suggestCollection:
|
||||||
|
// RLS-governed (app_can_read_collection).
|
||||||
|
sqlStr = `SELECT id::text, name FROM collections WHERE id = ANY($1)`
|
||||||
|
args = []any{idArgs}
|
||||||
|
case suggestAddedBy:
|
||||||
|
sqlStr = `SELECT id::text, username FROM users WHERE id = ANY($1)`
|
||||||
|
args = []any{idArgs}
|
||||||
|
default:
|
||||||
|
return labels
|
||||||
|
}
|
||||||
|
rows, err := s.raw.Query(ctx, sqlStr, args...)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[filter-labels] resolve %q failed: %v", field, err)
|
||||||
|
return labels
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var v, display string
|
||||||
|
if err := rows.Scan(&v, &display); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
labels[v] = display
|
||||||
|
}
|
||||||
|
return labels
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
|
|
||||||
"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"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -75,11 +76,20 @@ JOIN libraries l ON mi.library_id = l.id
|
|||||||
JOIN library_types lt ON l.library_type_id = lt.id
|
JOIN library_types lt ON l.library_type_id = lt.id
|
||||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = `
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = `
|
||||||
|
|
||||||
// headWhere renders the mandatory WHERE head: the family lens +
|
// headWhere renders the mandatory WHERE head: the SCOPE (books excludes
|
||||||
// integrity facts, plus the optional library scope (validated upstream —
|
// podcasts; episodes is episodes only — plan item 3.1 closes the leak
|
||||||
// an explicit out-of-scope library never reaches the SQL).
|
// where the composed head happily returned episodes on All-Books
|
||||||
func (b *filterBuilder) headWhere(libraryID pgtype.UUID) string {
|
// queries), the family lens, integrity facts, plus the optional library
|
||||||
|
// scope (validated upstream — an explicit out-of-scope library never
|
||||||
|
// reaches the SQL).
|
||||||
|
func (b *filterBuilder) headWhere(libraryID pgtype.UUID, scope string) string {
|
||||||
w := "COALESCE(lv.is_visible, true) = true AND mi.archived_at IS NULL AND mi.missing_scan_count = 0"
|
w := "COALESCE(lv.is_visible, true) = true AND mi.archived_at IS NULL AND mi.missing_scan_count = 0"
|
||||||
|
switch scope {
|
||||||
|
case ScopeEpisodes:
|
||||||
|
w += " AND mi.source = 'podcast_episode'"
|
||||||
|
default: // books — the default legacy scope
|
||||||
|
w += " AND mi.library_type_name <> 'podcasts'"
|
||||||
|
}
|
||||||
if libraryID.Valid {
|
if libraryID.Valid {
|
||||||
w += " AND mi.library_id = " + b.bind(libraryID)
|
w += " AND mi.library_id = " + b.bind(libraryID)
|
||||||
}
|
}
|
||||||
@@ -369,6 +379,24 @@ func clauseFileAvailability(r FilterRule, b *filterBuilder) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// clauseAddedBy — the admin who added the book.
|
// clauseAddedBy — the admin who added the book.
|
||||||
|
// parseUUIDValues — the wire shape for id-valued rules: the builder's
|
||||||
|
// pick loop stores picked ids as ONE comma-joined value
|
||||||
|
// (["id1,id2"]), while hand-written trees may carry separate elements.
|
||||||
|
// Both are legal; split on commas, trim, and keep what parses. An
|
||||||
|
// unsplit parse matched NOTHING for multi-picks (2026-11-01 user
|
||||||
|
// report: two feeds in "feeds in" matched zero episodes).
|
||||||
|
func parseUUIDValues(values []string) []uuid.UUID {
|
||||||
|
ids := make([]uuid.UUID, 0, len(values))
|
||||||
|
for _, v := range values {
|
||||||
|
for _, part := range strings.Split(v, ",") {
|
||||||
|
if id, err := uuid.Parse(strings.TrimSpace(part)); err == nil {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
func clauseAddedBy(r FilterRule, b *filterBuilder) string {
|
func clauseAddedBy(r FilterRule, b *filterBuilder) string {
|
||||||
switch r.Op {
|
switch r.Op {
|
||||||
case OpIs:
|
case OpIs:
|
||||||
@@ -600,12 +628,7 @@ func clauseCollection(r FilterRule, b *filterBuilder) string {
|
|||||||
}
|
}
|
||||||
return "(NOT " + ex + ")"
|
return "(NOT " + ex + ")"
|
||||||
}
|
}
|
||||||
ids := make([]uuid.UUID, 0, len(r.Values))
|
ids := parseUUIDValues(r.Values)
|
||||||
for _, v := range r.Values {
|
|
||||||
if id, err := uuid.Parse(v); err == nil {
|
|
||||||
ids = append(ids, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ex := fmt.Sprintf(`(EXISTS (
|
ex := fmt.Sprintf(`(EXISTS (
|
||||||
SELECT 1 FROM collection_items ci
|
SELECT 1 FROM collection_items ci
|
||||||
JOIN collections c ON c.id = ci.collection_id
|
JOIN collections c ON c.id = ci.collection_id
|
||||||
@@ -623,12 +646,7 @@ func clauseCollection(r FilterRule, b *filterBuilder) string {
|
|||||||
// library IDs; visibility is enforced by the mandatory head regardless
|
// library IDs; visibility is enforced by the mandatory head regardless
|
||||||
// (a rule naming a hidden library simply matches nothing).
|
// (a rule naming a hidden library simply matches nothing).
|
||||||
func clauseLibrary(r FilterRule, b *filterBuilder) string {
|
func clauseLibrary(r FilterRule, b *filterBuilder) string {
|
||||||
ids := make([]uuid.UUID, 0, len(r.Values))
|
ids := parseUUIDValues(r.Values)
|
||||||
for _, v := range r.Values {
|
|
||||||
if id, err := uuid.Parse(v); err == nil {
|
|
||||||
ids = append(ids, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if r.Op == OpIs {
|
if r.Op == OpIs {
|
||||||
return "(l.id = ANY(" + b.bind(ids) + "::uuid[]))"
|
return "(l.id = ANY(" + b.bind(ids) + "::uuid[]))"
|
||||||
}
|
}
|
||||||
@@ -637,6 +655,45 @@ func clauseLibrary(r FilterRule, b *filterBuilder) string {
|
|||||||
|
|
||||||
// ─── tree → SQL ──────────────────────────────────────────────────────
|
// ─── tree → SQL ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// clauseFeed — the episode's source feed (episodes scope, item 3.1).
|
||||||
|
// Values are podcast-feed UUIDs; visibility rides RLS (podcast_feeds is
|
||||||
|
// governed by the same subscription predicate as episodes, so a rule
|
||||||
|
// naming an unsubscribed feed matches nothing).
|
||||||
|
func clauseFeed(r FilterRule, b *filterBuilder) string {
|
||||||
|
b.needJoin("pf", "LEFT JOIN podcast_feeds pf ON pf.id = mi.source_feed_id")
|
||||||
|
ids := parseUUIDValues(r.Values)
|
||||||
|
in := "(pf.id = ANY(" + b.bind(ids) + "::uuid[]))"
|
||||||
|
switch r.Op {
|
||||||
|
case OpIs, OpIn:
|
||||||
|
return in
|
||||||
|
default:
|
||||||
|
return "(NOT " + in + ")"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// clauseDownloaded — the episode's download state (episodes scope).
|
||||||
|
// is_true = the playable file is on disk; is_false = anything else.
|
||||||
|
func clauseDownloaded(r FilterRule, b *filterBuilder) string {
|
||||||
|
if r.Op == OpIsTrue {
|
||||||
|
return "(mi.download_state = 'downloaded')"
|
||||||
|
}
|
||||||
|
return "(mi.download_state <> 'downloaded')"
|
||||||
|
}
|
||||||
|
|
||||||
|
// clausePlayed — THE episode finished predicate (episodes scope): the
|
||||||
|
// viewer's progress >= 0.999, the same definition as hide_finished and
|
||||||
|
// the item-2.4 rules engine. Ties to the viewer via the shared
|
||||||
|
// reading_progress join (R2/D5 lineage: progress is per-user).
|
||||||
|
func clausePlayed(r FilterRule, b *filterBuilder) string {
|
||||||
|
b.readingProgress()
|
||||||
|
if r.Op == OpIsTrue {
|
||||||
|
return "(COALESCE(rp.percentage, 0) >= 0.999)"
|
||||||
|
}
|
||||||
|
return "(COALESCE(rp.percentage, 0) < 0.999)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── tree → SQL (end) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
// buildGroupClause renders a group: children joined by the group mode,
|
// buildGroupClause renders a group: children joined by the group mode,
|
||||||
// each child a rule clause or a nested group. A group ANDs into its
|
// each child a rule clause or a nested group. A group ANDs into its
|
||||||
// parent.
|
// parent.
|
||||||
@@ -742,14 +799,13 @@ func (s *SearchService) ComposedSearch(ctx context.Context, params SearchParams)
|
|||||||
// Build the WHERE first — clause construction requests the
|
// Build the WHERE first — clause construction requests the
|
||||||
// user-relative JOINs lazily, and joinBlock() must render AFTER
|
// user-relative JOINs lazily, and joinBlock() must render AFTER
|
||||||
// they are collected.
|
// they are collected.
|
||||||
whereSQL := b.headWhere(params.LibraryID) + treeWhere(params.FilterTree, b) + `
|
whereSQL := b.headWhere(params.LibraryID, params.FilterTree.FilterScope()) + treeWhere(params.FilterTree, b) + `
|
||||||
AND (` + libBind + `::uuid IS NULL OR mi.library_id = ` + libBind + `::uuid)
|
AND (` + libBind + `::uuid IS NULL OR mi.library_id = ` + libBind + `::uuid)
|
||||||
AND (` + qBind + ` = '' OR ` + composedQueryBlock(qBind, exactBind) + `)`
|
AND (` + qBind + ` = '' OR ` + composedQueryBlock(qBind, exactBind) + `)`
|
||||||
sqlStr := composedSelect + filterFrom + b.userIDBind +
|
sqlStr := composedSelect + filterFrom + b.userIDBind +
|
||||||
b.joinBlock() + `
|
b.joinBlock() + `
|
||||||
WHERE ` + whereSQL +
|
WHERE ` + whereSQL +
|
||||||
composedSortOrder(sortBind, qBind, exactBind) + ` ` + limitBind + ` OFFSET ` + offsetBind
|
composedSortOrder(sortBind, qBind, exactBind) + ` ` + limitBind + ` OFFSET ` + offsetBind
|
||||||
fmt.Printf("[DEBUG COMPOSED] %s\n", sqlStr)
|
|
||||||
|
|
||||||
rows, err := s.raw.Query(ctx, sqlStr, b.args...)
|
rows, err := s.raw.Query(ctx, sqlStr, b.args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -821,12 +877,62 @@ func composedQueryBlock(qBind, exactBind string) string {
|
|||||||
))`
|
))`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ComposedLiveMedia — full media rows matching the tree: the TREE path
|
||||||
|
// of the smart-collection evaluator (item 3.2). Same filters as
|
||||||
|
// ComposedSearch (scope head, family lens via the visibility join,
|
||||||
|
// tree clause, optional visible-library belt) but SELECT mi.* so
|
||||||
|
// collection surfaces get the whole media row (episode-card stamping,
|
||||||
|
// feed position for the replay chain). Replaces the candidate query's
|
||||||
|
// Sqlc embed for tree collections; ordering matches the legacy
|
||||||
|
// candidate query (title ASC).
|
||||||
|
func (s *SearchService) ComposedLiveMedia(ctx context.Context, userID, libraryID pgtype.UUID, visibleIDs []pgtype.UUID, tree *FilterTree) ([]database.MediaItems, error) {
|
||||||
|
// A rules-LESS tree (root with zero children — legal since the
|
||||||
|
// episodes scope door seeds one at create-modal time) matches
|
||||||
|
// NOTHING, mirroring the legacy engine's zero-enabled-rules → empty
|
||||||
|
// convention (EvaluateSmartCollection). Without this guard the
|
||||||
|
// composed query degrades to the bare scope head below and a fresh
|
||||||
|
// Episodes collection would swallow every episode in the library.
|
||||||
|
if len(tree.Children) == 0 {
|
||||||
|
return []database.MediaItems{}, nil
|
||||||
|
}
|
||||||
|
b := newFilterBuilder(userID)
|
||||||
|
head := b.headWhere(libraryID, tree.FilterScope()) + treeWhere(tree, b)
|
||||||
|
visibleClause := ""
|
||||||
|
if len(visibleIDs) > 0 {
|
||||||
|
visibleClause = " AND (l.id = ANY(" + b.bind(visibleIDs) + "::uuid[])"
|
||||||
|
if libraryID.Valid {
|
||||||
|
// Redundant by construction; keep SQL valid either way.
|
||||||
|
visibleClause += " OR l.id = " + b.bind(libraryID)
|
||||||
|
}
|
||||||
|
visibleClause += ")"
|
||||||
|
}
|
||||||
|
sqlStr := "SELECT mi.* " + filterFrom + b.userIDBind +
|
||||||
|
b.joinBlock() + `
|
||||||
|
WHERE ` + head + visibleClause + `
|
||||||
|
ORDER BY mi.title ASC`
|
||||||
|
|
||||||
|
rows, err := s.raw.Query(ctx, sqlStr, b.args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
// Name-based decoding:mi.* column order and the struct order stay
|
||||||
|
// decoupled (position-based scanning would silently break the next
|
||||||
|
// schema append).
|
||||||
|
return pgx.CollectRows(rows, pgx.RowToStructByName[database.MediaItems])
|
||||||
|
}
|
||||||
|
|
||||||
// ComposedCount — the live match count for the builder (rules + scope,
|
// ComposedCount — the live match count for the builder (rules + scope,
|
||||||
// no pagination): a true COUNT(*), not the page length.
|
// no pagination): a true COUNT(*), not the page length.
|
||||||
func (s *SearchService) ComposedCount(ctx context.Context, userID, libraryID pgtype.UUID, tree *FilterTree) (int, error) {
|
func (s *SearchService) ComposedCount(ctx context.Context, userID, libraryID pgtype.UUID, tree *FilterTree) (int, error) {
|
||||||
|
// Same empty-tree convention as ComposedLiveMedia: rules-less means
|
||||||
|
// zero matches, not "everything".
|
||||||
|
if len(tree.Children) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
b := newFilterBuilder(userID)
|
b := newFilterBuilder(userID)
|
||||||
libBind := b.bind(libraryID)
|
libBind := b.bind(libraryID)
|
||||||
whereSQL := b.headWhere(libraryID) + treeWhere(tree, b) + `
|
whereSQL := b.headWhere(libraryID, tree.FilterScope()) + treeWhere(tree, b) + `
|
||||||
AND (` + libBind + `::uuid IS NULL OR mi.library_id = ` + libBind + `::uuid)`
|
AND (` + libBind + `::uuid IS NULL OR mi.library_id = ` + libBind + `::uuid)`
|
||||||
sqlStr := `SELECT COUNT(*) ` + filterFrom + b.userIDBind +
|
sqlStr := `SELECT COUNT(*) ` + filterFrom + b.userIDBind +
|
||||||
b.joinBlock() + `
|
b.joinBlock() + `
|
||||||
@@ -921,6 +1027,14 @@ func (s *SearchService) ComposedFieldValues(ctx context.Context, p ComposedSugge
|
|||||||
return s.composedIDSourceValues(ctx, b, p,
|
return s.composedIDSourceValues(ctx, b, p,
|
||||||
"users u JOIN media_items mi ON mi.added_by_admin_id = u.id",
|
"users u JOIN media_items mi ON mi.added_by_admin_id = u.id",
|
||||||
"u.id::text", "u.username", "", limitBind, libBind)
|
"u.id::text", "u.username", "", limitBind, libBind)
|
||||||
|
case suggestFeed:
|
||||||
|
// The viewer's feed universe — RLS (podcast_feeds subscription
|
||||||
|
// predicate + admin escape) scopes both suggestions and matches.
|
||||||
|
return s.composedIDSourceValues(ctx, b, p,
|
||||||
|
"podcast_feeds pf",
|
||||||
|
"pf.id::text", "pf.title",
|
||||||
|
"JOIN media_items mi ON mi.source_feed_id = pf.id",
|
||||||
|
limitBind, libBind)
|
||||||
default:
|
default:
|
||||||
// Column / array sources: the value expr mirrors the field's
|
// Column / array sources: the value expr mirrors the field's
|
||||||
// match column (tags via the *_search column, like the flat
|
// match column (tags via the *_search column, like the flat
|
||||||
@@ -956,7 +1070,7 @@ func (s *SearchService) ComposedFieldValues(ctx context.Context, p ComposedSugge
|
|||||||
scoreExpr = fmt.Sprintf("CASE WHEN %s = '' THEN 0 ELSE word_similarity(%s, src.value) END AS score", tb, tb)
|
scoreExpr = fmt.Sprintf("CASE WHEN %s = '' THEN 0 ELSE word_similarity(%s, src.value) END AS score", tb, tb)
|
||||||
}
|
}
|
||||||
|
|
||||||
whereSQL := b.headWhere(p.LibraryID) + `
|
whereSQL := b.headWhere(p.LibraryID, p.Tree.FilterScope()) + `
|
||||||
AND (` + libBind + `::uuid IS NULL OR mi.library_id = ` + libBind + `::uuid)` +
|
AND (` + libBind + `::uuid IS NULL OR mi.library_id = ` + libBind + `::uuid)` +
|
||||||
treeWhere(p.Tree, b) + `
|
treeWhere(p.Tree, b) + `
|
||||||
AND src.value IS NOT NULL AND src.value <> ''` + typedFilter
|
AND src.value IS NOT NULL AND src.value <> ''` + typedFilter
|
||||||
@@ -1001,9 +1115,16 @@ func (s *SearchService) composedIDSourceValues(ctx context.Context, b *filterBui
|
|||||||
fromTables, valueExpr, displayExpr, joinMedia, limitBind, libBind string) ([]FieldValue, error) {
|
fromTables, valueExpr, displayExpr, joinMedia, limitBind, libBind string) ([]FieldValue, error) {
|
||||||
// The typed bind happens HERE and only when non-blank — an unused
|
// The typed bind happens HERE and only when non-blank — an unused
|
||||||
// parameter is a 42P18 error (the blank-browse case binds nothing).
|
// parameter is a 42P18 error (the blank-browse case binds nothing).
|
||||||
|
// Typing filters the NAME (ILIKE) — and also exact-matches a
|
||||||
|
// comma-separated ID list: the builder resolves a freshly ingested
|
||||||
|
// tree's stored ids to labels with the same call (2026-11-01: the
|
||||||
|
// library cell reverted to raw ids after Apply — labels only ever
|
||||||
|
// came from suggestion picks).
|
||||||
nameFilter := ""
|
nameFilter := ""
|
||||||
if p.Typed != "" {
|
if p.Typed != "" {
|
||||||
nameFilter = " AND " + displayExpr + " ILIKE '%' || " + b.bind(p.Typed) + " || '%'"
|
tb := b.bind(p.Typed)
|
||||||
|
nameFilter = " AND (" + displayExpr + " ILIKE '%' || " + tb + " || '%'" +
|
||||||
|
" OR " + valueExpr + "::text = ANY(string_to_array(" + tb + ", ',')))"
|
||||||
}
|
}
|
||||||
sqlStr := `SELECT ` + valueExpr + ` AS value, ` + displayExpr + ` AS display, COUNT(mi.id) AS count
|
sqlStr := `SELECT ` + valueExpr + ` AS value, ` + displayExpr + ` AS display, COUNT(mi.id) AS count
|
||||||
FROM ` + fromTables + `
|
FROM ` + fromTables + `
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ const (
|
|||||||
suggestHighlight SuggestSource = "highlight_text"
|
suggestHighlight SuggestSource = "highlight_text"
|
||||||
suggestLibrary SuggestSource = "library" // visibility-scoped libraries
|
suggestLibrary SuggestSource = "library" // visibility-scoped libraries
|
||||||
suggestCollection SuggestSource = "collection" // the viewer's collections
|
suggestCollection SuggestSource = "collection" // the viewer's collections
|
||||||
|
suggestFeed SuggestSource = "feed" // the viewer's subscribed feeds (RLS-governed)
|
||||||
suggestAddedBy SuggestSource = "added_by" // admins who added books in scope
|
suggestAddedBy SuggestSource = "added_by" // admins who added books in scope
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -99,8 +100,27 @@ const (
|
|||||||
GroupComics = "Comics & manga"
|
GroupComics = "Comics & manga"
|
||||||
GroupMyReading = "My reading"
|
GroupMyReading = "My reading"
|
||||||
GroupLibrary = "Library"
|
GroupLibrary = "Library"
|
||||||
|
// Episodes (plan item 3.1, filter-system unification): fields that
|
||||||
|
// only make sense on podcast-episode rows. Hidden in the books
|
||||||
|
// scope ("hide podcasts from the rules on the all books page, but
|
||||||
|
// allow them in collections").
|
||||||
|
GroupEpisodes = "Episodes"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Filter scopes (plan item 3.1): which row universe a tree matches.
|
||||||
|
// The top-level `scope` key on the qf tree declares it; absent = books
|
||||||
|
// (every existing saved tree and every legacy client stays valid).
|
||||||
|
const (
|
||||||
|
ScopeBooks = "books"
|
||||||
|
ScopeEpisodes = "episodes"
|
||||||
|
)
|
||||||
|
|
||||||
|
// episodeOnlyFields are meaningful ONLY in the episodes scope — a
|
||||||
|
// books-scope tree using one is a user error (400 at parse).
|
||||||
|
var episodeOnlyFields = map[string]bool{
|
||||||
|
"feed": true, "downloaded": true, "played": true,
|
||||||
|
}
|
||||||
|
|
||||||
// FilterRule is one leaf: field + operator + values (all bound params).
|
// FilterRule is one leaf: field + operator + values (all bound params).
|
||||||
type FilterRule struct {
|
type FilterRule struct {
|
||||||
Field string
|
Field string
|
||||||
@@ -112,10 +132,21 @@ type FilterRule struct {
|
|||||||
// children are rules or nested groups. Groups AND/OR their children
|
// children are rules or nested groups. Groups AND/OR their children
|
||||||
// together; a group ANDs into its parent.
|
// together; a group ANDs into its parent.
|
||||||
type FilterTree struct {
|
type FilterTree struct {
|
||||||
|
Scope string `json:"scope,omitempty"`
|
||||||
Mode string `json:"mode"`
|
Mode string `json:"mode"`
|
||||||
Children []FilterChild `json:"children"`
|
Children []FilterChild `json:"children"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FilterScope resolves the tree's row universe: an absent scope key is
|
||||||
|
// the books scope (legacy trees, saved filters, every pre-unification
|
||||||
|
// client). nil-safe (suggest paths can carry a nil tree).
|
||||||
|
func (t *FilterTree) FilterScope() string {
|
||||||
|
if t != nil && t.Scope == ScopeEpisodes {
|
||||||
|
return ScopeEpisodes
|
||||||
|
}
|
||||||
|
return ScopeBooks
|
||||||
|
}
|
||||||
|
|
||||||
// FilterChild is one node: a rule (Field set) or a group (Mode set).
|
// FilterChild is one node: a rule (Field set) or a group (Mode set).
|
||||||
type FilterChild struct {
|
type FilterChild struct {
|
||||||
Field string `json:"field,omitempty"`
|
Field string `json:"field,omitempty"`
|
||||||
@@ -135,6 +166,146 @@ type FilterField struct {
|
|||||||
DefaultOp string // operator a new rule starts on (field-switch reset)
|
DefaultOp string // operator a new rule starts on (field-switch reset)
|
||||||
Clause func(r FilterRule, b *filterBuilder) string
|
Clause func(r FilterRule, b *filterBuilder) string
|
||||||
Suggest SuggestSource
|
Suggest SuggestSource
|
||||||
|
// Scopes limits the field to the listed scopes (nil = available in
|
||||||
|
// every scope — most book fields no-op against episode rows since
|
||||||
|
// episode rows carry NULLs there). Episode-only fields declare
|
||||||
|
// exactly [ScopeEpisodes]; the books scope rejects them at parse.
|
||||||
|
Scopes []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// InScope reports whether the field may appear in a scope tree.
|
||||||
|
func (f FilterField) InScope(scope string) bool {
|
||||||
|
if len(f.Scopes) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, s := range f.Scopes {
|
||||||
|
if s == scope {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilterOpWire is one operator's presentation contract (item 3.3):
|
||||||
|
// THE system carries both grammar and rendering in one place — the
|
||||||
|
// frontend renders this wire, it invents nothing.
|
||||||
|
type FilterOpWire struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Negated bool `json:"negated,omitempty"`
|
||||||
|
// Values is the value-cell arity: "many" (one multi-value input),
|
||||||
|
// "one", "two", or "none".
|
||||||
|
Values string `json:"values"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var filterOps = map[string]FilterOpWire{
|
||||||
|
OpContains: {Key: OpContains, Label: "contains", Values: "many"},
|
||||||
|
OpNotContains: {Key: OpNotContains, Label: "doesn't contain", Negated: true, Values: "many"},
|
||||||
|
OpIs: {Key: OpIs, Label: "is", Values: "many"},
|
||||||
|
OpIsNot: {Key: OpIsNot, Label: "isn't", Negated: true, Values: "many"},
|
||||||
|
OpEmpty: {Key: OpEmpty, Label: "is empty", Values: "none"},
|
||||||
|
OpIsTrue: {Key: OpIsTrue, Label: "is true", Values: "none"},
|
||||||
|
OpIsFalse: {Key: OpIsFalse, Label: "is false", Values: "none"},
|
||||||
|
OpBetween: {Key: OpBetween, Label: "between", Values: "two"},
|
||||||
|
OpAtLeast: {Key: OpAtLeast, Label: "at least", Values: "one"},
|
||||||
|
OpAtMost: {Key: OpAtMost, Label: "at most", Values: "one"},
|
||||||
|
OpWithinDays: {Key: OpWithinDays, Label: "within the last", Values: "one"},
|
||||||
|
OpHas: {Key: OpHas, Label: "has", Values: "none"},
|
||||||
|
OpMissing: {Key: OpMissing, Label: "missing", Values: "none"},
|
||||||
|
OpUnrated: {Key: OpUnrated, Label: "unrated", Values: "none"},
|
||||||
|
OpIn: {Key: OpIn, Label: "in", Values: "many"},
|
||||||
|
OpNotIn: {Key: OpNotIn, Label: "isn't in", Negated: true, Values: "many"},
|
||||||
|
OpInAny: {Key: OpInAny, Label: "in any", Values: "none"},
|
||||||
|
OpInNone: {Key: OpInNone, Label: "in none", Negated: true, Values: "none"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilterFieldWire is one field's presentation entry (the registry's
|
||||||
|
// wire face): the frontend menu is scope-filtered OFF this list.
|
||||||
|
type FilterFieldWire struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Group string `json:"group"`
|
||||||
|
ValueKind string `json:"valueKind"`
|
||||||
|
Ops []FilterOpWire `json:"ops"`
|
||||||
|
DefaultOp string `json:"defaultOp"`
|
||||||
|
Suggest bool `json:"suggest"`
|
||||||
|
Options []string `json:"options,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// valueKinds — what the value cell renders (the registry's Kind mapped
|
||||||
|
// to frontend-cell semantics).
|
||||||
|
const (
|
||||||
|
VKText = "text"
|
||||||
|
VKNumber = "number"
|
||||||
|
VKDays = "days"
|
||||||
|
VKId = "id"
|
||||||
|
VKFixed = "fixed"
|
||||||
|
VKNone = "none"
|
||||||
|
)
|
||||||
|
|
||||||
|
// valueKindFor maps the SQL-facing Kind to the cell semantics.
|
||||||
|
func valueKindFor(kind FilterKind, key string) string {
|
||||||
|
switch kind {
|
||||||
|
case KindScalarFuzzy, KindArrayFuzzy, KindText:
|
||||||
|
return VKText
|
||||||
|
case KindEnum, KindStatus, KindLibrary, KindCollection, KindIdentifier:
|
||||||
|
return VKId
|
||||||
|
case KindBool:
|
||||||
|
return VKNone
|
||||||
|
case KindNumber, KindDate:
|
||||||
|
if kind == KindDate {
|
||||||
|
return VKDays
|
||||||
|
}
|
||||||
|
return VKNumber
|
||||||
|
case KindPresence, KindNoteText:
|
||||||
|
return VKNone
|
||||||
|
}
|
||||||
|
if key == "added_by" {
|
||||||
|
return VKId
|
||||||
|
}
|
||||||
|
return VKNone
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilterFieldsPayload is THE builder vocabulary for one scope: group-
|
||||||
|
// ordered wire fields (registry order), each with its ops' presentation.
|
||||||
|
// The frontend renders this; it hardcodes nothing.
|
||||||
|
func FilterFieldsPayload(scope string) []FilterFieldWire {
|
||||||
|
suggestKnown := map[SuggestSource]bool{
|
||||||
|
suggestAuthor: true, suggestNarrator: true, suggestContrib: true,
|
||||||
|
suggestPublisher: true, suggestTags: true, suggestColumn: true,
|
||||||
|
suggestSeries: true, suggestLibrary: true, suggestCollection: true,
|
||||||
|
suggestAddedBy: true, suggestFeed: true,
|
||||||
|
}
|
||||||
|
presented := make(map[string]bool)
|
||||||
|
out := []FilterFieldWire{}
|
||||||
|
for _, f := range filterFields {
|
||||||
|
if !f.InScope(scope) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if presented[f.Key] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
presented[f.Key] = true
|
||||||
|
ops := make([]FilterOpWire, 0, len(f.Ops))
|
||||||
|
for _, opKey := range f.Ops {
|
||||||
|
ops = append(ops, filterOps[opKey])
|
||||||
|
}
|
||||||
|
wire := FilterFieldWire{
|
||||||
|
Key: f.Key,
|
||||||
|
Label: f.Label,
|
||||||
|
Group: f.Group,
|
||||||
|
ValueKind: valueKindFor(f.Kind, f.Key),
|
||||||
|
Ops: ops,
|
||||||
|
DefaultOp: f.DefaultOp,
|
||||||
|
Suggest: suggestKnown[f.Suggest],
|
||||||
|
}
|
||||||
|
if domain, ok := fixedDomains[f.Key]; ok {
|
||||||
|
wire.ValueKind = VKFixed
|
||||||
|
wire.Options = domain
|
||||||
|
}
|
||||||
|
out = append(out, wire)
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// opsWithValue / opsWithoutValue drive validation.
|
// opsWithValue / opsWithoutValue drive validation.
|
||||||
@@ -154,41 +325,54 @@ var fixedDomains = map[string][]string{
|
|||||||
var filterFields = []FilterField{
|
var filterFields = []FilterField{
|
||||||
// ── People ────────────────────────────────────────────────────
|
// ── People ────────────────────────────────────────────────────
|
||||||
{Key: "author", Label: "Author", Group: GroupPeople, Kind: KindArrayFuzzy,
|
{Key: "author", Label: "Author", Group: GroupPeople, Kind: KindArrayFuzzy,
|
||||||
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
Scopes: []string{ScopeBooks},
|
||||||
|
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
||||||
Clause: clauseAuthorArray, Suggest: suggestAuthor},
|
Clause: clauseAuthorArray, Suggest: suggestAuthor},
|
||||||
{Key: "narrator", Label: "Narrator", Group: GroupPeople, Kind: KindArrayFuzzy,
|
{Key: "narrator", Label: "Narrator", Group: GroupPeople, Kind: KindArrayFuzzy,
|
||||||
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
Scopes: []string{ScopeBooks},
|
||||||
|
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
||||||
Clause: clauseArrayFuzzyFactory("mi.narrators"), Suggest: suggestNarrator},
|
Clause: clauseArrayFuzzyFactory("mi.narrators"), Suggest: suggestNarrator},
|
||||||
{Key: "contributor", Label: "Contributor", Group: GroupPeople, Kind: KindArrayFuzzy,
|
{Key: "contributor", Label: "Contributor", Group: GroupPeople, Kind: KindArrayFuzzy,
|
||||||
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
Scopes: []string{ScopeBooks},
|
||||||
|
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
||||||
Clause: clauseArrayFuzzyFactory("mi.contributors"), Suggest: suggestContrib},
|
Clause: clauseArrayFuzzyFactory("mi.contributors"), Suggest: suggestContrib},
|
||||||
|
|
||||||
// ── Publishing ────────────────────────────────────────────────
|
// ── Publishing ────────────────────────────────────────────────
|
||||||
{Key: "publisher", Label: "Publisher", Group: GroupPublish, Kind: KindArrayFuzzy,
|
{Key: "publisher", Label: "Publisher", Group: GroupPublish, Kind: KindArrayFuzzy,
|
||||||
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
Scopes: []string{ScopeBooks},
|
||||||
|
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
||||||
Clause: clauseArrayFuzzyFactory("mi.publishers"), Suggest: suggestPublisher},
|
Clause: clauseArrayFuzzyFactory("mi.publishers"), Suggest: suggestPublisher},
|
||||||
{Key: "imprint", Label: "Imprint", Group: GroupPublish, Kind: KindScalarFuzzy,
|
{Key: "imprint", Label: "Imprint", Group: GroupPublish, Kind: KindScalarFuzzy,
|
||||||
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
Scopes: []string{ScopeBooks},
|
||||||
|
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
||||||
Clause: clauseScalarFuzzyFactory("mi.imprint"), Suggest: suggestColumn},
|
Clause: clauseScalarFuzzyFactory("mi.imprint"), Suggest: suggestColumn},
|
||||||
{Key: "edition", Label: "Edition", Group: GroupPublish, Kind: KindScalarFuzzy,
|
{Key: "edition", Label: "Edition", Group: GroupPublish, Kind: KindScalarFuzzy,
|
||||||
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
Scopes: []string{ScopeBooks},
|
||||||
|
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
||||||
Clause: clauseScalarFuzzyFactory("mi.edition"), Suggest: suggestColumn},
|
Clause: clauseScalarFuzzyFactory("mi.edition"), Suggest: suggestColumn},
|
||||||
{Key: "published_year", Label: "Published year", Group: GroupPublish, Kind: KindNumber,
|
{Key: "published_year", Label: "Published year", Group: GroupPublish, Kind: KindNumber,
|
||||||
Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
|
Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
|
||||||
Clause: clausePublishedYear, Suggest: suggestNone},
|
Clause: clausePublishedYear, Suggest: suggestNone},
|
||||||
|
{Key: "published_date", Label: "Published date", Group: GroupPublish, Kind: KindDate,
|
||||||
|
Ops: []string{OpWithinDays, OpBetween, OpEmpty}, DefaultOp: OpWithinDays,
|
||||||
|
Clause: clauseDateFactory("mi.date_published"), Suggest: suggestNone},
|
||||||
|
|
||||||
// ── Topics ────────────────────────────────────────────────────
|
// ── Topics ────────────────────────────────────────────────────
|
||||||
{Key: "genre", Label: "Genre", Group: GroupTopics, Kind: KindScalarFuzzy,
|
{Key: "genre", Label: "Genre", Group: GroupTopics, Kind: KindScalarFuzzy,
|
||||||
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
Scopes: []string{ScopeBooks},
|
||||||
|
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
||||||
Clause: clauseScalarFuzzyFactory("mi.genre"), Suggest: suggestColumn},
|
Clause: clauseScalarFuzzyFactory("mi.genre"), Suggest: suggestColumn},
|
||||||
{Key: "tags", Label: "Tags", Group: GroupTopics, Kind: KindArrayFuzzy,
|
{Key: "tags", Label: "Tags", Group: GroupTopics, Kind: KindArrayFuzzy,
|
||||||
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
Scopes: []string{ScopeBooks},
|
||||||
|
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
||||||
Clause: clauseArrayFuzzyFactory("mi.tags_search"), Suggest: suggestTags},
|
Clause: clauseArrayFuzzyFactory("mi.tags_search"), Suggest: suggestTags},
|
||||||
{Key: "story_arc", Label: "Story arc", Group: GroupTopics, Kind: KindScalarFuzzy,
|
{Key: "story_arc", Label: "Story arc", Group: GroupTopics, Kind: KindScalarFuzzy,
|
||||||
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
Scopes: []string{ScopeBooks},
|
||||||
|
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
||||||
Clause: clauseScalarFuzzyFactory("mi.story_arc"), Suggest: suggestColumn},
|
Clause: clauseScalarFuzzyFactory("mi.story_arc"), Suggest: suggestColumn},
|
||||||
{Key: "age_rating", Label: "Age rating", Group: GroupTopics, Kind: KindEnum,
|
{Key: "age_rating", Label: "Age rating", Group: GroupTopics, Kind: KindEnum,
|
||||||
Ops: []string{OpIs, OpIsNot, OpEmpty}, DefaultOp: OpIs,
|
Scopes: []string{ScopeBooks},
|
||||||
|
Ops: []string{OpIs, OpIsNot, OpEmpty}, DefaultOp: OpIs,
|
||||||
Clause: clauseEnumFactory("mi.age_rating"), Suggest: suggestColumn},
|
Clause: clauseEnumFactory("mi.age_rating"), Suggest: suggestColumn},
|
||||||
|
|
||||||
// ── Book facts ────────────────────────────────────────────────
|
// ── Book facts ────────────────────────────────────────────────
|
||||||
@@ -196,28 +380,33 @@ var filterFields = []FilterField{
|
|||||||
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
||||||
Clause: clauseScalarFuzzyFactory("mi.title"), Suggest: suggestColumn},
|
Clause: clauseScalarFuzzyFactory("mi.title"), Suggest: suggestColumn},
|
||||||
{Key: "subtitle", Label: "Subtitle", Group: GroupBookFacts, Kind: KindText,
|
{Key: "subtitle", Label: "Subtitle", Group: GroupBookFacts, Kind: KindText,
|
||||||
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
Scopes: []string{ScopeBooks},
|
||||||
|
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
||||||
Clause: clauseScalarFuzzyFactory("mi.subtitle"), Suggest: suggestColumn},
|
Clause: clauseScalarFuzzyFactory("mi.subtitle"), Suggest: suggestColumn},
|
||||||
{Key: "description", Label: "Description", Group: GroupBookFacts, Kind: KindText,
|
{Key: "description", Label: "Description", Group: GroupBookFacts, Kind: KindText,
|
||||||
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
||||||
Clause: clauseScalarFuzzyFactory("mi.description"), Suggest: suggestNone},
|
Clause: clauseScalarFuzzyFactory("mi.description"), Suggest: suggestNone},
|
||||||
{Key: "summary", Label: "Summary", Group: GroupBookFacts, Kind: KindPresence,
|
{Key: "summary", Label: "Summary", Group: GroupBookFacts, Kind: KindPresence,
|
||||||
Ops: []string{OpHas, OpMissing}, DefaultOp: OpHas,
|
Scopes: []string{ScopeBooks},
|
||||||
|
Ops: []string{OpHas, OpMissing}, DefaultOp: OpHas,
|
||||||
Clause: clausePresenceFactory("mi.summary"), Suggest: suggestNone},
|
Clause: clausePresenceFactory("mi.summary"), Suggest: suggestNone},
|
||||||
{Key: "language", Label: "Language", Group: GroupBookFacts, Kind: KindScalarFuzzy,
|
{Key: "language", Label: "Language", Group: GroupBookFacts, Kind: KindScalarFuzzy,
|
||||||
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
||||||
Clause: clauseLanguage, Suggest: suggestColumn},
|
Clause: clauseLanguage, Suggest: suggestColumn},
|
||||||
{Key: "page_count", Label: "Page count", Group: GroupBookFacts, Kind: KindNumber,
|
{Key: "page_count", Label: "Page count", Group: GroupBookFacts, Kind: KindNumber,
|
||||||
Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
|
Scopes: []string{ScopeBooks},
|
||||||
|
Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
|
||||||
Clause: clauseNumberFactory("mi.page_count", parseNumberInt), Suggest: suggestNone},
|
Clause: clauseNumberFactory("mi.page_count", parseNumberInt), Suggest: suggestNone},
|
||||||
{Key: "chapter_count", Label: "Chapter count", Group: GroupBookFacts, Kind: KindNumber,
|
{Key: "chapter_count", Label: "Chapter count", Group: GroupBookFacts, Kind: KindNumber,
|
||||||
Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
|
Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
|
||||||
Clause: clauseNumberFactory("mi.chapter_count", parseNumberInt), Suggest: suggestNone},
|
Clause: clauseNumberFactory("mi.chapter_count", parseNumberInt), Suggest: suggestNone},
|
||||||
{Key: "total_characters", Label: "Total characters", Group: GroupBookFacts, Kind: KindNumber,
|
{Key: "total_characters", Label: "Total characters", Group: GroupBookFacts, Kind: KindNumber,
|
||||||
Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
|
Scopes: []string{ScopeBooks},
|
||||||
|
Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
|
||||||
Clause: clauseNumberFactory("mi.total_characters", parseNumberInt), Suggest: suggestNone},
|
Clause: clauseNumberFactory("mi.total_characters", parseNumberInt), Suggest: suggestNone},
|
||||||
{Key: "community_rating", Label: "Community rating", Group: GroupBookFacts, Kind: KindNumber,
|
{Key: "community_rating", Label: "Community rating", Group: GroupBookFacts, Kind: KindNumber,
|
||||||
Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
|
Scopes: []string{ScopeBooks},
|
||||||
|
Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
|
||||||
Clause: clauseNumberFactory("mi.community_rating", parseNumberFloat), Suggest: suggestNone},
|
Clause: clauseNumberFactory("mi.community_rating", parseNumberFloat), Suggest: suggestNone},
|
||||||
{Key: "added_at", Label: "Date added", Group: GroupBookFacts, Kind: KindDate,
|
{Key: "added_at", Label: "Date added", Group: GroupBookFacts, Kind: KindDate,
|
||||||
Ops: []string{OpWithinDays, OpBetween, OpEmpty}, DefaultOp: OpWithinDays,
|
Ops: []string{OpWithinDays, OpBetween, OpEmpty}, DefaultOp: OpWithinDays,
|
||||||
@@ -226,16 +415,20 @@ var filterFields = []FilterField{
|
|||||||
Ops: []string{OpWithinDays, OpBetween, OpEmpty}, DefaultOp: OpWithinDays,
|
Ops: []string{OpWithinDays, OpBetween, OpEmpty}, DefaultOp: OpWithinDays,
|
||||||
Clause: clauseDateFactory("mi.imported_at"), Suggest: suggestNone},
|
Clause: clauseDateFactory("mi.imported_at"), Suggest: suggestNone},
|
||||||
{Key: "web_url", Label: "Web URL", Group: GroupBookFacts, Kind: KindPresence,
|
{Key: "web_url", Label: "Web URL", Group: GroupBookFacts, Kind: KindPresence,
|
||||||
Ops: []string{OpHas, OpMissing}, DefaultOp: OpHas,
|
Scopes: []string{ScopeBooks},
|
||||||
|
Ops: []string{OpHas, OpMissing}, DefaultOp: OpHas,
|
||||||
Clause: clausePresenceFactory("mi.web_url"), Suggest: suggestNone},
|
Clause: clausePresenceFactory("mi.web_url"), Suggest: suggestNone},
|
||||||
{Key: "metadata_notes", Label: "Metadata notes", Group: GroupBookFacts, Kind: KindPresence,
|
{Key: "metadata_notes", Label: "Metadata notes", Group: GroupBookFacts, Kind: KindPresence,
|
||||||
Ops: []string{OpHas, OpMissing}, DefaultOp: OpHas,
|
Scopes: []string{ScopeBooks},
|
||||||
|
Ops: []string{OpHas, OpMissing}, DefaultOp: OpHas,
|
||||||
Clause: clausePresenceFactory("mi.metadata_notes"), Suggest: suggestNone},
|
Clause: clausePresenceFactory("mi.metadata_notes"), Suggest: suggestNone},
|
||||||
{Key: "isbn", Label: "ISBN", Group: GroupBookFacts, Kind: KindIdentifier,
|
{Key: "isbn", Label: "ISBN", Group: GroupBookFacts, Kind: KindIdentifier,
|
||||||
Ops: []string{OpIs, OpIsNot, OpHas, OpMissing}, DefaultOp: OpHas,
|
Scopes: []string{ScopeBooks},
|
||||||
|
Ops: []string{OpIs, OpIsNot, OpHas, OpMissing}, DefaultOp: OpHas,
|
||||||
Clause: clauseIdentifierFactory("mi.isbn"), Suggest: suggestNone},
|
Clause: clauseIdentifierFactory("mi.isbn"), Suggest: suggestNone},
|
||||||
{Key: "asin", Label: "ASIN", Group: GroupBookFacts, Kind: KindIdentifier,
|
{Key: "asin", Label: "ASIN", Group: GroupBookFacts, Kind: KindIdentifier,
|
||||||
Ops: []string{OpIs, OpIsNot, OpHas, OpMissing}, DefaultOp: OpHas,
|
Scopes: []string{ScopeBooks},
|
||||||
|
Ops: []string{OpIs, OpIsNot, OpHas, OpMissing}, DefaultOp: OpHas,
|
||||||
Clause: clauseIdentifierFactory("mi.asin"), Suggest: suggestNone},
|
Clause: clauseIdentifierFactory("mi.asin"), Suggest: suggestNone},
|
||||||
{Key: "file_size", Label: "File size", Group: GroupBookFacts, Kind: KindNumber,
|
{Key: "file_size", Label: "File size", Group: GroupBookFacts, Kind: KindNumber,
|
||||||
Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
|
Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
|
||||||
@@ -247,10 +440,12 @@ var filterFields = []FilterField{
|
|||||||
Ops: []string{OpIsTrue, OpIsFalse}, DefaultOp: OpIsTrue,
|
Ops: []string{OpIsTrue, OpIsFalse}, DefaultOp: OpIsTrue,
|
||||||
Clause: clauseBoolFactory("mi.explicit"), Suggest: suggestNone},
|
Clause: clauseBoolFactory("mi.explicit"), Suggest: suggestNone},
|
||||||
{Key: "abridged", Label: "Abridged", Group: GroupBookFacts, Kind: KindBool,
|
{Key: "abridged", Label: "Abridged", Group: GroupBookFacts, Kind: KindBool,
|
||||||
Ops: []string{OpIsTrue, OpIsFalse}, DefaultOp: OpIsTrue,
|
Scopes: []string{ScopeBooks},
|
||||||
|
Ops: []string{OpIsTrue, OpIsFalse}, DefaultOp: OpIsTrue,
|
||||||
Clause: clauseBoolFactory("mi.abridged"), Suggest: suggestNone},
|
Clause: clauseBoolFactory("mi.abridged"), Suggest: suggestNone},
|
||||||
{Key: "manually_edited", Label: "Manually edited", Group: GroupBookFacts, Kind: KindBool,
|
{Key: "manually_edited", Label: "Manually edited", Group: GroupBookFacts, Kind: KindBool,
|
||||||
Ops: []string{OpIsTrue, OpIsFalse}, DefaultOp: OpIsTrue,
|
Scopes: []string{ScopeBooks},
|
||||||
|
Ops: []string{OpIsTrue, OpIsFalse}, DefaultOp: OpIsTrue,
|
||||||
// metadata_overrides is NOT NULL TEXT[] — '{}' means untouched.
|
// metadata_overrides is NOT NULL TEXT[] — '{}' means untouched.
|
||||||
Clause: func(r FilterRule, b *filterBuilder) string {
|
Clause: func(r FilterRule, b *filterBuilder) string {
|
||||||
switch r.Op {
|
switch r.Op {
|
||||||
@@ -262,53 +457,66 @@ var filterFields = []FilterField{
|
|||||||
return "TRUE"
|
return "TRUE"
|
||||||
}, Suggest: suggestNone},
|
}, Suggest: suggestNone},
|
||||||
{Key: "cover", Label: "Cover", Group: GroupBookFacts, Kind: KindPresence,
|
{Key: "cover", Label: "Cover", Group: GroupBookFacts, Kind: KindPresence,
|
||||||
Ops: []string{OpHas, OpMissing}, DefaultOp: OpHas,
|
Scopes: []string{ScopeBooks},
|
||||||
|
Ops: []string{OpHas, OpMissing}, DefaultOp: OpHas,
|
||||||
Clause: clauseCover, Suggest: suggestNone},
|
Clause: clauseCover, Suggest: suggestNone},
|
||||||
{Key: "added_by", Label: "Added by", Group: GroupBookFacts, Kind: KindIdentifier,
|
{Key: "added_by", Label: "Added by", Group: GroupBookFacts, Kind: KindIdentifier,
|
||||||
Ops: []string{OpIs, OpHas, OpMissing}, DefaultOp: OpHas,
|
Scopes: []string{ScopeBooks},
|
||||||
|
Ops: []string{OpIs, OpHas, OpMissing}, DefaultOp: OpHas,
|
||||||
Clause: clauseAddedBy, Suggest: suggestAddedBy},
|
Clause: clauseAddedBy, Suggest: suggestAddedBy},
|
||||||
|
|
||||||
// ── Comics & manga ────────────────────────────────────────────
|
// ── Comics & manga ────────────────────────────────────────────
|
||||||
{Key: "series", Label: "Series", Group: GroupComics, Kind: KindScalarFuzzy,
|
{Key: "series", Label: "Series", Group: GroupComics, Kind: KindScalarFuzzy,
|
||||||
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
Scopes: []string{ScopeBooks},
|
||||||
|
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
||||||
// Full series membership: the primary series column AND the
|
// Full series membership: the primary series column AND the
|
||||||
// additional series held in alternate_info — a book whose
|
// additional series held in alternate_info — a book whose
|
||||||
// membership of X is secondary must not hide.
|
// membership of X is secondary must not hide.
|
||||||
Clause: clauseSeriesMembership, Suggest: suggestSeries},
|
Clause: clauseSeriesMembership, Suggest: suggestSeries},
|
||||||
{Key: "series_number", Label: "Series number", Group: GroupComics, Kind: KindNumber,
|
{Key: "series_number", Label: "Series number", Group: GroupComics, Kind: KindNumber,
|
||||||
Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
|
Scopes: []string{ScopeBooks},
|
||||||
|
Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
|
||||||
Clause: clauseNumberFactory("mi.series_number", parseNumberFloat), Suggest: suggestNone},
|
Clause: clauseNumberFactory("mi.series_number", parseNumberFloat), Suggest: suggestNone},
|
||||||
{Key: "series_count", Label: "Series count", Group: GroupComics, Kind: KindNumber,
|
{Key: "series_count", Label: "Series count", Group: GroupComics, Kind: KindNumber,
|
||||||
Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
|
Scopes: []string{ScopeBooks},
|
||||||
|
Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
|
||||||
Clause: clauseNumberFactory("mi.series_count", parseNumberInt), Suggest: suggestNone},
|
Clause: clauseNumberFactory("mi.series_count", parseNumberInt), Suggest: suggestNone},
|
||||||
{Key: "volume", Label: "Volume", Group: GroupComics, Kind: KindNumber,
|
{Key: "volume", Label: "Volume", Group: GroupComics, Kind: KindNumber,
|
||||||
Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
|
Scopes: []string{ScopeBooks},
|
||||||
|
Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
|
||||||
Clause: clauseNumberFactory("mi.volume", parseNumberInt), Suggest: suggestNone},
|
Clause: clauseNumberFactory("mi.volume", parseNumberInt), Suggest: suggestNone},
|
||||||
{Key: "format_group", Label: "Format group", Group: GroupComics, Kind: KindEnum,
|
{Key: "format_group", Label: "Format group", Group: GroupComics, Kind: KindEnum,
|
||||||
Ops: []string{OpIs, OpIsNot}, DefaultOp: OpIs,
|
Scopes: []string{ScopeBooks},
|
||||||
|
Ops: []string{OpIs, OpIsNot}, DefaultOp: OpIs,
|
||||||
Clause: clauseEnumFactory("mi.format_group"), Suggest: suggestColumn},
|
Clause: clauseEnumFactory("mi.format_group"), Suggest: suggestColumn},
|
||||||
{Key: "mime_type", Label: "File type (mime)", Group: GroupComics, Kind: KindEnum,
|
{Key: "mime_type", Label: "File type (mime)", Group: GroupComics, Kind: KindEnum,
|
||||||
Ops: []string{OpIs, OpIsNot}, DefaultOp: OpIs,
|
Ops: []string{OpIs, OpIsNot}, DefaultOp: OpIs,
|
||||||
Clause: clauseEnumFactory("mi.mime_type"), Suggest: suggestColumn},
|
Clause: clauseEnumFactory("mi.mime_type"), Suggest: suggestColumn},
|
||||||
{Key: "is_reflowable", Label: "Reflowable", Group: GroupComics, Kind: KindBool,
|
{Key: "is_reflowable", Label: "Reflowable", Group: GroupComics, Kind: KindBool,
|
||||||
Ops: []string{OpIsTrue, OpIsFalse}, DefaultOp: OpIsTrue,
|
Scopes: []string{ScopeBooks},
|
||||||
|
Ops: []string{OpIsTrue, OpIsFalse}, DefaultOp: OpIsTrue,
|
||||||
Clause: clauseBoolFactory("mi.is_reflowable"), Suggest: suggestNone},
|
Clause: clauseBoolFactory("mi.is_reflowable"), Suggest: suggestNone},
|
||||||
{Key: "has_fixed_layout", Label: "Fixed layout", Group: GroupComics, Kind: KindBool,
|
{Key: "has_fixed_layout", Label: "Fixed layout", Group: GroupComics, Kind: KindBool,
|
||||||
Ops: []string{OpIsTrue, OpIsFalse}, DefaultOp: OpIsTrue,
|
Scopes: []string{ScopeBooks},
|
||||||
|
Ops: []string{OpIsTrue, OpIsFalse}, DefaultOp: OpIsTrue,
|
||||||
Clause: clauseBoolFactory("mi.has_fixed_layout"), Suggest: suggestNone},
|
Clause: clauseBoolFactory("mi.has_fixed_layout"), Suggest: suggestNone},
|
||||||
{Key: "is_black_and_white", Label: "Black & white", Group: GroupComics, Kind: KindBool,
|
{Key: "is_black_and_white", Label: "Black & white", Group: GroupComics, Kind: KindBool,
|
||||||
Ops: []string{OpIsTrue, OpIsFalse}, DefaultOp: OpIsTrue,
|
Scopes: []string{ScopeBooks},
|
||||||
|
Ops: []string{OpIsTrue, OpIsFalse}, DefaultOp: OpIsTrue,
|
||||||
Clause: clauseBoolFactory("mi.is_black_and_white"), Suggest: suggestNone},
|
Clause: clauseBoolFactory("mi.is_black_and_white"), Suggest: suggestNone},
|
||||||
{Key: "manga_type", Label: "Manga type", Group: GroupComics, Kind: KindEnum,
|
{Key: "manga_type", Label: "Manga type", Group: GroupComics, Kind: KindEnum,
|
||||||
Ops: []string{OpIs, OpIsNot, OpEmpty}, DefaultOp: OpIs,
|
Scopes: []string{ScopeBooks},
|
||||||
|
Ops: []string{OpIs, OpIsNot, OpEmpty}, DefaultOp: OpIs,
|
||||||
Clause: clauseEnumFactory("mi.manga_type"), Suggest: suggestColumn},
|
Clause: clauseEnumFactory("mi.manga_type"), Suggest: suggestColumn},
|
||||||
{Key: "reading_direction", Label: "Reading direction", Group: GroupComics, Kind: KindEnum,
|
{Key: "reading_direction", Label: "Reading direction", Group: GroupComics, Kind: KindEnum,
|
||||||
Ops: []string{OpIs, OpIsNot, OpEmpty}, DefaultOp: OpIs,
|
Scopes: []string{ScopeBooks},
|
||||||
|
Ops: []string{OpIs, OpIsNot, OpEmpty}, DefaultOp: OpIs,
|
||||||
Clause: clauseEnumFactory("mi.reading_direction"), Suggest: suggestColumn},
|
Clause: clauseEnumFactory("mi.reading_direction"), Suggest: suggestColumn},
|
||||||
|
|
||||||
// ── My reading (user-relative — the VIEWER's rows) ────────────
|
// ── My reading (user-relative — the VIEWER's rows) ────────────
|
||||||
{Key: "status", Label: "Reading status", Group: GroupMyReading, Kind: KindStatus,
|
{Key: "status", Label: "Reading status", Group: GroupMyReading, Kind: KindStatus,
|
||||||
Ops: []string{OpIs}, DefaultOp: OpIs,
|
Scopes: []string{ScopeBooks},
|
||||||
|
Ops: []string{OpIs}, DefaultOp: OpIs,
|
||||||
Clause: clauseStatus, Suggest: suggestNone},
|
Clause: clauseStatus, Suggest: suggestNone},
|
||||||
{Key: "progress_percent", Label: "Progress percent", Group: GroupMyReading, Kind: KindNumber,
|
{Key: "progress_percent", Label: "Progress percent", Group: GroupMyReading, Kind: KindNumber,
|
||||||
Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
|
Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
|
||||||
@@ -341,7 +549,8 @@ var filterFields = []FilterField{
|
|||||||
Ops: []string{OpBetween, OpAtLeast, OpAtMost}, DefaultOp: OpAtLeast,
|
Ops: []string{OpBetween, OpAtLeast, OpAtMost}, DefaultOp: OpAtLeast,
|
||||||
Clause: clauseTimeSpent, Suggest: suggestNone},
|
Clause: clauseTimeSpent, Suggest: suggestNone},
|
||||||
{Key: "pages_read", Label: "Pages read", Group: GroupMyReading, Kind: KindNumber,
|
{Key: "pages_read", Label: "Pages read", Group: GroupMyReading, Kind: KindNumber,
|
||||||
Ops: []string{OpBetween, OpAtLeast, OpAtMost}, DefaultOp: OpAtLeast,
|
Scopes: []string{ScopeBooks},
|
||||||
|
Ops: []string{OpBetween, OpAtLeast, OpAtMost}, DefaultOp: OpAtLeast,
|
||||||
Clause: clausePagesRead, Suggest: suggestNone},
|
Clause: clausePagesRead, Suggest: suggestNone},
|
||||||
{Key: "collection", Label: "In collection", Group: GroupMyReading, Kind: KindCollection,
|
{Key: "collection", Label: "In collection", Group: GroupMyReading, Kind: KindCollection,
|
||||||
// Membership mirrors the collection page: excluded items and
|
// Membership mirrors the collection page: excluded items and
|
||||||
@@ -349,6 +558,27 @@ var filterFields = []FilterField{
|
|||||||
Ops: []string{OpIn, OpNotIn, OpInAny, OpInNone}, DefaultOp: OpIn,
|
Ops: []string{OpIn, OpNotIn, OpInAny, OpInNone}, DefaultOp: OpIn,
|
||||||
Clause: clauseCollection, Suggest: suggestCollection},
|
Clause: clauseCollection, Suggest: suggestCollection},
|
||||||
|
|
||||||
|
// ── Episodes (podcast-episode rows; episodes scope only) ──────
|
||||||
|
// Plan item 3.1 / §0 item 3: the filter engine gains the episode
|
||||||
|
// fields so smart episode collections can ride the SAME engine.
|
||||||
|
// `feed` suggests the viewer's subscribed feeds (RLS governs both
|
||||||
|
// suggest and match); `downloaded` reads the download state;
|
||||||
|
// `played` is THE finished predicate (viewer progress >= 0.999 —
|
||||||
|
// the same definition as hide_finished, item 2.4).
|
||||||
|
{Key: "feed", Label: "Feed", Group: GroupEpisodes, Kind: KindIdentifier,
|
||||||
|
// One name per semantic (2026-11-01, Option A): is/is_not were
|
||||||
|
// exact synonyms of in/not_in (the clause compiles both to the
|
||||||
|
// same ANY). Stored trees carrying the legacy keys keep
|
||||||
|
// parsing — see legacyFieldOps.
|
||||||
|
Ops: []string{OpIn, OpNotIn, OpHas, OpMissing}, DefaultOp: OpIn,
|
||||||
|
Clause: clauseFeed, Suggest: suggestFeed, Scopes: []string{ScopeEpisodes}},
|
||||||
|
{Key: "downloaded", Label: "Downloaded", Group: GroupEpisodes, Kind: KindBool,
|
||||||
|
Ops: []string{OpIsTrue, OpIsFalse}, DefaultOp: OpIsTrue,
|
||||||
|
Clause: clauseDownloaded, Suggest: suggestNone, Scopes: []string{ScopeEpisodes}},
|
||||||
|
{Key: "played", Label: "Played", Group: GroupEpisodes, Kind: KindBool,
|
||||||
|
Ops: []string{OpIsTrue, OpIsFalse}, DefaultOp: OpIsFalse,
|
||||||
|
Clause: clausePlayed, Suggest: suggestNone, Scopes: []string{ScopeEpisodes}},
|
||||||
|
|
||||||
// ── Library (holding structure + family lens) ─────────────────
|
// ── Library (holding structure + family lens) ─────────────────
|
||||||
{Key: "library", Label: "Library", Group: GroupLibrary, Kind: KindLibrary,
|
{Key: "library", Label: "Library", Group: GroupLibrary, Kind: KindLibrary,
|
||||||
// Values are library IDs; the menu only ever offers the viewer's
|
// Values are library IDs; the menu only ever offers the viewer's
|
||||||
@@ -405,13 +635,21 @@ func FilterFieldKeys() []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// fieldOpsAllowed reports whether op is allowed on the field.
|
// fieldOpsAllowed reports whether op is allowed on the field.
|
||||||
|
// legacyFieldOps — operator synonyms still ACCEPTED at parse for
|
||||||
|
// trees saved before the vocabulary collapse (2026-11-01): feed once
|
||||||
|
// offered is/is_not alongside in/not_in with identical semantics. The
|
||||||
|
// menus offer one name per field; stored trees parse forever.
|
||||||
|
var legacyFieldOps = map[string]map[string]bool{
|
||||||
|
"feed": {OpIs: true, OpIsNot: true},
|
||||||
|
}
|
||||||
|
|
||||||
func fieldOpsAllowed(f FilterField, op string) bool {
|
func fieldOpsAllowed(f FilterField, op string) bool {
|
||||||
for _, o := range f.Ops {
|
for _, o := range f.Ops {
|
||||||
if o == op {
|
if o == op {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return legacyFieldOps[f.Key][op]
|
||||||
}
|
}
|
||||||
|
|
||||||
// maxFilterDepth bounds group nesting (BookOrbit allows 5; the UI
|
// maxFilterDepth bounds group nesting (BookOrbit allows 5; the UI
|
||||||
@@ -426,30 +664,41 @@ func ParseFilterTree(raw string) (*FilterTree, error) {
|
|||||||
if err := json.Unmarshal([]byte(raw), &t); err != nil {
|
if err := json.Unmarshal([]byte(raw), &t); err != nil {
|
||||||
return nil, fmt.Errorf("invalid qf JSON: %w", err)
|
return nil, fmt.Errorf("invalid qf JSON: %w", err)
|
||||||
}
|
}
|
||||||
if err := validateGroup(&FilterChild{Mode: t.Mode, Children: t.Children}, 1); err != nil {
|
if t.Scope != "" && t.Scope != ScopeBooks && t.Scope != ScopeEpisodes {
|
||||||
|
return nil, fmt.Errorf("filter scope must be books|episodes, got %q", t.Scope)
|
||||||
|
}
|
||||||
|
if err := validateGroup(&FilterChild{Mode: t.Mode, Children: t.Children}, 1, t.FilterScope()); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &t, nil
|
return &t, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateGroup(g *FilterChild, depth int) error {
|
func validateGroup(g *FilterChild, depth int, scope string) error {
|
||||||
if depth > maxFilterDepth {
|
if depth > maxFilterDepth {
|
||||||
return fmt.Errorf("filter group nesting deeper than %d", maxFilterDepth)
|
return fmt.Errorf("filter group nesting deeper than %d", maxFilterDepth)
|
||||||
}
|
}
|
||||||
if g.Mode != "all" && g.Mode != "any" {
|
if g.Mode != "all" && g.Mode != "any" {
|
||||||
return fmt.Errorf("filter group mode must be all|any, got %q", g.Mode)
|
return fmt.Errorf("filter group mode must be all|any, got %q", g.Mode)
|
||||||
}
|
}
|
||||||
if len(g.Children) == 0 {
|
// The ROOT arrives as depth 1 (call convention below), so "depth > 1"
|
||||||
|
// exempts exactly the root: an EMPTY ROOT is legal as of the episodes
|
||||||
|
// scope door — the create modal seeds
|
||||||
|
// `{"scope":"episodes","mode":"all","children":[]}` so a fresh
|
||||||
|
// Episodes smart collection has a stored scope before its first
|
||||||
|
// rule. Nested empty groups stay invalid, and the composers define
|
||||||
|
// the semantics: zero children matches nothing (mirrors
|
||||||
|
// EvaluateSmartCollection's zero-rules convention).
|
||||||
|
if len(g.Children) == 0 && depth > 1 {
|
||||||
return fmt.Errorf("filter group has no children")
|
return fmt.Errorf("filter group has no children")
|
||||||
}
|
}
|
||||||
for _, c := range g.Children {
|
for _, c := range g.Children {
|
||||||
if c.Field == "" && c.Mode != "" {
|
if c.Field == "" && c.Mode != "" {
|
||||||
if err := validateGroup(&c, depth+1); err != nil {
|
if err := validateGroup(&c, depth+1, scope); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := validateRule(c); err != nil {
|
if err := validateRule(c, scope); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -475,11 +724,18 @@ var numericParsers = map[string]func(string) (any, error){
|
|||||||
"pages_read": parseNumberFloat,
|
"pages_read": parseNumberFloat,
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateRule(c FilterChild) error {
|
func validateRule(c FilterChild, scope string) error {
|
||||||
f, ok := FilterFieldByKey(c.Field)
|
f, ok := FilterFieldByKey(c.Field)
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("unknown filter field %q", c.Field)
|
return fmt.Errorf("unknown filter field %q", c.Field)
|
||||||
}
|
}
|
||||||
|
// Episode-only fields are a user error in a books tree (plan item
|
||||||
|
// 3.1: "hide podcasts from the rules on the all books page"). The
|
||||||
|
// reverse is NOT an error: book fields that no-op against episode
|
||||||
|
// rows (NULLs) are allowed in episodes trees.
|
||||||
|
if scope == ScopeBooks && episodeOnlyFields[c.Field] {
|
||||||
|
return fmt.Errorf("field %q only exists in the episodes scope", c.Field)
|
||||||
|
}
|
||||||
if !fieldOpsAllowed(f, c.Op) {
|
if !fieldOpsAllowed(f, c.Op) {
|
||||||
return fmt.Errorf("operator %q not allowed on field %q", c.Op, c.Field)
|
return fmt.Errorf("operator %q not allowed on field %q", c.Op, c.Field)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,15 @@ func TestFilterRegistryCompleteness(t *testing.T) {
|
|||||||
"pages_read", "collection",
|
"pages_read", "collection",
|
||||||
// holding structure
|
// holding structure
|
||||||
"library",
|
"library",
|
||||||
|
// episode quantifier (item 3.1)
|
||||||
|
"published_date",
|
||||||
|
}
|
||||||
|
// Episode-scope fields (item 3.1): they declare exactly the
|
||||||
|
// episodes scope; no other field declares a scope restriction.
|
||||||
|
for _, key := range []string{"feed", "downloaded", "played"} {
|
||||||
|
assert.True(t, seen[key], "episode filter field %q missing from the registry", key)
|
||||||
|
f, _ := FilterFieldByKey(key)
|
||||||
|
assert.Equal(t, []string{ScopeEpisodes}, f.Scopes, "%q must be episodes-scope only", key)
|
||||||
}
|
}
|
||||||
for _, key := range mandated {
|
for _, key := range mandated {
|
||||||
assert.True(t, seen[key], "mandated filter field %q missing from the registry", key)
|
assert.True(t, seen[key], "mandated filter field %q missing from the registry", key)
|
||||||
@@ -88,7 +97,11 @@ func TestFilterParseValidation(t *testing.T) {
|
|||||||
"12345678-1234-1234-1234-123456789012" + `"]}]}`)
|
"12345678-1234-1234-1234-123456789012" + `"]}]}`)
|
||||||
|
|
||||||
invalid(`{not json`, "invalid qf JSON")
|
invalid(`{not json`, "invalid qf JSON")
|
||||||
invalid(`{"mode":"all","children":[]}`, "no children")
|
// An EMPTY ROOT is legal since the episodes scope door (the create
|
||||||
|
// modal seeds one; semantics = matches nothing) — see
|
||||||
|
// TestEmptyRootTreeSeedsEpisodesScope. A NESTED empty group stays
|
||||||
|
// invalid:
|
||||||
|
invalid(`{"mode":"all","children":[{"mode":"any","children":[]}]}`, "no children")
|
||||||
invalid(`{"mode":"sometimes","children":[{"field":"author","op":"contains","values":["x"]}]}`, "mode must be all|any")
|
invalid(`{"mode":"sometimes","children":[{"field":"author","op":"contains","values":["x"]}]}`, "mode must be all|any")
|
||||||
invalid(`{"mode":"all","children":[{"field":"not_a_field","op":"is","values":["x"]}]}`, "unknown filter field")
|
invalid(`{"mode":"all","children":[{"field":"not_a_field","op":"is","values":["x"]}]}`, "unknown filter field")
|
||||||
invalid(`{"mode":"all","children":[{"field":"author","op":"explodes","values":["x"]}]}`, "not allowed on field")
|
invalid(`{"mode":"all","children":[{"field":"author","op":"explodes","values":["x"]}]}`, "not allowed on field")
|
||||||
@@ -102,6 +115,39 @@ func TestFilterParseValidation(t *testing.T) {
|
|||||||
invalid(`{"mode":"all","children":[{"field":"author","op":"contains"}]}`, "needs a value")
|
invalid(`{"mode":"all","children":[{"field":"author","op":"contains"}]}`, "needs a value")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFilterScopeValidation(t *testing.T) {
|
||||||
|
valid := func(raw string) {
|
||||||
|
_, err := ParseFilterTree(raw)
|
||||||
|
require.NoError(t, err, "expected %s to parse", raw)
|
||||||
|
}
|
||||||
|
invalid := func(raw, wantContain string) {
|
||||||
|
_, err := ParseFilterTree(raw)
|
||||||
|
require.Error(t, err, "expected %s to be rejected", raw)
|
||||||
|
require.Contains(t, err.Error(), wantContain)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy trees (no scope key) stay valid — the default is books.
|
||||||
|
valid(`{"mode":"all","children":[{"field":"author","op":"contains","values":["Voss"]}]}`)
|
||||||
|
|
||||||
|
// Books trees REJECT episode-only fields (the item-3 hide rule).
|
||||||
|
invalid(`{"scope":"books","mode":"all","children":[{"field":"played","op":"is_true"}]}`,
|
||||||
|
"only exists in the episodes scope")
|
||||||
|
invalid(`{"mode":"all","children":[{"field":"downloaded","op":"is_true"}]}`,
|
||||||
|
"only exists in the episodes scope")
|
||||||
|
|
||||||
|
// Episodes trees carry the episode fields.
|
||||||
|
valid(`{"scope":"episodes","mode":"all","children":[
|
||||||
|
{"field":"feed","op":"in","values":["12345678-1234-1234-1234-123456789012"]},
|
||||||
|
{"field":"downloaded","op":"is_true"},
|
||||||
|
{"field":"played","op":"is_false"},
|
||||||
|
{"field":"published_date","op":"within_days","values":["7"]},
|
||||||
|
{"field":"title","op":"contains","values":["news"]}]}`)
|
||||||
|
|
||||||
|
// Unknown scope values are rejected.
|
||||||
|
invalid(`{"scope":"manga","mode":"all","children":[{"field":"title","op":"contains","values":["x"]}]}`,
|
||||||
|
"scope must be books|episodes")
|
||||||
|
}
|
||||||
|
|
||||||
func TestFilterParseDepthCap(t *testing.T) {
|
func TestFilterParseDepthCap(t *testing.T) {
|
||||||
// Nesting beyond maxFilterDepth is rejected.
|
// Nesting beyond maxFilterDepth is rejected.
|
||||||
raw := `{"mode":"all","children":[`
|
raw := `{"mode":"all","children":[`
|
||||||
@@ -129,3 +175,102 @@ func TestFilterFieldsByGroupOrdering(t *testing.T) {
|
|||||||
require.NotEmpty(t, g.Fields, "group %s must not be empty", g.Group)
|
require.NotEmpty(t, g.Fields, "group %s must not be empty", g.Group)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The episodes scope door (2026-10-31): the create modal seeds an EMPTY
|
||||||
|
// episodes root so a fresh Episodes smart collection has a stored scope
|
||||||
|
// before its first rule. The parser accepts an empty ROOT (nested empty
|
||||||
|
// groups stay invalid), and the composers define the semantics — zero
|
||||||
|
// children matches NOTHING, mirroring EvaluateSmartCollection's
|
||||||
|
// zero-rules convention (a bare scope head would match everything).
|
||||||
|
func TestEmptyRootTreeSeedsEpisodesScope(t *testing.T) {
|
||||||
|
tree, err := ParseFilterTree(`{"scope":"episodes","mode":"all","children":[]}`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseFilterTree(seed): %v", err)
|
||||||
|
}
|
||||||
|
if got := tree.FilterScope(); got != ScopeEpisodes {
|
||||||
|
t.Fatalf("FilterScope = %q, want %q", got, ScopeEpisodes)
|
||||||
|
}
|
||||||
|
if len(tree.Children) != 0 {
|
||||||
|
t.Fatalf("children = %d, want 0", len(tree.Children))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nested empty groups remain invalid.
|
||||||
|
nested := `{"mode":"all","children":[{"mode":"any","children":[]}]}`
|
||||||
|
if _, err := ParseFilterTree(nested); err == nil {
|
||||||
|
t.Fatal("nested empty group must stay invalid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The episodes-scope vocabulary leads with what the scope can match:
|
||||||
|
// book-only metadata (people, publishing metadata, series/comics,
|
||||||
|
// book identifiers) is hidden from the menu. Parse still accepts them
|
||||||
|
// (their composed clauses simply match nothing on episode rows) — this
|
||||||
|
// pins the AUTHORING surface, not the grammar.
|
||||||
|
func TestEpisodesScopeHidesBookOnlyFields(t *testing.T) {
|
||||||
|
episodes := FilterFieldsPayload(ScopeEpisodes)
|
||||||
|
keys := map[string]bool{}
|
||||||
|
for _, f := range episodes {
|
||||||
|
keys[f.Key] = true
|
||||||
|
}
|
||||||
|
hidden := []string{"author", "narrator", "publisher", "isbn", "series", "format_group", "subtitle"}
|
||||||
|
for _, k := range hidden {
|
||||||
|
if keys[k] {
|
||||||
|
t.Errorf("episodes vocabulary must not offer book-only field %q", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, want := range []string{"feed", "downloaded", "played", "published_date", "title"} {
|
||||||
|
if !keys[want] {
|
||||||
|
t.Errorf("episodes vocabulary must offer %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The books scope keeps the book fields and still rejects episode
|
||||||
|
// fields.
|
||||||
|
books := FilterFieldsPayload(ScopeBooks)
|
||||||
|
bkeys := map[string]bool{}
|
||||||
|
for _, f := range books {
|
||||||
|
bkeys[f.Key] = true
|
||||||
|
}
|
||||||
|
if !bkeys["author"] || bkeys["feed"] {
|
||||||
|
t.Fatalf("books vocabulary wrong: author=%v feed=%v", bkeys["author"], bkeys["feed"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2026-11-01, Option A: one operator name per semantic ────────────
|
||||||
|
// feed's MENU offers in/not_in only (is/is_not were exact synonyms);
|
||||||
|
// trees saved before the collapse keep parsing forever.
|
||||||
|
|
||||||
|
func TestFeedVocabularyCollapse(t *testing.T) {
|
||||||
|
f, ok := FilterFieldByKey("feed")
|
||||||
|
require.True(t, ok)
|
||||||
|
for _, o := range f.Ops {
|
||||||
|
assert.NotEqual(t, OpIs, o, "the feed menu must not offer the is synonym")
|
||||||
|
assert.NotEqual(t, OpIsNot, o, "the feed menu must not offer the is_not synonym")
|
||||||
|
}
|
||||||
|
assert.Contains(t, f.Ops, OpIn)
|
||||||
|
assert.Contains(t, f.Ops, OpNotIn)
|
||||||
|
|
||||||
|
// The payload (the client's menus) carries the same collapse.
|
||||||
|
for _, wf := range FilterFieldsPayload(ScopeEpisodes) {
|
||||||
|
if wf.Key != "feed" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, op := range wf.Ops {
|
||||||
|
assert.NotEqual(t, OpIs, op.Key, "payload must not offer is on feed")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatal("feed missing from the episodes payload")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFeedLegacyOpsStillParse(t *testing.T) {
|
||||||
|
// Stored trees from before the collapse: is/is_not on feed parse
|
||||||
|
// and validate like their in/not_in twins.
|
||||||
|
legacy := `{"scope":"episodes","mode":"any","children":[
|
||||||
|
{"field":"feed","op":"is","values":["11111111-1111-1111-1111-111111111111"]},
|
||||||
|
{"field":"feed","op":"is_not","values":["22222222-2222-2222-2222-222222222222"]}
|
||||||
|
]}`
|
||||||
|
tree, err := ParseFilterTree(legacy)
|
||||||
|
require.NoError(t, err, "legacy feed is/is_not trees parse forever")
|
||||||
|
require.Len(t, tree.Children, 2)
|
||||||
|
}
|
||||||
@@ -29,6 +29,12 @@ const (
|
|||||||
LibraryTypeComics = "comics"
|
LibraryTypeComics = "comics"
|
||||||
LibraryTypeManga = "manga"
|
LibraryTypeManga = "manga"
|
||||||
LibraryTypeAudiobooks = "audiobooks"
|
LibraryTypeAudiobooks = "audiobooks"
|
||||||
|
// Podcasts libraries are feed containers (RSS subscribe+download),
|
||||||
|
// not FS-scanned media: AllowedExtensions is deliberately empty —
|
||||||
|
// the scanner skips these folders wholesale (podcast downloads are
|
||||||
|
// written by the fetcher's download worker, never discovered by
|
||||||
|
// the walker). See podcast_service.go.
|
||||||
|
LibraryTypePodcasts = "podcasts"
|
||||||
)
|
)
|
||||||
|
|
||||||
var AllowedExtensions = map[string][]string{
|
var AllowedExtensions = map[string][]string{
|
||||||
@@ -43,6 +49,9 @@ var AllowedExtensions = map[string][]string{
|
|||||||
// browser-playable lossless/next-gen containers join for Batch B
|
// browser-playable lossless/next-gen containers join for Batch B
|
||||||
// metadata parity (.wma deliberately absent: browsers can't play it).
|
// metadata parity (.wma deliberately absent: browsers can't play it).
|
||||||
LibraryTypeAudiobooks: {".m4b", ".m4a", ".mp3", ".flac", ".zip", ".opus", ".ogg", ".oga", ".aac", ".wav"},
|
LibraryTypeAudiobooks: {".m4b", ".m4a", ".mp3", ".flac", ".zip", ".opus", ".ogg", ".oga", ".aac", ".wav"},
|
||||||
|
// Empty BY DESIGN: nothing in a podcasts library is scanner-facing.
|
||||||
|
// The scanner additionally skips these folders outright.
|
||||||
|
LibraryTypePodcasts: {},
|
||||||
}
|
}
|
||||||
|
|
||||||
var MimeTypes = map[string]string{
|
var MimeTypes = map[string]string{
|
||||||
@@ -149,10 +158,39 @@ func (s *LibraryService) DeleteLibrary(ctx context.Context, libraryID pgtype.UUI
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AddLibraryFolder adds a folder to a library
|
// AddLibraryFolder adds a folder to a library
|
||||||
|
// Folder purposes (library_folders.purpose): 'scan' roots feed the FS
|
||||||
|
// walker; 'podcast' roots are download targets written only by the
|
||||||
|
// podcast download worker.
|
||||||
|
const (
|
||||||
|
FolderPurposeScan = "scan"
|
||||||
|
FolderPurposePodcast = "podcast"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AddLibraryFolder registers a folder against a library. The purpose is
|
||||||
|
// derived from the library type: podcasts libraries register their
|
||||||
|
// download root (purpose='podcast', exactly ONE folder — feeds lay out
|
||||||
|
// <download-root>/<feed-slug>/...); every other type registers a
|
||||||
|
// scanner root (purpose='scan').
|
||||||
func (s *LibraryService) AddLibraryFolder(ctx context.Context, libraryID pgtype.UUID, folderPath string) (*database.LibraryFolders, error) {
|
func (s *LibraryService) AddLibraryFolder(ctx context.Context, libraryID pgtype.UUID, folderPath string) (*database.LibraryFolders, error) {
|
||||||
|
purpose := FolderPurposeScan
|
||||||
|
|
||||||
|
lib, err := s.db.GetLibrary(ctx, libraryID)
|
||||||
|
if err == nil && lib.TypeName == LibraryTypePodcasts {
|
||||||
|
// Podcasts libraries own exactly one folder: the download root.
|
||||||
|
existing, err := s.db.GetLibraryFolders(ctx, libraryID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(existing) > 0 {
|
||||||
|
return nil, fmt.Errorf("podcasts libraries have a single download root; %s is already registered", existing[0].FolderPath)
|
||||||
|
}
|
||||||
|
purpose = FolderPurposePodcast
|
||||||
|
}
|
||||||
|
|
||||||
folder, err := s.db.AddLibraryFolder(ctx, database.AddLibraryFolderParams{
|
folder, err := s.db.AddLibraryFolder(ctx, database.AddLibraryFolderParams{
|
||||||
LibraryID: libraryID,
|
LibraryID: libraryID,
|
||||||
FolderPath: folderPath,
|
FolderPath: folderPath,
|
||||||
|
Purpose: purpose,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -433,7 +433,6 @@ func (s *MediaScanner) upsertPlaylistCollection(ctx context.Context, entry Serie
|
|||||||
UserID: owner,
|
UserID: owner,
|
||||||
Name: entry.Name,
|
Name: entry.Name,
|
||||||
Description: pgtype.Text{String: description, Valid: true},
|
Description: pgtype.Text{String: description, Valid: true},
|
||||||
AutoAssignRules: nil, // jsonb NULL — an empty []byte encodes as bytea and 22P02s
|
|
||||||
ViewSettings: viewSettings,
|
ViewSettings: viewSettings,
|
||||||
ShowOnDashboard: pgtype.Bool{Bool: true, Valid: true}, // playlists land on the dashboard by design; hide via dashboard prefs
|
ShowOnDashboard: pgtype.Bool{Bool: true, Valid: true}, // playlists land on the dashboard by design; hide via dashboard prefs
|
||||||
})
|
})
|
||||||
@@ -504,6 +503,12 @@ func (s *MediaScanner) GetStats() (int, int, int) {
|
|||||||
return s.totalFiles, s.newItems, s.errors
|
return s.totalFiles, s.newItems, s.errors
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FolderCount reports the active root-folder count (test/audit helper:
|
||||||
|
// the podcast-exclusion pin asserts it stays 0 for podcasts roots).
|
||||||
|
func (s *MediaScanner) FolderCount() int {
|
||||||
|
return len(s.folders)
|
||||||
|
}
|
||||||
|
|
||||||
// SetFolders configures the scanner's folders and (optionally) sets up an
|
// SetFolders configures the scanner's folders and (optionally) sets up an
|
||||||
// fsnotify watcher over the full directory tree.
|
// fsnotify watcher over the full directory tree.
|
||||||
//
|
//
|
||||||
@@ -512,9 +517,34 @@ func (s *MediaScanner) GetStats() (int, int, int) {
|
|||||||
// watcher (and thus no fd/inotify watches) is allocated — the watcher is never
|
// watcher (and thus no fd/inotify watches) is allocated — the watcher is never
|
||||||
// read by scan jobs and previously leaked one watcher per job.
|
// read by scan jobs and previously leaked one watcher per job.
|
||||||
func (s *MediaScanner) SetFolders(folders []string, watch bool) error {
|
func (s *MediaScanner) SetFolders(folders []string, watch bool) error {
|
||||||
s.folders = folders
|
// Podcasts libraries are feed-managed (plan: podcast-integration.md
|
||||||
|
// task 1.2, invariant 4): their download roots are written by the
|
||||||
|
// download worker, never discovered by the walker — including
|
||||||
|
// scoped single-folder scans (0f1d0abb), which all funnel through
|
||||||
|
// here. Filter them out before anything else touches the list.
|
||||||
|
scannable := folders[:0:0]
|
||||||
|
for _, folder := range folders {
|
||||||
|
lib, err := s.db.GetLibraryByFolder(context.Background(), folder)
|
||||||
|
if err != nil {
|
||||||
|
// Unresolvable folders keep the old behavior (scan walk
|
||||||
|
// skips them per-file; watch adds them harmlessly).
|
||||||
|
scannable = append(scannable, folder)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
libType, err := s.db.GetLibraryType(context.Background(), lib.LibraryTypeID)
|
||||||
|
if err != nil {
|
||||||
|
scannable = append(scannable, folder)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if libType.Name == LibraryTypePodcasts {
|
||||||
|
fmt.Printf("Scanner: skipping %s (podcasts library %q — feed-managed, not scanned)\n", folder, lib.Name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
scannable = append(scannable, folder)
|
||||||
|
}
|
||||||
|
folders = scannable
|
||||||
|
|
||||||
// Always close any previously-owned watcher so reconfiguration doesn't leak.
|
s.folders = folders // Always close any previously-owned watcher so reconfiguration doesn't leak.
|
||||||
if s.watcher != nil {
|
if s.watcher != nil {
|
||||||
if err := s.watcher.Close(); err != nil {
|
if err := s.watcher.Close(); err != nil {
|
||||||
fmt.Printf("Warning: failed to close old watcher during folder reconfiguration: %v\n", err)
|
fmt.Printf("Warning: failed to close old watcher during folder reconfiguration: %v\n", err)
|
||||||
@@ -524,6 +554,12 @@ func (s *MediaScanner) SetFolders(folders []string, watch bool) error {
|
|||||||
|
|
||||||
// Create + populate a fresh watcher only when the caller intends to read events.
|
// Create + populate a fresh watcher only when the caller intends to read events.
|
||||||
if watch {
|
if watch {
|
||||||
|
if len(folders) == 0 {
|
||||||
|
// All requested folders filtered (e.g. a podcasts library:
|
||||||
|
// nothing scannable, nothing to watch). Fail loudly at the
|
||||||
|
// API boundary instead of panicking on folders[0] below.
|
||||||
|
return fmt.Errorf("no scannable folders (podcasts libraries are feed-managed and cannot be scanned)")
|
||||||
|
}
|
||||||
watcher, err := fsnotify.NewWatcher()
|
watcher, err := fsnotify.NewWatcher()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Return an error instead of panicking so a failed watcher can't
|
// Return an error instead of panicking so a failed watcher can't
|
||||||
|
|||||||
@@ -110,6 +110,14 @@ func (w *MetadataWriter) WriteMetadataToFile(ctx context.Context, itemID uuid.UU
|
|||||||
return nil, fmt.Errorf("media item not found: %w", err)
|
return nil, fmt.Errorf("media item not found: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Edit segregation (plan D10/D9, task 1.8): podcast episodes are
|
||||||
|
// feed-managed; writing their metadata into the file would be
|
||||||
|
// clobbered by the next download/refresh and violates the
|
||||||
|
// fetcher's sole-writer rule.
|
||||||
|
if item.Source == "podcast_episode" {
|
||||||
|
return nil, fmt.Errorf("podcast episodes are managed by their feed; metadata write-to-file is not available")
|
||||||
|
}
|
||||||
|
|
||||||
absPath, err := w.libraries.ResolveMediaPath(ctx, item.LibraryID, item.FilePath)
|
absPath, err := w.libraries.ResolveMediaPath(ctx, item.LibraryID, item.FilePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("cannot resolve library folder: %w", err)
|
return nil, fmt.Errorf("cannot resolve library folder: %w", err)
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
// SSRF-guarded HTTP fetching for the podcast fetcher (plan §7).
|
||||||
|
//
|
||||||
|
// This is the code path that turns USER-SUPPLIED URLs into server
|
||||||
|
// fetches — treat it as a security boundary, not a convenience wrapper:
|
||||||
|
//
|
||||||
|
// - https-only schemes (feed URL, enclosure, artwork, EVERY redirect hop)
|
||||||
|
// - private/loopback/link-local/multicast/unspecified IP ranges blocked
|
||||||
|
// at DIAL time via Dialer.Control — the check sees the RESOLVED
|
||||||
|
// address, so a DNS name that rebinding-resolves to 127.0.0.1 (or a
|
||||||
|
// redirect into the internal network) is refused at the socket, not
|
||||||
|
// merely at URL-parse time
|
||||||
|
// - hard response size caps on every body (feed XML, artwork, enclosures)
|
||||||
|
//
|
||||||
|
// Go's redirect follower re-dials through the same dialer per hop, so
|
||||||
|
// the IP guard re-applies automatically; the scheme guard re-applies
|
||||||
|
// via CheckRedirect.
|
||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Response size caps (bytes). Feeds are XML; artwork is an image;
|
||||||
|
// enclosures are audio episodes — the enclosure cap is deliberately the
|
||||||
|
// large one (audiobook chapters run long) but still bounded.
|
||||||
|
const (
|
||||||
|
maxFeedSizeBytes = 25 << 20 // 25 MiB
|
||||||
|
maxArtworkSizeBytes = 20 << 20 // 20 MiB
|
||||||
|
maxEnclosureCapBytes = 1 << 30 // 1 GiB
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrBlockedPrivateAddress marks SSRF-guard rejections (dial-time IP or
|
||||||
|
// scheme checks). Handlers surface it as a validation failure, and the
|
||||||
|
// download worker records it as the download_error text.
|
||||||
|
var ErrBlockedPrivateAddress = errors.New("address blocked: private, loopback, or otherwise non-public")
|
||||||
|
|
||||||
|
// podcastHTTPClient builds the shared guard client. Timeout is generous
|
||||||
|
// (podcast servers are slow) but bounded; enclosures stream through a
|
||||||
|
// LimitReader so a lying Content-Length cannot exhaust disk.
|
||||||
|
func podcastHTTPClient() *http.Client {
|
||||||
|
dialer := &net.Dialer{
|
||||||
|
Timeout: 15 * time.Second,
|
||||||
|
Control: func(network, address string, _ syscall.RawConn) error {
|
||||||
|
return guardDialAddress(address)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
transport := &http.Transport{
|
||||||
|
DialContext: dialer.DialContext,
|
||||||
|
TLSHandshakeTimeout: 15 * time.Second,
|
||||||
|
ResponseHeaderTimeout: 60 * time.Second,
|
||||||
|
// Enclosure downloads are long; idle conn reuse still applies.
|
||||||
|
IdleConnTimeout: 90 * time.Second,
|
||||||
|
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
|
||||||
|
Proxy: nil, // never route through a user-controllable proxy
|
||||||
|
}
|
||||||
|
return &http.Client{
|
||||||
|
Transport: transport,
|
||||||
|
Timeout: 15 * time.Minute, // outer bound (biggest enclosures on slow lines)
|
||||||
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
|
if len(via) >= 10 {
|
||||||
|
return fmt.Errorf("stopped after 10 redirects")
|
||||||
|
}
|
||||||
|
if err := guardURLScheme(req.URL); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// guardDialAddress validates the RESOLVED host:port at dial time.
|
||||||
|
func guardDialAddress(address string) error {
|
||||||
|
host, _, err := net.SplitHostPort(address)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("dial guard: %w", err)
|
||||||
|
}
|
||||||
|
ip := net.ParseIP(host)
|
||||||
|
if ip == nil {
|
||||||
|
return fmt.Errorf("dial guard: unparseable address %q", host)
|
||||||
|
}
|
||||||
|
if !isPublicIP(ip) {
|
||||||
|
return ErrBlockedPrivateAddress
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// guardURLScheme enforces https on any URL we are about to touch.
|
||||||
|
func guardURLScheme(u *url.URL) error {
|
||||||
|
if u == nil || u.Scheme != "https" {
|
||||||
|
return fmt.Errorf("only https feed/enclosure URLs are supported")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isPublicIP reports whether ip is a routable public unicast address.
|
||||||
|
// Everything loopback/private/link-local/multicast/unspecified —
|
||||||
|
// including IPv4-mapped IPv6 spellings of those — is rejected.
|
||||||
|
func isPublicIP(ip net.IP) bool {
|
||||||
|
if ip == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Normalize IPv4-mapped IPv6 (::ffff:10.0.0.1) to its v4 form so
|
||||||
|
// the v4 range checks below actually fire.
|
||||||
|
if ip4 := ip.To4(); ip4 != nil {
|
||||||
|
ip = ip4
|
||||||
|
}
|
||||||
|
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
|
||||||
|
ip.IsLinkLocalMulticast() || ip.IsInterfaceLocalMulticast() ||
|
||||||
|
ip.IsMulticast() || ip.IsUnspecified() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// guardFetch performs a GET with the scheme/IP guards, conditional-GET
|
||||||
|
// validators, and a hard body cap. Returns the body bytes, the FINAL
|
||||||
|
// URL after redirects (new-feed-url / migration detection), validators
|
||||||
|
// to persist, and whether the server answered 304 Not Modified.
|
||||||
|
type fetchResult struct {
|
||||||
|
Body []byte
|
||||||
|
FinalURL string
|
||||||
|
ETag string
|
||||||
|
LastModified string
|
||||||
|
NotModified bool
|
||||||
|
StatusCode int
|
||||||
|
}
|
||||||
|
|
||||||
|
// podcastHTTPClientFactory is the indirection TESTS use: the SSRF suite
|
||||||
|
// exercises the REAL client; fetch-driven integration tests swap in a
|
||||||
|
// client that trusts httptest's loopback TLS cert. Never reassign
|
||||||
|
// outside tests (use SetPodcastHTTPClientForTests).
|
||||||
|
var podcastHTTPClientFactory = podcastHTTPClient
|
||||||
|
|
||||||
|
// SetPodcastHTTPClientForTests swaps the fetch client. nil restores the
|
||||||
|
// production client (SSRF guards intact).
|
||||||
|
func SetPodcastHTTPClientForTests(c *http.Client) {
|
||||||
|
if c == nil {
|
||||||
|
podcastHTTPClientFactory = podcastHTTPClient
|
||||||
|
return
|
||||||
|
}
|
||||||
|
podcastHTTPClientFactory = func() *http.Client { return c }
|
||||||
|
}
|
||||||
|
|
||||||
|
func guardFetch(ctx context.Context, rawURL, etag, lastModified string, maxSize int64) (*fetchResult, error) {
|
||||||
|
u, err := url.Parse(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid URL: %w", err)
|
||||||
|
}
|
||||||
|
if err := guardURLScheme(u); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// A recognizable UA; some podcast hosts 403 empty/default agents.
|
||||||
|
req.Header.Set("User-Agent", "Bookhoard/1.0 (podcast fetcher; +https://github.com/bookhoard)")
|
||||||
|
req.Header.Set("Accept", "*/*")
|
||||||
|
if etag != "" {
|
||||||
|
req.Header.Set("If-None-Match", etag)
|
||||||
|
}
|
||||||
|
if lastModified != "" {
|
||||||
|
req.Header.Set("If-Modified-Since", lastModified)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := podcastHTTPClientFactory().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
// Unwrap redirect/scheme guard errors so callers can classify.
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
res := &fetchResult{
|
||||||
|
FinalURL: resp.Request.URL.String(),
|
||||||
|
ETag: resp.Header.Get("ETag"),
|
||||||
|
LastModified: resp.Header.Get("Last-Modified"),
|
||||||
|
StatusCode: resp.StatusCode,
|
||||||
|
}
|
||||||
|
if resp.StatusCode == http.StatusNotModified {
|
||||||
|
res.NotModified = true
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return res, fmt.Errorf("fetch %s: HTTP %d", rawURL, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, maxSize+1))
|
||||||
|
if err != nil {
|
||||||
|
return res, fmt.Errorf("fetch %s: %w", rawURL, err)
|
||||||
|
}
|
||||||
|
if int64(len(body)) > maxSize {
|
||||||
|
return res, fmt.Errorf("fetch %s: response exceeds %d byte cap", rawURL, maxSize)
|
||||||
|
}
|
||||||
|
res.Body = body
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bookhoard/internal/database"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Podcast episode ordering — THE single source of order truth (§0 item
|
||||||
|
// 3 + item 5a, 2026-10-30). Every surface (sequence JSON, reader chain
|
||||||
|
// context, SSR page list, download queueing) routes through
|
||||||
|
// OrderEpisodes; per-surface sort blocks were the drift risk (three
|
||||||
|
// near-identical comparators existed by hand).
|
||||||
|
|
||||||
|
// PodcastEpisodeOrder values:
|
||||||
|
// - native: feed position ASC, date ASC tiebreak (fetch-maintained order)
|
||||||
|
// - reverse: feed position DESC, date DESC tiebreak ("Reverse feed
|
||||||
|
// order" — no pub dates involved, so Scribl's messy/all-equal
|
||||||
|
// migrated podiobooks dates can't break it)
|
||||||
|
// - oldest / newest: chronological by pub date string (2006-01-02 or
|
||||||
|
// anything lexical-comparable), position as same-date tiebreak
|
||||||
|
func ValidPodcastEpisodeOrder(o string) bool {
|
||||||
|
return o == "native" || o == "reverse" || o == "oldest" || o == "newest"
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrderEpisodes sorts eps in place into the given order. Accessor
|
||||||
|
// functions supply each episode's feed position and pub date key
|
||||||
|
// (LEN lexical-comparable, e.g. RFC3339 or 2006-01-02).
|
||||||
|
func OrderEpisodes[T any](eps []T, order string, pos func(T) int32, date func(T) string) {
|
||||||
|
switch order {
|
||||||
|
case "reverse":
|
||||||
|
sort.SliceStable(eps, func(i, j int) bool {
|
||||||
|
if pos(eps[i]) != pos(eps[j]) {
|
||||||
|
return pos(eps[i]) > pos(eps[j])
|
||||||
|
}
|
||||||
|
return date(eps[i]) > date(eps[j])
|
||||||
|
})
|
||||||
|
case "oldest", "newest":
|
||||||
|
sort.SliceStable(eps, func(i, j int) bool {
|
||||||
|
ka := date(eps[i]) + episodePosKey(pos(eps[i]))
|
||||||
|
kb := date(eps[j]) + episodePosKey(pos(eps[j]))
|
||||||
|
if order == "newest" {
|
||||||
|
return ka > kb
|
||||||
|
}
|
||||||
|
return ka < kb
|
||||||
|
})
|
||||||
|
default: // native
|
||||||
|
sort.SliceStable(eps, func(i, j int) bool {
|
||||||
|
if pos(eps[i]) != pos(eps[j]) {
|
||||||
|
return pos(eps[i]) < pos(eps[j])
|
||||||
|
}
|
||||||
|
return date(eps[i]) < date(eps[j])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrderEpisodeMedia applies the curated listening order to a media
|
||||||
|
// match list — the SMART collection page renders exactly what the
|
||||||
|
// sequence will play (2026-11-01 user report: flipping the order select
|
||||||
|
// changed nothing on the page, and Play launches from the first card).
|
||||||
|
// Non-episode match lists are left untouched (title order stays).
|
||||||
|
func OrderEpisodeMedia(items []database.MediaItems, order string) {
|
||||||
|
anyEpisode := false
|
||||||
|
for _, m := range items {
|
||||||
|
if m.Source == "podcast_episode" {
|
||||||
|
anyEpisode = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !anyEpisode {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
OrderEpisodes(items, order,
|
||||||
|
func(m database.MediaItems) int32 { return m.FeedPosition.Int32 },
|
||||||
|
func(m database.MediaItems) string {
|
||||||
|
if m.DatePublished.Valid {
|
||||||
|
return m.DatePublished.Time.Format("2006-01-02")
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// episodePosKey: zero-padded position so lexical date keys tiebreak
|
||||||
|
// numerically.
|
||||||
|
func episodePosKey(pos int32) string {
|
||||||
|
return fmt.Sprintf("%06d", pos)
|
||||||
|
}
|
||||||
Loaded 100 of 203 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user