Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
+3
-1
@@ -41,12 +41,14 @@ RUN npm run build:ts
|
||||
# Declared after `COPY . .` so a version bump only invalidates this layer.
|
||||
# The release workflow passes the git tag, e.g. APP_VERSION=v1.5.
|
||||
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)
|
||||
RUN --mount=type=cache,target=/root/go/pkg/mod \
|
||||
--mount=type=cache,target=/root/.cache/go-build \
|
||||
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
|
||||
|
||||
# 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
|
||||
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).
|
||||
# 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
|
||||
|
||||
@@ -60,3 +60,13 @@ variables:
|
||||
name: book_sha256
|
||||
- secret: true
|
||||
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);
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"bookhoard/internal/router"
|
||||
"bookhoard/internal/services"
|
||||
"bookhoard/internal/sync"
|
||||
"bookhoard/internal/version"
|
||||
"bookhoard/templates"
|
||||
"context"
|
||||
"log"
|
||||
@@ -33,6 +34,7 @@ func (cv *CustomValidator) Validate(i interface{}) error {
|
||||
}
|
||||
|
||||
func main() {
|
||||
log.Printf("Bookhoard %s (commit %s) starting", version.Version, version.Commit)
|
||||
cfg := config.LoadConfig()
|
||||
|
||||
dbPool, err := pgxpool.New(context.Background(), cfg.DatabaseURL())
|
||||
@@ -177,6 +179,14 @@ func main() {
|
||||
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
|
||||
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()
|
||||
|
||||
// Set up validator
|
||||
@@ -237,6 +247,9 @@ func main() {
|
||||
CollectionHandler: collectionHandler,
|
||||
FiltersHandler: filtersHandler,
|
||||
DashboardHandler: dashboardHandler,
|
||||
PodcastHandler: podcastHandler,
|
||||
PodcastService: podcastService,
|
||||
PodcastWorker: podcastWorker,
|
||||
DashboardService: dashboardService,
|
||||
SeriesHandler: seriesHandler,
|
||||
OPDSHandler: opdsHandler,
|
||||
|
||||
@@ -752,6 +752,19 @@ func TestContractPin_MediaListBothBranches(t *testing.T) {
|
||||
"tags_search", "contributors_search", "narrators_search", "file_sha256",
|
||||
"opf_identifier", "opf_uuid", "hash_confidence", "library_name",
|
||||
"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{
|
||||
"id", "library_id", "title", "subtitle", "author", "authors", "isbn", "description",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -610,6 +610,14 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
||||
conversionService := services.NewConversionService(queries, getCachePath())
|
||||
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
|
||||
e := echo.New()
|
||||
|
||||
@@ -672,6 +680,9 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
||||
// hands the kobo/kepub CFI converter a typed-nil (kobo markup
|
||||
// panic-recovered as 500s in TestProgressWeb_KoboIntegration).
|
||||
LibraryService: libraryService,
|
||||
PodcastHandler: podcastHandler,
|
||||
PodcastService: podcastService,
|
||||
PodcastWorker: podcastWorker,
|
||||
}
|
||||
|
||||
router.RegisterRoutes(routerConfig)
|
||||
|
||||
+211
-3
@@ -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']),
|
||||
('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']),
|
||||
('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;
|
||||
|
||||
-- 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_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_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;
|
||||
|
||||
-- Create refresh_tokens table
|
||||
@@ -1878,6 +1883,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)
|
||||
ALTER TABLE hash_conflicts ALTER COLUMN resolution TYPE VARCHAR(50);
|
||||
|
||||
-- ============================================================
|
||||
-- PODCASTS / RSS FEEDS
|
||||
-- Plan: ~/.opencode/plan/podcast-integration.md (§6).
|
||||
-- Feeds are per-library entities (UNIQUE(library_id, feed_url)); users
|
||||
-- subscribe personally (podcast_subscriptions). Episodes are
|
||||
-- media_items rows with source='podcast_episode' — per-episode
|
||||
-- progress/stats for free — grouped by source_feed_id. Visibility is
|
||||
-- RLS-enforced (see the policy block at the end of this file): an
|
||||
-- episode/feed row is SELECT-visible only to subscribers of the feed
|
||||
-- and to admins. The fetcher/download worker is the SOLE writer of
|
||||
-- feed/episode metadata; users own only their subscription row and the
|
||||
-- reader_settings order override. ALL DDL IDEMPOTENT: schema.sql
|
||||
-- re-executes on EVERY boot.
|
||||
-- ============================================================
|
||||
|
||||
-- First per-library boolean knob (plain column, not KV): when false,
|
||||
-- only admins may add feeds to the library (members keep subscribe
|
||||
-- rights to existing feeds).
|
||||
ALTER TABLE libraries ADD COLUMN IF NOT EXISTS allow_member_feeds BOOLEAN NOT NULL DEFAULT TRUE;
|
||||
|
||||
-- Folder purpose: 'scan' (default; the FS walker's root) vs 'podcast'
|
||||
-- (a podcasts library's download root — scanner skips it wholesale,
|
||||
-- belt-and-braces on top of the library-type skip).
|
||||
ALTER TABLE library_folders ADD COLUMN IF NOT EXISTS purpose VARCHAR(20) NOT NULL DEFAULT 'scan';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS podcast_feeds (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
library_id UUID NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
|
||||
feed_url TEXT NOT NULL,
|
||||
-- podcast:guid — the feed's canonical identity; dedupes across URL
|
||||
-- migrations (fetcher honors itunes:new-feed-url + redirects).
|
||||
podcast_guid TEXT,
|
||||
title VARCHAR(500) NOT NULL,
|
||||
description TEXT,
|
||||
-- Channel itunes:author (semantically the host) and itunes:owner.
|
||||
host VARCHAR(500),
|
||||
publisher VARCHAR(500),
|
||||
owner_name VARCHAR(500),
|
||||
owner_email VARCHAR(500),
|
||||
language VARCHAR(50),
|
||||
tags TEXT[] DEFAULT '{}',
|
||||
explicit BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
artwork_path VARCHAR(500),
|
||||
-- itunes:type: 'serial' | 'episodic'
|
||||
feed_type VARCHAR(20),
|
||||
-- podcast:medium: 'audiobook' | 'podcast' | 'music' | ...
|
||||
medium VARCHAR(20),
|
||||
-- itunes:complete: poller backs off once the publisher ends the feed.
|
||||
is_complete BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
-- Conditional-GET validators (opaque; echoed verbatim).
|
||||
etag VARCHAR(500),
|
||||
last_modified VARCHAR(500),
|
||||
last_fetched_at TIMESTAMPTZ,
|
||||
last_build_date TIMESTAMPTZ,
|
||||
created_by_admin_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(library_id, feed_url)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_podcast_feeds_library ON podcast_feeds(library_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_podcast_feeds_guid ON podcast_feeds(podcast_guid);
|
||||
|
||||
-- User-owned subscription row: the ONLY podcast state a user writes.
|
||||
-- display_name NULL renders as the feed title. auto_download:
|
||||
-- 'off' | 'all' | 'newest' (+ auto_download_count for newest N).
|
||||
CREATE TABLE IF NOT EXISTS podcast_subscriptions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
feed_id UUID NOT NULL REFERENCES podcast_feeds(id) ON DELETE CASCADE,
|
||||
display_name VARCHAR(500),
|
||||
auto_download VARCHAR(20) NOT NULL DEFAULT 'off',
|
||||
auto_download_count INT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(user_id, feed_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_podcast_subs_user ON podcast_subscriptions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_podcast_subs_feed ON podcast_subscriptions(feed_id);
|
||||
|
||||
-- Episode columns (NULL for scan-sourced items). See the plan §5 for
|
||||
-- the full RSS/iTunes/Podcasting-2.0 field mapping — existing columns
|
||||
-- (title, subtitle, description, authors, explicit, date_published,
|
||||
-- cover_image_path, file_size, mime_type, language, genre/tags) carry
|
||||
-- the shared fields; only genuinely podcast-only storage lives here.
|
||||
ALTER TABLE media_items
|
||||
ADD COLUMN IF NOT EXISTS source VARCHAR(20) NOT NULL DEFAULT 'scan',
|
||||
ADD COLUMN IF NOT EXISTS source_feed_id UUID REFERENCES podcast_feeds(id) ON DELETE CASCADE,
|
||||
-- Stable reorder-proof addressing key (invariant 3): progress
|
||||
-- anchors on guid, never ordinal.
|
||||
ADD COLUMN IF NOT EXISTS episode_guid TEXT,
|
||||
-- Native order as of the latest fetch (renumbered per fetch).
|
||||
ADD COLUMN IF NOT EXISTS feed_position INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS season_number INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS episode_number INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS enclosure_url TEXT,
|
||||
-- itunes:duration claim (often HH:MM:SS or prose); display until
|
||||
-- ffprobe reconciles after download.
|
||||
ADD COLUMN IF NOT EXISTS declared_duration_seconds INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS transcript_url TEXT,
|
||||
ADD COLUMN IF NOT EXISTS chapters_url TEXT,
|
||||
ADD COLUMN IF NOT EXISTS hosts TEXT[] DEFAULT '{}',
|
||||
ADD COLUMN IF NOT EXISTS guests TEXT[] DEFAULT '{}',
|
||||
-- Download state machine: pending | queued | downloading |
|
||||
-- downloaded | failed (+ reason text + attempt count). The episode
|
||||
-- list renders this inline (D8/D19) — never Processing Issues.
|
||||
ADD COLUMN IF NOT EXISTS download_state VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
ADD COLUMN IF NOT EXISTS download_error TEXT,
|
||||
ADD COLUMN IF NOT EXISTS download_attempts INT NOT NULL DEFAULT 0,
|
||||
-- Monotonic queue ticket (Postgres SEQUENCE nextval at enqueue
|
||||
-- time): the download pool claims in ticket order, so the enqueue
|
||||
-- loop's ordering IS the download order (effective listening order
|
||||
-- — §0 items 4/5). NULL for rows never queued.
|
||||
ADD COLUMN IF NOT EXISTS download_queue_pos BIGINT;
|
||||
|
||||
-- Queue ticket source: atomic across concurrent enqueues (per-feed
|
||||
-- locks serialize within a feed; the sequence covers across feeds).
|
||||
CREATE SEQUENCE IF NOT EXISTS podcast_queue_seq;
|
||||
|
||||
-- Identity + ordering + worker-worklist indexes (all partial: scan
|
||||
-- rows never touch these columns).
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_media_items_feed_guid
|
||||
ON media_items(source_feed_id, episode_guid) WHERE source_feed_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_media_items_feed_pos
|
||||
ON media_items(source_feed_id, feed_position) WHERE source_feed_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_media_items_feed_undownloaded
|
||||
ON media_items(source_feed_id) WHERE source_feed_id IS NOT NULL AND download_state <> 'downloaded';
|
||||
CREATE INDEX IF NOT EXISTS idx_media_items_queue
|
||||
ON media_items(download_queue_pos) WHERE source = 'podcast_episode' AND download_state = 'queued';
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- RLS: PER-USER LIBRARY VISIBILITY (ONE SOURCE OF TRUTH)
|
||||
-- Plan: ~/.opencode/plan/server-rls-library-visibility.md REV 2.
|
||||
@@ -1912,6 +2048,13 @@ ALTER TABLE media_items FORCE ROW LEVEL SECURITY;
|
||||
-- semantics — row absent -> visible; row true -> visible; row false
|
||||
-- -> hidden. No new index needed: library_visibility's
|
||||
-- UNIQUE(user_id, library_id) constraint already indexes the join.
|
||||
-- PODCAST EPISODES (plan D20): source='podcast_episode' rows carry the
|
||||
-- ADDITIONAL subscription predicate — visible only to subscribers of
|
||||
-- source_feed_id, or to admins (admins manage feeds regardless of
|
||||
-- subscription; the escape also satisfies background jobs, which
|
||||
-- declare a first-admin identity). Scan rows pass unchanged via the
|
||||
-- IS DISTINCT FROM guard — the predicate costs them one NULL compare.
|
||||
-- Unset identity FAILS LOUD, never fails open (see the block header).
|
||||
CREATE POLICY p_media_items_visible ON media_items
|
||||
FOR SELECT
|
||||
USING (
|
||||
@@ -1922,6 +2065,19 @@ CREATE POLICY p_media_items_visible ON media_items
|
||||
AND lv.library_id = media_items.library_id
|
||||
AND lv.is_visible = false
|
||||
)
|
||||
AND (
|
||||
media_items.source IS DISTINCT FROM 'podcast_episode'
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM podcast_subscriptions s
|
||||
WHERE s.user_id = current_setting('app.user_id')::uuid
|
||||
AND s.feed_id = media_items.source_feed_id
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM users u
|
||||
WHERE u.id = current_setting('app.user_id')::uuid
|
||||
AND u.role = 'admin'
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
-- WRITE policies: PERMISSIVE STAND-INS for the deferred scoped write
|
||||
@@ -1942,7 +2098,59 @@ CREATE POLICY p_media_items_update ON media_items
|
||||
CREATE POLICY p_media_items_delete ON media_items
|
||||
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
|
||||
-- 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).
|
||||
-- ============================================================
|
||||
@@ -12,6 +12,8 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./Dockerfile
|
||||
args:
|
||||
GIT_COMMIT: ${GIT_COMMIT:-none}
|
||||
|
||||
# Integration Tests - runs against containerized app and db (dev only)
|
||||
tests:
|
||||
|
||||
@@ -443,3 +443,28 @@ Both answers are gated: 403 (Go-level explicit-library checks remain on
|
||||
listing endpoints) and 404 (row-level invisibility — no existence leak).
|
||||
Pins asserting 403 on item-scoped content endpoints widen to accept
|
||||
{403, 404}. Listing/scoped endpoints keep the strict 403 contract.
|
||||
|
||||
## 2026-10-29 — Podcast episode columns join media_items (additive widening)
|
||||
|
||||
The podcast integration (plan podcast-integration.md tasks 1.1/1.10) adds
|
||||
fifteen nullable/no-null-default columns to `media_items` — they surface in
|
||||
raw sqlc rows ONLY (`library-scoped list emits raw sqlc rows`), never in the
|
||||
resolved unscoped map:
|
||||
|
||||
`source` ('scan' default), `source_feed_id`, `episode_guid`, `feed_position`,
|
||||
`season_number`, `episode_number`, `enclosure_url`,
|
||||
`declared_duration_seconds`, `transcript_url`, `chapters_url`, `hosts`,
|
||||
`guests`, `download_state`, `download_error`, `download_attempts`.
|
||||
|
||||
Scan-sourced rows carry the inert defaults ('scan' / NULLs / 'pending');
|
||||
raw-row consumers that filter `source='scan'` (scanner paths) are unaffected.
|
||||
Nothing removed, nothing renamed.
|
||||
|
||||
## 2026-10-30 — download_queue_pos joins media_items (additive widening)
|
||||
|
||||
§0 item 4 of podcast-integration.md: the download pool's monotonic
|
||||
queue ticket. `download_queue_pos BIGINT` (NULL for scan rows and
|
||||
never-queued episodes) is stamped from the `podcast_queue_seq` SEQUENCE
|
||||
at enqueue time so claim order = enqueue order (the effective listening
|
||||
order). Raw-row pins (TestContractPin_MediaListBothBranches →
|
||||
rawRowKeys) whitelist the key. Nothing removed, nothing renamed.
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// sqlc v1.31.0
|
||||
|
||||
package database
|
||||
|
||||
|
||||
+70
-15
@@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// sqlc v1.31.0
|
||||
|
||||
package database
|
||||
|
||||
@@ -150,6 +150,7 @@ type Libraries struct {
|
||||
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"`
|
||||
AllowMemberFeeds bool `db:"allow_member_feeds" json:"allow_member_feeds"`
|
||||
}
|
||||
|
||||
type LibraryFolders struct {
|
||||
@@ -157,6 +158,7 @@ type LibraryFolders struct {
|
||||
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||
FolderPath string `db:"folder_path" json:"folder_path"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
Purpose string `db:"purpose" json:"purpose"`
|
||||
}
|
||||
|
||||
type LibraryTypes struct {
|
||||
@@ -324,20 +326,36 @@ type MediaItems struct {
|
||||
// Scan information from ComicInfo.xml (scanner group, resolution, etc.)
|
||||
ScanInformation pgtype.Text `db:"scan_information" json:"scan_information"`
|
||||
// Summary from ComicInfo.xml (may be merged with description from Calibre)
|
||||
Summary pgtype.Text `db:"summary" json:"summary"`
|
||||
MetadataOverrides []string `db:"metadata_overrides" json:"metadata_overrides"`
|
||||
MissingScanCount int32 `db:"missing_scan_count" json:"missing_scan_count"`
|
||||
ArchivedAt pgtype.Timestamptz `db:"archived_at" json:"archived_at"`
|
||||
ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"`
|
||||
LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"`
|
||||
AudioMetadata []byte `db:"audio_metadata" json:"audio_metadata"`
|
||||
TagsSearch []string `db:"tags_search" json:"tags_search"`
|
||||
ContributorsSearch []string `db:"contributors_search" json:"contributors_search"`
|
||||
NarratorsSearch []string `db:"narrators_search" json:"narrators_search"`
|
||||
FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"`
|
||||
OpfIdentifier pgtype.Text `db:"opf_identifier" json:"opf_identifier"`
|
||||
OpfUuid pgtype.Text `db:"opf_uuid" json:"opf_uuid"`
|
||||
HashConfidence pgtype.Text `db:"hash_confidence" json:"hash_confidence"`
|
||||
Summary pgtype.Text `db:"summary" json:"summary"`
|
||||
MetadataOverrides []string `db:"metadata_overrides" json:"metadata_overrides"`
|
||||
MissingScanCount int32 `db:"missing_scan_count" json:"missing_scan_count"`
|
||||
ArchivedAt pgtype.Timestamptz `db:"archived_at" json:"archived_at"`
|
||||
ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"`
|
||||
LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"`
|
||||
AudioMetadata []byte `db:"audio_metadata" json:"audio_metadata"`
|
||||
TagsSearch []string `db:"tags_search" json:"tags_search"`
|
||||
ContributorsSearch []string `db:"contributors_search" json:"contributors_search"`
|
||||
NarratorsSearch []string `db:"narrators_search" json:"narrators_search"`
|
||||
FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"`
|
||||
OpfIdentifier pgtype.Text `db:"opf_identifier" json:"opf_identifier"`
|
||||
OpfUuid pgtype.Text `db:"opf_uuid" json:"opf_uuid"`
|
||||
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 {
|
||||
@@ -405,6 +423,43 @@ type PanelData struct {
|
||||
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.
|
||||
type ProcessingIssues struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// sqlc v1.31.0
|
||||
|
||||
package database
|
||||
|
||||
@@ -22,6 +22,8 @@ type Querier interface {
|
||||
// ============================================
|
||||
AddBookToKoboShelf(ctx context.Context, arg AddBookToKoboShelfParams) (KoboShelves, error)
|
||||
// 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)
|
||||
// Second consecutive missing scan: hide the item from all browsing while
|
||||
// preserving reading history in case the file returns.
|
||||
@@ -30,6 +32,11 @@ type Querier interface {
|
||||
BulkUpdateFormatGroups(ctx context.Context) error
|
||||
BulkUpdateProgressFromSync(ctx context.Context, arg BulkUpdateProgressFromSyncParams) ([]interface{}, 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
|
||||
CleanupExpiredOpdsTokens(ctx context.Context) 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
|
||||
// future-hybrid excluded exclusion and an optional library scope.
|
||||
CountCollectionLiveItems(ctx context.Context, arg CountCollectionLiveItemsParams) (int32, error)
|
||||
CountDownloadedEpisodes(ctx context.Context, sourceFeedID pgtype.UUID) (int64, error)
|
||||
// Sibling-book count for sidecar naming (MetadataWriter): a dir-scoped
|
||||
// metadata.opf may only be written when NO other non-archived item
|
||||
// shares the directory; otherwise the sidecar goes to
|
||||
// <basename>.opf so one book's metadata can never poison dir-mates.
|
||||
CountOtherMediaItemsInDir(ctx context.Context, arg CountOtherMediaItemsInDirParams) (int32, error)
|
||||
CountPodcastEpisodesForFeed(ctx context.Context, sourceFeedID pgtype.UUID) (int64, error)
|
||||
CountPodcastFeedSubscribers(ctx context.Context, feedID pgtype.UUID) (int64, error)
|
||||
// Sidebar/page gate (D13): >=1 podcasts-type library the user can see
|
||||
// (COALESCE(is_visible,true) semantics — mirrors GetUserVisibleLibraries).
|
||||
CountPodcastLibrariesForUser(ctx context.Context, userID pgtype.UUID) (int64, error)
|
||||
CountQueuedEpisodes(ctx context.Context) (int64, error)
|
||||
CountUndownloadedEpisodes(ctx context.Context, sourceFeedID pgtype.UUID) (int64, error)
|
||||
// Count unlinked books for a device (ignored rows stay hidden)
|
||||
CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error)
|
||||
// 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)
|
||||
// ============================================================================
|
||||
// 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
|
||||
// ============================================================================
|
||||
// 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
|
||||
DeleteMediaRating(ctx context.Context, arg DeleteMediaRatingParams) 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)
|
||||
DeleteReadingProgress(ctx context.Context, arg DeleteReadingProgressParams) error
|
||||
DeleteSavedFilter(ctx context.Context, arg DeleteSavedFilterParams) (SavedFilters, error)
|
||||
@@ -170,6 +206,7 @@ type Querier interface {
|
||||
// Delete unlinked book
|
||||
DeleteUnlinkedBook(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
|
||||
// Device deletion (deregister on logout): detach the device's reading
|
||||
// history instead of deleting it — these rows are real captured stats
|
||||
@@ -421,6 +458,25 @@ type Querier interface {
|
||||
// Get OPDS tokens by device
|
||||
GetOpdsTokensByDevice(ctx context.Context, deviceID pgtype.UUID) ([]OpdsTokens, 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
|
||||
GetPopularBooks(ctx context.Context, arg GetPopularBooksParams) ([]GetPopularBooksRow, error)
|
||||
// Single-issue read for the resolve/delete endpoints' family-lens gate.
|
||||
@@ -521,6 +577,12 @@ type Querier interface {
|
||||
// END instant (the push time; NULL falls back to created_at) — the same
|
||||
// value the capture stamps every write with.
|
||||
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)
|
||||
GetVisibleLibraryMediaCounts(ctx context.Context, userID pgtype.UUID) ([]GetVisibleLibraryMediaCountsRow, error)
|
||||
// Smart ("dynamic") collections: rules are evaluated LIVE at render, so
|
||||
@@ -540,6 +602,12 @@ type Querier interface {
|
||||
// Link unlinked book to media item
|
||||
LinkUnlinkedBook(ctx context.Context, arg LinkUnlinkedBookParams) (UnlinkedBooks, 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)
|
||||
ListConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListConflictsByUserRow, error)
|
||||
// ============================================
|
||||
@@ -552,6 +620,9 @@ type Querier interface {
|
||||
ListDeletedAnnotationsForBook(ctx context.Context, arg ListDeletedAnnotationsForBookParams) ([]ListDeletedAnnotationsForBookRow, error)
|
||||
ListDevicesByType(ctx context.Context, deviceType string) ([]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
|
||||
// second scan that archives them) or already archived and pending purge.
|
||||
// Feeds the admin archived-items page. match_* columns identify an active
|
||||
@@ -563,6 +634,9 @@ type Querier interface {
|
||||
// collapsible "Ignored" section so a misclick is recoverable.
|
||||
ListIgnoredUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) ([]ListIgnoredUnlinkedBooksForUserRow, 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)
|
||||
ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryRow, error)
|
||||
ListMediaItemsByLibraryIncludingArchived(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryIncludingArchivedRow, error)
|
||||
@@ -571,18 +645,35 @@ type Querier interface {
|
||||
// List media items that have no stored SHA-256 (imported before hashing existed)
|
||||
ListMediaItemsMissingHash(ctx context.Context) ([]MediaItems, 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)
|
||||
// DeviceID NULL = every device's pending items (the queue processor's
|
||||
// enumerator passes NULL; a bare `device_id = NULL` never matches, which
|
||||
// silently disabled the offline flush until 2026-10-17).
|
||||
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
|
||||
// row — an inner join would silently drop the ledger's newest kind.
|
||||
// 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.
|
||||
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)
|
||||
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
|
||||
ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnresolvedUnlinkedBooksParams) ([]ListUnresolvedUnlinkedBooksRow, error)
|
||||
// Page list: unresolved queue rows for the caller's own devices (ignored
|
||||
@@ -591,6 +682,17 @@ type Querier interface {
|
||||
ListUsers(ctx context.Context) ([]ListUsersRow, error)
|
||||
// First consecutive scan that cannot find the file on disk.
|
||||
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
|
||||
// a move. Repoints the row (and refreshes file-level fields) so reading
|
||||
// history follows the book; archive/missing state is cleared separately.
|
||||
@@ -625,6 +727,17 @@ type Querier interface {
|
||||
ReorderCollectionItems(ctx context.Context, arg ReorderCollectionItemsParams) error
|
||||
// Re-parent all child rows of p_source onto p_target (defined in schema.sql)
|
||||
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
|
||||
ResolveHashConflict(ctx context.Context, arg ResolveHashConflictParams) error
|
||||
// Ledger self-heal (faceted plan Phase 11): a scan that processes the
|
||||
@@ -654,14 +767,31 @@ type Querier interface {
|
||||
// Search Media Items queries
|
||||
SearchMediaItems(ctx context.Context, arg SearchMediaItemsParams) ([]SearchMediaItemsRow, 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)
|
||||
SearchSeriesValues(ctx context.Context, arg SearchSeriesValuesParams) ([]SearchSeriesValuesRow, error)
|
||||
SearchTagsValues(ctx context.Context, arg SearchTagsValuesParams) ([]SearchTagsValuesRow, error)
|
||||
SetDeviceSyncEnabled(ctx context.Context, arg SetDeviceSyncEnabledParams) (Devices, error)
|
||||
// Library Visibility queries
|
||||
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
|
||||
SetSystemConfig(ctx context.Context, arg SetSystemConfigParams) (SystemConfig, error)
|
||||
SubscribePodcastFeed(ctx context.Context, arg SubscribePodcastFeedParams) (PodcastSubscriptions, error)
|
||||
SyncLibraryTypeExtensions(ctx context.Context, arg SyncLibraryTypeExtensionsParams) error
|
||||
TombstoneMediaBookmarkByDedupKey(ctx context.Context, arg TombstoneMediaBookmarkByDedupKeyParams) error
|
||||
TombstoneMediaBookmarkByID(ctx context.Context, arg TombstoneMediaBookmarkByIDParams) error
|
||||
@@ -726,6 +856,20 @@ type Querier interface {
|
||||
UpdateMediaNoteForSync(ctx context.Context, arg UpdateMediaNoteForSyncParams) (MediaNotes, error)
|
||||
UpdateMediaRating(ctx context.Context, arg UpdateMediaRatingParams) (MediaRatings, 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)
|
||||
UpdateReadingSpeed(ctx context.Context, arg UpdateReadingSpeedParams) (ReadingSpeed, error)
|
||||
UpdateSavedFilter(ctx context.Context, arg UpdateSavedFilterParams) (SavedFilters, error)
|
||||
@@ -751,11 +895,23 @@ type Querier interface {
|
||||
// content identical), and after a manifest-gated re-extract.
|
||||
UpsertMediaScanState(ctx context.Context, arg UpsertMediaScanStateParams) 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)
|
||||
UpsertSystemSetting(ctx context.Context, arg UpsertSystemSettingParams) (SystemSettings, error)
|
||||
// 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.
|
||||
UpsertUnlinkedBook(ctx context.Context, arg UpsertUnlinkedBookParams) (UnlinkedBooks, error)
|
||||
UpsertUserSettingByKey(ctx context.Context, arg UpsertUserSettingByKeyParams) error
|
||||
}
|
||||
|
||||
var _ Querier = (*Queries)(nil)
|
||||
+3626
-659
File diff suppressed because it is too large.
Load diff
@@ -97,7 +97,9 @@ DELETE FROM libraries WHERE id = $1;
|
||||
|
||||
-- Library Folders queries
|
||||
-- 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
|
||||
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;
|
||||
|
||||
-- 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
|
||||
FROM media_items mi
|
||||
JOIN libraries l ON mi.library_id = l.id
|
||||
JOIN library_types lt ON l.library_type_id = lt.id
|
||||
WHERE mi.archived_at IS NULL
|
||||
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[]))
|
||||
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
|
||||
WHERE mi.archived_at IS NULL
|
||||
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')
|
||||
ORDER BY
|
||||
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
|
||||
AND mi.archived_at IS NULL
|
||||
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 (
|
||||
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
|
||||
AND mi.archived_at IS NULL
|
||||
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)
|
||||
-- Fuzzy author filter — per-element against the authors[] array (A2c
|
||||
@@ -932,6 +941,7 @@ FROM media_items mi
|
||||
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')
|
||||
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.missing_scan_count = 0
|
||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
@@ -951,6 +961,7 @@ FROM media_items mi
|
||||
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')
|
||||
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.missing_scan_count = 0
|
||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
@@ -971,6 +982,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')
|
||||
CROSS JOIN LATERAL unnest(mi.tags_search) AS tag
|
||||
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 tag IS NOT NULL
|
||||
AND word_similarity(sqlc.narg('search_query'), tag) > 0.3
|
||||
@@ -988,6 +1000,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')
|
||||
CROSS JOIN LATERAL unnest(mi.narrators) AS narrator
|
||||
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 narrator IS NOT NULL
|
||||
AND word_similarity(sqlc.narg('search_query'), narrator) > 0.3
|
||||
@@ -1004,6 +1017,7 @@ FROM media_items mi
|
||||
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')
|
||||
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.missing_scan_count = 0
|
||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
@@ -1023,6 +1037,7 @@ FROM media_items mi
|
||||
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')
|
||||
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.missing_scan_count = 0
|
||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
@@ -1061,6 +1076,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')
|
||||
CROSS JOIN LATERAL unnest(mi.authors) AS a
|
||||
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.missing_scan_count = 0
|
||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
@@ -1129,6 +1145,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')
|
||||
CROSS JOIN LATERAL unnest(mi.publishers) AS p
|
||||
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.missing_scan_count = 0
|
||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
@@ -1196,6 +1213,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')
|
||||
CROSS JOIN LATERAL unnest(mi.narrators) AS n
|
||||
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.missing_scan_count = 0
|
||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
@@ -1265,6 +1283,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')
|
||||
CROSS JOIN LATERAL unnest(mi.tags) AS t
|
||||
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.missing_scan_count = 0
|
||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
@@ -1333,6 +1352,7 @@ FROM media_items mi
|
||||
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')
|
||||
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.missing_scan_count = 0
|
||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
@@ -1404,6 +1424,7 @@ FROM media_items mi
|
||||
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')
|
||||
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.missing_scan_count = 0
|
||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
@@ -1475,6 +1496,7 @@ FROM media_items mi
|
||||
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')
|
||||
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.missing_scan_count = 0
|
||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
@@ -1546,6 +1568,7 @@ FROM media_items mi
|
||||
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')
|
||||
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.missing_scan_count = 0
|
||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
@@ -1613,6 +1636,7 @@ FROM media_items mi
|
||||
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')
|
||||
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.missing_scan_count = 0
|
||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
@@ -1680,6 +1704,7 @@ FROM media_items mi
|
||||
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')
|
||||
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.missing_scan_count = 0
|
||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
@@ -1747,6 +1772,7 @@ FROM media_items mi
|
||||
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')
|
||||
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.missing_scan_count = 0
|
||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
@@ -1818,6 +1844,7 @@ FROM media_items mi
|
||||
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')
|
||||
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.missing_scan_count = 0
|
||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
@@ -1889,6 +1916,7 @@ FROM media_items mi
|
||||
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')
|
||||
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.missing_scan_count = 0
|
||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
@@ -1960,6 +1988,7 @@ FROM media_items mi
|
||||
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')
|
||||
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.missing_scan_count = 0
|
||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
@@ -2604,6 +2633,7 @@ SELECT
|
||||
FROM media_items mi
|
||||
JOIN reading_history rh ON rh.media_item_id = mi.id
|
||||
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
|
||||
ORDER BY read_count DESC
|
||||
LIMIT $2;
|
||||
@@ -2666,6 +2696,7 @@ SELECT
|
||||
FROM reading_history rh
|
||||
JOIN media_items mi ON mi.id = rh.media_item_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('end_time')
|
||||
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
||||
@@ -3962,6 +3993,7 @@ INNER JOIN (
|
||||
ORDER BY media_item_id, last_read_at DESC
|
||||
) 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)
|
||||
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
ORDER BY rp.last_read_at DESC
|
||||
@@ -3970,6 +4002,7 @@ LIMIT sqlc.narg('limit');
|
||||
-- name: GetRecentlyAddedItems :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 (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
ORDER BY mi.imported_at DESC NULLS LAST, mi.created_at DESC
|
||||
@@ -3985,6 +4018,7 @@ INNER JOIN (
|
||||
ORDER BY media_item_id, last_read_at DESC
|
||||
) 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)
|
||||
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
ORDER BY rp.last_read_at DESC
|
||||
@@ -3993,6 +4027,7 @@ LIMIT sqlc.narg('limit');
|
||||
-- name: GetNotStartedItems :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 (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND NOT EXISTS (
|
||||
@@ -4079,6 +4114,7 @@ SELECT mi.* FROM media_items mi
|
||||
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.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;
|
||||
|
||||
-- name: GetDistinctSeries :many
|
||||
@@ -4111,6 +4147,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 archived_at IS NULL
|
||||
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 != ''
|
||||
-- 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
|
||||
@@ -4171,6 +4208,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 archived_at IS NULL
|
||||
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 (sqlc.narg('series_query')::text IS NULL OR
|
||||
series ILIKE '%' || sqlc.narg('series_query')::text || '%'
|
||||
@@ -4590,3 +4628,481 @@ SET file_size = $2,
|
||||
updated_at = NOW()
|
||||
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;
|
||||
|
||||
-- ============================================================================
|
||||
-- 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.
|
||||
const (
|
||||
SettingTypeInt = "int"
|
||||
SettingTypeFloat = "float"
|
||||
SettingTypeBool = "bool"
|
||||
SettingTypeString = "string"
|
||||
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: "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: "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.
|
||||
@@ -172,6 +176,26 @@ func (r *SettingsRegistry) getInt(key string) int {
|
||||
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 {
|
||||
v := r.raw(key)
|
||||
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
|
||||
}
|
||||
|
||||
// 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.
|
||||
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 {
|
||||
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:
|
||||
if d.Value == "" {
|
||||
t.Errorf("string setting %s has empty default", d.Key)
|
||||
|
||||
@@ -1542,6 +1542,13 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
|
||||
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
|
||||
coverUploaded := false
|
||||
|
||||
@@ -1874,6 +1881,17 @@ func (mh *MediaHandler) DeleteMediaItem(c *echo.Context) 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 is unreachable so the cascade can't destroy annotations for
|
||||
// a file that will re-import on the next scan.
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/opds"
|
||||
@@ -18,6 +17,24 @@ import (
|
||||
"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 {
|
||||
db *database.Queries
|
||||
libraryService *services.LibraryService
|
||||
@@ -209,7 +226,7 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
|
||||
|
||||
// Get user's visible libraries
|
||||
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 {
|
||||
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
|
||||
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 {
|
||||
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.
|
||||
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 {
|
||||
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
|
||||
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 {
|
||||
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
|
||||
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 {
|
||||
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
|
||||
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 {
|
||||
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
|
||||
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 {
|
||||
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
|
||||
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
|
||||
if err := c.Bind(&req); err != nil {
|
||||
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 {
|
||||
if folder.FolderPath == req.FolderPath {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,6 +178,21 @@ func validateSettingValue(def database.SettingDefault, value string) error {
|
||||
if _, err := strconv.ParseBool(value); err != nil {
|
||||
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:
|
||||
if value == "" {
|
||||
return fmt.Errorf("value must not be empty")
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"bookhoard/internal/config"
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/rls"
|
||||
"bookhoard/internal/handlers"
|
||||
"bookhoard/internal/services"
|
||||
"bookhoard/internal/utils"
|
||||
@@ -1507,8 +1508,7 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
|
||||
// Admin hash conflicts page: content-duplicate groups flagged during hash
|
||||
// backfill or rescan, resolved by keeping all copies or merging into one.
|
||||
frontendProtected.GET("/admin/hash-conflicts", handlers.AdminMiddleware(func(c *echo.Context) error {
|
||||
user, err := getTemplateUserWithTheme(c, cfg)
|
||||
frontendProtected.GET("/admin/hash-conflicts", handlers.AdminMiddleware(func(c *echo.Context) error { user, err := getTemplateUserWithTheme(c, cfg)
|
||||
if err != nil {
|
||||
return renderErrorPage(c, "Error loading user", "user_load_error")
|
||||
}
|
||||
@@ -1562,6 +1562,62 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
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
|
||||
frontendProtected.GET("/admin/users", handlers.AdminMiddleware(func(c *echo.Context) error {
|
||||
user, err := getTemplateUserWithTheme(c, cfg)
|
||||
@@ -1984,6 +2040,7 @@ func (cfg *Config) GetHealth(c *echo.Context) error {
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"status": "healthy",
|
||||
"version": version.Version,
|
||||
"commit": version.Commit,
|
||||
"database": "connected",
|
||||
"scan": map[string]interface{}{
|
||||
"scan_in_progress": cfg.Worker.HasActiveScans(),
|
||||
|
||||
@@ -55,6 +55,14 @@ func getTemplateUserWithTheme(c *echo.Context, cfg *Config) (templates.User, err
|
||||
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{
|
||||
ID: userID,
|
||||
Email: userEmail,
|
||||
@@ -68,6 +76,7 @@ func getTemplateUserWithTheme(c *echo.Context, cfg *Config) (templates.User, err
|
||||
CreatedAt: userDB.CreatedAt.Time,
|
||||
DefaultAudioSpeed: float64(userDB.DefaultAudioSpeed),
|
||||
KeepScreenAwake: userDB.KeepScreenAwake,
|
||||
ShowPodcastsNav: showPodcasts,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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,474 @@
|
||||
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).
|
||||
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 "native"
|
||||
}
|
||||
var v struct {
|
||||
Order string `json:"order"`
|
||||
}
|
||||
if err := json.Unmarshal(row, &v); err != nil {
|
||||
return "native"
|
||||
}
|
||||
if !services.ValidPodcastEpisodeOrder(v.Order) {
|
||||
return "native"
|
||||
}
|
||||
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,12 +1,14 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/handlers"
|
||||
"bookhoard/internal/services"
|
||||
"bookhoard/internal/sync"
|
||||
"bookhoard/internal/utils"
|
||||
"bookhoard/templates"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -102,6 +104,18 @@ func registerReaderRoutes(cfg *Config) {
|
||||
TreatAsComic: handlers.ShouldTreatAsComic(libraryWithType.TypeName, mediaItem.FormatGroup),
|
||||
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
|
||||
var buf bytes.Buffer
|
||||
// Audiobooks get the audio player page — the foliate document
|
||||
@@ -144,3 +158,90 @@ func registerReaderRoutes(cfg *Config) {
|
||||
reader.GET("/settings", cfg.ReaderHandler.GetSettings)
|
||||
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()
|
||||
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.
|
||||
order := "native"
|
||||
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,
|
||||
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
|
||||
}
|
||||
@@ -80,6 +80,9 @@ type Config struct {
|
||||
SidecarHandler *handlers.SidecarHandler
|
||||
ReaderHandler *handlers.ReaderHandler
|
||||
LibraryService *services.LibraryService
|
||||
PodcastHandler *handlers.PodcastHandler
|
||||
PodcastService *services.PodcastService
|
||||
PodcastWorker *services.PodcastDownloadWorker
|
||||
}
|
||||
|
||||
// getBaseURL returns the configured base URL from the database, falling back to
|
||||
@@ -344,12 +347,14 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
|
||||
registerConflictRoutes(cfg)
|
||||
registerAnalyticsRoutes(cfg)
|
||||
registerQueueRoutes(cfg)
|
||||
registerPodcastRoutes(cfg)
|
||||
registerJobRoutes(cfg)
|
||||
registerFiltersRoutes(cfg)
|
||||
registerOPDSRoutes(cfg)
|
||||
registerReaderRoutes(cfg)
|
||||
registerWebSocketRoutes(cfg)
|
||||
registerFrontendRoutes(cfg)
|
||||
registerPodcastPages(cfg)
|
||||
registerDocumentationRoutes(cfg)
|
||||
|
||||
// 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)
|
||||
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)
|
||||
go func() {
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
@@ -29,6 +29,12 @@ const (
|
||||
LibraryTypeComics = "comics"
|
||||
LibraryTypeManga = "manga"
|
||||
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{
|
||||
@@ -43,6 +49,9 @@ var AllowedExtensions = map[string][]string{
|
||||
// browser-playable lossless/next-gen containers join for Batch B
|
||||
// metadata parity (.wma deliberately absent: browsers can't play it).
|
||||
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{
|
||||
@@ -149,10 +158,39 @@ func (s *LibraryService) DeleteLibrary(ctx context.Context, libraryID pgtype.UUI
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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{
|
||||
LibraryID: libraryID,
|
||||
FolderPath: folderPath,
|
||||
Purpose: purpose,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -504,6 +504,12 @@ func (s *MediaScanner) GetStats() (int, int, int) {
|
||||
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
|
||||
// fsnotify watcher over the full directory tree.
|
||||
//
|
||||
@@ -512,9 +518,34 @@ func (s *MediaScanner) GetStats() (int, int, int) {
|
||||
// watcher (and thus no fd/inotify watches) is allocated — the watcher is never
|
||||
// read by scan jobs and previously leaked one watcher per job.
|
||||
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 err := s.watcher.Close(); err != nil {
|
||||
fmt.Printf("Warning: failed to close old watcher during folder reconfiguration: %v\n", err)
|
||||
@@ -524,6 +555,12 @@ func (s *MediaScanner) SetFolders(folders []string, watch bool) error {
|
||||
|
||||
// Create + populate a fresh watcher only when the caller intends to read events.
|
||||
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()
|
||||
if err != nil {
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
if err != nil {
|
||||
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,60 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"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])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// episodePosKey: zero-padded position so lexical date keys tiebreak
|
||||
// numerically.
|
||||
func episodePosKey(pos int32) string {
|
||||
return fmt.Sprintf("%06d", pos)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// Podcast feed poller (plan §7 task 1.6): one periodic worker ticking
|
||||
// on the podcast_poll_interval_seconds system setting (re-read each
|
||||
// cycle — the settings API path keeps the registry cache honest),
|
||||
// polling every non-complete feed and applying subscriber auto-download
|
||||
// policies after each successful fetch (D6).
|
||||
//
|
||||
// RLS: detached work under the first-admin identity — the service's
|
||||
// adminCtx supplies it (invariant 12). Back-off: itunes:complete feeds
|
||||
// are skipped by the ticker (explicit refresh still works).
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"bookhoard/internal/database"
|
||||
)
|
||||
|
||||
type PodcastPoller struct {
|
||||
db *database.Queries
|
||||
svc *PodcastService
|
||||
worker *PodcastDownloadWorker
|
||||
settings *database.SettingsRegistry
|
||||
}
|
||||
|
||||
func NewPodcastPoller(db *database.Queries, svc *PodcastService, worker *PodcastDownloadWorker, settings *database.SettingsRegistry) *PodcastPoller {
|
||||
return &PodcastPoller{db: db, svc: svc, worker: worker, settings: settings}
|
||||
}
|
||||
|
||||
// Start launches the poll loop; exits when ctx is cancelled.
|
||||
func (p *PodcastPoller) Start(ctx context.Context) {
|
||||
go func() {
|
||||
for {
|
||||
interval := p.settings.PodcastPollInterval()
|
||||
if interval < time.Minute {
|
||||
interval = time.Minute // floor against bad settings rows
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(interval):
|
||||
}
|
||||
p.tick(ctx)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// tick polls every non-complete feed once.
|
||||
func (p *PodcastPoller) tick(ctx context.Context) {
|
||||
actx, err := p.svc.adminCtx(ctx)
|
||||
if err != nil {
|
||||
log.Printf("[PODCAST-POLL] no acting identity, skipping tick: %v", err)
|
||||
return
|
||||
}
|
||||
feeds, err := p.db.ListPodcastFeedsForPolling(actx)
|
||||
if err != nil {
|
||||
log.Printf("[PODCAST-POLL] worklist: %v", err)
|
||||
return
|
||||
}
|
||||
for _, feed := range feeds {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if feed.IsComplete {
|
||||
continue // back off ended feeds (explicit refresh still works)
|
||||
}
|
||||
if err := p.svc.PollFeed(actx, &feed); err != nil {
|
||||
log.Printf("[PODCAST-POLL] %s: %v", feed.Title, err)
|
||||
continue
|
||||
}
|
||||
// Fresh episodes → apply the subscriber policy union (auto-
|
||||
// download without waiting for the next tick).
|
||||
if _, err := p.worker.ApplyAutoPolicies(actx, feed.ID); err != nil {
|
||||
log.Printf("[PODCAST-POLL] auto-download policies for %s: %v", feed.Title, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
// RSS 2.0 + iTunes + Podcasting-2.0 feed parsing for the podcast
|
||||
// fetcher (plan podcast-integration.md §5/§7). Stdlib encoding/xml only
|
||||
// — no new deps.
|
||||
//
|
||||
// Namespace strategy: iTunes fields MUST be namespace-qualified in the
|
||||
// struct tags (the itunes URI) because their local names collide with
|
||||
// core RSS ("title", "subtitle", "author", "summary", "explicit",
|
||||
// "image", "duration"). Podcasting-2.0 fields (podcast:guid,
|
||||
// podcast:transcript, ...) have collision-free local names, so they
|
||||
// match on local name — encoding/xml matches Name.Local when the tag
|
||||
// carries no namespace, which also tolerates both declared namespace
|
||||
// URIs the ecosystem uses.
|
||||
//
|
||||
// Out of scope at parse time (plan §5): podcast:value, liveItem,
|
||||
// soundbite, alternateEnclosure, socialInteract, remoteItem/podroll,
|
||||
// locked/block, txt, chat. lastBuildDate/ttl are poll hints only.
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// itunesNS is the canonical iTunes podcast namespace URI.
|
||||
const itunesNS = "http://www.itunes.com/dtds/podcast-1.0.dtd"
|
||||
|
||||
// PodcastFeedXML is one parsed RSS document.
|
||||
type PodcastFeedXML struct {
|
||||
XMLName xml.Name `xml:"rss"`
|
||||
Channel PodcastChannelXML `xml:"channel"`
|
||||
}
|
||||
|
||||
type PodcastChannelXML struct {
|
||||
Title string `xml:"title"`
|
||||
Link string `xml:"link"`
|
||||
Language string `xml:"language"`
|
||||
Description string `xml:"description"`
|
||||
|
||||
// iTunes channel namespace.
|
||||
ITunesAuthor string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd author"`
|
||||
ITunesOwner struct {
|
||||
Name string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd name"`
|
||||
Email string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd email"`
|
||||
} `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd owner"`
|
||||
ITunesImage struct {
|
||||
Href string `xml:"href,attr"`
|
||||
} `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd image"`
|
||||
ITunesExplicit string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd explicit"`
|
||||
ITunesType string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd type"`
|
||||
ITunesCategory struct {
|
||||
Text string `xml:"text,attr"`
|
||||
Subcategory struct {
|
||||
Text string `xml:"text,attr"`
|
||||
} `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd category"`
|
||||
} `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd category"`
|
||||
ITunesNewFeedURL string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd new-feed-url"`
|
||||
ITunesComplete string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd complete"`
|
||||
|
||||
// Podcasting 2.0 (matched on local name; see file comment).
|
||||
PodcastGUID string `xml:"guid"`
|
||||
PodcastMedium string `xml:"medium"`
|
||||
|
||||
LastBuildDate string `xml:"lastBuildDate"`
|
||||
|
||||
Items []PodcastItemXML `xml:"item"`
|
||||
}
|
||||
|
||||
type PodcastItemXML struct {
|
||||
GUID string `xml:"guid"`
|
||||
Title string `xml:"title"`
|
||||
ITunesTitle string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd title"`
|
||||
Subtitle string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd subtitle"`
|
||||
// Description resolution order (plan §5): description >
|
||||
// itunes:summary > content:encoded.
|
||||
Description string `xml:"description"`
|
||||
ITunesSummary string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd summary"`
|
||||
ContentEncoded string `xml:"encoded"`
|
||||
|
||||
// Item author: itunes:author preferred, then RSS author, then dc:creator.
|
||||
ITunesAuthor string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd author"`
|
||||
Author string `xml:"author"`
|
||||
Creator string `xml:"http://purl.org/dc/elements/1.1/ creator"`
|
||||
|
||||
ITunesImage struct {
|
||||
Href string `xml:"href,attr"`
|
||||
} `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd image"`
|
||||
ITunesExplicit string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd explicit"`
|
||||
ITunesDuration string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd duration"`
|
||||
ITunesEpisode string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd episode"`
|
||||
ITunesSeason string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd season"`
|
||||
|
||||
PubDate string `xml:"pubDate"`
|
||||
|
||||
Enclosure struct {
|
||||
URL string `xml:"url,attr"`
|
||||
Length int64 `xml:"length,attr"`
|
||||
Type string `xml:"type,attr"`
|
||||
} `xml:"enclosure"`
|
||||
|
||||
// Podcasting 2.0: store-now, feature-later URLs.
|
||||
Transcript struct {
|
||||
URL string `xml:"url,attr"`
|
||||
} `xml:"transcript"`
|
||||
Chapters struct {
|
||||
URL string `xml:"url,attr"`
|
||||
} `xml:"chapters"`
|
||||
Persons []struct {
|
||||
Href string `xml:"href,attr"`
|
||||
Role string `xml:"role,attr"`
|
||||
} `xml:"person"`
|
||||
}
|
||||
|
||||
// ParsePodcastFeed decodes a feed document via a namespace-aware token
|
||||
// decoder (see decodePodcastFeed). Returns an error when the XML is
|
||||
// unparseable or the document is not an RSS channel.
|
||||
func ParsePodcastFeed(data []byte) (*PodcastFeedXML, error) {
|
||||
feed, err := decodePodcastFeed(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("feed parse: %w", err)
|
||||
}
|
||||
if feed.Channel.Title == "" && len(feed.Channel.Items) == 0 {
|
||||
return nil, fmt.Errorf("feed parse: not an RSS channel (no title, no items)")
|
||||
}
|
||||
return feed, nil
|
||||
}
|
||||
|
||||
// EpisodeTitle prefers itunes:title over the raw RSS title (plan §5).
|
||||
func (it *PodcastItemXML) EpisodeTitle() string {
|
||||
if t := strings.TrimSpace(it.ITunesTitle); t != "" {
|
||||
return t
|
||||
}
|
||||
return strings.TrimSpace(it.Title)
|
||||
}
|
||||
|
||||
// EpisodeDescription resolves the description fallback chain.
|
||||
func (it *PodcastItemXML) EpisodeDescription() string {
|
||||
for _, c := range []string{it.Description, it.ITunesSummary, it.ContentEncoded} {
|
||||
if t := strings.TrimSpace(c); t != "" {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// EpisodeAuthor resolves the author fallback chain.
|
||||
func (it *PodcastItemXML) EpisodeAuthor() string {
|
||||
for _, c := range []string{it.ITunesAuthor, it.Author, it.Creator} {
|
||||
if t := strings.TrimSpace(c); t != "" {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// EpisodeExplicit maps the itunes:explicit spelling variants to bool.
|
||||
func (it *PodcastItemXML) EpisodeExplicit() bool {
|
||||
return parseExplicit(it.ITunesExplicit)
|
||||
}
|
||||
|
||||
func parseExplicit(v string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||
case "true", "yes", "explicit", "1":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HostsAndGuests splits podcast:person entries by role (plan §5).
|
||||
func (it *PodcastItemXML) HostsAndGuests() (hosts []string, guests []string) {
|
||||
for _, p := range it.Persons {
|
||||
name := p.Href
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
switch strings.ToLower(p.Role) {
|
||||
case "guest":
|
||||
guests = append(guests, name)
|
||||
default: // host and unlabeled roles surface as hosts
|
||||
hosts = append(hosts, name)
|
||||
}
|
||||
}
|
||||
return hosts, guests
|
||||
}
|
||||
|
||||
// parseITunesDuration accepts the three spellings the spec allows —
|
||||
// plain seconds, HH:MM:SS, MM:SS — plus a best-effort prose fallback
|
||||
// ("58 minutes"). Returns 0 for anything unparseable (the column stays
|
||||
// NULL; ffprobe reconciles after download anyway).
|
||||
func parseITunesDuration(v string) int {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return 0
|
||||
}
|
||||
if secs, err := strconv.Atoi(v); err == nil {
|
||||
return secs
|
||||
}
|
||||
parts := strings.Split(v, ":")
|
||||
if len(parts) <= 3 {
|
||||
total := 0
|
||||
ok := true
|
||||
for _, p := range parts {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(p))
|
||||
if err != nil {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
total = total*60 + n
|
||||
}
|
||||
if ok {
|
||||
return total
|
||||
}
|
||||
}
|
||||
// Prose best-effort: "<N> minutes/hours/seconds".
|
||||
lower := strings.ToLower(v)
|
||||
fields := strings.Fields(lower)
|
||||
for i, f := range fields {
|
||||
if n, err := strconv.Atoi(f); err == nil && i+1 < len(fields) {
|
||||
switch {
|
||||
case strings.HasPrefix(fields[i+1], "hour"):
|
||||
return n * 3600
|
||||
case strings.HasPrefix(fields[i+1], "minute"):
|
||||
return n * 60
|
||||
case strings.HasPrefix(fields[i+1], "second"):
|
||||
return n
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// parseFeedTime parses the RFC-822/1123 variants feeds actually emit.
|
||||
func parseFeedTime(v string) (t time.Time, ok bool) {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
layouts := []string{
|
||||
time.RFC1123Z,
|
||||
time.RFC1123,
|
||||
time.RFC822Z,
|
||||
time.RFC822,
|
||||
"Mon, 2 Jan 2006 15:04:05 -0700",
|
||||
"Mon, 2 Jan 2006 15:04:05 MST",
|
||||
"2 Jan 2006 15:04:05 -0700",
|
||||
"2 Jan 2006 15:04:05 MST",
|
||||
time.ANSIC,
|
||||
time.UnixDate,
|
||||
}
|
||||
for _, layout := range layouts {
|
||||
if t, err := time.Parse(layout, v); err == nil {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Namespace-aware decoding.
|
||||
//
|
||||
// WHY NOT encoding/xml: its field matching for UNQUALIFIED tags ignores
|
||||
// namespaces — a field tagged `xml:"title"` also matches
|
||||
// `<itunes:title>` (first field in declaration order wins, document
|
||||
// order clobbers), so the two collide unpredictably. RSS feeds stack
|
||||
// core RSS, iTunes and Podcasting-2.0 namespaces over the SAME local
|
||||
// names (title, author, description, guid), so a token-loop decoder
|
||||
// dispatching on (namespace, local) is the only deterministic shape.
|
||||
// It also tolerates BOTH podcast-namespace URIs the ecosystem uses and
|
||||
// arbitrary prefix spellings, because matching is on resolved
|
||||
// namespace, never on the prefix string.
|
||||
// ============================================================================
|
||||
|
||||
// decodePodcastFeed runs the token loop and fills the document struct.
|
||||
func decodePodcastFeed(data []byte) (*PodcastFeedXML, error) {
|
||||
dec := xml.NewDecoder(bytes.NewReader(data))
|
||||
dec.Strict = false // feeds in the wild carry stray entities; be liberal
|
||||
|
||||
feed := &PodcastFeedXML{}
|
||||
// Find the <rss> start element (tolerate comments/PIs/doctype).
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if se, ok := tok.(xml.StartElement); ok {
|
||||
if se.Name.Local == "rss" {
|
||||
break
|
||||
}
|
||||
// Some feeds wrap in nothing else; only rss is accepted.
|
||||
return nil, fmt.Errorf("unexpected root element %q", se.Name.Local)
|
||||
}
|
||||
}
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch t := tok.(type) {
|
||||
case xml.StartElement:
|
||||
if t.Name.Local == "channel" {
|
||||
if err := decodeChannel(dec, &feed.Channel); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := skipElement(dec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case xml.EndElement:
|
||||
// </rss> — done.
|
||||
return feed, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// decodeChannel walks the channel's children.
|
||||
func decodeChannel(dec *xml.Decoder, ch *PodcastChannelXML) error {
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch t := tok.(type) {
|
||||
case xml.StartElement:
|
||||
ns, local := t.Name.Space, t.Name.Local
|
||||
switch {
|
||||
case ns == "" && local == "title":
|
||||
ch.Title, err = elementText(dec)
|
||||
case ns == "" && local == "description":
|
||||
ch.Description, err = elementText(dec)
|
||||
case ns == "" && local == "language":
|
||||
ch.Language, err = elementText(dec)
|
||||
case ns == "" && local == "link":
|
||||
ch.Link, err = elementText(dec)
|
||||
case ns == "" && local == "lastBuildDate":
|
||||
ch.LastBuildDate, err = elementText(dec)
|
||||
case ns == itunesNS && local == "author":
|
||||
ch.ITunesAuthor, err = elementText(dec)
|
||||
case ns == itunesNS && local == "owner":
|
||||
err = decodeITunesOwner(dec, ch)
|
||||
case ns == itunesNS && local == "image":
|
||||
ch.ITunesImage.Href = attrValue(t.Attr, "href")
|
||||
err = skipElement(dec)
|
||||
case ns == itunesNS && local == "explicit":
|
||||
ch.ITunesExplicit, err = elementText(dec)
|
||||
case ns == itunesNS && local == "type":
|
||||
ch.ITunesType, err = elementText(dec)
|
||||
case ns == itunesNS && local == "new-feed-url":
|
||||
ch.ITunesNewFeedURL, err = elementText(dec)
|
||||
case ns == itunesNS && local == "complete":
|
||||
ch.ITunesComplete, err = elementText(dec)
|
||||
case ns == itunesNS && local == "category":
|
||||
ch.ITunesCategory.Text = attrValue(t.Attr, "text")
|
||||
err = decodeITunesCategory(dec, ch)
|
||||
case ns != "" && local == "guid":
|
||||
// podcast:guid (either declared URI); core RSS has no
|
||||
// channel-level guid, so any namespaced guid is it.
|
||||
ch.PodcastGUID, err = elementText(dec)
|
||||
case ns != "" && local == "medium":
|
||||
ch.PodcastMedium, err = elementText(dec)
|
||||
case ns == "" && local == "item":
|
||||
var item PodcastItemXML
|
||||
if err := decodeItem(dec, &item); err != nil {
|
||||
return err
|
||||
}
|
||||
ch.Items = append(ch.Items, item)
|
||||
continue
|
||||
default:
|
||||
err = skipElement(dec)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
case xml.EndElement:
|
||||
return nil // </channel>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func decodeITunesOwner(dec *xml.Decoder, ch *PodcastChannelXML) error {
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch t := tok.(type) {
|
||||
case xml.StartElement:
|
||||
if t.Name.Space == itunesNS && t.Name.Local == "name" {
|
||||
if ch.ITunesOwner.Name, err = elementText(dec); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if t.Name.Space == itunesNS && t.Name.Local == "email" {
|
||||
if ch.ITunesOwner.Email, err = elementText(dec); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := skipElement(dec); err != nil {
|
||||
return err
|
||||
}
|
||||
case xml.EndElement:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func decodeITunesCategory(dec *xml.Decoder, ch *PodcastChannelXML) error {
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch t := tok.(type) {
|
||||
case xml.StartElement:
|
||||
if t.Name.Space == itunesNS && t.Name.Local == "category" {
|
||||
ch.ITunesCategory.Subcategory.Text = attrValue(t.Attr, "text")
|
||||
}
|
||||
if err := skipElement(dec); err != nil {
|
||||
return err
|
||||
}
|
||||
case xml.EndElement:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// decodeItem walks one <item>.
|
||||
func decodeItem(dec *xml.Decoder, it *PodcastItemXML) error {
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch t := tok.(type) {
|
||||
case xml.StartElement:
|
||||
ns, local := t.Name.Space, t.Name.Local
|
||||
switch {
|
||||
case ns == "" && local == "guid":
|
||||
it.GUID, err = elementText(dec)
|
||||
case ns == "" && local == "title":
|
||||
it.Title, err = elementText(dec)
|
||||
case ns == "" && local == "description":
|
||||
it.Description, err = elementText(dec)
|
||||
case ns == "" && local == "pubDate":
|
||||
it.PubDate, err = elementText(dec)
|
||||
case ns == "" && local == "author":
|
||||
it.Author, err = elementText(dec)
|
||||
case ns == "" && local == "enclosure":
|
||||
it.Enclosure.URL = attrValue(t.Attr, "url")
|
||||
it.Enclosure.Type = attrValue(t.Attr, "type")
|
||||
if raw := attrValue(t.Attr, "length"); raw != "" {
|
||||
it.Enclosure.Length, _ = strconv.ParseInt(raw, 10, 64)
|
||||
}
|
||||
err = skipElement(dec)
|
||||
case ns == itunesNS && local == "title":
|
||||
it.ITunesTitle, err = elementText(dec)
|
||||
case ns == itunesNS && local == "subtitle":
|
||||
it.Subtitle, err = elementText(dec)
|
||||
case ns == itunesNS && local == "summary":
|
||||
it.ITunesSummary, err = elementText(dec)
|
||||
case ns == itunesNS && local == "author":
|
||||
it.ITunesAuthor, err = elementText(dec)
|
||||
case ns == itunesNS && local == "explicit":
|
||||
it.ITunesExplicit, err = elementText(dec)
|
||||
case ns == itunesNS && local == "duration":
|
||||
it.ITunesDuration, err = elementText(dec)
|
||||
case ns == itunesNS && local == "episode":
|
||||
it.ITunesEpisode, err = elementText(dec)
|
||||
case ns == itunesNS && local == "season":
|
||||
it.ITunesSeason, err = elementText(dec)
|
||||
case ns == itunesNS && local == "image":
|
||||
it.ITunesImage.Href = attrValue(t.Attr, "href")
|
||||
err = skipElement(dec)
|
||||
case ns == "http://purl.org/dc/elements/1.1/" && local == "creator":
|
||||
it.Creator, err = elementText(dec)
|
||||
case ns == "http://purl.org/rss/1.0/modules/content/" && local == "encoded":
|
||||
it.ContentEncoded, err = elementText(dec)
|
||||
case ns != "" && local == "transcript":
|
||||
it.Transcript.URL = attrValue(t.Attr, "url")
|
||||
err = skipElement(dec)
|
||||
case ns != "" && local == "chapters":
|
||||
it.Chapters.URL = attrValue(t.Attr, "url")
|
||||
err = skipElement(dec)
|
||||
case ns != "" && local == "person":
|
||||
href, role := attrValue(t.Attr, "href"), attrValue(t.Attr, "role")
|
||||
it.Persons = append(it.Persons, struct {
|
||||
Href string `xml:"href,attr"`
|
||||
Role string `xml:"role,attr"`
|
||||
}{Href: href, Role: role})
|
||||
err = skipElement(dec)
|
||||
default:
|
||||
err = skipElement(dec)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
case xml.EndElement:
|
||||
return nil // </item>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// elementText accumulates chardata until this element closes (leaf-ish
|
||||
// elements: CDATA sections come through as xml.CharData and are
|
||||
// included; child elements are skipped).
|
||||
func elementText(dec *xml.Decoder) (string, error) {
|
||||
var sb strings.Builder
|
||||
depth := 1
|
||||
for depth > 0 {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch t := tok.(type) {
|
||||
case xml.StartElement:
|
||||
depth++
|
||||
case xml.EndElement:
|
||||
depth--
|
||||
case xml.CharData:
|
||||
if depth == 1 {
|
||||
sb.Write(t)
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(sb.String()), nil
|
||||
}
|
||||
|
||||
// skipElement consumes an element we don't model (depth-safe).
|
||||
func skipElement(dec *xml.Decoder) error {
|
||||
depth := 1
|
||||
for depth > 0 {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch tok.(type) {
|
||||
case xml.StartElement:
|
||||
depth++
|
||||
case xml.EndElement:
|
||||
depth--
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// attrValue returns the named attribute (local-name match; the
|
||||
// attributes we read carry no namespace).
|
||||
func attrValue(attrs []xml.Attr, local string) string {
|
||||
for _, a := range attrs {
|
||||
if a.Name.Local == local {
|
||||
return strings.TrimSpace(a.Value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
// Unit tests for the podcast fetcher's pure logic (plan task 1.4/1.10):
|
||||
// RSS/iTunes/Podcasting-2.0 parsing, duration/pubDate spellings, slug/
|
||||
// path layout, and the SSRF guard (scheme + resolved-IP checks). No DB.
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// pgUUID alias keeps the helper signatures short.
|
||||
type pgUUID = pgtype.UUID
|
||||
|
||||
// Full-fixture feed covering the three namespaces and the fallback
|
||||
// chains (itunes:title vs title; description vs itunes:summary vs
|
||||
// content:encoded; itunes:author vs author vs dc:creator).
|
||||
const podcastFixtureXML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"
|
||||
xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"
|
||||
xmlns:content="http://purl.org/rss/1.0/modules/content/"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:podcast="https://podcastindex.org/namespace/1.0">
|
||||
<channel>
|
||||
<title>The Test Feed</title>
|
||||
<link>https://example.com/show</link>
|
||||
<language>en-us</language>
|
||||
<description><p>A feed about <b>testing</b>.</p></description>
|
||||
<itunes:author>Host Name</itunes:author>
|
||||
<itunes:owner>
|
||||
<itunes:name>Owner Name</itunes:name>
|
||||
<itunes:email>owner@example.com</itunes:email>
|
||||
</itunes:owner>
|
||||
<itunes:image href="https://cdn.example.com/art.jpg"/>
|
||||
<itunes:explicit>false</itunes:explicit>
|
||||
<itunes:type>serial</itunes:type>
|
||||
<itunes:category text="Fiction">
|
||||
<itunes:category text="Drama"/>
|
||||
</itunes:category>
|
||||
<podcast:guid>9b0241c2-xxxx-4xxx-yxxx-xxxxxxxxxxxx</podcast:guid>
|
||||
<podcast:medium>audiobook</podcast:medium>
|
||||
<lastBuildDate>Mon, 26 Oct 2026 08:00:00 +0000</lastBuildDate>
|
||||
<item>
|
||||
<guid isPermaLink="false">episode-guid-1</guid>
|
||||
<itunes:title>Episode One: The Beginning</itunes:title>
|
||||
<title>Ep 1</title>
|
||||
<itunes:subtitle>First chapter</itunes:subtitle>
|
||||
<description><![CDATA[<p>Chapter one text.</p>]]></description>
|
||||
<itunes:author>Narrator A</itunes:author>
|
||||
<itunes:duration>1:02:03</itunes:duration>
|
||||
<itunes:episode>1</itunes:episode>
|
||||
<itunes:season>2</itunes:season>
|
||||
<pubDate>Sun, 25 Oct 2026 12:00:00 +0000</pubDate>
|
||||
<enclosure url="https://cdn.example.com/ep1.mp3" length="52428800" type="audio/mpeg"/>
|
||||
<podcast:transcript url="https://cdn.example.com/ep1.json" type="application/json"/>
|
||||
<podcast:chapters url="https://cdn.example.com/ep1-chapters.json" type="application/json+chapters"/>
|
||||
<podcast:person href="Narrator A" role="host"/>
|
||||
<podcast:person href="Guest B" role="guest"/>
|
||||
</item>
|
||||
<item>
|
||||
<guid>episode-guid-2</guid>
|
||||
<title>Episode Two</title>
|
||||
<itunes:summary>Summary only description.</itunes:summary>
|
||||
<dc:creator>Writer C</dc:creator>
|
||||
<itunes:duration>5400</itunes:duration>
|
||||
<pubDate>Mon, 26 Oct 2026 12:00:00 GMT</pubDate>
|
||||
<enclosure url="https://cdn.example.com/ep2.m4a" length="1048576" type="audio/mp4"/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
func TestParsePodcastFeed(t *testing.T) {
|
||||
feed, err := ParsePodcastFeed([]byte(podcastFixtureXML))
|
||||
require.NoError(t, err)
|
||||
|
||||
ch := feed.Channel
|
||||
assert.Equal(t, "The Test Feed", ch.Title)
|
||||
assert.Equal(t, "en-us", ch.Language)
|
||||
assert.Equal(t, "Host Name", ch.ITunesAuthor)
|
||||
assert.Equal(t, "Owner Name", ch.ITunesOwner.Name)
|
||||
assert.Equal(t, "owner@example.com", ch.ITunesOwner.Email)
|
||||
assert.Equal(t, "https://cdn.example.com/art.jpg", ch.ITunesImage.Href)
|
||||
assert.Equal(t, "serial", ch.ITunesType)
|
||||
assert.Equal(t, "Fiction", ch.ITunesCategory.Text)
|
||||
assert.Equal(t, "Drama", ch.ITunesCategory.Subcategory.Text)
|
||||
assert.Equal(t, "9b0241c2-xxxx-4xxx-yxxx-xxxxxxxxxxxx", ch.PodcastGUID)
|
||||
assert.Equal(t, "audiobook", ch.PodcastMedium)
|
||||
assert.False(t, parseExplicit(ch.ITunesExplicit))
|
||||
|
||||
buildAt, ok := parseFeedTime(ch.LastBuildDate)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, 2026, buildAt.Year())
|
||||
|
||||
require.Len(t, ch.Items, 2)
|
||||
|
||||
// Item 1: itunes:title preferred, description chain, hosts/guests.
|
||||
it1 := &ch.Items[0]
|
||||
assert.Equal(t, "Episode One: The Beginning", it1.EpisodeTitle())
|
||||
assert.Equal(t, "First chapter", it1.Subtitle)
|
||||
assert.Equal(t, "<p>Chapter one text.</p>", it1.EpisodeDescription()) // raw HTML; stripping happens at storage time
|
||||
assert.Equal(t, "Narrator A", it1.EpisodeAuthor())
|
||||
assert.Equal(t, 3723, parseITunesDuration(it1.ITunesDuration)) // 1:02:03
|
||||
assert.Equal(t, 2, intMust(it1.ITunesSeason))
|
||||
assert.Equal(t, 1, intMust(it1.ITunesEpisode))
|
||||
assert.Equal(t, "https://cdn.example.com/ep1.mp3", it1.Enclosure.URL)
|
||||
assert.Equal(t, int64(52428800), it1.Enclosure.Length)
|
||||
assert.Equal(t, "audio/mpeg", it1.Enclosure.Type)
|
||||
assert.Equal(t, "https://cdn.example.com/ep1.json", it1.Transcript.URL)
|
||||
assert.Equal(t, "https://cdn.example.com/ep1-chapters.json", it1.Chapters.URL)
|
||||
hosts, guests := it1.HostsAndGuests()
|
||||
assert.Equal(t, []string{"Narrator A"}, hosts)
|
||||
assert.Equal(t, []string{"Guest B"}, guests)
|
||||
assert.Equal(t, "2026-10-25", mustDate(it1.PubDate))
|
||||
|
||||
// Item 2: fallback chains — plain title, itunes:summary, dc:creator.
|
||||
it2 := &ch.Items[1]
|
||||
assert.Equal(t, "Episode Two", it2.EpisodeTitle())
|
||||
assert.Equal(t, "Summary only description.", it2.EpisodeDescription())
|
||||
assert.Equal(t, "Writer C", it2.EpisodeAuthor())
|
||||
assert.Equal(t, 5400, parseITunesDuration(it2.ITunesDuration)) // seconds
|
||||
assert.Equal(t, "https://cdn.example.com/ep2.m4a", it2.Enclosure.URL)
|
||||
}
|
||||
|
||||
func intMust(s string) int {
|
||||
n, _ := parseSeasonOrEpisode(s)
|
||||
return n
|
||||
}
|
||||
|
||||
func parseSeasonOrEpisode(s string) (int, bool) {
|
||||
if s == "" {
|
||||
return 0, false
|
||||
}
|
||||
n := 0
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return 0, false
|
||||
}
|
||||
n = n*10 + int(r-'0')
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
func mustDate(pubDate string) string {
|
||||
if t, ok := parseFeedTime(pubDate); ok {
|
||||
return t.UTC().Format("2006-01-02")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestParseITunesDuration(t *testing.T) {
|
||||
assert.Equal(t, 0, parseITunesDuration(""))
|
||||
assert.Equal(t, 90, parseITunesDuration("90"))
|
||||
assert.Equal(t, 3723, parseITunesDuration("1:02:03"))
|
||||
assert.Equal(t, 123, parseITunesDuration("2:03"))
|
||||
assert.Equal(t, 3600, parseITunesDuration("1 hour"))
|
||||
assert.Equal(t, 120, parseITunesDuration("2 minutes"))
|
||||
assert.Equal(t, 0, parseITunesDuration("soonish"))
|
||||
}
|
||||
|
||||
func TestParseFeedTimeVariants(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
in string
|
||||
year int
|
||||
}{
|
||||
{"Mon, 26 Oct 2026 08:00:00 +0000", 2026},
|
||||
{"Mon, 26 Oct 2026 08:00:00 GMT", 2026},
|
||||
{"26 Oct 2026 08:00:00 +0000", 2026},
|
||||
} {
|
||||
tm, ok := parseFeedTime(tc.in)
|
||||
require.True(t, ok, "parse %q", tc.in)
|
||||
assert.Equal(t, tc.year, tm.Year())
|
||||
}
|
||||
_, ok := parseFeedTime("not a date")
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestEpisodeExplicitSpellings(t *testing.T) {
|
||||
assert.True(t, parseExplicit("true"))
|
||||
assert.True(t, parseExplicit("yes"))
|
||||
assert.True(t, parseExplicit("explicit"))
|
||||
assert.False(t, parseExplicit("false"))
|
||||
assert.False(t, parseExplicit("clean"))
|
||||
assert.False(t, parseExplicit(""))
|
||||
}
|
||||
|
||||
func TestSlugifyAndPaths(t *testing.T) {
|
||||
assert.Equal(t, "the-test-feed", slugify("The Test Feed!", 60))
|
||||
assert.Equal(t, "a-b-c", slugify(" A -- B // C ", 60))
|
||||
assert.Equal(t, "abcdefghij", slugify("ABCDEFGHIJ", 10))
|
||||
|
||||
slug := feedSlug("My Feed", testUUID("11111111-1111-1111-1111-111111111111"))
|
||||
assert.Equal(t, "my-feed-11111111", slug)
|
||||
|
||||
rel := episodeRelPath(slug, 42, "Episode: The Test", ".mp3")
|
||||
assert.Equal(t, "my-feed-11111111/0042-episode-the-test.mp3", rel)
|
||||
|
||||
assert.Equal(t, "my-feed-11111111/artwork.jpg", artworkRelPath(slug, ".jpg"))
|
||||
}
|
||||
|
||||
func TestEnclosureExtension(t *testing.T) {
|
||||
assert.Equal(t, ".mp3", enclosureExtension("https://x.test/a/b/ep.mp3?token=1", ""))
|
||||
assert.Equal(t, ".m4a", enclosureExtension("https://x.test/ep", "audio/mp4"))
|
||||
assert.Equal(t, ".flac", enclosureExtension("https://x.test/ep", "audio/flac"))
|
||||
assert.Equal(t, ".mp3", enclosureExtension("https://x.test/ep", "weird/type"))
|
||||
}
|
||||
|
||||
func TestStripHTML(t *testing.T) {
|
||||
assert.Equal(t, "hello world", stripHTML("<p>hello <b>world</b></p>"))
|
||||
assert.Equal(t, "a b", stripHTML("a\n\n b"))
|
||||
assert.Equal(t, "", stripHTML(""))
|
||||
}
|
||||
|
||||
func TestIsPublicIP(t *testing.T) {
|
||||
blocked := []string{
|
||||
"127.0.0.1", "10.0.0.5", "172.16.0.9", "192.168.1.1",
|
||||
"169.254.169.254", // cloud metadata endpoint
|
||||
"0.0.0.0", "::1", "fe80::1", "fd00::5", "::ffff:127.0.0.1",
|
||||
"::ffff:10.0.0.1", "224.0.0.1",
|
||||
}
|
||||
for _, s := range blocked {
|
||||
assert.False(t, isPublicIP(net.ParseIP(s)), "%s must be blocked", s)
|
||||
}
|
||||
allowed := []string{
|
||||
"93.184.216.34", "1.1.1.1", "2606:4700:4700::1111",
|
||||
}
|
||||
for _, s := range allowed {
|
||||
assert.True(t, isPublicIP(net.ParseIP(s)), "%s must be allowed", s)
|
||||
}
|
||||
assert.False(t, isPublicIP(nil))
|
||||
}
|
||||
|
||||
func TestGuardURLScheme(t *testing.T) {
|
||||
u, _ := url.Parse("http://example.com/feed.xml")
|
||||
assert.Error(t, guardURLScheme(u))
|
||||
u, _ = url.Parse("ftp://example.com/feed.xml")
|
||||
assert.Error(t, guardURLScheme(u))
|
||||
u, _ = url.Parse("https://example.com/feed.xml")
|
||||
assert.NoError(t, guardURLScheme(u))
|
||||
assert.Error(t, guardURLScheme(nil))
|
||||
}
|
||||
|
||||
// The REAL client (production factory) must refuse loopback targets —
|
||||
// this is the SSRF boundary; httptest servers live on 127.0.0.1.
|
||||
func TestGuardFetchBlocksLoopback(t *testing.T) {
|
||||
srv := newInsecureFeedServer(t, podcastFixtureXML)
|
||||
defer srv.Close()
|
||||
_, err := guardFetch(t.Context(), srv.URL, "", "", maxFeedSizeBytes)
|
||||
require.Error(t, err)
|
||||
assert.True(t, strings.Contains(err.Error(), "blocked") ||
|
||||
strings.Contains(err.Error(), "https"), "expected an SSRF/scheme rejection, got: %v", err)
|
||||
}
|
||||
|
||||
func TestGuardFetchRejectsPlainHTTP(t *testing.T) {
|
||||
// A public-looking http:// URL: scheme guard fires before any dial.
|
||||
_, err := guardFetch(t.Context(), "http://example.com/feed.xml", "", "", maxFeedSizeBytes)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "https")
|
||||
}
|
||||
|
||||
func TestClassifyFetchError(t *testing.T) {
|
||||
err := classifyFetchError(ErrBlockedPrivateAddress)
|
||||
assert.Contains(t, err.Error(), "blocked")
|
||||
someErr := &url.Error{Op: "Get", Err: errors.New("boom")}
|
||||
assert.Equal(t, someErr.Error(), classifyFetchError(someErr).Error())
|
||||
}
|
||||
|
||||
// testUUID builds a pgtype.UUID from a string (test helper).
|
||||
func testUUID(s string) pgUUID {
|
||||
u, _ := uuid.Parse(s)
|
||||
return pgUUID{Bytes: u, Valid: true}
|
||||
}
|
||||
|
||||
// newInsecureFeedServer serves body over httptest TLS (loopback) —
|
||||
// used to prove the production client REFUSES it.
|
||||
func newInsecureFeedServer(t *testing.T, body string) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/rss+xml")
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
// Apple Podcast launcher schemes normalize to the https feed they wrap.
|
||||
func TestNormalizeFeedURL(t *testing.T) {
|
||||
assert.Equal(t, "https://x.test/feed.xml", normalizeFeedURL("itpc://x.test/feed.xml"))
|
||||
assert.Equal(t, "https://x.test/feed.xml", normalizeFeedURL("podcasts://x.test/feed.xml"))
|
||||
assert.Equal(t, "https://x.test/feed.xml", normalizeFeedURL("feed://x.test/feed.xml"))
|
||||
assert.Equal(t, "https://x.test/feed.xml", normalizeFeedURL("ITPC://x.test/feed.xml"), "scheme match is case-insensitive")
|
||||
assert.Equal(t, "https://x.test/feed.xml", normalizeFeedURL("https://x.test/feed.xml"))
|
||||
assert.Equal(t, "http://x.test/feed.xml", normalizeFeedURL("http://x.test/feed.xml"), "http stays — the guard rejects it downstream")
|
||||
assert.Equal(t, "", normalizeFeedURL(" "))
|
||||
}
|
||||
@@ -0,0 +1,845 @@
|
||||
// Podcast fetcher (plan podcast-integration.md §7): fetch → parse →
|
||||
// upsert-without-delete. The fetcher is the SOLE writer of feed/episode
|
||||
// metadata (D9) — user-facing podcast writes are limited to
|
||||
// podcast_subscriptions rows and the reader_settings order override.
|
||||
//
|
||||
// RLS identity (invariant 12): fetches run detached from any request,
|
||||
// so every governed statement runs under the FIRST-ADMIN identity
|
||||
// (scanner fallback precedent). With the D20 admin escape that identity
|
||||
// sees and updates every feed/episode row. NEVER a bypass flag, never
|
||||
// the bootstrap superuser.
|
||||
//
|
||||
// Upsert semantics: episodes are created/updated METADATA-ONLY, keyed
|
||||
// on (source_feed_id, episode_guid); rows for items that vanish from
|
||||
// the XML are NEVER deleted (history accumulates — D14 search covers
|
||||
// gone episodes). Download state is owned by the download worker; the
|
||||
// upsert cannot clobber it (guarded in the query itself).
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/rls"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
// PodcastError is a status-carrying service error: handlers map it to
|
||||
// the HTTP response directly (services stay echo-free).
|
||||
type PodcastError struct {
|
||||
Status int
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *PodcastError) Error() string { return e.Message }
|
||||
|
||||
func errNotFound(msg string) error { return &PodcastError{Status: 404, Message: msg} }
|
||||
func errForbidden(msg string) error { return &PodcastError{Status: 403, Message: msg} }
|
||||
func errBadRequest(msg string) error { return &PodcastError{Status: 400, Message: msg} }
|
||||
|
||||
// PodcastService carries the fetcher/worker dependencies. One instance
|
||||
// per server (handlers + poller share it so the per-feed locks dedupe).
|
||||
type PodcastService struct {
|
||||
db *database.Queries
|
||||
|
||||
adminMu sync.Mutex
|
||||
adminID pgtype.UUID
|
||||
adminOnce bool
|
||||
|
||||
// Per-feed mutexes: a poll and a download for the same feed must
|
||||
// not race (e.g. both writing the episode row). Keyed by feed UUID.
|
||||
feedLocksMu sync.Mutex
|
||||
feedLocks map[string]*sync.Mutex
|
||||
}
|
||||
|
||||
func NewPodcastService(db *database.Queries) *PodcastService {
|
||||
return &PodcastService{
|
||||
db: db,
|
||||
feedLocks: make(map[string]*sync.Mutex),
|
||||
}
|
||||
}
|
||||
|
||||
// feedLock returns the per-feed mutex (creates on first use).
|
||||
func (s *PodcastService) feedLock(feedID string) *sync.Mutex {
|
||||
s.feedLocksMu.Lock()
|
||||
defer s.feedLocksMu.Unlock()
|
||||
if m, ok := s.feedLocks[feedID]; ok {
|
||||
return m
|
||||
}
|
||||
m := &sync.Mutex{}
|
||||
s.feedLocks[feedID] = m
|
||||
return m
|
||||
}
|
||||
|
||||
// adminIdentity resolves the acting identity for detached podcast work:
|
||||
// the first admin, resolved once per service lifetime. Errors surface
|
||||
// to the caller (no admin → no fetch — same contract as the scanner).
|
||||
func (s *PodcastService) adminIdentity() (pgtype.UUID, error) {
|
||||
s.adminMu.Lock()
|
||||
defer s.adminMu.Unlock()
|
||||
if !s.adminOnce && !s.adminID.Valid {
|
||||
id, err := s.db.GetFirstAdmin(context.Background())
|
||||
if err != nil {
|
||||
return pgtype.UUID{}, fmt.Errorf("podcasts: no admin identity available for detached work: %w", err)
|
||||
}
|
||||
s.adminID = id
|
||||
s.adminOnce = true
|
||||
}
|
||||
if !s.adminID.Valid {
|
||||
return pgtype.UUID{}, fmt.Errorf("podcasts: no admin identity available for detached work")
|
||||
}
|
||||
return s.adminID, nil
|
||||
}
|
||||
|
||||
// adminCtx wraps ctx with the first-admin identity (invariant 12).
|
||||
func (s *PodcastService) adminCtx(ctx context.Context) (context.Context, error) {
|
||||
id, err := s.adminIdentity()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rls.WithUser(ctx, id), nil
|
||||
}
|
||||
|
||||
// AddFeedResult carries what the add-feed flow did (the handler turns
|
||||
// it into the response / serial feed hint).
|
||||
type AddFeedResult struct {
|
||||
Feed *database.PodcastFeeds
|
||||
// Created is true when this call created the feed row; false when
|
||||
// an existing row was reused (URL dedupe or guid migration match).
|
||||
Created bool
|
||||
// Subscribed is true when the requesting user's subscription was
|
||||
// created by this call (false = already subscribed).
|
||||
Subscribed bool
|
||||
// EpisodeCount is the number of episode rows the fetch upserted.
|
||||
EpisodeCount int
|
||||
// FetchError, when set, means the feed row exists but the fetch
|
||||
// failed — the subscription stands, the error surfaces to the user.
|
||||
FetchError string
|
||||
}
|
||||
|
||||
// FeedError returns the fetch failure text ("" when the fetch was fine
|
||||
// or there is no feed row).
|
||||
func (r *AddFeedResult) FeedError() string {
|
||||
return r.FetchError
|
||||
}
|
||||
|
||||
// AddFeed implements POST /api/podcasts/feeds (plan §8): fetch+parse the
|
||||
// user-supplied URL, create-or-reuse the feed row, upsert metadata-only
|
||||
// episodes, subscribe the creator. Authorization on explicit arguments
|
||||
// (D20 scoping model): the target library must be visible to the actor
|
||||
// and, for non-admins, allow member feeds.
|
||||
func (s *PodcastService) AddFeed(ctx context.Context, userID pgtype.UUID, isAdmin bool, libraryID pgtype.UUID, rawFeedURL string) (*AddFeedResult, error) {
|
||||
// ---- explicit-argument authorization (the ONLY Go-level gate) ----
|
||||
lib, err := s.db.GetLibrary(ctx, libraryID)
|
||||
if err != nil {
|
||||
// Invisible-or-nonexistent: 404 semantics (existence leak).
|
||||
return nil, errNotFound("library not found")
|
||||
}
|
||||
if !isAdmin {
|
||||
if lib.TypeName != LibraryTypePodcasts {
|
||||
return nil, errNotFound("library not found")
|
||||
}
|
||||
if !lib.AllowMemberFeeds {
|
||||
return nil, errForbidden("this library does not allow members to add feeds")
|
||||
}
|
||||
}
|
||||
|
||||
feedURL := normalizeFeedURL(rawFeedURL)
|
||||
if feedURL == "" {
|
||||
return nil, errBadRequest("feed URL is required")
|
||||
}
|
||||
|
||||
// The fetch/upsert runs under the first-admin identity (detached-
|
||||
// equivalent); the request context never carries special powers into
|
||||
// feed writes.
|
||||
actx, err := s.adminCtx(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res := &AddFeedResult{}
|
||||
|
||||
// ---- fetch + parse (SSRF-guarded; plan §7) ----
|
||||
fetched, err := guardFetch(ctx, feedURL, "", "", maxFeedSizeBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not fetch feed: %w", classifyFetchError(err))
|
||||
}
|
||||
parsed, err := ParsePodcastFeed(fetched.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not parse feed: %w", err)
|
||||
}
|
||||
|
||||
// Follow itunes:new-feed-url once at add time so the row is born
|
||||
// pointing at the publisher's current home (migration semantics).
|
||||
finalURL := fetched.FinalURL
|
||||
if neu := strings.TrimSpace(parsed.Channel.ITunesNewFeedURL); neu != "" && neu != finalURL {
|
||||
if retried, err := guardFetch(ctx, neu, "", "", maxFeedSizeBytes); err == nil {
|
||||
if reparsed, err := ParsePodcastFeed(retried.Body); err == nil {
|
||||
parsed = reparsed
|
||||
finalURL = retried.FinalURL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
title := parsed.Channel.Title
|
||||
if title == "" {
|
||||
title = "Untitled feed"
|
||||
}
|
||||
|
||||
// ---- create-or-reuse (UNIQUE(library_id, feed_url) + guid dedupe) ----
|
||||
feed, err := s.db.CreatePodcastFeed(actx, database.CreatePodcastFeedParams{
|
||||
LibraryID: libraryID,
|
||||
FeedUrl: finalURL,
|
||||
Title: truncate(title, 500),
|
||||
CreatedByAdminID: pgtype.UUID{}, // fetcher owns feed metadata; creator attribution is not user metadata
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not create feed: %w", err)
|
||||
}
|
||||
res.Feed = &feed
|
||||
res.Created = true
|
||||
|
||||
// Guid migration dedupe: a row with the same canonical podcast_guid
|
||||
// under a different URL reuses THAT row (do not fork the feed).
|
||||
if guid := strings.TrimSpace(parsed.Channel.PodcastGUID); guid != "" {
|
||||
if feed.PodcastGuid.String == "" || feed.PodcastGuid.String != guid {
|
||||
if existing, err := s.db.GetPodcastFeedByGuid(actx, database.GetPodcastFeedByGuidParams{
|
||||
LibraryID: feed.LibraryID,
|
||||
PodcastGuid: pgtypeText(guid),
|
||||
}); err == nil && existing.ID != feed.ID {
|
||||
// Attach the subscriber to the canonical row; drop the
|
||||
// freshly created stub (nothing subscribed to it yet).
|
||||
_, _ = s.db.DeletePodcastFeed(actx, feed.ID)
|
||||
feed = existing
|
||||
res.Feed = &existing
|
||||
// Re-point the canonical row at the fetched URL if it moved.
|
||||
if feedUrlForComparison(existing.FeedUrl) != feedUrlForComparison(finalURL) {
|
||||
_ = s.db.UpdatePodcastFeedRedirect(actx, database.UpdatePodcastFeedRedirectParams{
|
||||
ID: existing.ID,
|
||||
FeedUrl: finalURL,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := s.db.SubscribePodcastFeed(actx, database.SubscribePodcastFeedParams{
|
||||
UserID: userID,
|
||||
FeedID: feed.ID,
|
||||
}); err == nil {
|
||||
res.Subscribed = true
|
||||
}
|
||||
|
||||
// ---- metadata + episodes ----
|
||||
if err := s.applyFetch(actx, &feed, parsed, fetched); err != nil {
|
||||
// Feed exists + subscribed, but the metadata pass failed: not
|
||||
// fatal for the request; the poller reconciles later.
|
||||
res.FetchError = err.Error()
|
||||
return res, nil
|
||||
}
|
||||
|
||||
updated, err := s.db.GetPodcastFeed(actx, feed.ID)
|
||||
if err == nil {
|
||||
res.Feed = &updated
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// RefreshFeed implements POST /api/podcasts/feeds/:id/refresh: a manual
|
||||
// poll. The per-feed lock serializes against poller ticks and downloads.
|
||||
func (s *PodcastService) RefreshFeed(ctx context.Context, feedID pgtype.UUID) error {
|
||||
actx, err := s.adminCtx(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
feed, err := s.db.GetPodcastFeed(actx, feedID)
|
||||
if err != nil {
|
||||
return errNotFound("feed not found")
|
||||
}
|
||||
return s.PollFeed(actx, &feed)
|
||||
}
|
||||
|
||||
// PollFeed fetches + reconciles ONE feed (caller supplies the acting
|
||||
// identity in ctx). Safe to call concurrently per feed — the per-feed
|
||||
// lock serializes poll/download.
|
||||
func (s *PodcastService) PollFeed(ctx context.Context, feed *database.PodcastFeeds) error {
|
||||
lock := s.feedLock(feed.ID.String())
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
// Complete feeds back off: they are fetched only on explicit
|
||||
// refresh (the poller skips them entirely — this path is manual).
|
||||
fetched, err := guardFetch(ctx, feed.FeedUrl, feed.Etag.String, feed.LastModified.String, maxFeedSizeBytes)
|
||||
if err != nil {
|
||||
if feed.LastFetchedAt.Valid {
|
||||
// A previously-good feed with a transient failure: keep the
|
||||
// old state; the poller retries next tick.
|
||||
return fmt.Errorf("fetch %s: %w", feed.Title, classifyFetchError(err))
|
||||
}
|
||||
return fmt.Errorf("fetch %s: %w", feed.Title, classifyFetchError(err))
|
||||
}
|
||||
if fetched.NotModified {
|
||||
_, _ = s.db.UpdatePodcastFeedFetch(ctx, database.UpdatePodcastFeedFetchParams{
|
||||
ID: feed.ID,
|
||||
Etag: pgtypeText(feed.Etag.String),
|
||||
LastModified: pgtypeText(feed.LastModified.String),
|
||||
LastBuildDate: feed.LastBuildDate,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
parsed, err := ParsePodcastFeed(fetched.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse %s: %w", feed.Title, err)
|
||||
}
|
||||
|
||||
// itunes:new-feed-url: the publisher MOVED the feed. Rewrite the
|
||||
// stored URL (row, subscriptions and episodes stay put) and refetch
|
||||
// the new location once so this poll applies its content.
|
||||
if neu := strings.TrimSpace(parsed.Channel.ITunesNewFeedURL); neu != "" && neu != feed.FeedUrl {
|
||||
_ = s.db.UpdatePodcastFeedRedirect(ctx, database.UpdatePodcastFeedRedirectParams{ID: feed.ID, FeedUrl: neu})
|
||||
if retried, err := guardFetch(ctx, neu, "", "", maxFeedSizeBytes); err == nil {
|
||||
if reparsed, err := ParsePodcastFeed(retried.Body); err == nil {
|
||||
parsed = reparsed
|
||||
fetched = retried
|
||||
}
|
||||
}
|
||||
} else if fetched.FinalURL != "" && feedUrlForComparison(fetched.FinalURL) != feedUrlForComparison(feed.FeedUrl) {
|
||||
// A permanent HTTP redirect chain moved the feed; persist the
|
||||
// final URL so conditional validators stop bouncing.
|
||||
_ = s.db.UpdatePodcastFeedRedirect(ctx, database.UpdatePodcastFeedRedirectParams{ID: feed.ID, FeedUrl: fetched.FinalURL})
|
||||
}
|
||||
|
||||
if err := s.applyFetch(ctx, feed, parsed, fetched); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyFetch writes channel metadata, upserts episodes metadata-only,
|
||||
// touches validators, and downloads artwork. Write-after-success rule
|
||||
// (invariant 7): failures here never clear existing rows.
|
||||
func (s *PodcastService) applyFetch(ctx context.Context, feed *database.PodcastFeeds, parsed *PodcastFeedXML, fetched *fetchResult) error {
|
||||
// --- channel metadata (fetcher-owned) ---
|
||||
title := parsed.Channel.Title
|
||||
if title == "" {
|
||||
title = feed.Title // never blank out a title on a broken doc
|
||||
}
|
||||
desc := stripHTML(parsed.Channel.Description)
|
||||
|
||||
host := parsed.Channel.ITunesAuthor
|
||||
publisher := parsed.Channel.ITunesOwner.Name
|
||||
|
||||
tagSet := map[string]bool{}
|
||||
if cat := strings.TrimSpace(parsed.Channel.ITunesCategory.Text); cat != "" {
|
||||
tagSet[cat] = true
|
||||
}
|
||||
if sub := strings.TrimSpace(parsed.Channel.ITunesCategory.Subcategory.Text); sub != "" {
|
||||
tagSet[sub] = true
|
||||
}
|
||||
tags := sortedStringSet(tagSet)
|
||||
|
||||
feedType := strings.TrimSpace(parsed.Channel.ITunesType)
|
||||
if feedType != "serial" && feedType != "episodic" {
|
||||
feedType = "" // unknown → column stays NULL
|
||||
}
|
||||
medium := strings.TrimSpace(parsed.Channel.PodcastMedium)
|
||||
|
||||
if _, err := s.db.UpdatePodcastFeedMetadata(ctx, database.UpdatePodcastFeedMetadataParams{
|
||||
ID: feed.ID,
|
||||
Title: truncate(title, 500),
|
||||
Description: pgtypeText(desc),
|
||||
Host: pgtypeText(truncate(host, 500)),
|
||||
Publisher: pgtypeText(truncate(publisher, 500)),
|
||||
OwnerName: pgtypeText(truncate(parsed.Channel.ITunesOwner.Name, 500)),
|
||||
OwnerEmail: pgtypeText(truncate(parsed.Channel.ITunesOwner.Email, 500)),
|
||||
Language: pgtypeText(truncate(parsed.Channel.Language, 50)),
|
||||
Tags: tags,
|
||||
Explicit: parseExplicit(parsed.Channel.ITunesExplicit),
|
||||
FeedType: pgtypeText(feedType),
|
||||
Medium: pgtypeText(truncate(medium, 20)),
|
||||
IsComplete: parseExplicit(parsed.Channel.ITunesComplete),
|
||||
PodcastGuid: pgtypeText(strings.TrimSpace(parsed.Channel.PodcastGUID)),
|
||||
}); err != nil {
|
||||
return fmt.Errorf("feed metadata update: %w", err)
|
||||
}
|
||||
// itunes:complete flips the poller's back-off flag (complete feeds
|
||||
// poll rarely); explicit refresh still works.
|
||||
|
||||
// --- episodes (metadata-only upsert, positions from THIS parse) ---
|
||||
root, err := s.downloadRootAbs(ctx, feed)
|
||||
if err != nil {
|
||||
return fmt.Errorf("download root: %w", err)
|
||||
}
|
||||
feedSlug := feedSlug(title, feed.ID)
|
||||
|
||||
count := 0
|
||||
for i := range parsed.Channel.Items {
|
||||
if err := s.upsertEpisode(ctx, feed, feedSlug, &parsed.Channel.Items[i], i+1); err != nil {
|
||||
// One bad item must not sink the batch.
|
||||
log.Printf("[PODCAST] episode upsert failed for feed %s item %d: %v", feed.ID, i+1, err)
|
||||
continue
|
||||
}
|
||||
count++
|
||||
}
|
||||
|
||||
// --- validators + fetch timestamp ---
|
||||
var lastBuildDate pgtype.Timestamptz
|
||||
if t, ok := parseFeedTime(parsed.Channel.LastBuildDate); ok {
|
||||
lastBuildDate = pgtypeTimestamptz(t)
|
||||
}
|
||||
if _, err := s.db.UpdatePodcastFeedFetch(ctx, database.UpdatePodcastFeedFetchParams{
|
||||
ID: feed.ID,
|
||||
Etag: pgtypeText(fetched.ETag),
|
||||
LastModified: pgtypeText(fetched.LastModified),
|
||||
LastBuildDate: lastBuildDate,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("fetch touch: %w", err)
|
||||
}
|
||||
|
||||
// --- artwork (best-effort; feed renders fine without it) ---
|
||||
s.refreshArtwork(ctx, feed, root, parsed)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// upsertEpisode maps one XML item onto a media_items row (plan §5 field
|
||||
// inventory). Positions are 1-based, renumbered per fetch; file_path is
|
||||
// the PLANNED final layout — the upsert only applies it to rows that
|
||||
// are not yet downloaded (query-side guard); the abs root resolves at
|
||||
// download time via the library folders.
|
||||
func (s *PodcastService) upsertEpisode(ctx context.Context, feed *database.PodcastFeeds, feedSlug string, item *PodcastItemXML, position int) error {
|
||||
title := item.EpisodeTitle()
|
||||
if title == "" {
|
||||
title = fmt.Sprintf("Episode %d", position)
|
||||
}
|
||||
guid := strings.TrimSpace(item.GUID)
|
||||
if guid == "" {
|
||||
// GUID-less feeds exist; fall back to a stable identity from the
|
||||
// enclosure URL (the next-best immutable per-episode key).
|
||||
if item.Enclosure.URL != "" {
|
||||
guid = "enc:" + item.Enclosure.URL
|
||||
} else {
|
||||
return fmt.Errorf("item %q has neither guid nor enclosure; skipping", title)
|
||||
}
|
||||
}
|
||||
|
||||
ext := enclosureExtension(item.Enclosure.URL, item.Enclosure.Type)
|
||||
relPath := episodeRelPath(feedSlug, position, title, ext)
|
||||
|
||||
var published pgtype.Date
|
||||
if t, ok := parseFeedTime(item.PubDate); ok {
|
||||
published = pgtypeDate(t)
|
||||
}
|
||||
|
||||
authors := []string{}
|
||||
if a := item.EpisodeAuthor(); a != "" {
|
||||
authors = append(authors, truncate(a, 255))
|
||||
}
|
||||
hosts, guests := item.HostsAndGuests()
|
||||
|
||||
_, err := s.db.UpsertPodcastEpisode(ctx, database.UpsertPodcastEpisodeParams{
|
||||
LibraryID: feed.LibraryID,
|
||||
Title: truncate(title, 255),
|
||||
Subtitle: pgtypeText(truncate(item.Subtitle, 500)),
|
||||
Description: pgtypeText(stripHTML(item.EpisodeDescription())),
|
||||
Authors: authors,
|
||||
AuthorsSearch: lowerSearchSlice(authors),
|
||||
FilePath: relPath,
|
||||
FileSize: pgtypeInt8(item.Enclosure.Length),
|
||||
MimeType: pgtypeText(truncate(item.Enclosure.Type, 100)),
|
||||
CoverImagePath: pgtypeText(artworkRelPath(feedSlug, artworkExtFromURL(item.ITunesImage.Href))),
|
||||
Explicit: item.EpisodeExplicit(),
|
||||
DatePublished: published,
|
||||
Language: pgtypeText(truncate(feed.Language.String, 10)),
|
||||
Tags: feed.Tags,
|
||||
Genre: pgtypeText(strings.Join(feed.Tags, ", ")),
|
||||
SourceFeedID: feed.ID,
|
||||
EpisodeGuid: pgtypeText(truncate(guid, 500)),
|
||||
FeedPosition: pgtypeInt4(int32(position)),
|
||||
SeasonNumber: pgtypeInt4FromStr(item.ITunesSeason),
|
||||
EpisodeNumber: pgtypeInt4FromStr(item.ITunesEpisode),
|
||||
EnclosureUrl: pgtypeText(item.Enclosure.URL),
|
||||
DeclaredDurationSeconds: pgtypeInt4(int32(parseITunesDuration(item.ITunesDuration))),
|
||||
TranscriptUrl: pgtypeText(item.Transcript.URL),
|
||||
ChaptersUrl: pgtypeText(item.Chapters.URL),
|
||||
Hosts: truncateSlice(hosts, 255),
|
||||
Guests: truncateSlice(guests, 255),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// refreshArtwork downloads the channel image once per changed URL.
|
||||
// Artwork is feed-level: episodes point at the same relative path.
|
||||
func (s *PodcastService) refreshArtwork(ctx context.Context, feed *database.PodcastFeeds, downloadRoot string, parsed *PodcastFeedXML) {
|
||||
imgURL := strings.TrimSpace(parsed.Channel.ITunesImage.Href)
|
||||
if imgURL == "" {
|
||||
return
|
||||
}
|
||||
target := filepath.Join(downloadRoot, artworkRelPath(feedSlug(feed.Title, feed.ID), artworkExtFromURL(imgURL)))
|
||||
if feed.ArtworkPath.Valid {
|
||||
if existing := filepath.Join(downloadRoot, feed.ArtworkPath.String); existing == target {
|
||||
if _, err := os.Stat(existing); err == nil {
|
||||
return // unchanged
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return
|
||||
}
|
||||
res, err := guardFetch(ctx, imgURL, "", "", maxArtworkSizeBytes)
|
||||
if err != nil || len(res.Body) == 0 {
|
||||
log.Printf("[PODCAST] artwork fetch failed for feed %s: %v", feed.ID, err)
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(target, res.Body, 0o644); err != nil {
|
||||
log.Printf("[PODCAST] artwork write failed for feed %s: %v", feed.ID, err)
|
||||
return
|
||||
}
|
||||
rel, err := filepath.Rel(downloadRoot, target)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = s.db.UpdatePodcastFeedArtwork(ctx, database.UpdatePodcastFeedArtworkParams{
|
||||
ID: feed.ID,
|
||||
ArtworkPath: pgtypeText(rel),
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteFeedFiles removes a feed's episode files + artwork from the
|
||||
// download root (admin feed-removal path; ?files=false skips it).
|
||||
// Best-effort per file — the row cascade proceeds regardless.
|
||||
func (s *PodcastService) DeleteFeedFiles(ctx context.Context, feed *database.PodcastFeeds, episodes []database.MediaItems) {
|
||||
root, err := s.downloadRootAbs(ctx, feed)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, ep := range episodes {
|
||||
if ep.FilePath != "" {
|
||||
_ = os.Remove(filepath.Join(root, ep.FilePath))
|
||||
// Embedded-cover sibling (extractEmbeddedAudioCover convention).
|
||||
base := filepath.Join(root, strings.TrimSuffix(ep.FilePath, filepath.Ext(ep.FilePath)))
|
||||
_ = os.Remove(base + ".cover.jpg")
|
||||
}
|
||||
}
|
||||
if feed.ArtworkPath.Valid {
|
||||
_ = os.Remove(filepath.Join(root, feed.ArtworkPath.String))
|
||||
}
|
||||
// The feed directory itself: remove when empty (other feeds never
|
||||
// share a slug — the feed-id suffix guarantees that).
|
||||
_ = os.Remove(filepath.Join(root, feedSlug(feed.Title, feed.ID)))
|
||||
}
|
||||
|
||||
// AdminDeleteEpisodeFile (§0 item 8): ONE downloaded episode's file is
|
||||
// removed from disk and its row reset to pending (facts cleared,
|
||||
// file_path kept — fetch-maintained layout). Runs under the first-admin
|
||||
// identity (caller supplies the admin ctx — invariant 12). The poll
|
||||
// upsert never touches download_state, so the file stays deleted.
|
||||
func (s *PodcastService) AdminDeleteEpisodeFile(ctx context.Context, episodeID pgtype.UUID) error {
|
||||
ep, err := s.db.GetPodcastEpisode(ctx, episodeID)
|
||||
if err != nil {
|
||||
return errNotFound("episode not found")
|
||||
}
|
||||
if !ep.SourceFeedID.Valid {
|
||||
return fmt.Errorf("episode has no feed reference")
|
||||
}
|
||||
if ep.DownloadState != "downloaded" {
|
||||
return nil // nothing on disk; idempotent no-op
|
||||
}
|
||||
feed, err := s.db.GetPodcastFeed(ctx, ep.SourceFeedID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("feed lookup: %w", errNotFound("feed not found"))
|
||||
}
|
||||
root, err := s.downloadRootAbs(ctx, &feed)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ep.FilePath != "" {
|
||||
_ = os.Remove(filepath.Join(root, ep.FilePath))
|
||||
base := filepath.Join(root, strings.TrimSuffix(ep.FilePath, filepath.Ext(ep.FilePath)))
|
||||
_ = os.Remove(base + ".cover.jpg")
|
||||
}
|
||||
return s.db.ResetPodcastEpisodeFileDeleted(ctx, episodeID)
|
||||
}
|
||||
|
||||
// AdminDeleteFeedFiles (§0 item 8): every DOWNLOADED episode of the
|
||||
// feed loses its disk file; rows reset to pending. Returns how many
|
||||
// rows were reset. Poll upsert never touches download_state → a
|
||||
// refresh re-lists metadata but does NOT re-materialize files.
|
||||
func (s *PodcastService) AdminDeleteFeedFiles(ctx context.Context, feedID pgtype.UUID) (int64, error) {
|
||||
feed, err := s.db.GetPodcastFeed(ctx, feedID)
|
||||
if err != nil {
|
||||
return 0, errNotFound("feed not found")
|
||||
}
|
||||
eps, err := s.db.ListPodcastEpisodes(ctx, feedID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
root, err := s.downloadRootAbs(ctx, &feed)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, ep := range eps {
|
||||
if ep.DownloadState != "downloaded" || ep.FilePath == "" {
|
||||
continue
|
||||
}
|
||||
_ = os.Remove(filepath.Join(root, ep.FilePath))
|
||||
base := filepath.Join(root, strings.TrimSuffix(ep.FilePath, filepath.Ext(ep.FilePath)))
|
||||
_ = os.Remove(base + ".cover.jpg")
|
||||
}
|
||||
return s.db.ResetPodcastFeedFilesDeleted(ctx, feedID)
|
||||
}
|
||||
|
||||
// downloadRootAbs resolves the podcasts library's single download root
|
||||
// (purpose='podcast') as an absolute filesystem path.
|
||||
func (s *PodcastService) downloadRootAbs(ctx context.Context, feed *database.PodcastFeeds) (string, error) {
|
||||
folders, err := s.db.GetPodcastLibraryFolder(ctx, feed.LibraryID)
|
||||
if err != nil || len(folders) == 0 {
|
||||
return "", fmt.Errorf("library %s has no download root registered", feed.LibraryID)
|
||||
}
|
||||
return folders[0].FolderPath, nil
|
||||
}
|
||||
|
||||
// episodeRelPath is the pinned episode layout (plan §6):
|
||||
// <feed-slug>/<feed_position:04d>-<slug>.<ext> — relative to the
|
||||
// download root. getFullFilePath/ServeFile/Range streaming resolve it
|
||||
// unchanged.
|
||||
func episodeRelPath(feedSlug string, position int, title, ext string) string {
|
||||
return fmt.Sprintf("%s/%04d-%s%s", feedSlug, position, slugify(title, 80), ext)
|
||||
}
|
||||
|
||||
// artworkRelPath is the feed-level artwork location (feed-level, so
|
||||
// every episode row can point at the same file).
|
||||
func artworkRelPath(feedSlug, ext string) string {
|
||||
return fmt.Sprintf("%s/artwork%s", feedSlug, ext)
|
||||
}
|
||||
|
||||
// feedSlug derives the stable per-feed directory name: slugged title +
|
||||
// short feed-id suffix so two identically-titled feeds never collide.
|
||||
func feedSlug(title string, feedID pgtype.UUID) string {
|
||||
base := slugify(title, 60)
|
||||
suffix := "feed"
|
||||
if feedID.Valid {
|
||||
s := feedID.String()
|
||||
if len(s) >= 8 {
|
||||
suffix = s[:8]
|
||||
}
|
||||
}
|
||||
if base == "" {
|
||||
base = "feed"
|
||||
}
|
||||
return base + "-" + suffix
|
||||
}
|
||||
|
||||
var slugStrip = regexp.MustCompile(`[^a-z0-9]+`)
|
||||
|
||||
// slugify lowercases, strips non-alphanumerics to dashes, and clamps
|
||||
// the length (file-name-safe; unicode letters drop — file layout is
|
||||
// an internal key, never display text).
|
||||
func slugify(s string, maxLen int) string {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
s = slugStrip.ReplaceAllString(s, "-")
|
||||
s = strings.Trim(s, "-")
|
||||
if len(s) > maxLen {
|
||||
s = strings.TrimRight(s[:maxLen], "-")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// enclosureExtension picks a file extension from the URL, then the
|
||||
// MIME type, then a safe default.
|
||||
func enclosureExtension(rawURL, mimeType string) string {
|
||||
if u, err := urlParse(rawURL); err == nil {
|
||||
if ext := strings.ToLower(filepath.Ext(u.Path)); ext == ".mp3" || ext == ".m4a" || ext == ".m4b" ||
|
||||
ext == ".flac" || ext == ".ogg" || ext == ".oga" || ext == ".opus" || ext == ".wav" || ext == ".aac" {
|
||||
return ext
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case strings.Contains(mimeType, "mp4") || strings.Contains(mimeType, "m4a"):
|
||||
return ".m4a"
|
||||
case strings.Contains(mimeType, "mpeg") || strings.Contains(mimeType, "mp3"):
|
||||
return ".mp3"
|
||||
case strings.Contains(mimeType, "flac"):
|
||||
return ".flac"
|
||||
case strings.Contains(mimeType, "ogg"):
|
||||
return ".ogg"
|
||||
case strings.Contains(mimeType, "wav"):
|
||||
return ".wav"
|
||||
}
|
||||
return ".mp3"
|
||||
}
|
||||
|
||||
// artworkExtFromURL keeps the source extension for artwork (jpg/png/webp).
|
||||
func artworkExtFromURL(rawURL string) string {
|
||||
if u, err := urlParse(rawURL); err == nil {
|
||||
switch strings.ToLower(filepath.Ext(u.Path)) {
|
||||
case ".png":
|
||||
return ".png"
|
||||
case ".webp":
|
||||
return ".webp"
|
||||
case ".gif":
|
||||
return ".gif"
|
||||
}
|
||||
}
|
||||
return ".jpg"
|
||||
}
|
||||
|
||||
// stripHTML removes tags and collapses whitespace — feed descriptions
|
||||
// are HTML; storage wants text (the TUI-ish surfaces render it plain).
|
||||
func stripHTML(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
inTag := false
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r == '<':
|
||||
inTag = true
|
||||
case r == '>':
|
||||
inTag = false
|
||||
case !inTag:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
out := b.String()
|
||||
// Collapse runs of whitespace.
|
||||
out = strings.Join(strings.Fields(out), " ")
|
||||
return strings.TrimSpace(out)
|
||||
}
|
||||
|
||||
// truncate clamps a string to max runes (VARCHAR safety at the DB edge).
|
||||
func truncate(s string, max int) string {
|
||||
s = strings.TrimSpace(s)
|
||||
runes := []rune(s)
|
||||
if len(runes) <= max {
|
||||
return s
|
||||
}
|
||||
return string(runes[:max])
|
||||
}
|
||||
|
||||
// truncateSlice clamps each element and drops empties.
|
||||
func truncateSlice(in []string, max int) []string {
|
||||
out := make([]string, 0, len(in))
|
||||
for _, s := range in {
|
||||
if t := truncate(s, max); t != "" {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// lowerSearchSlice mirrors authors into authors_search (scanner convention).
|
||||
func lowerSearchSlice(in []string) []string {
|
||||
out := make([]string, 0, len(in))
|
||||
for _, s := range in {
|
||||
out = append(out, strings.ToLower(s))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sortedStringSet → deterministic TEXT[] writes.
|
||||
func sortedStringSet(set map[string]bool) []string {
|
||||
out := make([]string, 0, len(set))
|
||||
for k := range set {
|
||||
if strings.TrimSpace(k) == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, strings.TrimSpace(k))
|
||||
}
|
||||
// tiny insertion sort — tag sets are 1–5 entries
|
||||
for i := 1; i < len(out); i++ {
|
||||
for j := i; j > 0 && out[j] < out[j-1]; j-- {
|
||||
out[j], out[j-1] = out[j-1], out[j]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// classifyFetchError maps guard/HTTP errors to user-facing text (the
|
||||
// add-feed dialog and the download_error column both render it).
|
||||
// normalizeFeedURL accepts the launcher-scheme spellings podcast
|
||||
// pages hand out alongside plain RSS (issue follow-up: "one says RSS,
|
||||
// the other says Apple Podcast") and rewrites them to the https URL
|
||||
// they wrap: itpc:// and podcasts:// are Apple's subscribe-launcher
|
||||
// aliases for the SAME feed document; feed:// is the generic form.
|
||||
// Anything else keeps its scheme — guardFetch enforces https.
|
||||
func normalizeFeedURL(raw string) string {
|
||||
u := strings.TrimSpace(raw)
|
||||
lowered := strings.ToLower(u)
|
||||
for _, prefix := range []string{"itpc://", "podcasts://", "feed://"} {
|
||||
if strings.HasPrefix(lowered, prefix) {
|
||||
return "https://" + u[len(prefix):]
|
||||
}
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func classifyFetchError(err error) error {
|
||||
if errors.Is(err, ErrBlockedPrivateAddress) {
|
||||
return fmt.Errorf("blocked: private/loopback addresses are not fetchable")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// urlParse local helper so podcast files don't each import net/url.
|
||||
func urlParse(raw string) (*url.URL, error) {
|
||||
return url.Parse(raw)
|
||||
}
|
||||
|
||||
// ---- pgtype helpers shared by the podcast files ----
|
||||
|
||||
func pgtypeText(s string) pgtype.Text {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return pgtype.Text{}
|
||||
}
|
||||
return pgtype.Text{String: s, Valid: true}
|
||||
}
|
||||
|
||||
func pgtypeInt4(v int32) pgtype.Int4 {
|
||||
if v == 0 {
|
||||
return pgtype.Int4{}
|
||||
}
|
||||
return pgtype.Int4{Int32: v, Valid: true}
|
||||
}
|
||||
|
||||
func pgtypeInt4FromStr(s string) pgtype.Int4 {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(s))
|
||||
if err != nil || n <= 0 {
|
||||
return pgtype.Int4{}
|
||||
}
|
||||
return pgtype.Int4{Int32: int32(n), Valid: true}
|
||||
}
|
||||
|
||||
func pgtypeInt8(v int64) pgtype.Int8 {
|
||||
if v <= 0 {
|
||||
return pgtype.Int8{}
|
||||
}
|
||||
return pgtype.Int8{Int64: v, Valid: true}
|
||||
}
|
||||
|
||||
func pgtypeTimestamptz(t time.Time) pgtype.Timestamptz {
|
||||
return pgtype.Timestamptz{Time: t, Valid: true}
|
||||
}
|
||||
|
||||
func pgtypeDate(t time.Time) pgtype.Date {
|
||||
return pgtype.Date{Time: t.UTC().Truncate(24 * time.Hour), Valid: true}
|
||||
}
|
||||
|
||||
// feedUrlForComparison normalizes trailing slashes for the redirect check.
|
||||
func feedUrlForComparison(u string) string {
|
||||
return strings.TrimRight(strings.TrimSpace(u), "/")
|
||||
}
|
||||
@@ -0,0 +1,840 @@
|
||||
// Podcast download worker (plan §7 / D6 / D8 / D19; pool since §0 item
|
||||
// 4): a download-manager-style pool driving the episode state machine
|
||||
//
|
||||
// pending → queued → downloading → downloaded
|
||||
// ↘ failed (+ download_error)
|
||||
//
|
||||
// queued rows are the DB-backed worklist — the state is persisted so
|
||||
// the episode list ALWAYS renders truth (no in-memory queue to lose).
|
||||
// Each queue ticket (download_queue_pos, a SEQUENCE nextval at
|
||||
// enqueue) makes the enqueue loop's ordering the download order.
|
||||
//
|
||||
// Concurrency: up to podcast_download_concurrency episodes in flight
|
||||
// server-wide (default 4, admin-tunable live 1–16). Fair share: the
|
||||
// claim prefers the feed least-recently-served, so several books
|
||||
// download in parallel while chapters within a book land in ticket
|
||||
// order (start listening while the rest catch up). Bandwidth: an
|
||||
// optional shared token bucket (podcast_download_speed_mbps, 0 =
|
||||
// unlimited) paces all workers together.
|
||||
//
|
||||
// Idempotency: a download request for an episode whose file already
|
||||
// exists and probes cleanly re-marks it downloaded without re-fetching
|
||||
// (crash-between-move-and-update reconciliation); a downloaded episode
|
||||
// re-request is a full no-op.
|
||||
//
|
||||
// Retry semantics: manual retry (same endpoint as download) resets
|
||||
// download_attempts; auto-download policies re-queue failed episodes on
|
||||
// later polls up to maxAutoDownloadAttempts, then stay failed until a
|
||||
// human retries.
|
||||
//
|
||||
// RLS: the worker runs detached — every statement executes under the
|
||||
// first-admin identity (invariant 12), same contract as the fetcher.
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"bookhoard/internal/database"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
// maxAutoDownloadAttempts caps POLICY-driven re-queues of a failing
|
||||
// episode; a human retry always resets the budget.
|
||||
const maxAutoDownloadAttempts = 5
|
||||
|
||||
// PodcastDownloadWorker processes queued episode downloads.
|
||||
type PodcastDownloadWorker struct {
|
||||
db *database.Queries
|
||||
svc *PodcastService
|
||||
settings *database.SettingsRegistry
|
||||
wake chan struct{}
|
||||
|
||||
// Fair-share bookkeeping (§0 item 4): feed UUID string → last
|
||||
// time the pool served it. Claim prefers the smallest value;
|
||||
// never-served feeds (missing key) go first. Guarded by fairMu.
|
||||
fairMu sync.Mutex
|
||||
lastServed map[string]time.Time
|
||||
|
||||
// Shared bandwidth bucket across all pool workers (§0 item 4).
|
||||
limiter bandwidthLimiter
|
||||
|
||||
// Live byte-progress slots for in-flight downloads (issue #1).
|
||||
// Keyed by episode UUID string; guarded by progMu.
|
||||
progMu sync.Mutex
|
||||
progress map[string]*episodeProgress
|
||||
}
|
||||
|
||||
func NewPodcastDownloadWorker(db *database.Queries, svc *PodcastService) *PodcastDownloadWorker {
|
||||
return &PodcastDownloadWorker{
|
||||
db: db,
|
||||
svc: svc,
|
||||
wake: make(chan struct{}, 1),
|
||||
lastServed: make(map[string]time.Time),
|
||||
progress: make(map[string]*episodeProgress),
|
||||
}
|
||||
}
|
||||
|
||||
// SetSettings wires the live settings registry (admin tunables:
|
||||
// concurrency + bandwidth cap). Without it the worker runs on the
|
||||
// registered defaults (4 in flight, unlimited speed) — the test
|
||||
// harness constructs workers without settings.
|
||||
func (w *PodcastDownloadWorker) SetSettings(r *database.SettingsRegistry) {
|
||||
w.settings = r
|
||||
}
|
||||
|
||||
// concurrency re-reads the live setting each drain cycle.
|
||||
func (w *PodcastDownloadWorker) concurrency() int {
|
||||
if w.settings == nil {
|
||||
return 4 // registered default
|
||||
}
|
||||
if n := w.settings.PodcastDownloadConcurrency(); n >= 1 {
|
||||
return n
|
||||
}
|
||||
return 4
|
||||
}
|
||||
|
||||
// refillLimiter re-syncs the shared bucket's rate (bytes/sec) from the
|
||||
// live setting; 0 = unlimited.
|
||||
func (w *PodcastDownloadWorker) refillLimiter() {
|
||||
if w.settings == nil {
|
||||
return
|
||||
}
|
||||
w.limiter.setRate(int64(w.settings.PodcastDownloadSpeedMbps() * 125000)) // Mbps → bytes/sec
|
||||
}
|
||||
|
||||
// Wake nudges the loop (new work queued).
|
||||
func (w *PodcastDownloadWorker) Wake() {
|
||||
select {
|
||||
case w.wake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Start launches the loop; it exits when ctx is cancelled. Called once
|
||||
// from route registration (scanner background-tasks precedent).
|
||||
func (w *PodcastDownloadWorker) Start(ctx context.Context) {
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-w.wake:
|
||||
case <-time.After(5 * time.Second):
|
||||
// Poll fallback: a queued row written by another process
|
||||
// (or a crashed worker's leftovers) still gets picked up.
|
||||
}
|
||||
w.drain(ctx)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// DrainNow runs one bounded pool round synchronously — the test hook
|
||||
// and the future admin "retry now" seam (normally the wake channel
|
||||
// drives rounds).
|
||||
func (w *PodcastDownloadWorker) DrainNow(ctx context.Context) {
|
||||
w.drain(ctx)
|
||||
}
|
||||
|
||||
// drain runs one pool round: up to concurrency() claim-loop goroutines
|
||||
// each claim (fair-share) → download → settle, until the queue is
|
||||
// empty. The cap setting is re-read every round — live-tunable.
|
||||
func (w *PodcastDownloadWorker) drain(ctx context.Context) {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
w.refillLimiter()
|
||||
limit := w.concurrency()
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < limit; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
ep, ok := w.claimNext(ctx)
|
||||
if !ok {
|
||||
return // queue drained (or nothing claimable)
|
||||
}
|
||||
w.runOne(ctx, ep)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// claimNext atomically claims the next queued episode, preferring the
|
||||
// feed least-recently-served (fair share — books proceed in parallel)
|
||||
// and the earliest queue ticket within a feed (chapters land in
|
||||
// enqueue order). Returns ok=false when nothing is claimable.
|
||||
func (w *PodcastDownloadWorker) claimNext(ctx context.Context) (database.MediaItems, bool) {
|
||||
actx, err := w.svc.adminCtx(ctx)
|
||||
if err != nil {
|
||||
return database.MediaItems{}, false
|
||||
}
|
||||
cands, err := w.db.ListQueuedPodcastEpisodes(actx)
|
||||
if err != nil || len(cands) == 0 {
|
||||
return database.MediaItems{}, false
|
||||
}
|
||||
|
||||
// Group per feed preserving ticket order (the SQL scan is ordered).
|
||||
perFeed := make(map[pgtype.UUID][]pgtype.UUID, 8)
|
||||
for _, c := range cands {
|
||||
perFeed[c.SourceFeedID] = append(perFeed[c.SourceFeedID], c.ID)
|
||||
}
|
||||
|
||||
w.fairMu.Lock()
|
||||
defer w.fairMu.Unlock()
|
||||
pickFeed := pickFeedToServe(perFeed, w.lastServed)
|
||||
if !pickFeed.Valid {
|
||||
return database.MediaItems{}, false
|
||||
}
|
||||
|
||||
// Claim the head ticket of the chosen feed; if another claimant
|
||||
// won it meanwhile (retry raced), fall through the feed's tickets.
|
||||
for _, id := range perFeed[pickFeed] {
|
||||
ep, err := w.db.ClaimQueuedPodcastEpisode(actx, id)
|
||||
if err != nil {
|
||||
continue // claimed elsewhere / state moved on
|
||||
}
|
||||
w.lastServed[pickFeed.String()] = time.Now()
|
||||
return ep, true
|
||||
}
|
||||
return database.MediaItems{}, false
|
||||
}
|
||||
|
||||
// pickFeedToServe chooses the feed least-recently-served (never-served
|
||||
// first); among never-served feeds the first encountered wins (any is
|
||||
// equally fair). Unit-testable seam for the fair-share rule.
|
||||
func pickFeedToServe(perFeed map[pgtype.UUID][]pgtype.UUID, lastServed map[string]time.Time) pgtype.UUID {
|
||||
var pick pgtype.UUID
|
||||
var best time.Time
|
||||
haveNever := false
|
||||
for feed := range perFeed {
|
||||
last, seen := lastServed[feed.String()]
|
||||
if !seen {
|
||||
if !haveNever {
|
||||
pick, haveNever = feed, true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if haveNever {
|
||||
continue // a never-served feed already won
|
||||
}
|
||||
if !pick.Valid || last.Before(best) {
|
||||
pick, best = feed, last
|
||||
}
|
||||
}
|
||||
return pick
|
||||
}
|
||||
|
||||
// runOne downloads a claimed episode and settles the row either way
|
||||
// (failed/rejected: state + reason persist; metadata/file facts
|
||||
// untouched — invariant 7, write-after-success only).
|
||||
func (w *PodcastDownloadWorker) runOne(ctx context.Context, ep database.MediaItems) {
|
||||
actx, err := w.svc.adminCtx(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err := w.runPipeline(actx, ep); err != nil {
|
||||
_ = w.db.MarkPodcastEpisodeFailed(actx, database.MarkPodcastEpisodeFailedParams{
|
||||
ID: ep.ID,
|
||||
DownloadError: pgtypeText(err.Error()),
|
||||
Column3: false, // auto failure: never resets the attempt budget
|
||||
})
|
||||
log.Printf("[PODCAST-DL] episode %s failed: %v", ep.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// AdminCtx exposes the worker's first-admin identity (tests use it for
|
||||
// worker-scope DB statements).
|
||||
func (w *PodcastDownloadWorker) AdminCtx(ctx context.Context) (context.Context, error) {
|
||||
return w.svc.adminCtx(ctx)
|
||||
}
|
||||
|
||||
// QueueEpisode marks one episode queued (manual download / retry path).
|
||||
// Idempotent by design: downloaded → no-op. The per-feed lock
|
||||
// serializes against polls rewriting the same row.
|
||||
func (w *PodcastDownloadWorker) QueueEpisode(ctx context.Context, episodeID, feedID pgtype.UUID) error {
|
||||
actx, err := w.svc.adminCtx(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lock := w.svc.feedLock(feedID.String())
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
ep, err := w.db.GetPodcastEpisode(actx, episodeID)
|
||||
if err != nil {
|
||||
return errNotFound("episode not found")
|
||||
}
|
||||
if ep.DownloadState == "downloaded" {
|
||||
return nil // idempotent no-op
|
||||
}
|
||||
// Manual path resets the attempt budget (SetPodcastEpisodePending)
|
||||
// then queues — a human retry always overrides auto-retry capping.
|
||||
if err := w.db.SetPodcastEpisodePending(actx, episodeID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.db.SetPodcastEpisodeDownloadQueued(actx, episodeID); err != nil {
|
||||
return err
|
||||
}
|
||||
w.Wake()
|
||||
return nil
|
||||
}
|
||||
|
||||
// QueueFeedDownloadsAs is Download All honoring the CALLER's listening
|
||||
// order (§0 item 5b): episodes queue in the feed's effective order (the
|
||||
// caller's override, feed-native fallback). Tickets (§0 item 4) make
|
||||
// that queue order the download order — "getting the last chapter
|
||||
// first doesn't help me". userID zero = no override (feed order).
|
||||
func (w *PodcastDownloadWorker) QueueFeedDownloadsAs(ctx context.Context, userID, feedID pgtype.UUID) (int, error) {
|
||||
actx, err := w.svc.adminCtx(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
lock := w.svc.feedLock(feedID.String())
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
eps, err := w.db.ListUndownloadedEpisodes(actx, feedID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
order := "native"
|
||||
if userID.Valid {
|
||||
order = w.orderOverrideFor(actx, userID, feedID)
|
||||
}
|
||||
// THE order helper (§0 item 5a) — same semantics as the sequence,
|
||||
// reader chain, and page list; no per-surface sort drift.
|
||||
OrderEpisodes(eps, order,
|
||||
func(ep database.MediaItems) int32 { return ep.FeedPosition.Int32 },
|
||||
func(ep database.MediaItems) string {
|
||||
if ep.DatePublished.Valid {
|
||||
return ep.DatePublished.Time.Format("2006-01-02")
|
||||
}
|
||||
return ""
|
||||
},
|
||||
)
|
||||
|
||||
queued := 0
|
||||
for _, ep := range eps {
|
||||
if _, err := w.db.SetPodcastEpisodeDownloadQueued(actx, ep.ID); err == nil {
|
||||
queued++
|
||||
}
|
||||
}
|
||||
w.Wake()
|
||||
return queued, nil
|
||||
}
|
||||
|
||||
// orderOverrideFor reads a user's podcast_order override via the
|
||||
// reader_settings KV (the D5 storage; native when absent/invalid).
|
||||
func (w *PodcastDownloadWorker) orderOverrideFor(ctx context.Context, userID, feedID pgtype.UUID) string {
|
||||
row, err := w.db.GetUserSettingByKey(ctx, database.GetUserSettingByKeyParams{
|
||||
UserID: userID, SettingKey: "podcast_order:" + feedID.String(),
|
||||
})
|
||||
if err != nil {
|
||||
return "native"
|
||||
}
|
||||
var v struct {
|
||||
Order string `json:"order"`
|
||||
}
|
||||
if json.Unmarshal(row, &v) != nil || !ValidPodcastEpisodeOrder(v.Order) {
|
||||
return "native"
|
||||
}
|
||||
return v.Order
|
||||
}
|
||||
|
||||
// ApplyAutoPolicies unions subscriber policies for a feed (D6: most
|
||||
// aggressive wins for what ARRIVES — deletion stays admin-only) and
|
||||
// queues the target set, respecting the auto-retry attempt cap.
|
||||
func (w *PodcastDownloadWorker) ApplyAutoPolicies(ctx context.Context, feedID pgtype.UUID) (int, error) {
|
||||
actx, err := w.svc.adminCtx(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
subs, err := w.db.ListFeedSubscribers(actx, feedID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
wantAll := false
|
||||
newestN := 0
|
||||
for _, sub := range subs {
|
||||
switch sub.AutoDownload {
|
||||
case "all":
|
||||
wantAll = true
|
||||
case "newest":
|
||||
if n := int(sub.AutoDownloadCount.Int32); n > newestN {
|
||||
newestN = n
|
||||
}
|
||||
}
|
||||
}
|
||||
if !wantAll && newestN == 0 {
|
||||
return 0, nil // nobody's policy asks for anything
|
||||
}
|
||||
|
||||
lock := w.svc.feedLock(feedID.String())
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
eps, err := w.db.ListUndownloadedEpisodes(actx, feedID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// ListUndownloadedEpisodes is native-order (oldest first); "newest
|
||||
// N" takes the TAIL (highest positions = latest releases).
|
||||
targets := eps
|
||||
if !wantAll && newestN > 0 && len(eps) > newestN {
|
||||
targets = eps[len(eps)-newestN:]
|
||||
}
|
||||
|
||||
queued := 0
|
||||
for _, ep := range targets {
|
||||
// Auto-retry budget: only re-queue within the cap. A failed
|
||||
// episode past the cap waits for a human (its row renders the
|
||||
// reason + retry button — D8/D19).
|
||||
if ep.DownloadState == "failed" && int(ep.DownloadAttempts) >= maxAutoDownloadAttempts {
|
||||
continue
|
||||
}
|
||||
if _, err := w.db.SetPodcastEpisodeDownloadQueued(actx, ep.ID); err == nil {
|
||||
queued++
|
||||
}
|
||||
}
|
||||
if queued > 0 {
|
||||
w.Wake()
|
||||
}
|
||||
return queued, nil
|
||||
}
|
||||
|
||||
// runPipeline: fetch enclosure (SSRF/size guards) → temp file → MIME/
|
||||
// size validation → ffprobe reconcile → move into place → SUCCESS-only
|
||||
// row update.
|
||||
func (w *PodcastDownloadWorker) runPipeline(ctx context.Context, ep database.MediaItems) error {
|
||||
if !ep.EnclosureUrl.Valid || ep.EnclosureUrl.String == "" {
|
||||
return fmt.Errorf("episode has no enclosure URL")
|
||||
}
|
||||
if !ep.SourceFeedID.Valid {
|
||||
return fmt.Errorf("episode has no feed reference")
|
||||
}
|
||||
|
||||
feed, err := w.db.GetPodcastFeed(ctx, ep.SourceFeedID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("feed lookup: %w", errNotFound("feed not found"))
|
||||
}
|
||||
root, err := w.svc.downloadRootAbs(ctx, &feed)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Declared-size pre-check: reject hopeless downloads before
|
||||
// transferring a byte (validation rejections render like failures,
|
||||
// with their reason — D8).
|
||||
if ep.FileSize.Valid && ep.FileSize.Int64 > maxEnclosureCapBytes {
|
||||
return fmt.Errorf("rejected: enclosure declares %d bytes (cap %d)", ep.FileSize.Int64, maxEnclosureCapBytes)
|
||||
}
|
||||
|
||||
feedDir := filepath.Join(root, filepath.Dir(ep.FilePath))
|
||||
finalPath := filepath.Join(root, ep.FilePath)
|
||||
if err := os.MkdirAll(feedDir, 0o755); err != nil {
|
||||
return fmt.Errorf("download dir: %w", err)
|
||||
}
|
||||
|
||||
// Idempotency reconciliation: file already on disk and probes?
|
||||
if _, err := os.Stat(finalPath); err == nil {
|
||||
if payload := probeEpisodeAudio(finalPath); payload != nil {
|
||||
return w.markDownloaded(ctx, ep, &feed, finalPath, payload)
|
||||
}
|
||||
// Unprobeable stray: fall through and re-fetch over it.
|
||||
}
|
||||
|
||||
// ---- fetch to temp (streamed with byte-progress tracking) ----
|
||||
// Enclosures stream straight to disk through a counting loop — no
|
||||
// whole-body buffering — so the episode list can render a real
|
||||
// progress bar while the worker runs (issue #1).
|
||||
tmpPath := finalPath + ".part-" + randomSuffix()
|
||||
cleanup := func() { _ = os.Remove(tmpPath) }
|
||||
|
||||
received, total, fetchErr := w.streamEnclosure(ctx, ep, tmpPath)
|
||||
w.endProgress(ep.ID) // terminal either way; the row state is the truth
|
||||
if fetchErr != nil {
|
||||
cleanup()
|
||||
return fetchErr
|
||||
}
|
||||
_, _ = received, total
|
||||
|
||||
// ---- validation ----
|
||||
info, err := os.Stat(tmpPath)
|
||||
if err != nil || info.Size() == 0 {
|
||||
cleanup()
|
||||
return fmt.Errorf("rejected: empty download")
|
||||
}
|
||||
if info.Size() > maxEnclosureCapBytes {
|
||||
cleanup()
|
||||
return fmt.Errorf("rejected: enclosure exceeds %d byte cap", maxEnclosureCapBytes)
|
||||
}
|
||||
if !looksLikeAudioFile(tmpPath) {
|
||||
cleanup()
|
||||
return fmt.Errorf("rejected: downloaded file does not look like audio media")
|
||||
}
|
||||
|
||||
// ---- ffprobe reconcile + embedded cover (scanner machinery reuse) ----
|
||||
payload := probeEpisodeAudio(tmpPath)
|
||||
|
||||
// ---- move into place (crash-safe: rename is atomic on-same-fs) ----
|
||||
if err := os.Rename(tmpPath, finalPath); err != nil {
|
||||
cleanup()
|
||||
return fmt.Errorf("finalize: %w", err)
|
||||
}
|
||||
|
||||
return w.markDownloaded(ctx, ep, &feed, finalPath, payload)
|
||||
}
|
||||
|
||||
// ---- byte-progress tracking (issue #1) ----
|
||||
// In-memory, deliberately NOT persisted: a downloading row renders a
|
||||
// bar from this snapshot while the worker holds it; the DB row's state
|
||||
// remains the durable truth (queued/downloading/downloaded).
|
||||
|
||||
type episodeProgress struct {
|
||||
Received int64
|
||||
Total int64 // 0 = unknown (no Content-Length, no declared length)
|
||||
}
|
||||
|
||||
// beginProgress registers a download slot (total 0 = unknown until the
|
||||
// response's Content-Length arrives).
|
||||
func (w *PodcastDownloadWorker) beginProgress(episodeID pgtype.UUID, total int64) {
|
||||
w.progMu.Lock()
|
||||
defer w.progMu.Unlock()
|
||||
w.progress[episodeID.String()] = &episodeProgress{Total: total}
|
||||
}
|
||||
|
||||
// addProgress accumulates streamed bytes.
|
||||
func (w *PodcastDownloadWorker) addProgress(episodeID pgtype.UUID, n int64) {
|
||||
w.progMu.Lock()
|
||||
defer w.progMu.Unlock()
|
||||
if p, ok := w.progress[episodeID.String()]; ok {
|
||||
p.Received += n
|
||||
if p.Total == 0 {
|
||||
// Total unknown at start; keep received-only (bar renders
|
||||
// indeterminate).
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// setProgressTotal records the authoritative total once known.
|
||||
func (w *PodcastDownloadWorker) setProgressTotal(episodeID pgtype.UUID, total int64) {
|
||||
w.progMu.Lock()
|
||||
defer w.progMu.Unlock()
|
||||
if p, ok := w.progress[episodeID.String()]; ok && total > 0 {
|
||||
p.Total = total
|
||||
}
|
||||
}
|
||||
|
||||
// endProgress drops the slot (terminal state reached — the row itself
|
||||
// now tells the story).
|
||||
func (w *PodcastDownloadWorker) endProgress(episodeID pgtype.UUID) {
|
||||
w.progMu.Lock()
|
||||
defer w.progMu.Unlock()
|
||||
delete(w.progress, episodeID.String())
|
||||
}
|
||||
|
||||
// EpisodeProgress reports the live byte counts for a downloading
|
||||
// episode (ok=false when nothing is in flight for it).
|
||||
func (w *PodcastDownloadWorker) EpisodeProgress(episodeID pgtype.UUID) (received, total int64, ok bool) {
|
||||
w.progMu.Lock()
|
||||
defer w.progMu.Unlock()
|
||||
p, ok := w.progress[episodeID.String()]
|
||||
if !ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
return p.Received, p.Total, true
|
||||
}
|
||||
|
||||
// ---- shared bandwidth bucket (§0 item 4) ----
|
||||
// One leaky-bucket-with-reservations limiter across ALL pool workers:
|
||||
// each caller reserves the next slot of bandwidth and sleeps outside
|
||||
// the lock — reservation order = transmission order, and N workers
|
||||
// share one cap instead of N caps.
|
||||
|
||||
type bandwidthLimiter struct {
|
||||
mu sync.Mutex
|
||||
rate int64 // bytes per second; <=0 = unlimited
|
||||
next time.Time // when the currently-reserved budget runs out
|
||||
}
|
||||
|
||||
func (l *bandwidthLimiter) setRate(bytesPerSec int64) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.rate = bytesPerSec
|
||||
}
|
||||
|
||||
// take reserves n bytes of bandwidth and returns how long the caller
|
||||
// must wait before sending them (0 when free flow).
|
||||
func (l *bandwidthLimiter) take(n int64) time.Duration {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if l.rate <= 0 {
|
||||
return 0
|
||||
}
|
||||
now := time.Now()
|
||||
if l.next.Before(now) {
|
||||
l.next = now
|
||||
}
|
||||
d := time.Duration(float64(n) / float64(l.rate) * float64(time.Second))
|
||||
start := l.next // my window begins when earlier reservations end
|
||||
l.next = start.Add(d)
|
||||
return start.Sub(now)
|
||||
}
|
||||
|
||||
// streamEnclosure performs the SSRF-guarded enclosure download straight
|
||||
// to tmpPath while updating the progress slot. Returns bytes received
|
||||
// and the authoritative total (0 when unknown). Scheme and size guards
|
||||
// match guardFetch; redirects re-dial through the same client so the
|
||||
// IP guard re-applies per hop.
|
||||
func (w *PodcastDownloadWorker) streamEnclosure(ctx context.Context, ep database.MediaItems, tmpPath string) (int64, int64, error) {
|
||||
u, err := url.Parse(ep.EnclosureUrl.String)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("download failed: invalid enclosure URL: %w", err)
|
||||
}
|
||||
if err := guardURLScheme(u); err != nil {
|
||||
return 0, 0, fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ep.EnclosureUrl.String, nil)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "Bookhoard/1.0 (podcast fetcher; +https://github.com/bookhoard)")
|
||||
|
||||
// Declared total (feed's enclosure length) seeds the bar until the
|
||||
// response's Content-Length replaces it.
|
||||
declared := int64(0)
|
||||
if ep.FileSize.Valid {
|
||||
declared = ep.FileSize.Int64
|
||||
}
|
||||
w.beginProgress(ep.ID, declared)
|
||||
|
||||
resp, err := podcastHTTPClientFactory().Do(req)
|
||||
if err != nil {
|
||||
return 0, declared, fmt.Errorf("download failed: %w", classifyFetchError(err))
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return 0, declared, fmt.Errorf("download failed: HTTP %d for enclosure", resp.StatusCode)
|
||||
}
|
||||
|
||||
total := resp.ContentLength // -1 when unknown
|
||||
if total < 0 {
|
||||
total = declared
|
||||
}
|
||||
w.setProgressTotal(ep.ID, total)
|
||||
|
||||
out, err := os.Create(tmpPath)
|
||||
if err != nil {
|
||||
return 0, total, fmt.Errorf("write: %w", err)
|
||||
}
|
||||
defer func() { _ = out.Close() }()
|
||||
|
||||
buf := make([]byte, 64<<10)
|
||||
var received int64
|
||||
for {
|
||||
n, readErr := resp.Body.Read(buf)
|
||||
if n > 0 {
|
||||
received += int64(n)
|
||||
if received > maxEnclosureCapBytes {
|
||||
return received, total, fmt.Errorf("rejected: enclosure exceeds %d byte cap", maxEnclosureCapBytes)
|
||||
}
|
||||
// Shared bandwidth cap: pace against the server-wide
|
||||
// bucket (0 rate = unlimited, take returns 0).
|
||||
if wait := w.limiter.take(int64(n)); wait > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return received, total, fmt.Errorf("download failed: cancelled")
|
||||
case <-time.After(wait):
|
||||
}
|
||||
}
|
||||
w.addProgress(ep.ID, int64(n))
|
||||
if _, werr := out.Write(buf[:n]); werr != nil {
|
||||
return received, total, fmt.Errorf("write: %w", werr)
|
||||
}
|
||||
}
|
||||
if readErr == io.EOF {
|
||||
break
|
||||
}
|
||||
if readErr != nil {
|
||||
return received, total, fmt.Errorf("download failed: %w", readErr)
|
||||
}
|
||||
}
|
||||
return received, total, nil
|
||||
}
|
||||
|
||||
// markDownloaded is the SUCCESS-ONLY row write (invariant 7): the file
|
||||
// is on disk and (when ffprobe is available) probed before this runs.
|
||||
func (w *PodcastDownloadWorker) markDownloaded(ctx context.Context, ep database.MediaItems, feed *database.PodcastFeeds, absPath string, payload *AudioMetadataPayload) error {
|
||||
info, err := os.Stat(absPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("post-move stat: %w", err)
|
||||
}
|
||||
|
||||
// Embedded cover extraction (optional enrichment; scanner precedent).
|
||||
coverRel := pgtype.Text{}
|
||||
base := strings.TrimSuffix(absPath, filepath.Ext(absPath))
|
||||
if coverAbs := extractEmbeddedAudioCover(absPath, base); coverAbs != "" {
|
||||
if root, err := w.svc.downloadRootAbs(ctx, feed); err == nil {
|
||||
if rel, err := filepath.Rel(root, coverAbs); err == nil {
|
||||
coverRel = pgtypeText(rel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var chapterCount pgtype.Int4
|
||||
if payload != nil && len(payload.Chapters) > 0 {
|
||||
chapterCount = pgtypeInt4(int32(len(payload.Chapters)))
|
||||
}
|
||||
|
||||
_, err = w.db.MarkPodcastEpisodeDownloaded(ctx, database.MarkPodcastEpisodeDownloadedParams{
|
||||
ID: ep.ID,
|
||||
FilePath: ep.FilePath,
|
||||
FileSize: pgtypeInt8(info.Size()),
|
||||
MimeType: pgtypeText(strings.TrimSpace(pickMimeType(ep, absPath))),
|
||||
CoverImagePath: coverRel,
|
||||
AudioMetadata: audioPayloadJSONB(payload),
|
||||
ChapterCount: chapterCount,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark downloaded: %w", err)
|
||||
}
|
||||
log.Printf("[PODCAST-DL] downloaded %q (%s) for feed %s", ep.Title, ep.FilePath, feed.Title)
|
||||
return nil
|
||||
}
|
||||
|
||||
// probeEpisodeAudio shells out to ffprobe and builds the audio_metadata
|
||||
// payload (scanner machinery reuse; nil when ffprobe is absent — the
|
||||
// episode still downloads, facts degrade to declared values).
|
||||
func probeEpisodeAudio(path string) *AudioMetadataPayload {
|
||||
report := runFFProbe(path)
|
||||
if report == nil {
|
||||
return nil
|
||||
}
|
||||
payload := &AudioMetadataPayload{}
|
||||
if report.Format.Duration > 0 {
|
||||
payload.DurationSeconds = float64(report.Format.Duration)
|
||||
}
|
||||
if report.Format.BitRate > 0 {
|
||||
payload.BitRate = int64(report.Format.BitRate)
|
||||
}
|
||||
if len(report.Streams) > 0 {
|
||||
profile := report.Streams[0].CodecName
|
||||
if p := report.Streams[0].Profile; p != "" && p != "unknown" {
|
||||
profile += " / " + p
|
||||
}
|
||||
payload.CodecProfile = profile
|
||||
}
|
||||
for _, ch := range report.Chapters {
|
||||
payload.Chapters = append(payload.Chapters, AudioChapter{
|
||||
Index: ch.ID,
|
||||
Title: ch.Tags.Title,
|
||||
Start: float64(ch.StartTime),
|
||||
End: float64(ch.EndTime),
|
||||
})
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
// looksLikeAudioFile sniffs content: an audio container starts with
|
||||
// known magic bytes, or (ID3v1/v2, ADTS) has recognizable heads. HTML
|
||||
// error pages and JSON never pass.
|
||||
func looksLikeAudioFile(path string) bool {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
head := make([]byte, 16)
|
||||
n, _ := io.ReadFull(f, head)
|
||||
if n < 4 {
|
||||
return false
|
||||
}
|
||||
h := head[:n]
|
||||
switch {
|
||||
// MP3: ID3 tag or MPEG frame sync.
|
||||
case string(h[:3]) == "ID3":
|
||||
return true
|
||||
case h[0] == 0xFF && h[1]&0xE0 == 0xE0:
|
||||
return true
|
||||
// MP4/M4A/M4B: ftyp box.
|
||||
case string(h[4:8]) == "ftyp":
|
||||
return true
|
||||
// FLAC
|
||||
case string(h[:4]) == "fLaC":
|
||||
return true
|
||||
// OGG/Opus
|
||||
case string(h[:4]) == "OggS":
|
||||
return true
|
||||
// WAV/RIFF
|
||||
case string(h[:4]) == "RIFF":
|
||||
return true
|
||||
// AAC ADTS (sync word)
|
||||
case h[0] == 0xFF && (h[1]&0xF6) == 0xF0:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// pickMimeType prefers the feed-declared type; falls back by extension.
|
||||
func pickMimeType(ep database.MediaItems, absPath string) string {
|
||||
if ep.MimeType.Valid && strings.HasPrefix(ep.MimeType.String, "audio/") {
|
||||
return ep.MimeType.String
|
||||
}
|
||||
switch strings.ToLower(filepath.Ext(absPath)) {
|
||||
case ".m4a", ".m4b":
|
||||
return "audio/mp4"
|
||||
case ".mp3":
|
||||
return "audio/mpeg"
|
||||
case ".flac":
|
||||
return "audio/flac"
|
||||
case ".ogg", ".oga", ".opus":
|
||||
return "audio/ogg"
|
||||
case ".wav":
|
||||
return "audio/wav"
|
||||
case ".aac":
|
||||
return "audio/aac"
|
||||
}
|
||||
return "audio/mpeg"
|
||||
}
|
||||
|
||||
// audioPayloadJSONB marshals the probed payload for the JSONB column
|
||||
// (nil payload → NULL: keep the column honest, never an empty object).
|
||||
func audioPayloadJSONB(p *AudioMetadataPayload) []byte {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
b, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// randomSuffix disambiguates concurrent temp files (single worker, but
|
||||
// a crashed worker's stale .part files must never collide).
|
||||
func randomSuffix() string {
|
||||
sum := sha256.Sum256([]byte(time.Now().Format(time.RFC3339Nano) + fmt.Sprint(os.Getpid())))
|
||||
return hex.EncodeToString(sum[:4])
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Unit tests for the §0 item 4 download-pool mechanics: the shared
|
||||
// bandwidth bucket (reservation math, unlimited passthrough) and the
|
||||
// fair-share feed pick (never-served first, least-recently-served
|
||||
// after). Pure logic — no DB.
|
||||
package services
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func uuidOf(s string) pgtype.UUID {
|
||||
var u pgtype.UUID
|
||||
_ = u.Scan(s)
|
||||
return u
|
||||
}
|
||||
|
||||
func TestBandwidthLimiter(t *testing.T) {
|
||||
t.Run("zero rate is unlimited", func(t *testing.T) {
|
||||
var l bandwidthLimiter
|
||||
l.setRate(0)
|
||||
assert.Zero(t, l.take(1<<20), "unlimited → no wait, ever")
|
||||
assert.Zero(t, l.take(1<<20))
|
||||
})
|
||||
|
||||
t.Run("reservations share one bucket", func(t *testing.T) {
|
||||
var l bandwidthLimiter
|
||||
l.setRate(1_000_000) // 1 MB/s
|
||||
start := time.Now()
|
||||
|
||||
w1 := l.take(1_000_000) // first caller: free flow
|
||||
w2 := l.take(1_000_000) // second caller: waits one full second
|
||||
w3 := l.take(1_000_000) // third caller: waits two seconds
|
||||
|
||||
assert.Less(t, int64(w1), int64(50*time.Millisecond), "first reservation starts immediately")
|
||||
assert.GreaterOrEqual(t, int64(w2), int64(900*time.Millisecond), "second waits ~1s at 1MB/s")
|
||||
assert.GreaterOrEqual(t, int64(w3), int64(1900*time.Millisecond), "third waits ~2s — SHARED bucket, not per-worker")
|
||||
assert.Less(t, time.Since(start), 100*time.Millisecond, "take() only reserves — callers sleep outside the lock")
|
||||
})
|
||||
|
||||
t.Run("idle bucket recovers", func(t *testing.T) {
|
||||
var l bandwidthLimiter
|
||||
l.setRate(1_000_000)
|
||||
_ = l.take(1_000_000)
|
||||
l.next = time.Now().Add(-time.Second) // simulate a second of idle flow
|
||||
assert.Less(t, int64(l.take(500_000)), int64(50*time.Millisecond),
|
||||
"budget accrued while idle → next take flows immediately")
|
||||
})
|
||||
}
|
||||
|
||||
func TestPickFeedToServe(t *testing.T) {
|
||||
a, b, c := uuidOf("00000000-0000-0000-0000-00000000000a"),
|
||||
uuidOf("00000000-0000-0000-0000-00000000000b"),
|
||||
uuidOf("00000000-0000-0000-0000-00000000000c")
|
||||
all := map[pgtype.UUID][]pgtype.UUID{a: {}, b: {}, c: {}}
|
||||
|
||||
t.Run("never-served beats served", func(t *testing.T) {
|
||||
served := map[string]time.Time{
|
||||
a.String(): time.Now().Add(-time.Hour),
|
||||
b.String(): time.Now().Add(-time.Minute),
|
||||
}
|
||||
assert.Equal(t, c, pickFeedToServe(all, served), "c was never served → c wins even though a starved longest")
|
||||
})
|
||||
|
||||
t.Run("least-recently-served wins", func(t *testing.T) {
|
||||
served := map[string]time.Time{
|
||||
a.String(): time.Now().Add(-time.Hour), // starved longest
|
||||
b.String(): time.Now().Add(-time.Minute), // served recently
|
||||
}
|
||||
two := map[pgtype.UUID][]pgtype.UUID{a: {}, b: {}}
|
||||
assert.Equal(t, a, pickFeedToServe(two, served))
|
||||
})
|
||||
|
||||
t.Run("empty queue picks nothing", func(t *testing.T) {
|
||||
assert.False(t, pickFeedToServe(map[pgtype.UUID][]pgtype.UUID{}, nil).Valid)
|
||||
})
|
||||
}
|
||||
@@ -296,6 +296,13 @@ func (s *StatisticsService) UserSummary(ctx context.Context, userID pgtype.UUID,
|
||||
entry.Sessions++
|
||||
summary.SessionCount++
|
||||
|
||||
// Podcast episodes (plan D15): sessions flow into time-spent
|
||||
// (the minutes/pages/sessions math above already ran), but an
|
||||
// episode is not a book — never enters BooksActive/BooksFinished.
|
||||
if strings.EqualFold(strings.TrimSpace(sess.LibraryTypeName.String), "podcasts") {
|
||||
continue
|
||||
}
|
||||
|
||||
mediaUUID, err := uuid.FromBytes(sess.MediaItemID.Bytes[0:16])
|
||||
if err == nil {
|
||||
key := mediaUUID.String()
|
||||
|
||||
@@ -8,3 +8,11 @@ package version
|
||||
// The release workflow passes the git tag (e.g. v1.5) as the APP_VERSION
|
||||
// Docker build arg, which the Dockerfile forwards to this flag.
|
||||
var Version = "dev"
|
||||
|
||||
// Commit is the git commit the binary was built from (short hash),
|
||||
// injected alongside Version via ldflags (GIT_COMMIT build arg). It
|
||||
// exists so "the UI I'm looking at" vs "the code in the repo" drift is
|
||||
// VISIBLE — the 2026-10-29 podcast debugging session burned an hour on
|
||||
// a stale container that predated the feature. "none" for unstampeded
|
||||
// local builds.
|
||||
var Commit = "none"
|
||||
@@ -0,0 +1,127 @@
|
||||
package templates
|
||||
|
||||
// Admin all-podcasts view (§0 item 8 / Phase-4 item 1): EVERY feed from
|
||||
// EVERY user with its maintenance aggregates, sorted by disk usage.
|
||||
// The maintenance surface for the podcast subsystem — files are the
|
||||
// disk hogs, so the hogs surface first. Actions: Refresh (re-fetch),
|
||||
// Delete files (rows → pending, disk freed), Remove feed (rows + files
|
||||
// + subscriptions gone — the same admin delete the detail page offers).
|
||||
|
||||
// AdminPodcastFeedData is one row of the admin all-podcasts table.
|
||||
type AdminPodcastFeedData struct {
|
||||
ID string
|
||||
Title string
|
||||
Host string
|
||||
LibraryName string
|
||||
Subscribers int
|
||||
SubscriberNames string
|
||||
EpisodeTotal int
|
||||
EpisodeDownloaded int
|
||||
DiskBytes int64
|
||||
LastFetched string
|
||||
}
|
||||
|
||||
templ AdminPodcasts(user User, feeds []AdminPodcastFeedData, totalFeeds int, totalDownloaded int, totalEpisodes int, totalBytes int64) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>All Podcasts - Bookhoard</title>
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
<script type="module" src={ "/static/main.js?v=" + MainJSVersion() }></script>
|
||||
</head>
|
||||
<body class={ "theme-" + user.Theme }>
|
||||
@Header(user, "/admin/podcasts")
|
||||
<main class="container mx-auto px-4 py-8">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||||
<h1 class="text-2xl font-bold" style="color: var(--text-primary)">All Podcasts</h1>
|
||||
<div class="text-sm tabular-nums" style="color: var(--text-secondary)">
|
||||
{ podcastInt(totalFeeds) } feeds · { podcastInt(totalDownloaded) }/{ podcastInt(totalEpisodes) } episodes downloaded · { FormatEpisodeSize(totalBytes) } on disk
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm mb-6" style="color: var(--text-secondary)">
|
||||
Maintenance view: every feed from every user. "Delete files" frees the disk and
|
||||
resets episodes to pending (they re-download on demand — a feed refresh will NOT
|
||||
re-materialize them). "Remove feed" deletes rows, files and subscriptions for everyone.
|
||||
</p>
|
||||
if len(feeds) == 0 {
|
||||
<div class="card p-6 text-center text-sm" style="color: var(--text-secondary)">No podcast feeds exist yet.</div>
|
||||
} else {
|
||||
<div class="oc-table card">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-left text-xs uppercase tracking-wide" style="color: var(--text-secondary); border-bottom: 1px solid var(--border);">
|
||||
<th class="px-4 py-3">Feed</th>
|
||||
<th class="px-4 py-3">Library</th>
|
||||
<th class="px-4 py-3">Subscribers</th>
|
||||
<th class="px-4 py-3 text-right">Downloaded</th>
|
||||
<th class="px-4 py-3 text-right">Disk</th>
|
||||
<th class="px-4 py-3">Last fetch</th>
|
||||
<th class="px-4 py-3 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
for _, f := range feeds {
|
||||
<tr style="border-bottom: 1px solid var(--border);" id={ "admin-feed-" + f.ID }>
|
||||
<td class="px-4 py-3">
|
||||
<a href={ "/podcasts/" + f.ID } class="font-medium hover:underline" style="color: var(--text-primary)">{ f.Title }</a>
|
||||
if f.Host != "" {
|
||||
<div class="text-xs" style="color: var(--text-secondary)">{ f.Host }</div>
|
||||
}
|
||||
</td>
|
||||
<td class="px-4 py-3" style="color: var(--text-secondary)">{ f.LibraryName }</td>
|
||||
<td class="px-4 py-3">
|
||||
if f.Subscribers > 0 {
|
||||
<span title={ f.SubscriberNames } style="color: var(--text-primary)">{ podcastInt(f.Subscribers) }</span>
|
||||
} else {
|
||||
<span class="badge" style="background-color: var(--surface-secondary); color: var(--text-secondary)">orphaned</span>
|
||||
}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right tabular-nums" style="color: var(--text-primary)">
|
||||
{ podcastInt(f.EpisodeDownloaded) }/{ podcastInt(f.EpisodeTotal) }
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right tabular-nums" style="color: var(--text-primary)">{ FormatEpisodeSize(f.DiskBytes) }</td>
|
||||
<td class="px-4 py-3 text-xs" style="color: var(--text-secondary)">{ f.LastFetched }</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
class="btn btn-secondary text-xs"
|
||||
hx-post={ "/api/podcasts/feeds/" + f.ID + "/refresh" }
|
||||
hx-swap="none"
|
||||
title="Check the feed for new episodes now"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
if f.EpisodeDownloaded > 0 {
|
||||
<button
|
||||
class="btn btn-secondary text-xs"
|
||||
hx-post={ "/api/podcasts/feeds/" + f.ID + "/delete-files" }
|
||||
hx-confirm={ "Delete the " + podcastInt(f.EpisodeDownloaded) + " downloaded file(s) of \"" + f.Title + "\"? Episodes reset to pending; a feed refresh will not re-materialize them." }
|
||||
hx-swap="none"
|
||||
style="color: var(--status-warning, #d97706);"
|
||||
>
|
||||
Delete files
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
class="btn btn-secondary text-xs"
|
||||
hx-delete={ "/api/podcasts/feeds/" + f.ID }
|
||||
hx-confirm={ "ADMIN: remove \"" + f.Title + "\" for everyone? Episode rows AND downloaded files are deleted." }
|
||||
hx-swap="none"
|
||||
style="color: var(--status-danger);"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1020
|
||||
package templates
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
// Admin all-podcasts view (§0 item 8 / Phase-4 item 1): EVERY feed from
|
||||
// EVERY user with its maintenance aggregates, sorted by disk usage.
|
||||
// The maintenance surface for the podcast subsystem — files are the
|
||||
// disk hogs, so the hogs surface first. Actions: Refresh (re-fetch),
|
||||
// Delete files (rows → pending, disk freed), Remove feed (rows + files
|
||||
// + subscriptions gone — the same admin delete the detail page offers).
|
||||
|
||||
// AdminPodcastFeedData is one row of the admin all-podcasts table.
|
||||
type AdminPodcastFeedData struct {
|
||||
ID string
|
||||
Title string
|
||||
Host string
|
||||
LibraryName string
|
||||
Subscribers int
|
||||
SubscriberNames string
|
||||
EpisodeTotal int
|
||||
EpisodeDownloaded int
|
||||
DiskBytes int64
|
||||
LastFetched string
|
||||
}
|
||||
|
||||
func AdminPodcasts(user User, feeds []AdminPodcastFeedData, totalFeeds int, totalDownloaded int, totalEpisodes int, totalBytes int64) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>All Podcasts - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"><script type=\"module\" src=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("/static/main.js?v=" + MainJSVersion())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 32, Col: 69}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\"></script></head>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 = []any{"theme-" + user.Theme}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var3...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<body class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var3).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Header(user, "/admin/podcasts").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<main class=\"container mx-auto px-4 py-8\"><div class=\"flex flex-wrap items-center justify-between gap-3 mb-4\"><h1 class=\"text-2xl font-bold\" style=\"color: var(--text-primary)\">All Podcasts</h1><div class=\"text-sm tabular-nums\" style=\"color: var(--text-secondary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(totalFeeds))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 40, Col: 30}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, " feeds · ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(totalDownloaded))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 40, Col: 71}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "/")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(totalEpisodes))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 40, Col: 101}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, " episodes downloaded · ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(FormatEpisodeSize(totalBytes))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 40, Col: 158}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " on disk</div></div><p class=\"text-sm mb-6\" style=\"color: var(--text-secondary)\">Maintenance view: every feed from every user. \"Delete files\" frees the disk and resets episodes to pending (they re-download on demand — a feed refresh will NOT re-materialize them). \"Remove feed\" deletes rows, files and subscriptions for everyone.</p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if len(feeds) == 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div class=\"card p-6 text-center text-sm\" style=\"color: var(--text-secondary)\">No podcast feeds exist yet.</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"oc-table card\"><table class=\"w-full text-sm\"><thead><tr class=\"text-left text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary); border-bottom: 1px solid var(--border);\"><th class=\"px-4 py-3\">Feed</th><th class=\"px-4 py-3\">Library</th><th class=\"px-4 py-3\">Subscribers</th><th class=\"px-4 py-3 text-right\">Downloaded</th><th class=\"px-4 py-3 text-right\">Disk</th><th class=\"px-4 py-3\">Last fetch</th><th class=\"px-4 py-3 text-right\">Actions</th></tr></thead> <tbody>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, f := range feeds {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<tr style=\"border-bottom: 1px solid var(--border);\" id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("admin-feed-" + f.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 66, Col: 86}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\"><td class=\"px-4 py-3\"><a href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 templ.SafeURL
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinURLErrs("/podcasts/" + f.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 68, Col: 40}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" class=\"font-medium hover:underline\" style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(f.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 68, Col: 123}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</a> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if f.Host != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<div class=\"text-xs\" style=\"color: var(--text-secondary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(f.Host)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 70, Col: 78}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</td><td class=\"px-4 py-3\" style=\"color: var(--text-secondary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(f.LibraryName)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 73, Col: 84}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</td><td class=\"px-4 py-3\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if f.Subscribers > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<span title=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.SubscriberNames)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 76, Col: 43}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(f.Subscribers))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 76, Col: 108}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<span class=\"badge\" style=\"background-color: var(--surface-secondary); color: var(--text-secondary)\">orphaned</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</td><td class=\"px-4 py-3 text-right tabular-nums\" style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(f.EpisodeDownloaded))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 82, Col: 44}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "/")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(f.EpisodeTotal))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 82, Col: 75}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</td><td class=\"px-4 py-3 text-right tabular-nums\" style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(FormatEpisodeSize(f.DiskBytes))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 84, Col: 123}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</td><td class=\"px-4 py-3 text-xs\" style=\"color: var(--text-secondary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(f.LastFetched)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 85, Col: 92}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</td><td class=\"px-4 py-3\"><div class=\"flex justify-end gap-2\"><button class=\"btn btn-secondary text-xs\" hx-post=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + f.ID + "/refresh")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 90, Col: 65}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" hx-swap=\"none\" title=\"Check the feed for new episodes now\">Refresh</button> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if f.EpisodeDownloaded > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<button class=\"btn btn-secondary text-xs\" hx-post=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + f.ID + "/delete-files")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 99, Col: 71}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\" hx-confirm=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("Delete the " + podcastInt(f.EpisodeDownloaded) + " downloaded file(s) of \"" + f.Title + "\"? Episodes reset to pending; a feed refresh will not re-materialize them.")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 100, Col: 194}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\" hx-swap=\"none\" style=\"color: var(--status-warning, #d97706);\">Delete files</button> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<button class=\"btn btn-secondary text-xs\" hx-delete=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var23 string
|
||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + f.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 109, Col: 54}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\" hx-confirm=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var24 string
|
||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue("ADMIN: remove \"" + f.Title + "\" for everyone? Episode rows AND downloaded files are deleted.")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 110, Col: 122}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" hx-swap=\"none\" style=\"color: var(--status-danger);\">Remove</button></div></td></tr>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</tbody></table></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</main></body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
@@ -244,6 +244,20 @@ templ TunableSettingRow(e SettingEntry) {
|
||||
}
|
||||
class="input py-1.5 text-sm w-32"
|
||||
/>
|
||||
} else if e.Type == "float" {
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
name="value"
|
||||
value={ e.Value }
|
||||
if e.Min != "" {
|
||||
min={ e.Min }
|
||||
}
|
||||
if e.Max != "" {
|
||||
max={ e.Max }
|
||||
}
|
||||
class="input py-1.5 text-sm w-32"
|
||||
/>
|
||||
} else {
|
||||
<input
|
||||
type="text"
|
||||
|
||||
@@ -813,26 +813,86 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "<input type=\"text\" name=\"value\" value=\"")
|
||||
} else if e.Type == "float" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "<input type=\"number\" step=\"any\" name=\"value\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Value)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 251, Col: 20}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 252, Col: 20}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "\" class=\"input py-1.5 text-sm w-40\"> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if e.Min != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, " min=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var23 string
|
||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Min)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 254, Col: 17}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if e.Max != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, " max=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var24 string
|
||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Max)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 257, Col: 17}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, " class=\"input py-1.5 text-sm w-32\"> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "<input type=\"text\" name=\"value\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var25 string
|
||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Value)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 265, Col: 20}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "\" class=\"input py-1.5 text-sm w-40\"> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "<button type=\"submit\" class=\"btn btn-secondary px-3 py-1.5 text-sm\" hx-disabled-elt=\"this\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "<button type=\"submit\" class=\"btn btn-secondary px-3 py-1.5 text-sm\" hx-disabled-elt=\"this\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -840,20 +900,20 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "Save</button></form><span id=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "Save</button></form><span id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var23 string
|
||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("status-" + e.Key)
|
||||
var templ_7745c5c3_Var26 string
|
||||
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue("status-" + e.Key)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 260, Col: 30}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 274, Col: 30}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "\" class=\"text-xs w-24 text-right\" style=\"color: var(--text-secondary)\"></span></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "\" class=\"text-xs w-24 text-right\" style=\"color: var(--text-secondary)\"></span></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -880,12 +940,12 @@ func AboutSection() templ.Component {
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var24 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var24 == nil {
|
||||
templ_7745c5c3_Var24 = templ.NopComponent
|
||||
templ_7745c5c3_Var27 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var27 == nil {
|
||||
templ_7745c5c3_Var27 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "<div class=\"mt-6 card p-6\"><div class=\"flex items-center gap-2 mb-4\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "<div class=\"mt-6 card p-6\"><div class=\"flex items-center gap-2 mb-4\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -893,20 +953,20 @@ func AboutSection() templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "<h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">About</h3></div><div class=\"space-y-2 text-sm\" style=\"color: var(--text-secondary);\"><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">Version:</span> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "<h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">About</h3></div><div class=\"space-y-2 text-sm\" style=\"color: var(--text-secondary);\"><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">Version:</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var25 string
|
||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(version.Version)
|
||||
var templ_7745c5c3_Var28 string
|
||||
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(version.Version)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 274, Col: 134}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 288, Col: 134}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "</p></div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "</p></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
+131
-42
@@ -7,7 +7,49 @@ package templates
|
||||
// <audio> element streams the m4b through the existing cookie-authed
|
||||
// Range-serving /uploads path.
|
||||
|
||||
import "fmt"
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// AudioReaderConfigJSON builds the reader's body[data-json] config: the
|
||||
// chapter/progress endpoints plus, for podcast episodes, the sequence
|
||||
// context (chain playback — plan D7). Books render the same JSON shape
|
||||
// WITHOUT the sequence key, so the reader code path is identical.
|
||||
func AudioReaderConfigJSON(metadata ReaderMetadata) string {
|
||||
cfg := map[string]any{
|
||||
"chapterEndpoint": "/api/media-items/" + metadata.MediaItemID + "/chapters",
|
||||
"progressEndpoint": "/api/media-items/" + metadata.MediaItemID + "/progress",
|
||||
}
|
||||
if metadata.PodcastSequence != nil {
|
||||
cfg["podcastSequence"] = metadata.PodcastSequence
|
||||
}
|
||||
b, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
// Unreachable for these shapes; degrade to the book-only config.
|
||||
return fmt.Sprintf(`{"chapterEndpoint":"%s","progressEndpoint":"%s"}`,
|
||||
"/api/media-items/"+metadata.MediaItemID+"/chapters",
|
||||
"/api/media-items/"+metadata.MediaItemID+"/progress")
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// audioStepPrevIcon / audioStepNextIcon: the transport row's outer
|
||||
// step icons (chapter-step for books, EPISODE-step for podcasts — the
|
||||
// same buttons serve both, round-3 user design 2026-10-30).
|
||||
templ audioStepPrevIcon() {
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M19 20 9 12l10-8v16z"></path>
|
||||
<path d="M5 19V5"></path>
|
||||
</svg>
|
||||
}
|
||||
|
||||
templ audioStepNextIcon() {
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M5 4l10 8-10 8V4z"></path>
|
||||
<path d="M19 5v14"></path>
|
||||
</svg>
|
||||
}
|
||||
|
||||
templ AudioReader(user User, metadata ReaderMetadata) {
|
||||
<!DOCTYPE html>
|
||||
@@ -41,9 +83,7 @@ templ AudioReader(user User, metadata ReaderMetadata) {
|
||||
data-cover={ metadata.CoverImagePath }
|
||||
data-file-url={ metadata.FileURL }
|
||||
data-keep-awake={ metadata.KeepScreenAwake }
|
||||
data-json={ fmt.Sprintf(`{"chapterEndpoint":"%s","progressEndpoint":"%s"}`,
|
||||
"/api/media-items/"+metadata.MediaItemID+"/chapters",
|
||||
"/api/media-items/"+metadata.MediaItemID+"/progress") }
|
||||
data-json={ AudioReaderConfigJSON(metadata) }
|
||||
>
|
||||
<!-- §17 wind-down dim: the sleep ticker eases this toward 35%
|
||||
black across a countdown's final 30s; pointer-events-none
|
||||
@@ -55,18 +95,29 @@ templ AudioReader(user User, metadata ReaderMetadata) {
|
||||
<!-- Header: back to the detail page (reader-style exit) and the
|
||||
chapter launcher. Floating drawer, never in-flow (A2b). -->
|
||||
<div class="absolute top-0 inset-x-0 z-30 flex items-center justify-between px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="backToDetail()"
|
||||
class="rounded-full p-2 hover:bg-surface-hover transition-colors"
|
||||
style="color: var(--text-primary);"
|
||||
aria-label="Back to book details"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M19 12H5"></path>
|
||||
<path d="m12 19-7-7 7-7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
@click="backToDetail()"
|
||||
class="rounded-full p-2 hover:bg-surface-hover transition-colors"
|
||||
style="color: var(--text-primary);"
|
||||
aria-label="Back to book details"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M19 12H5"></path>
|
||||
<path d="m12 19-7-7 7-7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Podcast sequence (D7 + round-3 user design): "Ep N of M"
|
||||
indicator. The episode prev/next live on the transport
|
||||
row's outer buttons (same buttons books use for chapter
|
||||
stepping) — not as separate icons here. -->
|
||||
if metadata.PodcastSequence != nil {
|
||||
<span class="text-xs tabular-nums ml-1" style="color: var(--text-secondary)">
|
||||
Ep { fmt.Sprintf("%d", metadata.PodcastSequence.EpisodeIndex) }/{ fmt.Sprintf("%d", metadata.PodcastSequence.IndexTotal) }
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@@ -292,19 +343,39 @@ templ AudioReader(user User, metadata ReaderMetadata) {
|
||||
>0:00</button>
|
||||
</div>
|
||||
<div class="flex items-center justify-center gap-3 md:gap-5">
|
||||
<button
|
||||
type="button"
|
||||
@click="stepChapter(-1)"
|
||||
class="rounded-full p-2.5 hover:bg-surface-hover transition-colors"
|
||||
aria-label="Previous chapter"
|
||||
title="Previous chapter"
|
||||
style="color: var(--text-primary);"
|
||||
>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M19 20 9 12l10-8v16z"></path>
|
||||
<path d="M5 19V5"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Outer left button: books = previous chapter; podcasts =
|
||||
previous EPISODE (round-3 user design: one set of big
|
||||
buttons does the big navigation; keyboard + hardware
|
||||
media keys route through stepChapter and follow). -->
|
||||
if metadata.PodcastSequence != nil {
|
||||
if metadata.PodcastSequence.PrevURL != "" {
|
||||
<button
|
||||
type="button"
|
||||
@click="goToPrevEpisode()"
|
||||
class="rounded-full p-2.5 hover:bg-surface-hover transition-colors"
|
||||
aria-label="Previous episode"
|
||||
title="Previous episode"
|
||||
style="color: var(--text-primary);"
|
||||
>
|
||||
@audioStepPrevIcon()
|
||||
</button>
|
||||
} else {
|
||||
<span class="rounded-full p-2.5 opacity-30 cursor-not-allowed" aria-disabled="true" title="No previous episode">
|
||||
@audioStepPrevIcon()
|
||||
</span>
|
||||
}
|
||||
} else {
|
||||
<button
|
||||
type="button"
|
||||
@click="stepChapter(-1)"
|
||||
class="rounded-full p-2.5 hover:bg-surface-hover transition-colors"
|
||||
aria-label="Previous chapter"
|
||||
title="Previous chapter"
|
||||
style="color: var(--text-primary);"
|
||||
>
|
||||
@audioStepPrevIcon()
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
@click="skip(-1)"
|
||||
@@ -357,19 +428,37 @@ templ AudioReader(user User, metadata ReaderMetadata) {
|
||||
<span x-text="skipSeconds">10</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="stepChapter(1)"
|
||||
class="rounded-full p-2.5 hover:bg-surface-hover transition-colors"
|
||||
aria-label="Next chapter"
|
||||
title="Next chapter"
|
||||
style="color: var(--text-primary);"
|
||||
>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M5 4l10 8-10 8V4z"></path>
|
||||
<path d="M19 5v14"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Outer right button: books = next chapter; podcasts = next
|
||||
EPISODE (disabled at the end of the feed). -->
|
||||
if metadata.PodcastSequence != nil {
|
||||
if metadata.PodcastSequence.NextURL != "" {
|
||||
<button
|
||||
type="button"
|
||||
@click="goToNextEpisode(false)"
|
||||
class="rounded-full p-2.5 hover:bg-surface-hover transition-colors"
|
||||
aria-label={ "Next episode: " + metadata.PodcastSequence.NextTitle }
|
||||
title={ "Next: " + metadata.PodcastSequence.NextTitle }
|
||||
style="color: var(--text-primary);"
|
||||
>
|
||||
@audioStepNextIcon()
|
||||
</button>
|
||||
} else {
|
||||
<span class="rounded-full p-2.5 opacity-30 cursor-not-allowed" aria-disabled="true" title="No next episode">
|
||||
@audioStepNextIcon()
|
||||
</span>
|
||||
}
|
||||
} else {
|
||||
<button
|
||||
type="button"
|
||||
@click="stepChapter(1)"
|
||||
class="rounded-full p-2.5 hover:bg-surface-hover transition-colors"
|
||||
aria-label="Next chapter"
|
||||
title="Next chapter"
|
||||
style="color: var(--text-primary);"
|
||||
>
|
||||
@audioStepNextIcon()
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+337
-99
@@ -15,9 +15,37 @@ import templruntime "github.com/a-h/templ/runtime"
|
||||
// <audio> element streams the m4b through the existing cookie-authed
|
||||
// Range-serving /uploads path.
|
||||
|
||||
import "fmt"
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
||||
// AudioReaderConfigJSON builds the reader's body[data-json] config: the
|
||||
// chapter/progress endpoints plus, for podcast episodes, the sequence
|
||||
// context (chain playback — plan D7). Books render the same JSON shape
|
||||
// WITHOUT the sequence key, so the reader code path is identical.
|
||||
func AudioReaderConfigJSON(metadata ReaderMetadata) string {
|
||||
cfg := map[string]any{
|
||||
"chapterEndpoint": "/api/media-items/" + metadata.MediaItemID + "/chapters",
|
||||
"progressEndpoint": "/api/media-items/" + metadata.MediaItemID + "/progress",
|
||||
}
|
||||
if metadata.PodcastSequence != nil {
|
||||
cfg["podcastSequence"] = metadata.PodcastSequence
|
||||
}
|
||||
b, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
// Unreachable for these shapes; degrade to the book-only config.
|
||||
return fmt.Sprintf(`{"chapterEndpoint":"%s","progressEndpoint":"%s"}`,
|
||||
"/api/media-items/"+metadata.MediaItemID+"/chapters",
|
||||
"/api/media-items/"+metadata.MediaItemID+"/progress")
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// audioStepPrevIcon / audioStepNextIcon: the transport row's outer
|
||||
// step icons (chapter-step for books, EPISODE-step for podcasts — the
|
||||
// same buttons serve both, round-3 user design 2026-10-30).
|
||||
func audioStepPrevIcon() templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
@@ -38,225 +66,435 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><title>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<svg width=\"22\" height=\"22\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M19 20 9 12l10-8v16z\"></path> <path d=\"M5 19V5\"></path></svg>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs("Listening · " + metadata.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 16, Col: 44}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func audioStepNextIcon() templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var2 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var2 == nil {
|
||||
templ_7745c5c3_Var2 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<svg width=\"22\" height=\"22\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M5 4l10 8-10 8V4z\"></path> <path d=\"M19 5v14\"></path></svg>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " · Bookhoard</title><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"><script type=\"module\" src=\"/static/main.js\"></script><style>\n\t\t\t\t/* Layered scrubber (plan §1): the native range stays for\n\t\t\t\t a11y (focus, arrow keys, aria) but its track is\n\t\t\t\t transparent so the tick/buffer/fill layers beneath show\n\t\t\t\t through. Static page CSS — templ owns the look. */\n\t\t\t\t#audio-scrub { -webkit-appearance: none; appearance: none; background: transparent; cursor: pointer; }\n\t\t\t\t#audio-scrub::-webkit-slider-runnable-track { background: transparent; border: none; }\n\t\t\t\t#audio-scrub::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; height: 14px; width: 14px; border-radius: 9999px; background: var(--accent); border: 2px solid var(--bg-primary); box-shadow: 0 1px 4px rgba(0,0,0,0.4); }\n\t\t\t\t#audio-scrub::-moz-range-track { background: transparent; border: none; }\n\t\t\t\t#audio-scrub::-moz-range-thumb { height: 14px; width: 14px; border-radius: 9999px; background: var(--accent); border: 2px solid var(--bg-primary); box-shadow: 0 1px 4px rgba(0,0,0,0.4); }\n\t\t\t</style></head>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
var templ_7745c5c3_Var3 = []any{"theme-" + user.Theme + " h-screen overflow-hidden"}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var3...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<body x-data=\"audioReader\" x-init=\"init()\" class=\"")
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var3 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var3 == nil {
|
||||
templ_7745c5c3_Var3 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<!doctype html><html lang=\"en\"><head><title>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var3).String())
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs("Listening · " + metadata.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 1, Col: 0}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 58, Col: 44}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" style=\"background-color: var(--bg-primary);\" data-media-item-id=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " · Bookhoard</title><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"><script type=\"module\" src=\"/static/main.js\"></script><style>\n\t\t\t\t/* Layered scrubber (plan §1): the native range stays for\n\t\t\t\t a11y (focus, arrow keys, aria) but its track is\n\t\t\t\t transparent so the tick/buffer/fill layers beneath show\n\t\t\t\t through. Static page CSS — templ owns the look. */\n\t\t\t\t#audio-scrub { -webkit-appearance: none; appearance: none; background: transparent; cursor: pointer; }\n\t\t\t\t#audio-scrub::-webkit-slider-runnable-track { background: transparent; border: none; }\n\t\t\t\t#audio-scrub::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; height: 14px; width: 14px; border-radius: 9999px; background: var(--accent); border: 2px solid var(--bg-primary); box-shadow: 0 1px 4px rgba(0,0,0,0.4); }\n\t\t\t\t#audio-scrub::-moz-range-track { background: transparent; border: none; }\n\t\t\t\t#audio-scrub::-moz-range-thumb { height: 14px; width: 14px; border-radius: 9999px; background: var(--accent); border: 2px solid var(--bg-primary); box-shadow: 0 1px 4px rgba(0,0,0,0.4); }\n\t\t\t</style></head>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.MediaItemID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 39, Col: 44}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
||||
var templ_7745c5c3_Var5 = []any{"theme-" + user.Theme + " h-screen overflow-hidden"}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var5...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" data-title=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<body x-data=\"audioReader\" x-init=\"init()\" class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.Title)
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var5).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 40, Col: 30}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" data-cover=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" style=\"background-color: var(--bg-primary);\" data-media-item-id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.CoverImagePath)
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.MediaItemID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 41, Col: 39}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 81, Col: 44}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" data-file-url=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" data-title=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.FileURL)
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 42, Col: 35}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 82, Col: 30}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" data-keep-awake=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" data-cover=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.KeepScreenAwake)
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.CoverImagePath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 43, Col: 45}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 83, Col: 39}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" data-json=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" data-file-url=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf(`{"chapterEndpoint":"%s","progressEndpoint":"%s"}`,
|
||||
"/api/media-items/"+metadata.MediaItemID+"/chapters",
|
||||
"/api/media-items/"+metadata.MediaItemID+"/progress"))
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.FileURL)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 46, Col: 57}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 84, Col: 35}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\"><!-- §17 wind-down dim: the sleep ticker eases this toward 35%\n\t\t\t black across a countdown's final 30s; pointer-events-none\n\t\t\t keeps the page usable, stop/finish resets it. --><div class=\"fixed inset-0 pointer-events-none z-40\" :style=\"'opacity:' + sleepDim + '; background: #000; transition: opacity 1s linear;'\"></div><div class=\"h-full flex flex-col items-center justify-center px-4 gap-6 select-none\"><!-- Header: back to the detail page (reader-style exit) and the\n\t\t\t chapter launcher. Floating drawer, never in-flow (A2b). --><div class=\"absolute top-0 inset-x-0 z-30 flex items-center justify-between px-4 py-3\"><button type=\"button\" @click=\"backToDetail()\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary);\" aria-label=\"Back to book details\"><svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M19 12H5\"></path> <path d=\"m12 19-7-7 7-7\"></path></svg></button> <span class=\"inline-flex items-center gap-2\"><button type=\"button\" @click=\"bookmarksOpen = !bookmarksOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" :aria-label=\"'Open bookmarks (' + bookmarks.length + ')'\" title=\"Bookmarks\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z\"></path></svg></button> <button type=\"button\" @click=\"chaptersOpen = !chaptersOpen\" class=\"text-sm font-semibold px-4 py-2 rounded-full hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" :aria-label=\"'Open chapter list (currently chapter ' + (currentChapterIdx + 1) + ' of ' + (chapters.length || 1) + ')'\" title=\"Chapters\"><span>Chapter</span> <span class=\"tabular-nums\" x-text=\"(currentChapterIdx + 1) + '/' + (chapters.length || 1)\">1/1</span> <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m6 9 6 6 6-6\"></path></svg></button> <button type=\"button\" @click=\"helpOpen = !helpOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" aria-label=\"Shortcuts & interactions\" title=\"Shortcuts & interactions (?)\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect x=\"2\" y=\"4\" width=\"20\" height=\"16\" rx=\"2\"></rect> <path d=\"M6 8h.01M10 8h.01M14 8h.01M18 8h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M8 16h8\"></path></svg></button><div class=\"relative\" @click.outside=\"settingsOpen = false\"><button type=\"button\" @click=\"settingsOpen = !settingsOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" :class=\"settingsOpen ? 'opacity-100' : 'opacity-90'\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" aria-label=\"Player settings\" title=\"Settings\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z\"></path> <circle cx=\"12\" cy=\"12\" r=\"3\"></circle></svg></button><div x-show=\"settingsOpen\" x-cloak x-transition class=\"absolute right-0 top-full mt-2 w-72 rounded-xl shadow-2xl p-3 z-50\" style=\"background-color: var(--bg-secondary); border: 1px solid var(--border);\"><div class=\"flex items-center justify-between mb-2 px-1\"><span class=\"text-sm font-seLine truncated
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" data-keep-awake=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, rate := range SpeedOptions {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<option value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%g", rate))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 169, Col: 41}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" :selected=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("isDefaultSpeed(%g)", rate))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 170, Col: 61}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\">")
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.KeepScreenAwake)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 85, Col: 45}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" data-json=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(AudioReaderConfigJSON(metadata))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 86, Col: 46}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\"><!-- §17 wind-down dim: the sleep ticker eases this toward 35%\n\t\t\t black across a countdown's final 30s; pointer-events-none\n\t\t\t keeps the page usable, stop/finish resets it. --><div class=\"fixed inset-0 pointer-events-none z-40\" :style=\"'opacity:' + sleepDim + '; background: #000; transition: opacity 1s linear;'\"></div><div class=\"h-full flex flex-col items-center justify-center px-4 gap-6 select-none\"><!-- Header: back to the detail page (reader-style exit) and the\n\t\t\t chapter launcher. Floating drawer, never in-flow (A2b). --><div class=\"absolute top-0 inset-x-0 z-30 flex items-center justify-between px-4 py-3\"><span class=\"inline-flex items-center gap-1\"><button type=\"button\" @click=\"backToDetail()\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary);\" aria-label=\"Back to book details\"><svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M19 12H5\"></path> <path d=\"m12 19-7-7 7-7\"></path></svg></button><!-- Podcast sequence (D7 + round-3 user design): \"Ep N of M\"\n\t\t\t\t\t indicator. The episode prev/next live on the transport\n\t\t\t\t\t row's outer buttons (same buttons books use for chapter\n\t\t\t\t\t stepping) — not as separate icons here. -->")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if metadata.PodcastSequence != nil {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<span class=\"text-xs tabular-nums ml-1\" style=\"color: var(--text-secondary)\">Ep ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%g×", rate))
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", metadata.PodcastSequence.EpisodeIndex))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 171, Col: 37}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 117, Col: 68}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</option>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</select></div></div></div></span></div><!-- Cover + title --><div class=\"flex flex-col items-center gap-3 text-center\"><div class=\"w-48 h-72 md:w-56 md:h-80 rounded-2xl overflow-hidden shadow-lg shrink-0 bg-cover bg-center\" style=\"background-color: var(--surface-hover);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if metadata.CoverImagePath != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<img src=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "/")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.CoverImagePath)
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", metadata.PodcastSequence.IndexTotal))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 185, Col: 36}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 117, Col: 127}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" alt=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</span> <span class=\"inline-flex items-center gap-2\"><button type=\"button\" @click=\"bookmarksOpen = !bookmarksOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" :aria-label=\"'Open bookmarks (' + bookmarks.length + ')'\" title=\"Bookmarks\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z\"></path></svg></button> <button type=\"button\" @click=\"chaptersOpen = !chaptersOpen\" class=\"text-sm font-semibold px-4 py-2 rounded-full hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" :aria-label=\"'Open chapter list (currently chapter ' + (currentChapterIdx + 1) + ' of ' + (chapters.length || 1) + ')'\" title=\"Chapters\"><span>Chapter</span> <span class=\"tabular-nums\" x-text=\"(currentChapterIdx + 1) + '/' + (chapters.length || 1)\">1/1</span> <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m6 9 6 6 6-6\"></path></svg></button> <button type=\"button\" @click=\"helpOpen = !helpOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" aria-label=\"Shortcuts & interactions\" title=\"Shortcuts & interactions (?)\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect x=\"2\" y=\"4\" width=\"20\" height=\"16\" rx=\"2\"></rect> <path d=\"M6 8h.01M10 8h.01M14 8h.01M18 8h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M8 16h8\"></path></svg></button><div class=\"relative\" @click.outside=\"settingsOpen = false\"><button type=\"button\" @click=\"settingsOpen = !settingsOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" :class=\"settingsOpen ? 'opacity-100' : 'opacity-90'\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" aria-label=\"Player settings\" title=\"Settings\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z\"></path> <circle cx=\"12\" cy=\"12\" r=\"3\"></circle></svg></button><div x-show=\"settingsOpen\" x-cloak x-transition class=\"absolute right-0 top-full mt-2 w-72 rounded-xl shadow-2xl p-3 z-50\" style=\"background-color: var(--bg-secondary); border: 1px solid var(--border);\"><div class=\"flex items-center justify-between mb-2 px-1\"><span class=\"text-sm font-semibold\" style=\"color: var(--text-primary);\">Player settings</span> <button type=\"button\" @click=\"settingsOpen = false\" class=\"rounded-full p-1.5 hover:bg-surface-hover\" style=\"color: var(--text-secondary);\" aria-label=\"Close settings\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M18 6 6 18\"></path> <path d=\"m6 6 12 12\"></path></svg></button></div><!-- Option rows: autoplay first, panel structured for more --><div class=\"flex items-center justify-between gap-3 px-1 py-2 rounded-lg\"><span class=\"text-sm\" style=\"color: var(--text-secondary);\">Autoplay on open</span> <button type=\"button\" role=\"switch\" :aria-checked=\"autoplayOn ? 'true' : 'false'\" @click=\"toggleAutoplay()\" class=\"relative h-5 w-9 rounded-full transition-colors shrink-0\" :style=\"autoplayOn ? 'background-color: var(--accent);' : 'background-color: var(--surface-hover); border: 1px solid var(--border);'\" aria-label=\"Autoplay on open\"><span class=\Line truncated
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, rate := range SpeedOptions {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<option value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.Title)
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%g", rate))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 186, Col: 27}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 220, Col: 41}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" class=\"w-full h-full object-cover\" data-fallback=\"hide\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" :selected=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("isDefaultSpeed(%g)", rate))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 221, Col: 61}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%g×", rate))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 222, Col: 37}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</option>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</select></div></div></div></span></div><!-- Cover + title --><div class=\"flex flex-col items-center gap-3 text-center\"><div class=\"w-48 h-72 md:w-56 md:h-80 rounded-2xl overflow-hidden shadow-lg shrink-0 bg-cover bg-center\" style=\"background-color: var(--surface-hover);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if metadata.CoverImagePath != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<img src=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.CoverImagePath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 236, Col: 36}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" alt=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 237, Col: 27}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" class=\"w-full h-full object-cover\" data-fallback=\"hide\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<span class=\"text-5xl flex items-center justify-center h-full\" style=\"color: var(--text-secondary);\">🎧</span>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<span class=\"text-5xl flex items-center justify-center h-full\" style=\"color: var(--text-secondary);\">🎧</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</div><div class=\"max-w-md px-4\"><h1 class=\"font-semibold text-lg leading-snug line-clamp-2\" style=\"color: var(--text-primary);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</div><div class=\"max-w-md px-4\"><h1 class=\"font-semibold text-lg leading-snug line-clamp-2\" style=\"color: var(--text-primary);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title)
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 195, Col: 117}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 246, Col: 117}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</h1><p class=\"text-sm mt-0.5 truncate\" style=\"color: var(--text-secondary);\" x-text=\"chapterTitle()\">—</p></div></div><!-- Scrubber + transports --><div class=\"w-full max-w-xl flex flex-col gap-2\"><div class=\"flex items-center gap-2 text-xs tabular-nums\" style=\"color: var(--text-secondary);\"><span class=\"inline-block min-w-[4.75rem]\" x-text=\"fmtTime(scrubbing ? scrubValue : currentTime)\">0:00</span><div id=\"scrub-area\" class=\"relative flex-1 h-6 flex items-center cursor-pointer\" style=\"touch-action: none;\" @pointerdown=\"scrubPointerDown($event)\" @pointermove=\"scrubPointerMove($event)\" @pointerup=\"scrubPointerUp($event)\" @pointercancel=\"scrubPointerUp($event)\" @pointerleave=\"scrubPointerLeave()\" @wheel.prevent=\"onScrubWheel($event)\"><!-- Base track --><div class=\"absolute inset-x-0 top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" style=\"background-color: var(--surface-hover);\"></div><!-- Elapsed fill --><div class=\"absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" :style=\"'left:0;width:' + scrubFillPct() + '%;background-color:var(--accent);'\"></div><!-- Buffered span: the ACTIVE track's element buffer only --><template x-if=\"bufferSpan()\"><div class=\"absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" :style=\"'left:' + bufferSpan().left + '%;width:' + bufferSpan().width + '%;background-color:var(--accent-muted);'\"></div></template><!-- Chapter ticks: density-decimated, current chapter tinted --><div id=\"scrub-ticks\" class=\"absolute inset-0 pointer-events-none\"><template x-for=\"idx in tickIndices\" :key=\"'tick' + idx\"><div class=\"absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-2 w-0.5 rounded-full\" :class=\"currentChapterIdx === idx ? 'opacity-100' : 'opacity-40'\" :style=\"'left:' + (chapters[idx].start / (chaptersDuration() || 1) * 100) + '%;background-color:' + (currentChapterIdx === idx ? 'var(--accent)' : 'var(--text-secondary)') + ';'\"></div></template></div><!-- §9 magnet: the line a click would land on (hover\n\t\t\t\t\t\t inside the pixel radius locks the tooltip here) --><div x-show=\"hoverSnapped && !scrubbing\" x-cloak class=\"absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-4 w-px rounded-full pointer-events-none\" :style=\"'left:' + hoverPct + '%;background-color:var(--accent);'\"></div><input id=\"audio-scrub\" type=\"range\" min=\"0\" step=\"0.1\" :max=\"chaptersDuration() || 1\" :value=\"scrubbing ? scrubValue : currentTime\" class=\"relative w-full\" @input=\"onScrubInput($event)\" @change=\"commitScrub()\" aria-label=\"Seek position\"><!-- Hover tooltip: time + chapter under the pointer --><div x-show=\"hoverSeconds !== null && !scrubbing\" x-cloak class=\"absolute bottom-full mb-1 -translate-x-1/2 whitespace-nowrap rounded-md px-2 py-1 text-[11px] shadow-lg pointer-events-none\" :style=\"'left:' + hoverPct + '%;background-color:var(--bg-secondary);border:1px solid var(--border);color:var(--text-primary);'\"><span x-text=\"hoverSeconds !== null ? fmtTime(hoverSeconds) : ''\"></span> <span style=\"color: var(--text-secondary);\" x-text=\"hoverSeconds !== null ? ' — ' + chapterTitleAt(hoverSeconds) : ''\"></span></div><!-- Precision rail (§9): Shift+drag on mouse, or a touch\n\t\t\t\t\t\t drag pulled off the bar — ±5 min or tighter, free\n\t\t\t\t\t\t mapping inside. Edge labels self-document the window\n\t\t\t\t\t\t (floats over the transports, page never shifts) --><div x-show=\"railVisible\" x-cloak class=\"absolute top-full mt-1 inset-x-0 h-12 rounded-lg shadow-lg p-1.5 pointer-events-none\" :style=\"'background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;' + (railLiftY ? ' transform: translateY(' + railLiftY + 'px);' : '')\"><div class=\"relative h-full\"><div class=\"absolute inset-x-0 top-1/2 h-px\" style=\"background-color: var(--border);\"></div><template x-for=\"tick in railTickList\" :key=\"'rtick' + tick.start\"><div class=\"absolute top-1 bottom-1 w-px\" :style=\"railTickStyle(tick)\"></div></template><div class=\"absolute top-0 bottom-0 w-0.5 -translate-x-1/2 rounded-full\" :style=\"'left:' + railMarkerPct() + '%;background-color:var(--accent);'\"></div><div class=\"absolute left-1 top-0.5 text-[10px] tabular-nums whitespace-nowrap\" style=\"color: var(--text-primary);\" x-text=\"fmtTime(scrubValue) + ' — ' + chapterTitleAt(scrubValue)\"></div><div class=\"absolute left-1 bottom-0.5 text-[9px] tabular-nums\" style=\"color: var(--text-secondary);\" x-text=\"fmtTime(railFrom)\"></div><div class=\"absolute right-1 bottom-0.5 text-[9px] tabular-nums\" style=\"color: var(--text-secondary);\" x-text=\"fmtTime(railTo)\"></div></div></div></div><button type=\"button\" class=\"inline-block min-w-[4.75rem] text-right cursor-pointer\" x-text=\"endTimeText()\" :aria-label=\"endTimeAria()\" :title=\"endTimeAria()\" @click=\"cycleEndTLine truncated
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</h1><p class=\"text-sm mt-0.5 truncate\" style=\"color: var(--text-secondary);\" x-text=\"chapterTitle()\">—</p></div></div><!-- Scrubber + transports --><div class=\"w-full max-w-xl flex flex-col gap-2\"><div class=\"flex items-center gap-2 text-xs tabular-nums\" style=\"color: var(--text-secondary);\"><span class=\"inline-block min-w-[4.75rem]\" x-text=\"fmtTime(scrubbing ? scrubValue : currentTime)\">0:00</span><div id=\"scrub-area\" class=\"relative flex-1 h-6 flex items-center cursor-pointer\" style=\"touch-action: none;\" @pointerdown=\"scrubPointerDown($event)\" @pointermove=\"scrubPointerMove($event)\" @pointerup=\"scrubPointerUp($event)\" @pointercancel=\"scrubPointerUp($event)\" @pointerleave=\"scrubPointerLeave()\" @wheel.prevent=\"onScrubWheel($event)\"><!-- Base track --><div class=\"absolute inset-x-0 top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" style=\"background-color: var(--surface-hover);\"></div><!-- Elapsed fill --><div class=\"absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" :style=\"'left:0;width:' + scrubFillPct() + '%;background-color:var(--accent);'\"></div><!-- Buffered span: the ACTIVE track's element buffer only --><template x-if=\"bufferSpan()\"><div class=\"absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" :style=\"'left:' + bufferSpan().left + '%;width:' + bufferSpan().width + '%;background-color:var(--accent-muted);'\"></div></template><!-- Chapter ticks: density-decimated, current chapter tinted --><div id=\"scrub-ticks\" class=\"absolute inset-0 pointer-events-none\"><template x-for=\"idx in tickIndices\" :key=\"'tick' + idx\"><div class=\"absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-2 w-0.5 rounded-full\" :class=\"currentChapterIdx === idx ? 'opacity-100' : 'opacity-40'\" :style=\"'left:' + (chapters[idx].start / (chaptersDuration() || 1) * 100) + '%;background-color:' + (currentChapterIdx === idx ? 'var(--accent)' : 'var(--text-secondary)') + ';'\"></div></template></div><!-- §9 magnet: the line a click would land on (hover\n\t\t\t\t\t\t inside the pixel radius locks the tooltip here) --><div x-show=\"hoverSnapped && !scrubbing\" x-cloak class=\"absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-4 w-px rounded-full pointer-events-none\" :style=\"'left:' + hoverPct + '%;background-color:var(--accent);'\"></div><input id=\"audio-scrub\" type=\"range\" min=\"0\" step=\"0.1\" :max=\"chaptersDuration() || 1\" :value=\"scrubbing ? scrubValue : currentTime\" class=\"relative w-full\" @input=\"onScrubInput($event)\" @change=\"commitScrub()\" aria-label=\"Seek position\"><!-- Hover tooltip: time + chapter under the pointer --><div x-show=\"hoverSeconds !== null && !scrubbing\" x-cloak class=\"absolute bottom-full mb-1 -translate-x-1/2 whitespace-nowrap rounded-md px-2 py-1 text-[11px] shadow-lg pointer-events-none\" :style=\"'left:' + hoverPct + '%;background-color:var(--bg-secondary);border:1px solid var(--border);color:var(--text-primary);'\"><span x-text=\"hoverSeconds !== null ? fmtTime(hoverSeconds) : ''\"></span> <span style=\"color: var(--text-secondary);\" x-text=\"hoverSeconds !== null ? ' — ' + chapterTitleAt(hoverSeconds) : ''\"></span></div><!-- Precision rail (§9): Shift+drag on mouse, or a touch\n\t\t\t\t\t\t drag pulled off the bar — ±5 min or tighter, free\n\t\t\t\t\t\t mapping inside. Edge labels self-document the window\n\t\t\t\t\t\t (floats over the transports, page never shifts) --><div x-show=\"railVisible\" x-cloak class=\"absolute top-full mt-1 inset-x-0 h-12 rounded-lg shadow-lg p-1.5 pointer-events-none\" :style=\"'background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;' + (railLiftY ? ' transform: translateY(' + railLiftY + 'px);' : '')\"><div class=\"relative h-full\"><div class=\"absolute inset-x-0 top-1/2 h-px\" style=\"background-color: var(--border);\"></div><template x-for=\"tick in railTickList\" :key=\"'rtick' + tick.start\"><div class=\"absolute top-1 bottom-1 w-px\" :style=\"railTickStyle(tick)\"></div></template><div class=\"absolute top-0 bottom-0 w-0.5 -translate-x-1/2 rounded-full\" :style=\"'left:' + railMarkerPct() + '%;background-color:var(--accent);'\"></div><div class=\"absolute left-1 top-0.5 text-[10px] tabular-nums whitespace-nowrap\" style=\"color: var(--text-primary);\" x-text=\"fmtTime(scrubValue) + ' — ' + chapterTitleAt(scrubValue)\"></div><div class=\"absolute left-1 bottom-0.5 text-[9px] tabular-nums\" style=\"color: var(--text-secondary);\" x-text=\"fmtTime(railFrom)\"></div><div class=\"absolute right-1 bottom-0.5 text-[9px] tabular-nums\" style=\"color: var(--text-secondary);\" x-text=\"fmtTime(railTo)\"></div></div></div></div><button type=\"button\" class=\"inline-block min-w-[4.75rem] text-right cursor-pointer\" x-text=\"endTimeText()\" :aria-label=\"endTimeAria()\" :title=\"endTimeAria()\" @click=\"cycleEndTLine truncated
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if metadata.PodcastSequence != nil {
|
||||
if metadata.PodcastSequence.PrevURL != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<button type=\"button\" @click=\"goToPrevEpisode()\" class=\"rounded-full p-2.5 hover:bg-surface-hover transition-colors\" aria-label=\"Previous episode\" title=\"Previous episode\" style=\"color: var(--text-primary);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = audioStepPrevIcon().Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</button> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<span class=\"rounded-full p-2.5 opacity-30 cursor-not-allowed\" aria-disabled=\"true\" title=\"No previous episode\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = audioStepPrevIcon().Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<button type=\"button\" @click=\"stepChapter(-1)\" class=\"rounded-full p-2.5 hover:bg-surface-hover transition-colors\" aria-label=\"Previous chapter\" title=\"Previous chapter\" style=\"color: var(--text-primary);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = audioStepPrevIcon().Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</button> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<button type=\"button\" @click=\"skip(-1)\" class=\"relative rounded-full h-14 w-14 grid place-items-center hover:bg-surface-hover transition-colors\" :aria-label=\"'Skip back ' + skipSeconds + 's'\" :title=\"'Skip back ' + skipSeconds + 's'\" style=\"color: var(--text-primary);\"><svg width=\"52\" height=\"52\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8\"></path> <path d=\"M3 3v5h5\"></path></svg> <span class=\"absolute inset-0 grid place-items-center text-[11px] font-semibold pointer-events-none\"><span x-text=\"skipSeconds\">10</span></span></button> <button id=\"audio-play\" type=\"button\" @click=\"togglePlay()\" class=\"rounded-full h-16 w-16 grid place-items-center\" style=\"background-color: var(--accent); color: var(--bg-primary);\" aria-label=\"Play or pause\"><span x-show=\"!playing\" class=\"grid place-items-center\"><svg width=\"28\" height=\"28\" viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\" style=\"margin-left: 2px;\"><path d=\"M8 5.2v13.6L19.2 12z\"></path></svg></span> <span x-show=\"playing\" class=\"grid place-items-center\" x-cloak><svg width=\"28\" height=\"28\" viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\"><rect x=\"6.2\" y=\"4.5\" width=\"4.2\" height=\"15\" rx=\"1.7\"></rect> <rect x=\"13.6\" y=\"4.5\" width=\"4.2\" height=\"15\" rx=\"1.7\"></rect></svg></span></button> <button type=\"button\" @click=\"skip(1)\" class=\"relative rounded-full h-14 w-14 grid place-items-center hover:bg-surface-hover transition-colors\" :aria-label=\"'Skip forward ' + skipSeconds + 's'\" :title=\"'Skip forward ' + skipSeconds + 's'\" style=\"color: var(--text-primary);\"><svg width=\"52\" height=\"52\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8\"></path> <path d=\"M21 3v5h-5\"></path></svg> <span class=\"absolute inset-0 grid place-items-center text-[11px] font-semibold pointer-events-none\"><span x-text=\"skipSeconds\">10</span></span></button><!-- Outer right button: books = next chapter; podcasts = next\n\t\t\t\t\t EPISODE (disabled at the end of the feed). -->")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if metadata.PodcastSequence != nil {
|
||||
if metadata.PodcastSequence.NextURL != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<button type=\"button\" @click=\"goToNextEpisode(false)\" class=\"rounded-full p-2.5 hover:bg-surface-hover transition-colors\" aria-label=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("Next episode: " + metadata.PodcastSequence.NextTitle)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 439, Col: 74}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\" title=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("Next: " + metadata.PodcastSequence.NextTitle)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 440, Col: 61}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "\" style=\"color: var(--text-primary);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = audioStepNextIcon().Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</button>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<span class=\"rounded-full p-2.5 opacity-30 cursor-not-allowed\" aria-disabled=\"true\" title=\"No next episode\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = audioStepNextIcon().Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<button type=\"button\" @click=\"stepChapter(1)\" class=\"rounded-full p-2.5 hover:bg-surface-hover transition-colors\" aria-label=\"Next chapter\" title=\"Next chapter\" style=\"color: var(--text-primary);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = audioStepNextIcon().Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</button>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</div></div><!-- Settings strip: Skip ▾ · Speed ▾ · Bookmark · Volume ▾ --><div class=\"w-full max-w-xl flex items-center justify-center gap-2\"><div class=\"relative\" @click.outside=\"skipOpen = false\"><button type=\"button\" @click=\"skipOpen = !skipOpen\" class=\"text-xs px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" :aria-label=\"'Skip duration, currently ' + skipSeconds + ' seconds'\" style=\"color: var(--text-secondary);\"><span>Skip</span> <span class=\"font-semibold\" style=\"color: var(--text-primary);\" x-text=\"skipSeconds + 's'\">10s</span> <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m6 9 6 6 6-6\"></path></svg></button><div x-show=\"skipOpen\" x-cloak class=\"absolute bottom-full mb-2 left-1/2 -translate-x-1/2 rounded-xl shadow-lg p-1.5 flex flex-col min-w-[7.5rem]\" style=\"background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;\"><template x-for=\"secs in skipChoices\" :key=\"'skip' + secs\"><button type=\"button\" @click=\"pickSkip(secs)\" class=\"w-full text-left px-3 py-1.5 rounded-md text-sm\" :class=\"isSkipActive(secs) ? 'font-semibold' : ''\" :style=\"isSkipActive(secs) ? 'background-color: var(--accent-muted); color: var(--text-primary);' : 'color: var(--text-secondary);'\" x-text=\"secs + ' seconds'\"></button></template></div></div><div class=\"relative\" @click.outside=\"speedOpen = false\"><button type=\"button\" @click=\"speedOpen = !speedOpen\" class=\"text-xs px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" :aria-label=\"'Playback speed, currently ' + speed + ' times'\" style=\"color: var(--text-secondary);\"><span>Speed</span> <span class=\"font-semibold\" style=\"color: var(--text-primary);\" x-text=\"speed + '×'\">1×</span> <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m6 9 6 6 6-6\"></path></svg></button><div x-show=\"speedOpen\" x-cloak class=\"absolute bottom-full mb-2 left-1/2 -translate-x-1/2 rounded-xl shadow-lg p-1.5 flex flex-col min-w-[7rem]\" style=\"background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;\"><template x-for=\"rate in speeds\" :key=\"'speed' + rate\"><button type=\"button\" @click=\"pickSpeed(rate)\" class=\"w-full text-left px-3 py-1.5 rounded-md text-sm\" :class=\"isSpeedActive(rate) ? 'font-semibold' : ''\" :style=\"isSpeedActive(rate) ? 'background-color: var(--accent-muted); color: var(--text-primary);' : 'color: var(--text-secondary);'\" x-text=\"rate + '×' + (isDefaultSpeed(rate) ? ' · default' : '')\"></button></template><!-- §8: pin the current rate as the user default —\n\t\t\t\t\t\t books without their own speed pick it up --><button type=\"button\" x-show=\"!isDefaultSpeed(speed)\" @click=\"pickDefaultSpeed(speed)\" class=\"w-full text-left px-3 py-1.5 mt-1 rounded-md text-xs border-t\" style=\"border-color: var(--border); color: var(--text-secondary);\" x-text=\"'Make ' + speed + '× my default'\"></button></div></div><button type=\"button\" @click=\"toggleBookmarkCapture()\" class=\"text-xs px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" :class=\"bookmarkCaptureOpen ? 'opacity-100' : 'opacity-70'\" aria-label=\"Add bookmark at current position\" style=\"color: var(--text-secondary);\"><svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z\"></path> <path d=\"M12 7v6M9 10h6\"></path></svg> <span>Bookmark</span></button><div class=\"relative\" @click.outside=\"sleepOpen = false\"><button type=\"button\" @click=\"sleepOpen = !sleepOpen\" class=\"text-xs px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" :aria-label=\"'Sleep timer, currently ' + sleepLabelStr\" style=\"color: var(--text-secondary);\"><svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z\"></path></svg> <span class=\"font-semibold tabular-nums\" style=\"color: var(--text-primary);\" x-text=\"sleepLabelStr === 'Off' ? 'Sleep' : sleepLabelStr\">Sleep</span> <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m6 9 6 6 6-6\"></path></svg></button><div xLine truncated
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -176,7 +176,7 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
|
||||
<span class="badge ml-1" style="background-color: var(--accent-muted); color: var(--accent);">{ book.NotesCount + book.HighlightsCount }</span>
|
||||
}
|
||||
</button>
|
||||
if user.Role == "admin" {
|
||||
if user.Role == "admin" && book.LibraryTypeName != "podcasts" {
|
||||
<button @click="showMetadataEditor()" class="btn btn-secondary px-5 py-2.5">
|
||||
@Icon("edit", "h-4 w-4")
|
||||
<span>Edit</span>
|
||||
|
||||
@@ -525,13 +525,13 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
||||
</div>
|
||||
<div>
|
||||
<label class="flex items-center gap-2 text-sm" style="color: var(--text-primary);">
|
||||
<input type="checkbox" name="abridged" checked={ book.Abridged } class="h-4 w-4 accent-current"/>
|
||||
<input type="checkbox" name="abridged" checked?={ book.Abridged } class="h-4 w-4 accent-current"/>
|
||||
<span>Abridged edition</span>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="flex items-center gap-2 text-sm" style="color: var(--text-primary);">
|
||||
<input type="checkbox" name="explicit" checked={ book.Explicit } class="h-4 w-4 accent-current"/>
|
||||
<input type="checkbox" name="explicit" checked?={ book.Explicit } class="h-4 w-4 accent-current"/>
|
||||
<span>Explicit content</span>
|
||||
</label>
|
||||
</div>
|
||||
@@ -589,19 +589,19 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
||||
<div>
|
||||
<label for="manga_type" class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Manga Type</label>
|
||||
<select id="manga_type" name="manga_type" class="input">
|
||||
<option value="unknown" selected={ textToString(book.MangaType) == "unknown" }>Unknown</option>
|
||||
<option value="no" selected={ textToString(book.MangaType) == "no" }>No</option>
|
||||
<option value="yes" selected={ textToString(book.MangaType) == "yes" }>Yes</option>
|
||||
<option value="yes_and_right_to_left" selected={ textToString(book.MangaType) == "yes_and_right_to_left" }>Yes (Right to Left)</option>
|
||||
<option value="unknown" selected?={ textToString(book.MangaType) == "unknown" }>Unknown</option>
|
||||
<option value="no" selected?={ textToString(book.MangaType) == "no" }>No</option>
|
||||
<option value="yes" selected?={ textToString(book.MangaType) == "yes" }>Yes</option>
|
||||
<option value="yes_and_right_to_left" selected?={ textToString(book.MangaType) == "yes_and_right_to_left" }>Yes (Right to Left)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="reading_direction" class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Reading Direction</label>
|
||||
<select id="reading_direction" name="reading_direction" class="input">
|
||||
<option value="auto" selected={ textToString(book.ReadingDirection) == "auto" }>Auto</option>
|
||||
<option value="ltr" selected={ textToString(book.ReadingDirection) == "ltr" }>Left to Right</option>
|
||||
<option value="rtl" selected={ textToString(book.ReadingDirection) == "rtl" }>Right to Left</option>
|
||||
<option value="vertical" selected={ textToString(book.ReadingDirection) == "vertical" }>Vertical</option>
|
||||
<option value="auto" selected?={ textToString(book.ReadingDirection) == "auto" }>Auto</option>
|
||||
<option value="ltr" selected?={ textToString(book.ReadingDirection) == "ltr" }>Left to Right</option>
|
||||
<option value="rtl" selected?={ textToString(book.ReadingDirection) == "rtl" }>Right to Left</option>
|
||||
<option value="vertical" selected?={ textToString(book.ReadingDirection) == "vertical" }>Vertical</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -1151,61 +1151,55 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "\" class=\"input\" placeholder=\"Comma-separated names\"></div><div><label class=\"flex items-center gap-2 text-sm\" style=\"color: var(--text-primary);\"><input type=\"checkbox\" name=\"abridged\" checked=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "\" class=\"input\" placeholder=\"Comma-separated names\"></div><div><label class=\"flex items-center gap-2 text-sm\" style=\"color: var(--text-primary);\"><input type=\"checkbox\" name=\"abridged\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var60 string
|
||||
templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Abridged)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 528, Col: 72}
|
||||
if book.Abridged {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, " checked")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var60)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, " class=\"h-4 w-4 accent-current\"> <span>Abridged edition</span></label></div><div><label class=\"flex items-center gap-2 text-sm\" style=\"color: var(--text-primary);\"><input type=\"checkbox\" name=\"explicit\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "\" class=\"h-4 w-4 accent-current\"> <span>Abridged edition</span></label></div><div><label class=\"flex items-center gap-2 text-sm\" style=\"color: var(--text-primary);\"><input type=\"checkbox\" name=\"explicit\" checked=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
if book.Explicit {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, " checked")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
var templ_7745c5c3_Var61 string
|
||||
templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Explicit)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 534, Col: 72}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var61)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "\" class=\"h-4 w-4 accent-current\"> <span>Explicit content</span></label></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, " class=\"h-4 w-4 accent-current\"> <span>Explicit content</span></label></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if book.AudioDuration() > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "<div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Duration (scanned)</label><p class=\"input opacity-60 cursor-default tabular-nums\" style=\"color: var(--text-primary);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "<div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Duration (scanned)</label><p class=\"input opacity-60 cursor-default tabular-nums\" style=\"color: var(--text-primary);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var62 string
|
||||
templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(book.AudioDurationLabel())
|
||||
var templ_7745c5c3_Var60 string
|
||||
templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(book.AudioDurationLabel())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 541, Col: 129}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "</p></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "</p></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "</div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "</div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "<div class=\"rounded-xl border overflow-hidden\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover\" style=\"color: var(--text-primary);\" @click=\"toggleSection('publication')\"><span class=\"font-semibold\">Publication Details</span> <span class=\"inline-flex\"><span x-show=\"openSections.publication\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "<div class=\"rounded-xl border overflow-hidden\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover\" style=\"color: var(--text-primary);\" @click=\"toggleSection('publication')\"><span class=\"font-semibold\">Publication Details</span> <span class=\"inline-flex\"><span x-show=\"openSections.publication\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -1213,7 +1207,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "</span> <span x-show=\"!openSections.publication\" x-cloak>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "</span> <span x-show=\"!openSections.publication\" x-cloak>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -1221,64 +1215,64 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "</span></span></button><div x-show=\"openSections.publication\" x-transition class=\"p-4 space-y-3 border-t\" style=\"border-color: var(--border);\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Age Rating</label> <input type=\"text\" name=\"age_rating\" value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "</span></span></button><div x-show=\"openSections.publication\" x-transition class=\"p-4 space-y-3 border-t\" style=\"border-color: var(--border);\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Age Rating</label> <input type=\"text\" name=\"age_rating\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var61 string
|
||||
templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.AgeRating))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 560, Col: 81}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var61)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Imprint</label> <input type=\"text\" name=\"imprint\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var62 string
|
||||
templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Imprint))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 564, Col: 76}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var62)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Scan Information</label> <input type=\"text\" name=\"scan_information\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var63 string
|
||||
templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.AgeRating))
|
||||
templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ScanInformation))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 560, Col: 81}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 568, Col: 93}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var63)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Imprint</label> <input type=\"text\" name=\"imprint\" value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Metadata Notes</label> <textarea name=\"metadata_notes\" rows=\"2\" class=\"input\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var64 string
|
||||
templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Imprint))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 564, Col: 76}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var64)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Scan Information</label> <input type=\"text\" name=\"scan_information\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var65 string
|
||||
templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ScanInformation))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 568, Col: 93}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var65)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Metadata Notes</label> <textarea name=\"metadata_notes\" rows=\"2\" class=\"input\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var66 string
|
||||
templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.JoinStringErrs(textToString(book.MetadataNotes))
|
||||
templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.JoinStringErrs(textToString(book.MetadataNotes))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 573, Col: 43}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var66))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var64))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "</textarea></div></div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "</textarea></div></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if book.ShowComicMetadata() {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "<div class=\"rounded-xl border overflow-hidden\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover\" style=\"color: var(--text-primary);\" @click=\"toggleSection('comic')\"><span class=\"font-semibold\">Comic/Manga</span> <span class=\"inline-flex\"><span x-show=\"openSections.comic\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "<div class=\"rounded-xl border overflow-hidden\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover\" style=\"color: var(--text-primary);\" @click=\"toggleSection('comic')\"><span class=\"font-semibold\">Comic/Manga</span> <span class=\"inline-flex\"><span x-show=\"openSections.comic\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -1286,7 +1280,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "</span> <span x-show=\"!openSections.comic\" x-cloak>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "</span> <span x-show=\"!openSections.comic\" x-cloak>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -1294,139 +1288,115 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "</span></span></button><div x-show=\"openSections.comic\" x-transition class=\"p-4 space-y-3 border-t\" style=\"border-color: var(--border);\"><div><label for=\"manga_type\" class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Manga Type</label> <select id=\"manga_type\" name=\"manga_type\" class=\"input\"><option value=\"unknown\" selected=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "</span></span></button><div x-show=\"openSections.comic\" x-transition class=\"p-4 space-y-3 border-t\" style=\"border-color: var(--border);\"><div><label for=\"manga_type\" class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Manga Type</label> <select id=\"manga_type\" name=\"manga_type\" class=\"input\"><option value=\"unknown\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var67 string
|
||||
templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MangaType) == "unknown")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 592, Col: 85}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var67)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "\">Unknown</option> <option value=\"no\" selected=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var68 string
|
||||
templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MangaType) == "no")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 593, Col: 75}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var68)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "\">No</option> <option value=\"yes\" selected=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var69 string
|
||||
templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MangaType) == "yes")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 594, Col: 77}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var69)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "\">Yes</option> <option value=\"yes_and_right_to_left\" selected=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var70 string
|
||||
templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MangaType) == "yes_and_right_to_left")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 595, Col: 113}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var70)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "\">Yes (Right to Left)</option></select></div><div><label for=\"reading_direction\" class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Reading Direction</label> <select id=\"reading_direction\" name=\"reading_direction\" class=\"input\"><option value=\"auto\" selected=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var71 string
|
||||
templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ReadingDirection) == "auto")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 601, Col: 86}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var71)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "\">Auto</option> <option value=\"ltr\" selected=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var72 string
|
||||
templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ReadingDirection) == "ltr")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 602, Col: 84}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var72)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "\">Left to Right</option> <option value=\"rtl\" selected=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var73 string
|
||||
templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ReadingDirection) == "rtl")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 603, Col: 84}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var73)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "\">Right to Left</option> <option value=\"vertical\" selected=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var74 string
|
||||
templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ReadingDirection) == "vertical")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 604, Col: 94}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var74)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "\">Vertical</option></select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Story Arc</label> <input type=\"text\" name=\"story_arc\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var75 string
|
||||
templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.StoryArc))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 609, Col: 79}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var75)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "\" class=\"input\"></div><div class=\"flex items-center gap-2\"><input type=\"checkbox\" name=\"is_black_and_white\" id=\"is_black_and_white\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if book.IsBlackAndWhite.Bool {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, " checked")
|
||||
if textToString(book.MangaType) == "unknown" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, " class=\"rounded\"> <label for=\"is_black_and_white\" class=\"text-sm\" style=\"color: var(--text-secondary);\">Black & White</label></div></div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, ">Unknown</option> <option value=\"no\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if textToString(book.MangaType) == "no" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, ">No</option> <option value=\"yes\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if textToString(book.MangaType) == "yes" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, ">Yes</option> <option value=\"yes_and_right_to_left\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if textToString(book.MangaType) == "yes_and_right_to_left" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, ">Yes (Right to Left)</option></select></div><div><label for=\"reading_direction\" class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Reading Direction</label> <select id=\"reading_direction\" name=\"reading_direction\" class=\"input\"><option value=\"auto\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if textToString(book.ReadingDirection) == "auto" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, ">Auto</option> <option value=\"ltr\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if textToString(book.ReadingDirection) == "ltr" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, ">Left to Right</option> <option value=\"rtl\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if textToString(book.ReadingDirection) == "rtl" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, ">Right to Left</option> <option value=\"vertical\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if textToString(book.ReadingDirection) == "vertical" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, ">Vertical</option></select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Story Arc</label> <input type=\"text\" name=\"story_arc\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var65 string
|
||||
templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.StoryArc))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 609, Col: 79}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var65)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "\" class=\"input\"></div><div class=\"flex items-center gap-2\"><input type=\"checkbox\" name=\"is_black_and_white\" id=\"is_black_and_white\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if book.IsBlackAndWhite.Bool {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, " checked")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, " class=\"rounded\"> <label for=\"is_black_and_white\" class=\"text-sm\" style=\"color: var(--text-secondary);\">Black & White</label></div></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "<div class=\"rounded-xl border overflow-hidden\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover\" style=\"color: var(--text-primary);\" @click=\"toggleSection('technical')\"><span class=\"font-semibold\">Technical</span> <span class=\"inline-flex\"><span x-show=\"openSections.technical\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "<div class=\"rounded-xl border overflow-hidden\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover\" style=\"color: var(--text-primary);\" @click=\"toggleSection('technical')\"><span class=\"font-semibold\">Technical</span> <span class=\"inline-flex\"><span x-show=\"openSections.technical\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -1434,7 +1404,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "</span> <span x-show=\"!openSections.technical\" x-cloak>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "</span> <span x-show=\"!openSections.technical\" x-cloak>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -1442,72 +1412,72 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "</span></span></button><div x-show=\"openSections.technical\" x-transition class=\"p-4 space-y-3 border-t\" style=\"border-color: var(--border);\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Page Count</label> <input type=\"number\" name=\"page_count\" value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "</span></span></button><div x-show=\"openSections.technical\" x-transition class=\"p-4 space-y-3 border-t\" style=\"border-color: var(--border);\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Page Count</label> <input type=\"number\" name=\"page_count\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var76 string
|
||||
templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", book.PageCount.Int32))
|
||||
var templ_7745c5c3_Var66 string
|
||||
templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", book.PageCount.Int32))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 634, Col: 56}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var76)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var66)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Contributors (comma-separated)</label> <input type=\"text\" name=\"contributors\" value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Contributors (comma-separated)</label> <input type=\"text\" name=\"contributors\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var77 string
|
||||
templ_7745c5c3_Var77, templ_7745c5c3_Err = templ.ResolveAttributeValue(stringSliceToString(book.Contributors))
|
||||
var templ_7745c5c3_Var67 string
|
||||
templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.ResolveAttributeValue(stringSliceToString(book.Contributors))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 639, Col: 93}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var77)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var67)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "\" class=\"input\"></div><div class=\"grid grid-cols-2 gap-3\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Format</label> <input type=\"text\" value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "\" class=\"input\"></div><div class=\"grid grid-cols-2 gap-3\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Format</label> <input type=\"text\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var78 string
|
||||
templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MimeType))
|
||||
var templ_7745c5c3_Var68 string
|
||||
templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MimeType))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 644, Col: 62}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var78)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var68)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "\" readonly class=\"input opacity-60\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">File Size</label> <input type=\"text\" value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "\" readonly class=\"input opacity-60\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">File Size</label> <input type=\"text\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var79 string
|
||||
templ_7745c5c3_Var79, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%.1f MB", float64(book.FileSize.Int64)/1024/1024))
|
||||
var templ_7745c5c3_Var69 string
|
||||
templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%.1f MB", float64(book.FileSize.Int64)/1024/1024))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 649, Col: 97}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var79)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var69)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "\" readonly class=\"input opacity-60\"></div></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">File Path</label> <input type=\"text\" value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "\" readonly class=\"input opacity-60\"></div></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">File Path</label> <input type=\"text\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var80 string
|
||||
templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.FileLocation)
|
||||
var templ_7745c5c3_Var70 string
|
||||
templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.FileLocation)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 655, Col: 51}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var80)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var70)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "\" readonly class=\"input opacity-60 font-mono text-xs\"></div></div></div></div></div><div class=\"flex items-center justify-between p-6 border-t flex-shrink-0\" style=\"border-color: var(--border);\"><div class=\"flex items-center gap-2\"><button @click=\"rescanBook()\" :disabled=\"rescanning\" class=\"btn btn-secondary\"><span x-show=\"!rescanning\" class=\"inline-flex items-center gap-2\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "\" readonly class=\"input opacity-60 font-mono text-xs\"></div></div></div></div></div><div class=\"flex items-center justify-between p-6 border-t flex-shrink-0\" style=\"border-color: var(--border);\"><div class=\"flex items-center gap-2\"><button @click=\"rescanBook()\" :disabled=\"rescanning\" class=\"btn btn-secondary\"><span x-show=\"!rescanning\" class=\"inline-flex items-center gap-2\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -1515,7 +1485,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "<span>Rescan</span></span> <svg x-show=\"rescanning\" class=\"animate-spin inline-block h-5 w-5\" viewBox=\"0 0 24 24\" fill=\"none\"><circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle> <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z\"></path></svg></button> <button @click=\"resetMetadata()\" :disabled=\"resetting\" class=\"btn btn-secondary\" title=\"Discard all manual edits and restore scanned metadata\"><span x-show=\"!resetting\" class=\"inline-flex items-center gap-2\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "<span>Rescan</span></span> <svg x-show=\"rescanning\" class=\"animate-spin inline-block h-5 w-5\" viewBox=\"0 0 24 24\" fill=\"none\"><circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle> <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z\"></path></svg></button> <button @click=\"resetMetadata()\" :disabled=\"resetting\" class=\"btn btn-secondary\" title=\"Discard all manual edits and restore scanned metadata\"><span x-show=\"!resetting\" class=\"inline-flex items-center gap-2\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -1523,7 +1493,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "<span>Reset to Scanned</span></span> <svg x-show=\"resetting\" class=\"animate-spin inline-block h-5 w-5\" viewBox=\"0 0 24 24\" fill=\"none\"><circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle> <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z\"></path></svg></button> <button @click=\"writeMetadataToFile()\" :disabled=\"writingFile\" class=\"btn btn-secondary\" :title=\"metadataWriteHint()\"><span x-show=\"!writingFile\" class=\"inline-flex items-center gap-2\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "<span>Reset to Scanned</span></span> <svg x-show=\"resetting\" class=\"animate-spin inline-block h-5 w-5\" viewBox=\"0 0 24 24\" fill=\"none\"><circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle> <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z\"></path></svg></button> <button @click=\"writeMetadataToFile()\" :disabled=\"writingFile\" class=\"btn btn-secondary\" :title=\"metadataWriteHint()\"><span x-show=\"!writingFile\" class=\"inline-flex items-center gap-2\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -1531,7 +1501,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "<span>Write to file</span></span> <svg x-show=\"writingFile\" class=\"animate-spin inline-block h-5 w-5\" viewBox=\"0 0 24 24\" fill=\"none\"><circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle> <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z\"></path></svg></button></div><div class=\"flex space-x-3\"><button @click=\"hideMetadataEditor()\" class=\"btn btn-secondary\">Cancel</button> <button @click=\"saveMetadata()\" class=\"btn btn-primary\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, "<span>Write to file</span></span> <svg x-show=\"writingFile\" class=\"animate-spin inline-block h-5 w-5\" viewBox=\"0 0 24 24\" fill=\"none\"><circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle> <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z\"></path></svg></button></div><div class=\"flex space-x-3\"><button @click=\"hideMetadataEditor()\" class=\"btn btn-secondary\">Cancel</button> <button @click=\"saveMetadata()\" class=\"btn btn-primary\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -1539,7 +1509,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "Save</button></div></div></div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 160, "Save</button></div></div></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -1567,12 +1537,12 @@ func WriteToFileConfirmDialog(book handlers.MediaDetail) templ.Component {
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var81 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var81 == nil {
|
||||
templ_7745c5c3_Var81 = templ.NopComponent
|
||||
templ_7745c5c3_Var71 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var71 == nil {
|
||||
templ_7745c5c3_Var71 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "<div id=\"write-confirm-dialog\" x-show=\"writeConfirmOpen\" x-cloak x-transition.opacity class=\"fixed inset-0 z-[60] flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card w-full max-w-md p-6\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex items-center gap-3 mb-4\"><span class=\"grid place-items-center h-10 w-10 rounded-xl shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 161, "<div id=\"write-confirm-dialog\" x-show=\"writeConfirmOpen\" x-cloak x-transition.opacity class=\"fixed inset-0 z-[60] flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card w-full max-w-md p-6\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex items-center gap-3 mb-4\"><span class=\"grid place-items-center h-10 w-10 rounded-xl shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -1580,7 +1550,7 @@ func WriteToFileConfirmDialog(book handlers.MediaDetail) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "</span><h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">Write metadata to file?</h3></div><p class=\"text-sm mb-2\" style=\"color: var(--text-secondary)\" x-text=\"writeConfirmMessage()\"></p><p class=\"text-xs mb-6\" style=\"color: var(--text-secondary)\">Your pending edits are saved first, then the file is rewritten and re-scanned.</p><div class=\"flex justify-end gap-3\"><button type=\"button\" @click=\"writeConfirmOpen = false\" class=\"btn btn-secondary\">Cancel</button> <button type=\"button\" @click=\"performWriteToFile()\" :disabled=\"writingFile\" class=\"btn btn-primary\"><span x-show=\"!writingFile\" class=\"inline-flex items-center gap-2\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 162, "</span><h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">Write metadata to file?</h3></div><p class=\"text-sm mb-2\" style=\"color: var(--text-secondary)\" x-text=\"writeConfirmMessage()\"></p><p class=\"text-xs mb-6\" style=\"color: var(--text-secondary)\">Your pending edits are saved first, then the file is rewritten and re-scanned.</p><div class=\"flex justify-end gap-3\"><button type=\"button\" @click=\"writeConfirmOpen = false\" class=\"btn btn-secondary\">Cancel</button> <button type=\"button\" @click=\"performWriteToFile()\" :disabled=\"writingFile\" class=\"btn btn-primary\"><span x-show=\"!writingFile\" class=\"inline-flex items-center gap-2\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -1588,7 +1558,7 @@ func WriteToFileConfirmDialog(book handlers.MediaDetail) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "<span>Save & write</span></span> <svg x-show=\"writingFile\" class=\"animate-spin inline-block h-5 w-5\" viewBox=\"0 0 24 24\" fill=\"none\"><circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle> <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z\"></path></svg></button></div></div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 163, "<span>Save & write</span></span> <svg x-show=\"writingFile\" class=\"animate-spin inline-block h-5 w-5\" viewBox=\"0 0 24 24\" fill=\"none\"><circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle> <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z\"></path></svg></button></div></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -566,7 +566,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Role == "admin" {
|
||||
if user.Role == "admin" && book.LibraryTypeName != "podcasts" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<button @click=\"showMetadataEditor()\" class=\"btn btn-secondary px-5 py-2.5\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
|
||||
@@ -75,12 +75,12 @@ templ CollectionModal(collection CollectionData) {
|
||||
<div class="mb-4 space-y-2">
|
||||
<label class="flex items-center gap-2 text-sm" style="color: var(--text-primary);">
|
||||
<input type="hidden" name="hide_finished" value="false"/>
|
||||
<input type="checkbox" name="hide_finished" value="true" checked={ collection.HideFinished } class="h-4 w-4 accent-current"/>
|
||||
<input type="checkbox" name="hide_finished" value="true" checked?={ collection.HideFinished } class="h-4 w-4 accent-current"/>
|
||||
<span>Hide finished books (reading-queue mode)</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm" style="color: var(--text-primary);">
|
||||
<input type="hidden" name="show_up_next" value="false"/>
|
||||
<input type="checkbox" name="show_up_next" value="true" checked={ collection.ShowUpNext } class="h-4 w-4 accent-current"/>
|
||||
<input type="checkbox" name="show_up_next" value="true" checked?={ collection.ShowUpNext } class="h-4 w-4 accent-current"/>
|
||||
<span>Show Up Next strip</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -143,56 +143,50 @@ func CollectionModal(collection CollectionData) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\"><div class=\"mb-4 space-y-2\"><label class=\"flex items-center gap-2 text-sm\" style=\"color: var(--text-primary);\"><input type=\"hidden\" name=\"hide_finished\" value=\"false\"> <input type=\"checkbox\" name=\"hide_finished\" value=\"true\" checked=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\"><div class=\"mb-4 space-y-2\"><label class=\"flex items-center gap-2 text-sm\" style=\"color: var(--text-primary);\"><input type=\"hidden\" name=\"hide_finished\" value=\"false\"> <input type=\"checkbox\" name=\"hide_finished\" value=\"true\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if collection.HideFinished {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, " checked")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " class=\"h-4 w-4 accent-current\"> <span>Hide finished books (reading-queue mode)</span></label> <label class=\"flex items-center gap-2 text-sm\" style=\"color: var(--text-primary);\"><input type=\"hidden\" name=\"show_up_next\" value=\"false\"> <input type=\"checkbox\" name=\"show_up_next\" value=\"true\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if collection.ShowUpNext {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, " checked")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, " class=\"h-4 w-4 accent-current\"> <span>Show Up Next strip</span></label></div></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"closeCollectionModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Update Collection</button></div></form>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<form hx-post=\"/api/collections\" hx-redirect=\"/collections\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Type</label><div class=\"grid grid-cols-2 gap-2\"><label class=\"relative cursor-pointer\"><input type=\"radio\" name=\"collection_type\" value=\"static\" checked class=\"peer sr-only\"><div class=\"p-3 rounded-lg border text-center peer-checked:border-blue-500\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><span class=\"font-semibold text-sm\" style=\"color: var(--text-primary)\">Static</span><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">Pick books by hand</p></div></label> <label class=\"relative cursor-pointer\"><input type=\"radio\" name=\"collection_type\" value=\"smart\" class=\"peer sr-only\"><div class=\"p-3 rounded-lg border text-center peer-checked:border-blue-500 peer-checked:ring-2 peer-checked:ring-blue-400\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><span class=\"font-semibold text-sm\" style=\"color: var(--text-primary)\">⚡ Smart</span><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">Match books by rules — always live</p></div></label></div></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Name</label> <input type=\"text\" name=\"name\" required class=\"input\" placeholder=\"My Reading List\"></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Description</label> <textarea name=\"description\" class=\"input\" placeholder=\"Optional description\" rows=\"3\"></textarea></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Icon</label> <input type=\"text\" id=\"icon-search\" class=\"input mb-2\" placeholder=\"Search or type emoji...\" maxlength=\"4\" @input=\"filterIcons($event.target.value)\" @focus=\"showAllIcons()\"> <input type=\"hidden\" name=\"icon\" id=\"collection-icon\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.HideFinished)
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Icon)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 78, Col: 98}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 147, Col: 83}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" class=\"h-4 w-4 accent-current\"> <span>Hide finished books (reading-queue mode)</span></label> <label class=\"flex items-center gap-2 text-sm\" style=\"color: var(--text-primary);\"><input type=\"hidden\" name=\"show_up_next\" value=\"false\"> <input type=\"checkbox\" name=\"show_up_next\" value=\"true\" checked=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ShowUpNext)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 83, Col: 95}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" class=\"h-4 w-4 accent-current\"> <span>Show Up Next strip</span></label></div></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"closeCollectionModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Update Collection</button></div></form>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<form hx-post=\"/api/collections\" hx-redirect=\"/collections\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Type</label><div class=\"grid grid-cols-2 gap-2\"><label class=\"relative cursor-pointer\"><input type=\"radio\" name=\"collection_type\" value=\"static\" checked class=\"peer sr-only\"><div class=\"p-3 rounded-lg border text-center peer-checked:border-blue-500\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><span class=\"font-semibold text-sm\" style=\"color: var(--text-primary)\">Static</span><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">Pick books by hand</p></div></label> <label class=\"relative cursor-pointer\"><input type=\"radio\" name=\"collection_type\" value=\"smart\" class=\"peer sr-only\"><div class=\"p-3 rounded-lg border text-center peer-checked:border-blue-500 peer-checked:ring-2 peer-checked:ring-blue-400\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><span class=\"font-semibold text-sm\" style=\"color: var(--text-primary)\">⚡ Smart</span><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">Match books by rules — always live</p></div></label></div></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Name</label> <input type=\"text\" name=\"name\" required class=\"input\" placeholder=\"My Reading List\"></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Description</label> <textarea name=\"description\" class=\"input\" placeholder=\"Optional description\" rows=\"3\"></textarea></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Icon</label> <input type=\"text\" id=\"icon-search\" class=\"input mb-2\" placeholder=\"Search or type emoji...\" maxlength=\"4\" @input=\"filterIcons($event.target.value)\" @focus=\"showAllIcons()\"> <input type=\"hidden\" name=\"icon\" id=\"collection-icon\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Icon)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 147, Col: 83}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\"><div id=\"icon-grid\" class=\"grid grid-cols-8 gap-1 max-h-32 overflow-y-auto p-2 rounded-lg border\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"></div></div><div class=\"mb-6\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Color</label><div class=\"flex gap-2\"><button type=\"button\" @click=\"selectColor('blue')\" data-color=\"blue\" class=\"w-8 h-8 rounded-full color-option bg-blue-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-blue-400\" aria-label=\"Blue\"></button> <button type=\"button\" @click=\"selectColor('red')\" data-color=\"red\" class=\"w-8 h-8 rounded-full color-option bg-red-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-red-400\" aria-label=\"Red\"></button> <button type=\"button\" @click=\"selectColor('yellow')\" data-color=\"yellow\" class=\"w-8 h-8 rounded-full color-option bg-yellow-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-yellow-400\" aria-label=\"Yellow\"></button> <button type=\"button\" @click=\"selectColor('green')\" data-color=\"green\" class=\"w-8 h-8 rounded-full color-option bg-green-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-green-400\" aria-label=\"Green\"></button> <button type=\"button\" @click=\"selectColor('purple')\" data-color=\"purple\" class=\"w-8 h-8 rounded-full color-option bg-purple-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-purple-400\" aria-label=\"Purple\"></button></div><input type=\"hidden\" name=\"color\" id=\"collection-color\" value=\"blue\"></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"closeCollectionModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Create Collection</button></div></form>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\"><div id=\"icon-grid\" class=\"grid grid-cols-8 gap-1 max-h-32 overflow-y-auto p-2 rounded-lg border\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"></div></div><div class=\"mb-6\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Color</label><div class=\"flex gap-2\"><button type=\"button\" @click=\"selectColor('blue')\" data-color=\"blue\" class=\"w-8 h-8 rounded-full color-option bg-blue-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-blue-400\" aria-label=\"Blue\"></button> <button type=\"button\" @click=\"selectColor('red')\" data-color=\"red\" class=\"w-8 h-8 rounded-full color-option bg-red-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-red-400\" aria-label=\"Red\"></button> <button type=\"button\" @click=\"selectColor('yellow')\" data-color=\"yellow\" class=\"w-8 h-8 rounded-full color-option bg-yellow-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-yellow-400\" aria-label=\"Yellow\"></button> <button type=\"button\" @click=\"selectColor('green')\" data-color=\"green\" class=\"w-8 h-8 rounded-full color-option bg-green-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-green-400\" aria-label=\"Green\"></button> <button type=\"button\" @click=\"selectColor('purple')\" data-color=\"purple\" class=\"w-8 h-8 rounded-full color-option bg-purple-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-purple-400\" aria-label=\"Purple\"></button></div><input type=\"hidden\" name=\"color\" id=\"collection-color\" value=\"blue\"></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"closeCollectionModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Create Collection</button></div></form>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -70,6 +70,12 @@ templ Header(user User, currentPath string) {
|
||||
@Icon("layers", "h-5 w-5 shrink-0")
|
||||
<span>Series</span>
|
||||
</a>
|
||||
if user.ShowPodcastsNav {
|
||||
<a href="/podcasts" class={ activeClass(currentPath, "/podcasts") }>
|
||||
@Icon("podcast", "h-5 w-5 shrink-0")
|
||||
<span>Podcasts</span>
|
||||
</a>
|
||||
}
|
||||
<a href="/collections" class={ activeClass(currentPath, "/collections") }>
|
||||
@Icon("folder", "h-5 w-5 shrink-0")
|
||||
<span>Collections</span>
|
||||
@@ -202,6 +208,10 @@ templ Header(user User, currentPath string) {
|
||||
@Icon("library", "h-5 w-5 shrink-0")
|
||||
<span>Libraries</span>
|
||||
</a>
|
||||
<a href="/admin/podcasts" class={ activeClass(currentPath, "/admin/podcasts") }>
|
||||
@Icon("podcast", "h-5 w-5 shrink-0")
|
||||
<span>Podcasts</span>
|
||||
</a>
|
||||
<a href="/admin/hash-conflicts" class={ activeClass(currentPath, "/admin/hash-conflicts") }>
|
||||
@Icon("copy", "h-5 w-5 shrink-0")
|
||||
<span>Hash Conflicts</span>
|
||||
@@ -219,6 +229,28 @@ templ Header(user User, currentPath string) {
|
||||
}
|
||||
</div>
|
||||
</aside>
|
||||
<!-- Global confirm modal (issue #5): ONE styled dialog answers every
|
||||
hx-confirm sitewide via the htmx:confirm interceptor in
|
||||
web/src/confirm.ts, and exposes the confirmDialog() promise for
|
||||
legacy JS call sites. Inert until shown. MUST render on every
|
||||
page (it previously lived inside SidebarSignIn — signed-in
|
||||
pages had no #confirm-modal, so confirmDialog degraded to the
|
||||
native window.confirm; fixed 2026-10-30, user report). -->
|
||||
<div
|
||||
id="confirm-modal"
|
||||
class="hidden fixed inset-0 z-[60] flex items-center justify-center p-4"
|
||||
style="background-color: var(--surface-overlay);"
|
||||
>
|
||||
<div class="card w-full max-w-sm" style="box-shadow: var(--shadow-pop);">
|
||||
<div class="p-5 border-b" style="border-color: var(--border);">
|
||||
<p id="confirm-modal-message" class="text-sm font-medium" style="color: var(--text-primary)"></p>
|
||||
</div>
|
||||
<div class="p-4 flex justify-end gap-2">
|
||||
<button id="confirm-modal-cancel" class="btn btn-secondary">Cancel</button>
|
||||
<button id="confirm-modal-accept" class="btn btn-primary">Confirm</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Topbar -->
|
||||
<header class="app-topbar">
|
||||
<div class="flex items-center gap-3 px-4 sm:px-6 h-full">
|
||||
|
||||
+331
-269
@@ -131,42 +131,44 @@ func Header(user User, currentPath string) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 = []any{activeClass(currentPath, "/collections")}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var8...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
if user.ShowPodcastsNav {
|
||||
var templ_7745c5c3_Var8 = []any{activeClass(currentPath, "/podcasts")}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var8...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<a href=\"/podcasts\" class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var8).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("podcast", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<span>Podcasts</span></a> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<a href=\"/collections\" class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var8).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("folder", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<span>Collections</span></a> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 = []any{activeClass(currentPath, "/progress")}
|
||||
var templ_7745c5c3_Var10 = []any{activeClass(currentPath, "/collections")}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var10...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<a href=\"/progress\" class=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<a href=\"/collections\" class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -183,20 +185,20 @@ func Header(user User, currentPath string) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("clock", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||
templ_7745c5c3_Err = Icon("folder", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<span>Progress</span></a> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<span>Collections</span></a> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var12 = []any{activeClass(currentPath, "/stats")}
|
||||
var templ_7745c5c3_Var12 = []any{activeClass(currentPath, "/progress")}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var12...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<a href=\"/stats\" class=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<a href=\"/progress\" class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -213,20 +215,20 @@ func Header(user User, currentPath string) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("chart", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||
templ_7745c5c3_Err = Icon("clock", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<span>Stats</span></a> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<span>Progress</span></a> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var14 = []any{activeClass(currentPath, "/devices")}
|
||||
var templ_7745c5c3_Var14 = []any{activeClass(currentPath, "/stats")}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var14...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<a href=\"/devices\" class=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<a href=\"/stats\" class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -243,24 +245,54 @@ func Header(user User, currentPath string) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("chart", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<span>Stats</span></a> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var16 = []any{activeClass(currentPath, "/devices")}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var16...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<a href=\"/devices\" class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var16).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("device", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<span>Devices</span></a></nav><!-- Bottom: theme picker + user --><div x-data=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<span>Devices</span></a></nav><!-- Bottom: theme picker + user --><div x-data=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("sidebarShell('" + string(ThemeModeOf(user.Theme)) + "')")
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("sidebarShell('" + string(ThemeModeOf(user.Theme)) + "')")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 92, Col: 70}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 98, Col: 70}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" class=\"shrink-0 px-3 py-3 space-y-2 border-t\" style=\"border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" class=\"shrink-0 px-3 py-3 space-y-2 border-t\" style=\"border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -275,7 +307,7 @@ func Header(user User, currentPath string) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<!-- Theme picker --><div class=\"sidebar-panel\"><button type=\"button\" @click=\"toggleAppearancePanel()\" class=\"w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors text-content-muted hover:text-content hover:bg-surface-hover\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<!-- Theme picker --><div class=\"sidebar-panel\"><button type=\"button\" @click=\"toggleAppearancePanel()\" class=\"w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors text-content-muted hover:text-content hover:bg-surface-hover\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -283,7 +315,7 @@ func Header(user User, currentPath string) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<span>Appearance</span> <span class=\"ml-auto h-4 w-4 transition-transform\" :class=\"{ 'rotate-180': openPanel === 'appearance' }\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<span>Appearance</span> <span class=\"ml-auto h-4 w-4 transition-transform\" :class=\"{ 'rotate-180': openPanel === 'appearance' }\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -291,48 +323,14 @@ func Header(user User, currentPath string) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</span></button><div x-show=\"openPanel === 'appearance'\" x-cloak x-transition class=\"mt-1 pl-1\"><!-- Dark / Light filter --><div class=\"grid grid-cols-2 gap-1 px-2 pt-1\"><button type=\"button\" @click=\"setThemeMode('dark')\" class=\"flex items-center justify-center gap-1.5 py-1.5 rounded-md text-xs font-medium transition-colors\" :class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("themeMode === 'dark' ? 'mode-toggle-active' : 'text-content-muted hover:text-content'")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 121, Col: 104}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("moon", "h-3.5 w-3.5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<span>Dark</span> <span class=\"tabular-nums opacity-50\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(len(ThemeOptionsFor(ModeDark)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 125, Col: 78}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</span></button> <button type=\"button\" @click=\"setThemeMode('light')\" class=\"flex items-center justify-center gap-1.5 py-1.5 rounded-md text-xs font-medium transition-colors\" :class=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</span></button><div x-show=\"openPanel === 'appearance'\" x-cloak x-transition class=\"mt-1 pl-1\"><!-- Dark / Light filter --><div class=\"grid grid-cols-2 gap-1 px-2 pt-1\"><button type=\"button\" @click=\"setThemeMode('dark')\" class=\"flex items-center justify-center gap-1.5 py-1.5 rounded-md text-xs font-medium transition-colors\" :class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue("themeMode === 'light' ? 'mode-toggle-active' : 'text-content-muted hover:text-content'")
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue("themeMode === 'dark' ? 'mode-toggle-active' : 'text-content-muted hover:text-content'")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 131, Col: 105}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 127, Col: 104}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -342,116 +340,150 @@ func Header(user User, currentPath string) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("sun", "h-3.5 w-3.5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||
templ_7745c5c3_Err = Icon("moon", "h-3.5 w-3.5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<span>Light</span> <span class=\"tabular-nums opacity-50\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<span>Dark</span> <span class=\"tabular-nums opacity-50\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(len(ThemeOptionsFor(ModeLight)))
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(len(ThemeOptionsFor(ModeDark)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 135, Col: 79}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 131, Col: 78}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</span></button></div><!-- Scrollable theme list (filtered by the mode above) --><div class=\"mt-1 max-h-72 overflow-y-auto pr-1 space-y-0.5\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</span></button> <button type=\"button\" @click=\"setThemeMode('light')\" class=\"flex items-center justify-center gap-1.5 py-1.5 rounded-md text-xs font-medium transition-colors\" :class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("themeMode === 'light' ? 'mode-toggle-active' : 'text-content-muted hover:text-content'")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 137, Col: 105}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("sun", "h-3.5 w-3.5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<span>Light</span> <span class=\"tabular-nums opacity-50\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(len(ThemeOptionsFor(ModeLight)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 141, Col: 79}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</span></button></div><!-- Scrollable theme list (filtered by the mode above) --><div class=\"mt-1 max-h-72 overflow-y-auto pr-1 space-y-0.5\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, opt := range ThemeOptions {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<button type=\"button\" @click=\"selectTheme(opt.Name)\" class=\"theme-btn w-full flex items-center gap-2.5 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover\" x-show=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("themeMode === '" + string(opt.Mode) + "'")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 145, Col: 60}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" data-theme=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 146, Col: 30}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\" data-mode=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<button type=\"button\" @click=\"selectTheme(opt.Name)\" class=\"theme-btn w-full flex items-center gap-2.5 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover\" x-show=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var23 string
|
||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(string(opt.Mode))
|
||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("themeMode === '" + string(opt.Mode) + "'")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 147, Col: 37}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 151, Col: 60}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "\" style=\"color: var(--text-secondary);\"><span class=\"inline-block w-3.5 h-3.5 rounded-full shrink-0 ring-1 ring-black/10\" style=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "\" data-theme=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var24 string
|
||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background-color: " + opt.Color)
|
||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 150, Col: 131}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 152, Col: 30}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "\"></span> <span class=\"flex-1 text-left\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\" data-mode=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var25 string
|
||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label)
|
||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(string(opt.Mode))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 151, Col: 51}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 153, Col: 37}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</span> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\" style=\"color: var(--text-secondary);\"><span class=\"inline-block w-3.5 h-3.5 rounded-full shrink-0 ring-1 ring-black/10\" style=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var26 = []any{"theme-check shrink-0" + themeCheckClass(opt.Name, user.Theme)}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var26...)
|
||||
var templ_7745c5c3_Var26 string
|
||||
templ_7745c5c3_Var26, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background-color: " + opt.Color)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 156, Col: 131}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<span class=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\"></span> <span class=\"flex-1 text-left\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var27 string
|
||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var26).String())
|
||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 1, Col: 0}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 157, Col: 51}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var28 = []any{"theme-check shrink-0" + themeCheckClass(opt.Name, user.Theme)}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var28...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<span class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var29 string
|
||||
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var28).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -459,94 +491,94 @@ func Header(user User, currentPath string) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</span></button>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</span></button>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</div><div class=\"my-1.5 mx-3 border-t\" style=\"border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);\"></div><p class=\"px-3 pb-1 text-xs font-medium uppercase tracking-wide\" style=\"color: var(--text-secondary);\">Bookshelf</p>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</div><div class=\"my-1.5 mx-3 border-t\" style=\"border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);\"></div><p class=\"px-3 pb-1 text-xs font-medium uppercase tracking-wide\" style=\"color: var(--text-secondary);\">Bookshelf</p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, w := range WoodOptions {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<button type=\"button\" @click=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<button type=\"button\" @click=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var28 string
|
||||
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue("changeWoodPaneling('" + w.Name + "')")
|
||||
var templ_7745c5c3_Var30 string
|
||||
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue("changeWoodPaneling('" + w.Name + "')")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 163, Col: 55}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 169, Col: 55}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\" class=\"wood-paneling-btn w-full flex items-center gap-2.5 px-3 py-1.5 rounded-lg text-sm transition-colors\" data-wood=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var29 string
|
||||
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(w.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 165, Col: 26}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\" style=\"color: var(--text-secondary);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if w.Name == "none" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<span class=\"inline-block w-3.5 h-3.5 rounded-full shrink-0 ring-1 ring-black/10\" style=\"background-color: color-mix(in srgb, var(--text-primary) 12%, transparent);\"></span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<span class=\"inline-block w-3.5 h-3.5 rounded-full shrink-0 ring-1 ring-black/10\" style=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var30 string
|
||||
templ_7745c5c3_Var30, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background-image: url(/static/textures/thumb-" + w.Name + ".webp); background-size: cover;")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 171, Col: 191}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\"></span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<span class=\"flex-1 text-left\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\" class=\"wood-paneling-btn w-full flex items-center gap-2.5 px-3 py-1.5 rounded-lg text-sm transition-colors\" data-wood=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var31 string
|
||||
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(w.Label)
|
||||
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(w.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 173, Col: 48}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 171, Col: 26}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</span></button>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\" style=\"color: var(--text-secondary);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if w.Name == "none" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<span class=\"inline-block w-3.5 h-3.5 rounded-full shrink-0 ring-1 ring-black/10\" style=\"background-color: color-mix(in srgb, var(--text-primary) 12%, transparent);\"></span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<span class=\"inline-block w-3.5 h-3.5 rounded-full shrink-0 ring-1 ring-black/10\" style=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var32 string
|
||||
templ_7745c5c3_Var32, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background-image: url(/static/textures/thumb-" + w.Name + ".webp); background-size: cover;")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 177, Col: 191}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "\"></span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<span class=\"flex-1 text-left\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var33 string
|
||||
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(w.Label)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 179, Col: 48}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</span></button>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</div></div><!-- Admin section (visible to admins only) -->")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</div></div><!-- Admin section (visible to admins only) -->")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Role == "admin" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<div class=\"sidebar-panel\"><button type=\"button\" @click=\"openPanel = openPanel === 'admin' ? null : 'admin'\" class=\"w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors text-content-muted hover:text-content hover:bg-surface-hover\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<div class=\"sidebar-panel\"><button type=\"button\" @click=\"openPanel = openPanel === 'admin' ? null : 'admin'\" class=\"w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors text-content-muted hover:text-content hover:bg-surface-hover\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -554,46 +586,16 @@ func Header(user User, currentPath string) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<span>Administration</span> <svg class=\"h-4 w-4 ml-auto transition-transform\" :class=\"{ 'rotate-180': openPanel === 'admin' }\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" viewBox=\"0 0 24 24\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M19 9l-7 7-7-7\"></path></svg></button><div x-show=\"openPanel === 'admin'\" x-cloak x-transition class=\"mt-1 space-y-0.5 pl-1\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<span>Administration</span> <svg class=\"h-4 w-4 ml-auto transition-transform\" :class=\"{ 'rotate-180': openPanel === 'admin' }\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" viewBox=\"0 0 24 24\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M19 9l-7 7-7-7\"></path></svg></button><div x-show=\"openPanel === 'admin'\" x-cloak x-transition class=\"mt-1 space-y-0.5 pl-1\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var32 = []any{activeClass(currentPath, "/admin")}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var32...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<a href=\"/admin\" class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var33 string
|
||||
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var32).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("grid", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<span>Dashboard</span></a> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var34 = []any{activeClass(currentPath, "/admin/library")}
|
||||
var templ_7745c5c3_Var34 = []any{activeClass(currentPath, "/admin")}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var34...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<a href=\"/admin/library\" class=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<a href=\"/admin\" class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -610,20 +612,20 @@ func Header(user User, currentPath string) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("library", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||
templ_7745c5c3_Err = Icon("grid", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<span>Libraries</span></a> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<span>Dashboard</span></a> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var36 = []any{activeClass(currentPath, "/admin/hash-conflicts")}
|
||||
var templ_7745c5c3_Var36 = []any{activeClass(currentPath, "/admin/library")}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var36...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<a href=\"/admin/hash-conflicts\" class=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<a href=\"/admin/library\" class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -640,20 +642,20 @@ func Header(user User, currentPath string) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("copy", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||
templ_7745c5c3_Err = Icon("library", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<span>Hash Conflicts</span></a> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<span>Libraries</span></a> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var38 = []any{activeClass(currentPath, "/admin/users")}
|
||||
var templ_7745c5c3_Var38 = []any{activeClass(currentPath, "/admin/podcasts")}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var38...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<a href=\"/admin/users\" class=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<a href=\"/admin/podcasts\" class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -670,20 +672,20 @@ func Header(user User, currentPath string) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("users", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||
templ_7745c5c3_Err = Icon("podcast", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<span>Users</span></a> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<span>Podcasts</span></a> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var40 = []any{activeClass(currentPath, "/admin/settings")}
|
||||
var templ_7745c5c3_Var40 = []any{activeClass(currentPath, "/admin/hash-conflicts")}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var40...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "<a href=\"/admin/settings\" class=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "<a href=\"/admin/hash-conflicts\" class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -700,16 +702,76 @@ func Header(user User, currentPath string) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("copy", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<span>Hash Conflicts</span></a> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var42 = []any{activeClass(currentPath, "/admin/users")}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var42...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "<a href=\"/admin/users\" class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var43 string
|
||||
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var42).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var43)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("users", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "<span>Users</span></a> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var44 = []any{activeClass(currentPath, "/admin/settings")}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var44...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "<a href=\"/admin/settings\" class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var45 string
|
||||
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var44).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var45)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("gear", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<span>Settings</span></a></div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "<span>Settings</span></a></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "</div></aside><!-- Topbar --><header class=\"app-topbar\"><div class=\"flex items-center gap-3 px-4 sm:px-6 h-full\"><button type=\"button\" class=\"app-mobile-only icon-btn\" @click=\"mobileMenuOpen = true\" aria-label=\"Open menu\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</div></aside><!-- Global confirm modal (issue #5): ONE styled dialog answers every\n\t\t hx-confirm sitewide via the htmx:confirm interceptor in\n\t\t web/src/confirm.ts, and exposes the confirmDialog() promise for\n\t\t legacy JS call sites. Inert until shown. MUST render on every\n\t\t page (it previously lived inside SidebarSignIn — signed-in\n\t\t pages had no #confirm-modal, so confirmDialog degraded to the\n\t\t native window.confirm; fixed 2026-10-30, user report). --><div id=\"confirm-modal\" class=\"hidden fixed inset-0 z-[60] flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card w-full max-w-sm\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"p-5 border-b\" style=\"border-color: var(--border);\"><p id=\"confirm-modal-message\" class=\"text-sm font-medium\" style=\"color: var(--text-primary)\"></p></div><div class=\"p-4 flex justify-end gap-2\"><button id=\"confirm-modal-cancel\" class=\"btn btn-secondary\">Cancel</button> <button id=\"confirm-modal-accept\" class=\"btn btn-primary\">Confirm</button></div></div></div><!-- Topbar --><header class=\"app-topbar\"><div class=\"flex items-center gap-3 px-4 sm:px-6 h-full\"><button type=\"button\" class=\"app-mobile-only icon-btn\" @click=\"mobileMenuOpen = true\" aria-label=\"Open menu\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -717,7 +779,7 @@ func Header(user User, currentPath string) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</button><div class=\"relative flex-1 max-w-xl\" x-data=\"headerSearch\" @click.outside=\"close()\"><span class=\"absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none\" style=\"color: var(--text-secondary);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "</button><div class=\"relative flex-1 max-w-xl\" x-data=\"headerSearch\" @click.outside=\"close()\"><span class=\"absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none\" style=\"color: var(--text-secondary);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -725,20 +787,20 @@ func Header(user User, currentPath string) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</span> <input type=\"text\" id=\"header-search\" name=\"q\" placeholder=\"Search your library…\" class=\"input pl-10\" autocomplete=\"off\" x-ref=\"input\" role=\"combobox\" :aria-expanded=\"open ? 'true' : 'false'\" aria-label=\"Search your library\" @input=\"onInput()\" @focus=\"onFocus()\" @keydown.arrow-down.prevent=\"moveSelection(1)\" @keydown.arrow-up.prevent=\"moveSelection(-1)\" @keydown.enter.prevent=\"openSelected()\" @keydown.escape=\"close()\" hx-get=\"/api/media-items/search?layout=dropdown\" hx-trigger=\"search\" hx-target=\"#search-results\" hx-swap=\"innerHTML\"><div id=\"search-results\" x-show=\"open\" x-cloak class=\"absolute z-50 w-full max-w-2xl mt-2 rounded-lg shadow-lg border overflow-hidden\" style=\"background-color: var(--bg-secondary); border-color: var(--border)\"></div></div></div></header><!-- module bundle; ?v=<content hash> busts stale intermediate\n\t\t builds across app restarts (browser caches are keyed by URL);\n\t\t the hash is computed at startup from the served file --><script type=\"module\" src=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</span> <input type=\"text\" id=\"header-search\" name=\"q\" placeholder=\"Search your library…\" class=\"input pl-10\" autocomplete=\"off\" x-ref=\"input\" role=\"combobox\" :aria-expanded=\"open ? 'true' : 'false'\" aria-label=\"Search your library\" @input=\"onInput()\" @focus=\"onFocus()\" @keydown.arrow-down.prevent=\"moveSelection(1)\" @keydown.arrow-up.prevent=\"moveSelection(-1)\" @keydown.enter.prevent=\"openSelected()\" @keydown.escape=\"close()\" hx-get=\"/api/media-items/search?layout=dropdown\" hx-trigger=\"search\" hx-target=\"#search-results\" hx-swap=\"innerHTML\"><div id=\"search-results\" x-show=\"open\" x-cloak class=\"absolute z-50 w-full max-w-2xl mt-2 rounded-lg shadow-lg border overflow-hidden\" style=\"background-color: var(--bg-secondary); border-color: var(--border)\"></div></div></div></header><!-- module bundle; ?v=<content hash> busts stale intermediate\n\t\t builds across app restarts (browser caches are keyed by URL);\n\t\t the hash is computed at startup from the served file --><script type=\"module\" src=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var42 string
|
||||
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.ResolveAttributeValue("/static/main.js?v=" + MainJSVersion())
|
||||
var templ_7745c5c3_Var46 string
|
||||
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.ResolveAttributeValue("/static/main.js?v=" + MainJSVersion())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 272, Col: 68}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 304, Col: 68}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var42)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var46)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "\"></script></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "\"></script></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -763,12 +825,12 @@ func SidebarUserMenu(user User) templ.Component {
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var43 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var43 == nil {
|
||||
templ_7745c5c3_Var43 = templ.NopComponent
|
||||
templ_7745c5c3_Var47 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var47 == nil {
|
||||
templ_7745c5c3_Var47 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "<div class=\"sidebar-panel\"><button type=\"button\" @click=\"openPanel = openPanel === 'user' ? null : 'user'\" class=\"w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors hover:bg-surface-hover\" style=\"color: var(--text-primary);\"><span class=\"grid place-items-center h-7 w-7 rounded-full shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<div class=\"sidebar-panel\"><button type=\"button\" @click=\"openPanel = openPanel === 'user' ? null : 'user'\" class=\"w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors hover:bg-surface-hover\" style=\"color: var(--text-primary);\"><span class=\"grid place-items-center h-7 w-7 rounded-full shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -776,20 +838,20 @@ func SidebarUserMenu(user User) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</span> <span class=\"flex-1 text-left truncate\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</span> <span class=\"flex-1 text-left truncate\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var44 string
|
||||
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
|
||||
var templ_7745c5c3_Var48 string
|
||||
templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 288, Col: 58}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 320, Col: 58}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</span> <span class=\"h-4 w-4 shrink-0 transition-transform\" :class=\"{ 'rotate-180': openPanel === 'user' }\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</span> <span class=\"h-4 w-4 shrink-0 transition-transform\" :class=\"{ 'rotate-180': openPanel === 'user' }\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -797,7 +859,7 @@ func SidebarUserMenu(user User) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "</span></button><div x-show=\"openPanel === 'user'\" x-cloak x-transition class=\"mt-1 space-y-0.5 pl-1\"><a href=\"/profile\" class=\"flex items-center gap-3 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover\" style=\"color: var(--text-secondary);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "</span></button><div x-show=\"openPanel === 'user'\" x-cloak x-transition class=\"mt-1 space-y-0.5 pl-1\"><a href=\"/profile\" class=\"flex items-center gap-3 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover\" style=\"color: var(--text-secondary);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -805,7 +867,7 @@ func SidebarUserMenu(user User) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "<span>Profile</span></a> <button type=\"button\" @click=\"logout()\" class=\"w-full flex items-center gap-3 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover\" style=\"color: var(--text-secondary);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "<span>Profile</span></a> <button type=\"button\" @click=\"logout()\" class=\"w-full flex items-center gap-3 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover\" style=\"color: var(--text-secondary);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -813,7 +875,7 @@ func SidebarUserMenu(user User) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<span>Logout</span></button></div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "<span>Logout</span></button></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -839,12 +901,12 @@ func SidebarSignIn(currentPath string) templ.Component {
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var45 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var45 == nil {
|
||||
templ_7745c5c3_Var45 = templ.NopComponent
|
||||
templ_7745c5c3_Var49 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var49 == nil {
|
||||
templ_7745c5c3_Var49 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<div class=\"sidebar-panel\"><button type=\"button\" @click=\"openPanel = openPanel === 'signin' ? null : 'signin'\" class=\"w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors hover:bg-surface-hover\" style=\"color: var(--text-primary);\"><span class=\"grid place-items-center h-7 w-7 rounded-full shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "<div class=\"sidebar-panel\"><button type=\"button\" @click=\"openPanel = openPanel === 'signin' ? null : 'signin'\" class=\"w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors hover:bg-surface-hover\" style=\"color: var(--text-primary);\"><span class=\"grid place-items-center h-7 w-7 rounded-full shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -852,7 +914,7 @@ func SidebarSignIn(currentPath string) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</span> <span class=\"flex-1 text-left\">Sign in</span> <span class=\"h-4 w-4 shrink-0 transition-transform\" :class=\"{ 'rotate-180': openPanel === 'signin' }\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "</span> <span class=\"flex-1 text-left\">Sign in</span> <span class=\"h-4 w-4 shrink-0 transition-transform\" :class=\"{ 'rotate-180': openPanel === 'signin' }\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -860,30 +922,30 @@ func SidebarSignIn(currentPath string) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</span></button><div x-show=\"openPanel === 'signin'\" x-cloak x-transition class=\"mt-1 px-1\"><form hx-post=\"/api/auth/login\" hx-target=\"#login-result\" hx-swap=\"innerHTML\" class=\"space-y-2\"><input type=\"hidden\" name=\"redirect\" value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "</span></button><div x-show=\"openPanel === 'signin'\" x-cloak x-transition class=\"mt-1 px-1\"><form hx-post=\"/api/auth/login\" hx-target=\"#login-result\" hx-swap=\"innerHTML\" class=\"space-y-2\"><input type=\"hidden\" name=\"redirect\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var46 string
|
||||
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath)
|
||||
var templ_7745c5c3_Var50 string
|
||||
templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 336, Col: 60}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 368, Col: 60}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var46)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var50)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "\"> <input type=\"text\" name=\"login\" class=\"input py-1.5 text-sm\" placeholder=\"Email or username\" required> <input type=\"password\" name=\"password\" class=\"input py-1.5 text-sm\" placeholder=\"Password\" required> <button type=\"submit\" class=\"btn btn-primary w-full py-1.5 text-sm\">Sign In</button></form><div id=\"login-result\"></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "\"> <input type=\"text\" name=\"login\" class=\"input py-1.5 text-sm\" placeholder=\"Email or username\" required> <input type=\"password\" name=\"password\" class=\"input py-1.5 text-sm\" placeholder=\"Password\" required> <button type=\"submit\" class=\"btn btn-primary w-full py-1.5 text-sm\">Sign In</button></form><div id=\"login-result\"></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if SignupVisible() {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "<a href=\"/register\" class=\"block text-center text-xs mt-2 hover:underline\" style=\"color: var(--text-secondary);\">Create an account</a>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "<a href=\"/register\" class=\"block text-center text-xs mt-2 hover:underline\" style=\"color: var(--text-secondary);\">Create an account</a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "</div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -73,6 +73,12 @@ templ Icon(name string, extra string) {
|
||||
case "library":
|
||||
<path d="M3 21h18"></path>
|
||||
<path d="M5 21V7l7-4 7 4v14"></path>
|
||||
case "podcast":
|
||||
<path d="M12 9a3 3 0 0 0-3 3v1a3 3 0 0 0 6 0v-1a3 3 0 0 0-3-3z"></path>
|
||||
<path d="M6.3 6.3a8 8 0 0 0 0 11.4"></path>
|
||||
<path d="M17.7 6.3a8 8 0 0 1 0 11.4"></path>
|
||||
<path d="M3.5 3.5a12 12 0 0 0 0 17"></path>
|
||||
<path d="M20.5 3.5a12 12 0 0 1 0 17"></path>
|
||||
case "layers":
|
||||
<path d="m12 3 9 5-9 5-9-5 9-5z"></path>
|
||||
<path d="m3 13 9 5 9-5"></path>
|
||||
|
||||
+46
-41
@@ -161,208 +161,213 @@ func Icon(name string, extra string) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "podcast":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<path d=\"M12 9a3 3 0 0 0-3 3v1a3 3 0 0 0 6 0v-1a3 3 0 0 0-3-3z\"></path> <path d=\"M6.3 6.3a8 8 0 0 0 0 11.4\"></path> <path d=\"M17.7 6.3a8 8 0 0 1 0 11.4\"></path> <path d=\"M3.5 3.5a12 12 0 0 0 0 17\"></path> <path d=\"M20.5 3.5a12 12 0 0 1 0 17\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "layers":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<path d=\"m12 3 9 5-9 5-9-5 9-5z\"></path> <path d=\"m3 13 9 5 9-5\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<path d=\"m12 3 9 5-9 5-9-5 9-5z\"></path> <path d=\"m3 13 9 5 9-5\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "folder":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<path d=\"M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<path d=\"M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "chart":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<path d=\"M3 3v18h18\"></path> <path d=\"M7 15v3M12 11v7M17 7v11\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<path d=\"M3 3v18h18\"></path> <path d=\"M7 15v3M12 11v7M17 7v11\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "device":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<rect x=\"7\" y=\"2\" width=\"10\" height=\"20\" rx=\"2\"></rect> <path d=\"M11 18h2\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<rect x=\"7\" y=\"2\" width=\"10\" height=\"20\" rx=\"2\"></rect> <path d=\"M11 18h2\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "trash":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<path d=\"M4 7h16M9 7V4h6v3M6 7l1 13a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-13\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<path d=\"M4 7h16M9 7V4h6v3M6 7l1 13a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-13\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "save":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<path d=\"M5 3h11l3 3v15H5z\"></path> <path d=\"M8 3v6h7V3\"></path> <path d=\"M8 14h8v7H8z\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<path d=\"M5 3h11l3 3v15H5z\"></path> <path d=\"M8 3v6h7V3\"></path> <path d=\"M8 14h8v7H8z\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "filter":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<path d=\"M3 4h18l-7 8v6l-4 2v-8z\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<path d=\"M3 4h18l-7 8v6l-4 2v-8z\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "check":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<path d=\"m4 12 5 5 11-11\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<path d=\"m4 12 5 5 11-11\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "check-circle":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle> <path d=\"m8 12 3 3 5-6\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle> <path d=\"m8 12 3 3 5-6\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "x-circle":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle> <path d=\"m9 9 6 6M15 9l-6 6\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle> <path d=\"m9 9 6 6M15 9l-6 6\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "circle":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<circle cx=\"12\" cy=\"12\" r=\"8\"></circle>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<circle cx=\"12\" cy=\"12\" r=\"8\"></circle>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "bookmark":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<path d=\"M7 3h10a1 1 0 0 1 1 1v17l-6-4-6 4V4a1 1 0 0 1 1-1z\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<path d=\"M7 3h10a1 1 0 0 1 1 1v17l-6-4-6 4V4a1 1 0 0 1 1-1z\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "star":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<path d=\"m12 3 2.7 5.5 6 .9-4.3 4.2 1 6L12 17l-5.4 2.6 1-6L3.3 9.4l6-.9z\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<path d=\"m12 3 2.7 5.5 6 .9-4.3 4.2 1 6L12 17l-5.4 2.6 1-6L3.3 9.4l6-.9z\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "arrow-right":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<path d=\"M5 12h14M13 6l6 6-6 6\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<path d=\"M5 12h14M13 6l6 6-6 6\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "arrow-left":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<path d=\"M19 12H5M11 6l-6 6 6 6\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<path d=\"M19 12H5M11 6l-6 6 6 6\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "home":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<path d=\"M3 11 12 3l9 8\"></path> <path d=\"M5 10v10h14V10\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<path d=\"M3 11 12 3l9 8\"></path> <path d=\"M5 10v10h14V10\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "logout":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<path d=\"M15 4h3a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-3\"></path> <path d=\"M10 17l5-5-5-5\"></path> <path d=\"M15 12H3\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<path d=\"M15 4h3a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-3\"></path> <path d=\"M10 17l5-5-5-5\"></path> <path d=\"M15 12H3\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "plus":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<path d=\"M12 5v14M5 12h14\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<path d=\"M12 5v14M5 12h14\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "edit":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<path d=\"M4 20h4L18 10l-4-4L4 16z\"></path> <path d=\"m14 6 4 4\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<path d=\"M4 20h4L18 10l-4-4L4 16z\"></path> <path d=\"m14 6 4 4\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "info":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle> <path d=\"M12 11v5M12 8h.01\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle> <path d=\"M12 11v5M12 8h.01\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "alert":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<path d=\"M12 3 2 20h20z\"></path> <path d=\"M12 10v4M12 17h.01\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<path d=\"M12 3 2 20h20z\"></path> <path d=\"M12 10v4M12 17h.01\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "clock":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle> <path d=\"M12 7v5l3 2\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle> <path d=\"M12 7v5l3 2\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "sync":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<path d=\"M3 8a9 9 0 0 1 15-3l3 3\"></path> <path d=\"M21 16a9 9 0 0 1-15 3l-3-3\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<path d=\"M3 8a9 9 0 0 1 15-3l3 3\"></path> <path d=\"M21 16a9 9 0 0 1-15 3l-3-3\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "download":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<path d=\"M12 3v12M7 10l5 5 5-5\"></path> <path d=\"M5 21h14\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<path d=\"M12 3v12M7 10l5 5 5-5\"></path> <path d=\"M5 21h14\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "upload":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<path d=\"M12 21V9M7 14l5-5 5 5\"></path> <path d=\"M5 3h14\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<path d=\"M12 21V9M7 14l5-5 5 5\"></path> <path d=\"M5 3h14\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "copy":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<rect x=\"9\" y=\"9\" width=\"12\" height=\"12\" rx=\"2\"></rect> <path d=\"M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<rect x=\"9\" y=\"9\" width=\"12\" height=\"12\" rx=\"2\"></rect> <path d=\"M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "external":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<path d=\"M14 4h6v6\"></path> <path d=\"M20 4 10 14\"></path> <path d=\"M19 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h5\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<path d=\"M14 4h6v6\"></path> <path d=\"M20 4 10 14\"></path> <path d=\"M19 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h5\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "list":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<path d=\"M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<path d=\"M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "grid":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<rect x=\"3\" y=\"3\" width=\"7\" height=\"7\" rx=\"1\"></rect> <rect x=\"14\" y=\"3\" width=\"7\" height=\"7\" rx=\"1\"></rect> <rect x=\"3\" y=\"14\" width=\"7\" height=\"7\" rx=\"1\"></rect> <rect x=\"14\" y=\"14\" width=\"7\" height=\"7\" rx=\"1\"></rect>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<rect x=\"3\" y=\"3\" width=\"7\" height=\"7\" rx=\"1\"></rect> <rect x=\"14\" y=\"3\" width=\"7\" height=\"7\" rx=\"1\"></rect> <rect x=\"3\" y=\"14\" width=\"7\" height=\"7\" rx=\"1\"></rect> <rect x=\"14\" y=\"14\" width=\"7\" height=\"7\" rx=\"1\"></rect>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "play":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<path d=\"M6 4l14 8-14 8z\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<path d=\"M6 4l14 8-14 8z\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "heart":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<path d=\"M12 21s-7-4.5-9.5-9A5 5 0 0 1 12 6a5 5 0 0 1 9.5 6c-2.5 4.5-9.5 9-9.5 9z\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<path d=\"M12 21s-7-4.5-9.5-9A5 5 0 0 1 12 6a5 5 0 0 1 9.5 6c-2.5 4.5-9.5 9-9.5 9z\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "calendar":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<rect x=\"3\" y=\"4\" width=\"18\" height=\"18\" rx=\"2\"></rect> <path d=\"M3 9h18M8 2v4M16 2v4\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<rect x=\"3\" y=\"4\" width=\"18\" height=\"18\" rx=\"2\"></rect> <path d=\"M3 9h18M8 2v4M16 2v4\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "tag":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<path d=\"M3 11V5a2 2 0 0 1 2-2h6l9 9-8 8-9-9z\"></path> <circle cx=\"7.5\" cy=\"7.5\" r=\"1\"></circle>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<path d=\"M3 11V5a2 2 0 0 1 2-2h6l9 9-8 8-9-9z\"></path> <circle cx=\"7.5\" cy=\"7.5\" r=\"1\"></circle>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "globe":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle> <path d=\"M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle> <path d=\"M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "shield":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<path d=\"M12 3 5 6v6c0 4 3 7 7 9 4-2 7-5 7-9V6z\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<path d=\"M12 3 5 6v6c0 4 3 7 7 9 4-2 7-5 7-9V6z\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "database":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<ellipse cx=\"12\" cy=\"5\" rx=\"8\" ry=\"3\"></ellipse> <path d=\"M4 5v6c0 1.7 3.6 3 8 3s8-1.3 8-3V5\"></path> <path d=\"M4 11v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<ellipse cx=\"12\" cy=\"5\" rx=\"8\" ry=\"3\"></ellipse> <path d=\"M4 5v6c0 1.7 3.6 3 8 3s8-1.3 8-3V5\"></path> <path d=\"M4 11v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "headphones":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<path d=\"M4 14v-2a8 8 0 0 1 16 0v2\"></path> <rect x=\"3\" y=\"14\" width=\"4\" height=\"7\" rx=\"1.5\"></rect> <rect x=\"17\" y=\"14\" width=\"4\" height=\"7\" rx=\"1.5\"></rect>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<path d=\"M4 14v-2a8 8 0 0 1 16 0v2\"></path> <rect x=\"3\" y=\"14\" width=\"4\" height=\"7\" rx=\"1.5\"></rect> <rect x=\"17\" y=\"14\" width=\"4\" height=\"7\" rx=\"1.5\"></rect>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "comic":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<path d=\"M4 4h16v12l-3 4H7l-3-4z\"></path> <path d=\"M4 9h16M9 9v11\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<path d=\"M4 4h16v12l-3 4H7l-3-4z\"></path> <path d=\"M4 9h16M9 9v11\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "manga":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<path d=\"M4 4h16v13a3 3 0 0 1-3 3H4z\"></path> <path d=\"M4 4v16M10 4h10v8\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<path d=\"M4 4h16v13a3 3 0 0 1-3 3H4z\"></path> <path d=\"M4 4v16M10 4h10v8\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
default:
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</svg>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "</svg>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -26,6 +26,13 @@ templ LibrarySwitcher(libData []LibraryData, currentLibraryID string, title stri
|
||||
>
|
||||
<option value="">All Libraries ({ TotalMediaCount(libData) })</option>
|
||||
for _, lib := range libData {
|
||||
// Podcasts libraries are feed-managed (plan D13): never
|
||||
// in the book-library switcher — feeds surface via
|
||||
// /podcasts only. Presentation-layer filter so every
|
||||
// switcher consumer is correct at once.
|
||||
if !IsBookLibrary(lib.TypeName) {
|
||||
continue
|
||||
}
|
||||
if lib.Offline {
|
||||
if lib.ID == currentLibraryID {
|
||||
<option value={ lib.ID } selected>{ lib.Name } ({ lib.MediaCount }) — storage offline</option>
|
||||
|
||||
@@ -82,187 +82,201 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, lib := range libData {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, " ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if !IsBookLibrary(lib.TypeName) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "continue")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if lib.Offline {
|
||||
if lib.ID == currentLibraryID {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<option value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<option value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 31, Col: 29}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 38, Col: 29}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" selected>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" selected>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 31, Col: 51}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 38, Col: 51}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " (")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " (")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 31, Col: 71}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 38, Col: 71}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, ") — storage offline</option>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, ") — storage offline</option>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<option value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<option value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 33, Col: 29}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 40, Col: 29}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 33, Col: 42}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 40, Col: 42}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, " (")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, " (")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 33, Col: 62}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 40, Col: 62}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, ") — storage offline</option>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, ") — storage offline</option>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
} else if lib.ID == currentLibraryID {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<option value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<option value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 36, Col: 28}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 43, Col: 28}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" selected>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" selected>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 36, Col: 50}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 43, Col: 50}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, " (")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, " (")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 36, Col: 70}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 43, Col: 70}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, ")</option>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, ")</option>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<option value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<option value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 38, Col: 28}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 45, Col: 28}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 38, Col: 41}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 45, Col: 41}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, " (")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, " (")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 38, Col: 61}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 45, Col: 61}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, ")</option>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, ")</option>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</select></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</select></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -273,7 +287,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
|
||||
}
|
||||
}
|
||||
if len(actions) > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<div class=\"flex items-center gap-2\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<div class=\"flex items-center gap-2\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -283,12 +297,12 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</div><div id=\"loading-spinner\" class=\"hidden fixed inset-0 flex items-center justify-center z-50\" style=\"background-color: color-mix(in srgb, var(--bg-primary) 55%, transparent);\"><div class=\"animate-spin rounded-full h-12 w-12 border-b-2\" style=\"border-color: var(--accent);\"></div></div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</div><div id=\"loading-spinner\" class=\"hidden fixed inset-0 flex items-center justify-center z-50\" style=\"background-color: color-mix(in srgb, var(--bg-primary) 55%, transparent);\"><div class=\"animate-spin rounded-full h-12 w-12 border-b-2\" style=\"border-color: var(--accent);\"></div></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -317,7 +331,7 @@ func DashboardActions() templ.Component {
|
||||
templ_7745c5c3_Var16 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<button data-action=\"open-dashboard-settings\" class=\"icon-btn\" title=\"Customize Dashboard\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<button data-action=\"open-dashboard-settings\" class=\"icon-btn\" title=\"Customize Dashboard\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -325,7 +339,7 @@ func DashboardActions() templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</button> <button data-action=\"reload-page\" class=\"icon-btn\" title=\"Refresh\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</button> <button data-action=\"reload-page\" class=\"icon-btn\" title=\"Refresh\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -333,7 +347,7 @@ func DashboardActions() templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</button>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</button>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,555 @@
|
||||
package templates
|
||||
|
||||
// Feed detail page (plan task 1.9, D5/D7/D8/D19): hero (artwork,
|
||||
// metadata, admin cluster), action row (Download All, Refresh, order
|
||||
// override, subscription policy, admin delete), and the episode list
|
||||
// with the download lifecycle inline. The episode list is a self-
|
||||
// refreshing htmx partial: it re-fetches on the podcast:changed event
|
||||
// AND polls every 4s ONLY while downloads are active (server decides —
|
||||
// the polling attributes render only then).
|
||||
|
||||
import "fmt"
|
||||
import "strings"
|
||||
|
||||
// boolAttr renders a boolean data attribute value.
|
||||
func boolAttr(b bool) string {
|
||||
if b {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
}
|
||||
|
||||
// podcastPct renders a listen percentage for the row's data attribute
|
||||
// (one decimal; the client-side hide-played filter reads it).
|
||||
func podcastPct(p float64) string {
|
||||
return fmt.Sprintf("%.1f", p)
|
||||
}
|
||||
|
||||
templ PodcastDetailPage(user User, data PodcastDetailData) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>{ data.Feed.DisplayTitle() } - Bookhoard</title>
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||
</head>
|
||||
<body x-data="podcastDetail" x-init="initPodcastFilterPersistence()" class={ "theme-" + user.Theme }>
|
||||
@Header(user, "/podcasts")
|
||||
<main class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
|
||||
<a href="/podcasts" class="inline-flex items-center gap-1 text-sm mb-4 hover:underline" style="color: var(--text-secondary)">
|
||||
@Icon("arrow-left", "h-4 w-4")
|
||||
<span>Podcasts</span>
|
||||
</a>
|
||||
|
||||
<!-- Hero -->
|
||||
<div class="flex flex-col sm:flex-row gap-6 mb-8">
|
||||
<div class="h-44 w-44 rounded-xl shrink-0 overflow-hidden grid place-items-center" style="background-color: var(--surface-secondary); color: var(--text-secondary);">
|
||||
if data.Feed.ArtworkPath != "" {
|
||||
<img
|
||||
src={ "/uploads/library-" + data.Feed.LibraryID + "/" + data.Feed.ArtworkPath }
|
||||
alt={ "Cover for " + data.Feed.DisplayTitle() }
|
||||
class="h-full w-full object-cover"
|
||||
data-image-fallback
|
||||
/>
|
||||
} else {
|
||||
@Icon("podcast", "h-12 w-12")
|
||||
}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2 mb-1">
|
||||
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent)">{ data.Feed.TypeLabel() }</span>
|
||||
if data.Feed.IsComplete {
|
||||
<span class="badge" style="background-color: var(--surface-secondary); color: var(--text-secondary)">complete</span>
|
||||
}
|
||||
if data.Feed.Language != "" {
|
||||
<span class="badge" style="background-color: var(--surface-secondary); color: var(--text-secondary)">{ data.Feed.Language }</span>
|
||||
}
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold tracking-tight mb-1" style="color: var(--text-primary)">{ data.Feed.DisplayTitle() }</h1>
|
||||
if data.Feed.Host != "" || data.Feed.Publisher != "" {
|
||||
<p class="text-sm mb-2" style="color: var(--text-secondary)">
|
||||
if data.Feed.Host != "" {
|
||||
{ "Host: " + data.Feed.Host }
|
||||
}
|
||||
if data.Feed.Publisher != "" && data.Feed.Publisher != data.Feed.Host {
|
||||
{ " · Publisher: " + data.Feed.Publisher }
|
||||
}
|
||||
</p>
|
||||
}
|
||||
if data.Feed.Description != "" {
|
||||
<p class="text-sm line-clamp-3 mb-2" style="color: var(--text-secondary)">{ data.Feed.Description }</p>
|
||||
}
|
||||
if len(data.Feed.Tags) > 0 {
|
||||
<div class="flex flex-wrap gap-1.5 mb-2">
|
||||
for _, t := range data.Feed.Tags {
|
||||
<span class="badge" style="background-color: var(--surface-secondary); color: var(--text-secondary)">{ t }</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<p class="text-xs" style="color: var(--text-secondary)">
|
||||
{ podcastInt(data.Feed.DownloadedN) + " of " + podcastInt(data.Feed.EpisodeTotal) + " episodes downloaded" }
|
||||
if data.Feed.LastFetched != "" {
|
||||
{ " · last checked " + data.Feed.LastFetched }
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex flex-wrap items-center gap-2 mb-6">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
hx-post={ "/api/podcasts/feeds/" + data.Feed.ID + "/episodes/all/download" }
|
||||
hx-target="#episode-list"
|
||||
hx-swap="outerHTML"
|
||||
title="Queue every episode that is not downloaded yet"
|
||||
>
|
||||
@Icon("save", "h-4 w-4")
|
||||
<span>Download All</span>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
hx-post={ "/api/podcasts/feeds/" + data.Feed.ID + "/refresh" }
|
||||
hx-target="#episode-list"
|
||||
hx-swap="outerHTML"
|
||||
title="Check the feed for new episodes now"
|
||||
>
|
||||
@Icon("refresh", "h-4 w-4")
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
|
||||
<!-- Order override (D5): per-user; refreshes the episode list -->
|
||||
<label class="text-sm ml-2" for="podcast-order-select" style="color: var(--text-secondary)">Order</label>
|
||||
<select
|
||||
id="podcast-order-select"
|
||||
name="order"
|
||||
class="input w-auto py-1.5 cursor-pointer"
|
||||
hx-put={ "/api/podcasts/feeds/" + data.Feed.ID + "/order" }
|
||||
hx-target="#episode-list"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<option value="native" selected?={ data.Order == "native" }>Feed order</option>
|
||||
<option value="reverse" selected?={ data.Order == "reverse" }>Feed order (reversed)</option>
|
||||
<option value="oldest" selected?={ data.Order == "oldest" }>Oldest first (pub date)</option>
|
||||
<option value="newest" selected?={ data.Order == "newest" }>Newest first (pub date)</option>
|
||||
</select>
|
||||
|
||||
<!-- Serial suggestion (D5, §0 item 3 rename): one-click,
|
||||
never silent. "reverse" sorts by FEED POSITION DESC —
|
||||
no pub dates involved, so messy dates can't break it. -->
|
||||
if data.IsSerial && data.Order == "native" {
|
||||
<button
|
||||
class="btn btn-secondary text-sm"
|
||||
hx-put={ "/api/podcasts/feeds/" + data.Feed.ID + "/order" }
|
||||
hx-vals='{"order": "reverse"}'
|
||||
hx-target="#episode-list"
|
||||
hx-swap="outerHTML"
|
||||
title="Serial feed: publishers list newest first — reverse the feed order to start at chapter 1"
|
||||
>
|
||||
<span>Reverse feed order</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
<!-- Auto-download policy (D6) -->
|
||||
<label class="text-sm ml-2" for="podcast-autodl-select" style="color: var(--text-secondary)">Auto-download</label>
|
||||
@PodcastAutoDownloadSelect(data.Feed)
|
||||
if data.Feed.AutoDownload == "newest" {
|
||||
<input
|
||||
type="number"
|
||||
id="podcast-autodl-count"
|
||||
name="auto_download_count"
|
||||
min="1"
|
||||
max="100"
|
||||
value={ podcastInt(data.Feed.AutoDownloadCount) }
|
||||
class="input w-20 py-1.5"
|
||||
aria-label="Newest N episodes"
|
||||
/>
|
||||
}
|
||||
|
||||
<!-- Unsubscribe (issues #4/#7): the sole subscriber who is
|
||||
an admin gets the three-choice modal (unsubscribe /
|
||||
remove feed / cancel); everyone else gets a plain
|
||||
confirm — file retention is admin knowledge only. -->
|
||||
if data.IsAdmin && data.SubscriberCount <= 1 {
|
||||
<button class="btn btn-secondary ml-auto" @click="unsubOpen = true">
|
||||
<span>Unsubscribe</span>
|
||||
</button>
|
||||
} else if data.IsAdmin {
|
||||
<button
|
||||
class="btn btn-secondary ml-auto"
|
||||
hx-delete={ "/api/podcasts/feeds/" + data.Feed.ID + "/subscription" }
|
||||
hx-confirm="Unsubscribe from this feed? Episodes and files are kept."
|
||||
hx-swap="none"
|
||||
>
|
||||
<span>Unsubscribe</span>
|
||||
</button>
|
||||
} else {
|
||||
<button
|
||||
class="btn btn-secondary ml-auto"
|
||||
hx-delete={ "/api/podcasts/feeds/" + data.Feed.ID + "/subscription" }
|
||||
hx-confirm="Unsubscribe from this feed?"
|
||||
hx-swap="none"
|
||||
>
|
||||
<span>Unsubscribe</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
if data.IsAdmin {
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
style="color: var(--status-danger);"
|
||||
hx-delete={ "/api/podcasts/feeds/" + data.Feed.ID }
|
||||
hx-confirm="ADMIN: remove this feed for everyone? Episode rows AND downloaded files are deleted."
|
||||
hx-swap="none"
|
||||
>
|
||||
@Icon("trash", "h-4 w-4")
|
||||
<span>Remove Feed</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Gap banner (D8): middle episodes missing -->
|
||||
if data.GapCount > 0 {
|
||||
<div class="mb-4 card p-4 flex items-center gap-3" style="border-color: var(--status-warning, #d97706);">
|
||||
@Icon("circle", "h-5 w-5")
|
||||
<div>
|
||||
<p class="font-semibold text-sm" style="color: var(--text-primary)">{ podcastInt(data.GapCount) + " episode(s) not downloaded in the middle of this feed" }</p>
|
||||
<p class="text-xs" style="color: var(--text-secondary)">Playback skips them and keeps going — use Download All to fill the gaps.</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Episode-list filters (§0 item 6): pure client-local
|
||||
presentation state (Alpine; survives the list's htmx
|
||||
swaps because this toolbar lives OUTSIDE #episode-list).
|
||||
Hidden rows re-apply after every swap via the component's
|
||||
htmx:afterSwap listener. -->
|
||||
<div class="flex flex-wrap items-center gap-2 mb-2">
|
||||
<input
|
||||
type="search"
|
||||
class="input w-auto py-1.5 text-sm flex-1 min-w-[150px]"
|
||||
placeholder="Filter episodes…"
|
||||
aria-label="Filter episodes by title or description"
|
||||
x-ref="episodeFilterInput"
|
||||
@input="applyEpisodeFilter($event)"
|
||||
/>
|
||||
<label class="flex items-center gap-1.5 text-sm cursor-pointer select-none" style="color: var(--text-secondary)">
|
||||
<input type="checkbox" class="cursor-pointer" @change="applyEpisodeFilter($event)" x-ref="hideFinished"/>
|
||||
Hide played/downloaded
|
||||
</label>
|
||||
<span class="text-xs hidden" x-ref="filterEmptyNote" style="color: var(--text-secondary)">No episodes match the filter.</span>
|
||||
|
||||
<!-- Admin bulk file deletion (§0 item 8 follow-up): the
|
||||
transport for the per-row checkboxes inside the episode
|
||||
list. Lives OUTSIDE #episode-list so the button survives
|
||||
the list's htmx swaps; hx-include gathers the CHECKED
|
||||
boxes into the post body. State (count/ids) lives in the
|
||||
podcastDetail component — CSP-safe method bindings only. -->
|
||||
if data.IsAdmin && data.Feed.DownloadedN > 0 {
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<span class="text-xs tabular-nums" style="color: var(--text-secondary)" x-text="bulkSelectionLabel()"></span>
|
||||
<button
|
||||
id="episode-bulk-delete"
|
||||
type="button"
|
||||
class="btn btn-secondary text-xs"
|
||||
:class="bulkSelectionClass()"
|
||||
hx-post={ "/api/podcasts/feeds/" + data.Feed.ID + "/episodes/delete-files" }
|
||||
hx-target="#episode-list"
|
||||
hx-swap="outerHTML"
|
||||
hx-include="input[name='episode-file-select']:checked"
|
||||
hx-confirm="Delete the selected episode files? Rows reset to pending (a feed refresh will not re-materialize them); episodes re-download on demand."
|
||||
style="color: var(--status-warning, #d97706);"
|
||||
>
|
||||
Delete selected files
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Episode list (self-refreshing htmx partial). Pick the
|
||||
polling variant at page-render time when downloads are
|
||||
active — otherwise a fresh page load mid-download
|
||||
renders the non-polling container and never updates
|
||||
(the polling variant was previously only reachable via
|
||||
the /episodes partial route). -->
|
||||
if HasActiveDownloads(data.Episodes) {
|
||||
@PodcastEpisodeListPolling(data)
|
||||
} else {
|
||||
@PodcastEpisodeList(data)
|
||||
}
|
||||
</main>
|
||||
@PodcastUnsubscribeModal(data)
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
|
||||
// PodcastEpisodeList is the refresh unit: re-fetched on the
|
||||
// podcast:changed event (download/refresh/order endpoints fire it) and
|
||||
// polled every 4s only while the server says downloads are active.
|
||||
templ PodcastEpisodeList(data PodcastDetailData) {
|
||||
<div
|
||||
id="episode-list"
|
||||
hx-get={ "/podcasts/" + data.Feed.ID + "/episodes" }
|
||||
hx-trigger="podcast:changed from:body"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<h2 class="text-lg font-bold mb-3" style="color: var(--text-primary)">Episodes</h2>
|
||||
if len(data.Episodes) == 0 {
|
||||
<div class="card p-6 text-center text-sm" style="color: var(--text-secondary)">
|
||||
No episodes yet — hit Refresh to check the feed.
|
||||
</div>
|
||||
} else {
|
||||
<div class="flex flex-col gap-2">
|
||||
for _, ep := range data.Episodes {
|
||||
@PodcastEpisodeRowView(ep, data.IsAdmin)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
// PodcastEpisodeListPolling renders the poll-active variant of the list
|
||||
// container (same content; the hx-trigger adds the 4s poll + load). The
|
||||
// SSR episode route picks one of the two by download activity.
|
||||
templ PodcastEpisodeListPolling(data PodcastDetailData) {
|
||||
<div
|
||||
id="episode-list"
|
||||
hx-get={ "/podcasts/" + data.Feed.ID + "/episodes" }
|
||||
hx-trigger="podcast:changed from:body, every 2s"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<h2 class="text-lg font-bold mb-3" style="color: var(--text-primary)">Episodes</h2>
|
||||
<div class="flex flex-col gap-2">
|
||||
for _, ep := range data.Episodes {
|
||||
@PodcastEpisodeRowView(ep, data.IsAdmin)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) {
|
||||
<div
|
||||
class="card p-3 flex items-center gap-3"
|
||||
data-episode-row="true"
|
||||
data-downloaded={ boolAttr(ep.Downloaded) }
|
||||
data-listen-pct={ podcastPct(ep.ListenPct) }
|
||||
data-search-text={ strings.ToLower(ep.Title + " " + ep.Description) }
|
||||
>
|
||||
<!-- Admin bulk-select (§0 item 8 follow-up): multi-select
|
||||
downloaded episodes for file deletion. Rendered ONLY for
|
||||
admins + DOWNLOADED rows — a checkbox on a file-less episode
|
||||
would be an invitation to confusion. State survives the
|
||||
list's htmx swaps via the podcastDetail component
|
||||
(reapplied on afterSwap). -->
|
||||
if isAdmin {
|
||||
if ep.Downloaded {
|
||||
<input
|
||||
type="checkbox"
|
||||
name="episode-file-select"
|
||||
value={ ep.ID }
|
||||
class="cursor-pointer shrink-0"
|
||||
aria-label={ "Select " + ep.Title + " for file deletion" }
|
||||
@change="toggleBulkSelection($event.target)"
|
||||
/>
|
||||
} else {
|
||||
<span class="w-4 shrink-0"></span>
|
||||
}
|
||||
}
|
||||
<span class="text-xs font-mono w-10 shrink-0 text-right" style="color: var(--text-secondary)">
|
||||
{ podcastInt(ep.Position) }
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<a href={ ep.PlayURL } class="font-medium text-sm hover:underline line-clamp-1" style="color: var(--text-primary)">{ ep.Title }</a>
|
||||
if ep.DownloadState == "failed" {
|
||||
<span class="badge shrink-0" style="background-color: color-mix(in srgb, var(--status-danger) 15%, transparent); color: var(--status-danger);">{ ep.StateBadge() }</span>
|
||||
} else if ep.DownloadState == "queued" || ep.DownloadState == "downloading" {
|
||||
<span class="badge shrink-0 animate-pulse" style="background-color: var(--accent-muted); color: var(--accent);">{ ep.StateBadge() }</span>
|
||||
} else if ep.DownloadState == "pending" {
|
||||
<span class="badge shrink-0" style="background-color: var(--surface-secondary); color: var(--text-secondary);">{ ep.StateBadge() }</span>
|
||||
}
|
||||
</div>
|
||||
if ep.DownloadError != "" {
|
||||
<p class="text-xs mt-0.5 line-clamp-2" style="color: var(--status-danger)">{ ep.DownloadError }</p>
|
||||
}
|
||||
if ep.DownloadState == "downloading" && ep.ProgressKnown {
|
||||
<div class="mt-1.5">
|
||||
if ep.ProgressTotal > 0 {
|
||||
<div class="h-1.5 w-full rounded-full overflow-hidden" style="background-color: var(--surface-secondary);">
|
||||
<div class="h-full rounded-full transition-all" style={ progressWidth(ep.ProgressPct) + " background-color: var(--accent);" }></div>
|
||||
</div>
|
||||
<p class="text-xs mt-0.5 tabular-nums" style="color: var(--text-secondary)">
|
||||
{ FormatEpisodeSize(ep.ProgressReceived) + " / " + FormatEpisodeSize(ep.ProgressTotal) }
|
||||
</p>
|
||||
} else {
|
||||
<div class="podcast-progress-indeterminate h-1.5 w-full rounded-full overflow-hidden" style="background-color: var(--surface-secondary);">
|
||||
<div class="h-full rounded-full" style="background-color: var(--accent);"></div>
|
||||
</div>
|
||||
<p class="text-xs mt-0.5 tabular-nums" style="color: var(--text-secondary)">{ FormatEpisodeSize(ep.ProgressReceived) }</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<p class="text-xs mt-0.5" style="color: var(--text-secondary)">
|
||||
{ ep.Date }
|
||||
if ep.Duration != "" {
|
||||
{ " · " + ep.Duration }
|
||||
}
|
||||
if ep.Size != "" {
|
||||
{ " · " + ep.Size }
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
if ep.Downloaded {
|
||||
<a href={ ep.PlayURL } class="btn btn-secondary shrink-0" title="Play — chains into the next downloaded episode (append ?single=1 to the URL to play just this one)">
|
||||
@Icon("book-open", "h-4 w-4")
|
||||
<span>Play</span>
|
||||
</a>
|
||||
} else if ep.DownloadState == "queued" || ep.DownloadState == "downloading" {
|
||||
<span class="btn btn-secondary shrink-0 pointer-events-none opacity-60" aria-busy="true">
|
||||
@Icon("refresh", "h-4 w-4")
|
||||
</span>
|
||||
} else {
|
||||
<!-- pending AND failed: (re)download — same endpoint, idempotent -->
|
||||
<button
|
||||
class={ "btn shrink-0", templ.KV("btn-secondary", ep.DownloadState != "failed"), templ.KV("btn-primary", ep.DownloadState == "failed") }
|
||||
hx-post={ "/api/podcasts/feeds/" + ep.FeedID + "/episodes/" + ep.ID + "/download" }
|
||||
hx-swap="none"
|
||||
title={ downloadButtonTitle(ep.DownloadState) }
|
||||
>
|
||||
@Icon("save", "h-4 w-4")
|
||||
<span>{ downloadButtonLabel(ep.DownloadState) }</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
// downloadButtonLabel: pending → Download; failed → Retry (the reason
|
||||
// text above it explains what went wrong — D8/D19 inline failure UX).
|
||||
func downloadButtonLabel(state string) string {
|
||||
if state == "failed" {
|
||||
return "Retry"
|
||||
}
|
||||
return "Download"
|
||||
}
|
||||
|
||||
// progressWidth renders the bar width as a quote-free inline style
|
||||
// fragment (templ gotcha #8: style attribute values must not carry
|
||||
// quote characters).
|
||||
func progressWidth(pct float64) string {
|
||||
if pct < 1 {
|
||||
pct = 1
|
||||
}
|
||||
if pct > 100 {
|
||||
pct = 100
|
||||
}
|
||||
return fmt.Sprintf("width: %.1f%%;", pct)
|
||||
}
|
||||
|
||||
func downloadButtonTitle(state string) string {
|
||||
if state == "failed" {
|
||||
return "Retry the download"
|
||||
}
|
||||
return "Download this episode"
|
||||
}
|
||||
|
||||
|
||||
// PodcastAutoDownloadSelect is the policy dropdown (issue #3): extracted
|
||||
// so the Download All response can out-of-band-swap it after flipping
|
||||
// the caller's policy — the dropdown must reflect what the user gets.
|
||||
templ PodcastAutoDownloadSelect(feed PodcastFeedCard) {
|
||||
<select
|
||||
id="podcast-autodl-select"
|
||||
name="auto_download"
|
||||
class="input w-auto py-1.5 cursor-pointer"
|
||||
hx-put={ "/api/podcasts/feeds/" + feed.ID + "/subscription" }
|
||||
hx-target="#episode-list"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<option value="off" selected?={ feed.AutoDownload == "off" || feed.AutoDownload == "" }>Off</option>
|
||||
<option value="all" selected?={ feed.AutoDownload == "all" }>All episodes</option>
|
||||
<option value="newest" selected?={ feed.AutoDownload == "newest" }>Newest</option>
|
||||
</select>
|
||||
}
|
||||
|
||||
// PodcastOrderSelectOOB is the out-of-band variant of the order
|
||||
// dropdown: same element id + hx-swap-oob so htmx replaces the
|
||||
// action-row select in place (§0 item 3 — the "Reverse feed order"
|
||||
// button flips the order server-side; the dropdown must show it).
|
||||
templ PodcastOrderSelectOOB(feedID string, order string) {
|
||||
<select
|
||||
id="podcast-order-select"
|
||||
name="order"
|
||||
hx-swap-oob="true"
|
||||
class="input w-auto py-1.5 cursor-pointer"
|
||||
hx-put={ "/api/podcasts/feeds/" + feedID + "/order" }
|
||||
hx-target="#episode-list"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<option value="native" selected?={ order == "native" }>Feed order</option>
|
||||
<option value="reverse" selected?={ order == "reverse" }>Feed order (reversed)</option>
|
||||
<option value="oldest" selected?={ order == "oldest" }>Oldest first (pub date)</option>
|
||||
<option value="newest" selected?={ order == "newest" }>Newest first (pub date)</option>
|
||||
</select>
|
||||
}
|
||||
|
||||
// PodcastAutoDownloadSelectOOB is the out-of-band variant: same element
|
||||
// id + hx-swap-oob so htmx replaces the action-row select in place.
|
||||
templ PodcastAutoDownloadSelectOOB(feed PodcastFeedCard) {
|
||||
<select
|
||||
id="podcast-autodl-select"
|
||||
name="auto_download"
|
||||
hx-swap-oob="true"
|
||||
class="input w-auto py-1.5 cursor-pointer"
|
||||
hx-put={ "/api/podcasts/feeds/" + feed.ID + "/subscription" }
|
||||
hx-target="#episode-list"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<option value="off" selected?={ feed.AutoDownload == "off" || feed.AutoDownload == "" }>Off</option>
|
||||
<option value="all" selected?={ feed.AutoDownload == "all" }>All episodes</option>
|
||||
<option value="newest" selected?={ feed.AutoDownload == "newest" }>Newest</option>
|
||||
</select>
|
||||
}
|
||||
|
||||
// PodcastUnsubscribeModal is the last-subscriber admin choice (issue
|
||||
// #7): unsubscribe (files kept — the D6 default), remove feed + files
|
||||
// entirely, or cancel. Reachable only for admins (the button that opens
|
||||
// it renders for admins alone).
|
||||
templ PodcastUnsubscribeModal(data PodcastDetailData) {
|
||||
<div
|
||||
id="podcast-unsub-modal"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4"
|
||||
style="background-color: var(--surface-overlay);"
|
||||
x-show="unsubOpen"
|
||||
x-cloak
|
||||
@click.self="unsubOpen = false"
|
||||
>
|
||||
<div class="card w-full max-w-md" style="box-shadow: var(--shadow-pop);">
|
||||
<div class="p-6 border-b" style="border-color: var(--border);">
|
||||
<h2 class="text-lg font-bold" style="color: var(--text-primary)">Unsubscribe from { data.Feed.DisplayTitle() }?</h2>
|
||||
<p class="text-sm mt-1" style="color: var(--text-secondary)">You are the only subscriber.</p>
|
||||
</div>
|
||||
<div class="p-6 flex flex-col gap-2">
|
||||
<button
|
||||
class="btn btn-secondary w-full"
|
||||
hx-delete={ "/api/podcasts/feeds/" + data.Feed.ID + "/subscription" }
|
||||
hx-swap="none"
|
||||
@click="unsubOpen = false"
|
||||
>
|
||||
<span>Unsubscribe — files kept</span>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-secondary w-full"
|
||||
style="color: var(--status-danger);"
|
||||
hx-delete={ "/api/podcasts/feeds/" + data.Feed.ID }
|
||||
hx-swap="none"
|
||||
@click="unsubOpen = false"
|
||||
>
|
||||
<span>Remove feed — delete episodes and files</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary w-full" @click="unsubOpen = false">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,238 @@
|
||||
// Podcast template view types (plan task 1.9): the SSR shape shared by
|
||||
// the /podcasts page and the feed-detail page. The server formats
|
||||
// everything display-ready (dates in the requesting user's timezone —
|
||||
// D17; durations human) so templates stay logic-free.
|
||||
package templates
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bookhoard/internal/utils"
|
||||
)
|
||||
|
||||
// PodcastFeedCard is one grid card on /podcasts (or the hero on the
|
||||
// detail page).
|
||||
// FacetValue is one facet-bar chip: the value plus how many subscribed
|
||||
// feeds carry it (§0 follow-up, 2026-10-30 — at scale the bar caps per
|
||||
// group and overflows into a +N more expander, frequency-sorted).
|
||||
type FacetValue struct {
|
||||
Value string
|
||||
Count int
|
||||
}
|
||||
|
||||
type PodcastFeedCard struct { ID string
|
||||
LibraryID string
|
||||
Title string
|
||||
DisplayName string // subscription-owned; renders instead of Title when set
|
||||
Description string
|
||||
Host string
|
||||
Publisher string
|
||||
Language string
|
||||
Tags []string
|
||||
Explicit bool
|
||||
ArtworkPath string
|
||||
FeedType string // serial | episodic
|
||||
Medium string
|
||||
IsComplete bool
|
||||
FeedURL string
|
||||
LastFetched string // display-formatted
|
||||
SubscribedAt string
|
||||
|
||||
EpisodeTotal int
|
||||
DownloadedN int
|
||||
UndownloadedN int
|
||||
NewEpisodes bool
|
||||
|
||||
// Subscription-owned policy (detail page settings block).
|
||||
AutoDownload string
|
||||
AutoDownloadCount int
|
||||
Subscribed bool
|
||||
|
||||
// EpisodeMatches (issue #8): when a search query is active, the
|
||||
// number of the feed's episodes whose title/description matched —
|
||||
// feeds match on EPISODES too, not just their own metadata.
|
||||
EpisodeMatches int
|
||||
|
||||
// Continue aggregation (plan 2.5): the episode the user is midway
|
||||
// through, most recent first across feeds.
|
||||
HasContinue bool
|
||||
ContinueTitle string
|
||||
ContinueEpisode int
|
||||
ContinuePercent float64
|
||||
ContinueLastRead time.Time // section sort key (most recent first)
|
||||
}
|
||||
|
||||
// DisplayTitle renders the subscription display name when set (D9).
|
||||
func (c PodcastFeedCard) DisplayTitle() string {
|
||||
if c.DisplayName != "" {
|
||||
return c.DisplayName
|
||||
}
|
||||
return c.Title
|
||||
}
|
||||
|
||||
// TypeLabel renders the feed's shape for the card badge.
|
||||
func (c PodcastFeedCard) TypeLabel() string {
|
||||
if c.FeedType == "serial" {
|
||||
return "Serial"
|
||||
}
|
||||
if c.Medium != "" {
|
||||
return strings.ToUpper(c.Medium[:1]) + c.Medium[1:]
|
||||
}
|
||||
return "Podcast"
|
||||
}
|
||||
|
||||
// PodcastEpisodeRow is one episode-list line with the download
|
||||
// lifecycle inline (D8/D19: pending greyed + download button,
|
||||
// queued/downloading spinner, failed/rejected red + reason + retry).
|
||||
type PodcastEpisodeRow struct {
|
||||
ID string
|
||||
FeedID string
|
||||
Title string
|
||||
Description string
|
||||
Date string // display-formatted (user tz)
|
||||
SortDate string // lexical-comparable pub-date key (2006-01-02) for ordering
|
||||
Position int
|
||||
Duration string // human ("58:02" / "1:02:10")
|
||||
Size string // human MB/GB when known
|
||||
PlayURL string // reader URL (audio reader serves episodes like audiobooks)
|
||||
CoverImage string
|
||||
Downloaded bool
|
||||
DownloadState string // pending|queued|downloading|downloaded|failed
|
||||
DownloadError string
|
||||
|
||||
// Playback progress (reading_progress; the row badge) — reuses the
|
||||
// ProgressPct field below when the row is downloading? No: keep the
|
||||
// two concerns separate (issue #1).
|
||||
ListenPct float64
|
||||
|
||||
// Live byte progress for rows in the downloading state (issue #1):
|
||||
// the worker's in-flight snapshot. ProgressKnown=false renders the
|
||||
// indeterminate bar; ProgressPct (0-100) drives the width when the
|
||||
// total is authoritative.
|
||||
ProgressKnown bool
|
||||
ProgressPct float64
|
||||
ProgressReceived int64
|
||||
ProgressTotal int64
|
||||
}
|
||||
|
||||
// StateBadge renders the canonical state label.
|
||||
func (r PodcastEpisodeRow) StateBadge() string {
|
||||
switch r.DownloadState {
|
||||
case "pending":
|
||||
return "Not downloaded"
|
||||
case "queued":
|
||||
return "Queued"
|
||||
case "downloading":
|
||||
return "Downloading…"
|
||||
case "failed":
|
||||
return "Failed"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// HasActiveDownloads: any episode queued/downloading — the partial
|
||||
// polls only while this is true (server-driven polling cadence).
|
||||
func HasActiveDownloads(rows []PodcastEpisodeRow) bool {
|
||||
for _, r := range rows {
|
||||
if r.DownloadState == "queued" || r.DownloadState == "downloading" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// FormatEpisodeDuration renders seconds as H:MM:SS or M:SS.
|
||||
func FormatEpisodeDuration(seconds int) string {
|
||||
if seconds <= 0 {
|
||||
return ""
|
||||
}
|
||||
h := seconds / 3600
|
||||
m := (seconds % 3600) / 60
|
||||
s := seconds % 60
|
||||
if h > 0 {
|
||||
return fmt.Sprintf("%d:%02d:%02d", h, m, s)
|
||||
}
|
||||
return fmt.Sprintf("%d:%02d", m, s)
|
||||
}
|
||||
|
||||
// FormatEpisodeSize renders bytes human (MB/GB).
|
||||
func FormatEpisodeSize(bytes int64) string {
|
||||
switch {
|
||||
case bytes <= 0:
|
||||
return ""
|
||||
case bytes >= 1<<30:
|
||||
return fmt.Sprintf("%.1f GB", float64(bytes)/(1<<30))
|
||||
default:
|
||||
return fmt.Sprintf("%.1f MB", float64(bytes)/(1<<20))
|
||||
}
|
||||
}
|
||||
|
||||
// PodcastsPageData carries everything the /podcasts page renders.
|
||||
type PodcastsPageData struct {
|
||||
// ContinueFeeds: the in-progress subset, most recently listened
|
||||
// first (plan 2.5) — split in the page builder (templ bodies can't
|
||||
// declare locals).
|
||||
ContinueFeeds []PodcastFeedCard
|
||||
Feeds []PodcastFeedCard
|
||||
Libraries []LibraryData // podcasts-type libraries (add-feed targets)
|
||||
Facets map[string][]FacetValue
|
||||
Query string
|
||||
ErrorMsg string
|
||||
HasLibrary bool
|
||||
}
|
||||
|
||||
// PodcastDetailData carries the feed-detail page render.
|
||||
type PodcastDetailData struct {
|
||||
Feed PodcastFeedCard
|
||||
Episodes []PodcastEpisodeRow
|
||||
GapCount int
|
||||
Order string // native | oldest | newest (the caller's override)
|
||||
IsAdmin bool
|
||||
IsSerial bool // serial/audiobook feeds get the oldest-first suggestion (D5)
|
||||
// SubscriberCount (issue #7): drives the last-subscriber
|
||||
// unsubscribe flow — the sole subscriber who is an admin gets the
|
||||
// three-choice modal (unsubscribe / remove feed / cancel).
|
||||
SubscriberCount int
|
||||
}
|
||||
|
||||
// podcastInt renders an int for templates.
|
||||
func podcastInt(n int) string {
|
||||
return fmt.Sprintf("%d", n)
|
||||
}
|
||||
|
||||
// podcastPlural returns "" for 1 and "s" otherwise.
|
||||
func podcastPlural(n int) string {
|
||||
if n == 1 {
|
||||
return ""
|
||||
}
|
||||
return "s"
|
||||
}
|
||||
|
||||
// timeStringPtr helper: display-format with empty fallback.
|
||||
func podcastTimeOrEmpty(t time.Time, valid bool, tz string) string {
|
||||
if !valid || t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return utils.FormatTimeInTimezone(t, tz)
|
||||
}
|
||||
|
||||
|
||||
// PodcastContinueFeeds returns the in-progress feeds, most recently
|
||||
// listened first (plan 2.5). ≥95% progress counts as finished and is
|
||||
// excluded (the continue query already applies that bound).
|
||||
func PodcastContinueFeeds(feeds []PodcastFeedCard) []PodcastFeedCard {
|
||||
out := make([]PodcastFeedCard, 0, len(feeds))
|
||||
for _, f := range feeds {
|
||||
if f.HasContinue {
|
||||
out = append(out, f)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
return out[i].ContinueLastRead.After(out[j].ContinueLastRead)
|
||||
})
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
package templates
|
||||
|
||||
import "net/url"
|
||||
|
||||
// PodcastFacetChip is the facet-bar chip: value + feed count, linking
|
||||
// back into the podcasts page's server-side filter (the /bookshelf
|
||||
// FacetLink targets the wrong surface for podcasts).
|
||||
templ PodcastFacetChip(facet string, v FacetValue) {
|
||||
<a href={ "/podcasts?" + facet + "=" + url.QueryEscape(v.Value) } class="badge hover:opacity-80 transition-opacity" style="background-color: var(--surface-secondary); color: var(--text-secondary); text-decoration: none;">
|
||||
{ v.Value }
|
||||
if v.Count > 1 {
|
||||
<span class="opacity-70 ml-1">{ podcastInt(v.Count) }</span>
|
||||
}
|
||||
</a>
|
||||
}
|
||||
|
||||
// The /podcasts page (plan task 1.9, D13/D14/D18): Add Feed wizard,
|
||||
// subscription-scoped search, facet bar, All-Feeds grid. Server-side
|
||||
// filtering (GET form, like the series page — no JS needed); the grid
|
||||
// is templ-rendered server data per the rendering-ownership rule.
|
||||
|
||||
templ PodcastsPage(user User, data PodcastsPageData, currentPath string) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Podcasts - Bookhoard</title>
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||
</head>
|
||||
<body x-data="podcastsPage" class={ "theme-" + user.Theme }>
|
||||
@Header(user, currentPath)
|
||||
<main class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="mb-6 flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||
@Icon("podcast", "h-5 w-5")
|
||||
</span>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Podcasts</h1>
|
||||
<p class="text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">Your subscriptions</p>
|
||||
</div>
|
||||
</div>
|
||||
if data.HasLibrary {
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
@click="showAddFeed()"
|
||||
>
|
||||
@Icon("plus", "h-4 w-4")
|
||||
<span>Add Feed</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
if data.ErrorMsg != "" {
|
||||
<div class="mb-4 card p-4" style="border-color: var(--status-danger); color: var(--status-danger);">
|
||||
{ data.ErrorMsg }
|
||||
</div>
|
||||
}
|
||||
|
||||
if !data.HasLibrary {
|
||||
<div class="card p-8 text-center">
|
||||
<p class="mb-2 font-semibold" style="color: var(--text-primary)">No podcasts library yet</p>
|
||||
<p class="text-sm" style="color: var(--text-secondary)">An admin creates a Podcasts library (with a download folder) first — then feeds can be added here.</p>
|
||||
</div>
|
||||
} else {
|
||||
@PodcastsFilterBar(data)
|
||||
if len(data.Feeds) == 0 {
|
||||
<div class="card p-8 text-center">
|
||||
<p class="mb-2 font-semibold" style="color: var(--text-primary)">No subscriptions</p>
|
||||
<p class="text-sm" style="color: var(--text-secondary)">Add an RSS feed to start listening — episodes land here and download on demand.</p>
|
||||
</div>
|
||||
} else {
|
||||
@PodcastsSections(data)
|
||||
}
|
||||
}
|
||||
</main>
|
||||
@PodcastAddFeedModal(data.Libraries)
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
|
||||
templ PodcastsFilterBar(data PodcastsPageData) {
|
||||
<form method="get" action="/podcasts" class="mb-6 flex flex-wrap items-center gap-3">
|
||||
<div class="relative flex-1 min-w-48">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" style="color: var(--text-secondary)">
|
||||
@Icon("search", "h-4 w-4")
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
name="q"
|
||||
value={ data.Query }
|
||||
placeholder="Search feeds, hosts, episodes…"
|
||||
class="input w-full pl-9"
|
||||
aria-label="Search your subscriptions"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-secondary" aria-label="Search">
|
||||
@Icon("search", "h-4 w-4")
|
||||
<span>Search</span>
|
||||
</button>
|
||||
</form>
|
||||
if len(data.Facets["hosts"]) > 0 || len(data.Facets["tags"]) > 0 || len(data.Facets["language"]) > 0 {
|
||||
<div class="mb-6 flex flex-wrap items-center gap-2 text-sm">
|
||||
<span class="font-semibold mr-1" style="color: var(--text-secondary)">Filter:</span>
|
||||
@podcastFacetGroup("host", data.Facets["hosts"])
|
||||
@podcastFacetGroup("tag", data.Facets["tags"])
|
||||
@podcastFacetGroup("language", data.Facets["language"])
|
||||
if data.Query != "" {
|
||||
<a href="/podcasts" class="badge" style="background-color: var(--surface-secondary); color: var(--text-secondary)">clear</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
// podcastFacetBatchSize: how many facet chips render flat before the
|
||||
// rest collapse into a "+N more" expander (native <details>, no JS).
|
||||
// With hundreds of feeds the flat row would be a wall of chips; the
|
||||
// frequency sort keeps the useful ones up front.
|
||||
var podcastFacetBatchSize = 6
|
||||
|
||||
templ podcastFacetGroup(facet string, values []FacetValue) {
|
||||
if len(values) > 0 {
|
||||
for _, v := range values[:min(len(values), podcastFacetBatchSize)] {
|
||||
@PodcastFacetChip(facet, v)
|
||||
}
|
||||
if len(values) > podcastFacetBatchSize {
|
||||
<details class="inline-block">
|
||||
<summary class="badge cursor-pointer select-none" style="background-color: var(--surface-secondary); color: var(--text-secondary)">+{ podcastInt(len(values) - podcastFacetBatchSize) } more</summary>
|
||||
<span class="inline-flex flex-wrap gap-2 align-middle ml-2">
|
||||
for _, v := range values[podcastFacetBatchSize:] {
|
||||
@PodcastFacetChip(facet, v)
|
||||
}
|
||||
</span>
|
||||
</details>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PodcastsSections renders the Continue section (feeds with in-progress
|
||||
// episodes, most recently listened first — plan 2.5) ahead of the
|
||||
// alphabetical All Feeds grid.
|
||||
templ PodcastsSections(data PodcastsPageData) {
|
||||
if len(data.ContinueFeeds) > 0 {
|
||||
<h2 class="text-lg font-bold mb-3 mt-2" style="color: var(--text-primary)">Continue</h2>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 mb-8">
|
||||
for _, feed := range data.ContinueFeeds {
|
||||
@PodcastFeedCardView(feed)
|
||||
}
|
||||
</div>
|
||||
<h2 class="text-lg font-bold mb-3" style="color: var(--text-primary)">All Feeds</h2>
|
||||
}
|
||||
@PodcastsGrid(data.Feeds)
|
||||
}
|
||||
|
||||
templ PodcastsGrid(feeds []PodcastFeedCard) {
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
for _, feed := range feeds {
|
||||
@PodcastFeedCardView(feed)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
templ PodcastFeedCardView(feed PodcastFeedCard) {
|
||||
<a
|
||||
href={ "/podcasts/" + feed.ID }
|
||||
class="card p-4 flex flex-col gap-3 hover:-translate-y-0.5 transition-transform"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="h-16 w-16 rounded-lg shrink-0 overflow-hidden grid place-items-center" style="background-color: var(--surface-secondary); color: var(--text-secondary);">
|
||||
if feed.ArtworkPath != "" {
|
||||
<img
|
||||
src={ "/uploads/library-" + feed.LibraryID + "/" + feed.ArtworkPath }
|
||||
alt={ "Cover for " + feed.DisplayTitle() }
|
||||
class="h-full w-full object-cover"
|
||||
data-image-fallback
|
||||
/>
|
||||
} else {
|
||||
@Icon("podcast", "h-7 w-7")
|
||||
}
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="font-semibold leading-snug line-clamp-2" style="color: var(--text-primary)">{ feed.DisplayTitle() }</p>
|
||||
<p class="text-xs mt-0.5" style="color: var(--text-secondary)">
|
||||
{ feed.TypeLabel() }
|
||||
if feed.Host != "" {
|
||||
{ " · " + feed.Host }
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
if feed.HasContinue {
|
||||
<p class="text-xs font-medium line-clamp-1" style="color: var(--accent)">
|
||||
@Icon("book-open", "h-3.5 w-3.5 inline")
|
||||
{ " " + podcastInt(feed.ContinueEpisode) + ". " + feed.ContinueTitle }
|
||||
</p>
|
||||
} else if feed.Description != "" {
|
||||
<p class="text-xs line-clamp-2" style="color: var(--text-secondary)">{ feed.Description }</p>
|
||||
}
|
||||
<div class="mt-auto flex flex-wrap items-center gap-1.5 text-xs">
|
||||
<span class="badge" style="background-color: var(--surface-secondary); color: var(--text-secondary)">
|
||||
{ podcastInt(feed.DownloadedN) + " / " + podcastInt(feed.EpisodeTotal) + " downloaded" }
|
||||
</span>
|
||||
if feed.EpisodeMatches > 0 {
|
||||
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent)">
|
||||
{ podcastInt(feed.EpisodeMatches) + " matching episode" + podcastPlural(feed.EpisodeMatches) }
|
||||
</span>
|
||||
}
|
||||
if feed.NewEpisodes {
|
||||
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent)">new episodes</span>
|
||||
}
|
||||
if feed.IsComplete {
|
||||
<span class="badge" style="background-color: var(--surface-secondary); color: var(--text-secondary)">complete</span>
|
||||
}
|
||||
if feed.Explicit {
|
||||
<span class="badge" style="background-color: var(--surface-secondary); color: var(--text-secondary)">explicit</span>
|
||||
}
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
|
||||
templ PodcastAddFeedModal(libraries []LibraryData) {
|
||||
<div
|
||||
id="podcast-add-feed-modal"
|
||||
class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4"
|
||||
style="background-color: var(--surface-overlay);"
|
||||
>
|
||||
<div class="card w-full max-w-lg" style="box-shadow: var(--shadow-pop);">
|
||||
<div class="flex justify-between items-center p-6 border-b" style="border-color: var(--border);">
|
||||
<h2 class="text-lg font-bold" style="color: var(--text-primary)">Add Podcast Feed</h2>
|
||||
<button class="icon-btn" aria-label="Close" @click="hideAddFeed()">
|
||||
@Icon("close", "h-5 w-5")
|
||||
</button>
|
||||
</div>
|
||||
<form
|
||||
hx-post="/api/podcasts/feeds"
|
||||
hx-swap="none"
|
||||
class="p-6 flex flex-col gap-4"
|
||||
>
|
||||
<div>
|
||||
<label class="text-sm font-medium mb-1 block" for="podcast-feed-url" style="color: var(--text-secondary)">RSS feed URL</label>
|
||||
<input
|
||||
type="url"
|
||||
id="podcast-feed-url"
|
||||
name="url"
|
||||
placeholder="https://example.com/feed.xml"
|
||||
class="input w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
if len(libraries) > 1 {
|
||||
<div>
|
||||
<label class="text-sm font-medium mb-1 block" for="podcast-feed-library" style="color: var(--text-secondary)">Library</label>
|
||||
<select id="podcast-feed-library" name="library_id" class="input w-full">
|
||||
for _, lib := range libraries {
|
||||
<option value={ lib.ID }>{ lib.Name }</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
} else if len(libraries) == 1 {
|
||||
<input type="hidden" name="library_id" value={ libraries[0].ID }/>
|
||||
}
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-2 text-sm">
|
||||
<input type="checkbox" id="podcast-download-all" name="download_all" value="true" class="cursor-pointer"/>
|
||||
<label for="podcast-download-all" style="color: var(--text-secondary)">Download all episodes now</label>
|
||||
<!-- Download order (§0 item 5c): revealed by pure CSS when
|
||||
the checkbox is checked — #podcast-download-all:checked ~
|
||||
.podcast-add-order. The checkbox and this row MUST stay
|
||||
siblings under this wrapper for the selector to fire. -->
|
||||
<div class="podcast-add-order flex items-center gap-2 pl-6">
|
||||
<label for="podcast-download-order" style="color: var(--text-secondary)">Download order</label>
|
||||
<select
|
||||
id="podcast-download-order"
|
||||
name="download_order"
|
||||
class="input w-auto py-1.5 cursor-pointer"
|
||||
title="Listen/download order for this feed — chapters land in this order"
|
||||
>
|
||||
<option value="feed" selected>Feed order</option>
|
||||
<option value="reverse">Feed order (reversed)</option>
|
||||
<option value="oldest">Oldest first (pub date)</option>
|
||||
<option value="newest">Newest first (pub date)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div id="podcast-add-feed-error" class="text-sm hidden" style="color: var(--status-danger)"></div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" class="btn btn-secondary" @click="hideAddFeed()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
@Icon("plus", "h-4 w-4")
|
||||
<span>Add & Subscribe</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,817 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1020
|
||||
package templates
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
import "net/url"
|
||||
|
||||
// PodcastFacetChip is the facet-bar chip: value + feed count, linking
|
||||
// back into the podcasts page's server-side filter (the /bookshelf
|
||||
// FacetLink targets the wrong surface for podcasts).
|
||||
func PodcastFacetChip(facet string, v FacetValue) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<a href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 templ.SafeURL
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinURLErrs("/podcasts?" + facet + "=" + url.QueryEscape(v.Value))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 9, Col: 64}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" class=\"badge hover:opacity-80 transition-opacity\" style=\"background-color: var(--surface-secondary); color: var(--text-secondary); text-decoration: none;\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(v.Value)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 10, Col: 11}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, " ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if v.Count > 1 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<span class=\"opacity-70 ml-1\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(v.Count))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 12, Col: 54}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// The /podcasts page (plan task 1.9, D13/D14/D18): Add Feed wizard,
|
||||
// subscription-scoped search, facet bar, All-Feeds grid. Server-side
|
||||
// filtering (GET form, like the series page — no JS needed); the grid
|
||||
// is templ-rendered server data per the rendering-ownership rule.
|
||||
func PodcastsPage(user User, data PodcastsPageData, currentPath string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var5 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var5 == nil {
|
||||
templ_7745c5c3_Var5 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Podcasts - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 = []any{"theme-" + user.Theme}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var6...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<body x-data=\"podcastsPage\" class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var6).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Header(user, currentPath).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<main class=\"mx-auto container px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-6 flex flex-wrap items-center justify-between gap-3\"><div class=\"flex items-center gap-3\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("podcast", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</span><div><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Podcasts</h1><p class=\"text-xs font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\">Your subscriptions</p></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if data.HasLibrary {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<button class=\"btn btn-primary\" @click=\"showAddFeed()\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("plus", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<span>Add Feed</span></button>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if data.ErrorMsg != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<div class=\"mb-4 card p-4\" style=\"border-color: var(--status-danger); color: var(--status-danger);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(data.ErrorMsg)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 58, Col: 21}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if !data.HasLibrary {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<div class=\"card p-8 text-center\"><p class=\"mb-2 font-semibold\" style=\"color: var(--text-primary)\">No podcasts library yet</p><p class=\"text-sm\" style=\"color: var(--text-secondary)\">An admin creates a Podcasts library (with a download folder) first — then feeds can be added here.</p></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = PodcastsFilterBar(data).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, " ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if len(data.Feeds) == 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<div class=\"card p-8 text-center\"><p class=\"mb-2 font-semibold\" style=\"color: var(--text-primary)\">No subscriptions</p><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Add an RSS feed to start listening — episodes land here and download on demand.</p></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = PodcastsSections(data).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</main>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = PodcastAddFeedModal(data.Libraries).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func PodcastsFilterBar(data PodcastsPageData) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var9 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var9 == nil {
|
||||
templ_7745c5c3_Var9 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<form method=\"get\" action=\"/podcasts\" class=\"mb-6 flex flex-wrap items-center gap-3\"><div class=\"relative flex-1 min-w-48\"><span class=\"absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none\" style=\"color: var(--text-secondary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("search", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</span> <input type=\"text\" name=\"q\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(data.Query)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 93, Col: 22}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" placeholder=\"Search feeds, hosts, episodes…\" class=\"input w-full pl-9\" aria-label=\"Search your subscriptions\"></div><button type=\"submit\" class=\"btn btn-secondary\" aria-label=\"Search\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("search", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<span>Search</span></button></form>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if len(data.Facets["hosts"]) > 0 || len(data.Facets["tags"]) > 0 || len(data.Facets["language"]) > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<div class=\"mb-6 flex flex-wrap items-center gap-2 text-sm\"><span class=\"font-semibold mr-1\" style=\"color: var(--text-secondary)\">Filter:</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = podcastFacetGroup("host", data.Facets["hosts"]).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = podcastFacetGroup("tag", data.Facets["tags"]).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = podcastFacetGroup("language", data.Facets["language"]).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if data.Query != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<a href=\"/podcasts\" class=\"badge\" style=\"background-color: var(--surface-secondary); color: var(--text-secondary)\">clear</a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// podcastFacetBatchSize: how many facet chips render flat before the
|
||||
// rest collapse into a "+N more" expander (native <details>, no JS).
|
||||
// With hundreds of feeds the flat row would be a wall of chips; the
|
||||
// frequency sort keeps the useful ones up front.
|
||||
var podcastFacetBatchSize = 6
|
||||
|
||||
func podcastFacetGroup(facet string, values []FacetValue) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var11 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var11 == nil {
|
||||
templ_7745c5c3_Var11 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
if len(values) > 0 {
|
||||
for _, v := range values[:min(len(values), podcastFacetBatchSize)] {
|
||||
templ_7745c5c3_Err = PodcastFacetChip(facet, v).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, " ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if len(values) > podcastFacetBatchSize {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<details class=\"inline-block\"><summary class=\"badge cursor-pointer select-none\" style=\"background-color: var(--surface-secondary); color: var(--text-secondary)\">+")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(len(values) - podcastFacetBatchSize))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 130, Col: 185}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, " more</summary> <span class=\"inline-flex flex-wrap gap-2 align-middle ml-2\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, v := range values[podcastFacetBatchSize:] {
|
||||
templ_7745c5c3_Err = PodcastFacetChip(facet, v).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</span></details>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// PodcastsSections renders the Continue section (feeds with in-progress
|
||||
// episodes, most recently listened first — plan 2.5) ahead of the
|
||||
// alphabetical All Feeds grid.
|
||||
func PodcastsSections(data PodcastsPageData) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var13 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var13 == nil {
|
||||
templ_7745c5c3_Var13 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
if len(data.ContinueFeeds) > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<h2 class=\"text-lg font-bold mb-3 mt-2\" style=\"color: var(--text-primary)\">Continue</h2><div class=\"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 mb-8\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, feed := range data.ContinueFeeds {
|
||||
templ_7745c5c3_Err = PodcastFeedCardView(feed).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</div><h2 class=\"text-lg font-bold mb-3\" style=\"color: var(--text-primary)\">All Feeds</h2>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = PodcastsGrid(data.Feeds).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func PodcastsGrid(feeds []PodcastFeedCard) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var14 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var14 == nil {
|
||||
templ_7745c5c3_Var14 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<div class=\"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, feed := range feeds {
|
||||
templ_7745c5c3_Err = PodcastFeedCardView(feed).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func PodcastFeedCardView(feed PodcastFeedCard) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var15 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var15 == nil {
|
||||
templ_7745c5c3_Var15 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<a href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var16 templ.SafeURL
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinURLErrs("/podcasts/" + feed.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 167, Col: 31}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "\" class=\"card p-4 flex flex-col gap-3 hover:-translate-y-0.5 transition-transform\"><div class=\"flex items-start gap-3\"><div class=\"h-16 w-16 rounded-lg shrink-0 overflow-hidden grid place-items-center\" style=\"background-color: var(--surface-secondary); color: var(--text-secondary);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if feed.ArtworkPath != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<img src=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("/uploads/library-" + feed.LibraryID + "/" + feed.ArtworkPath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 174, Col: 73}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\" alt=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("Cover for " + feed.DisplayTitle())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 175, Col: 46}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\" class=\"h-full w-full object-cover\" data-image-fallback>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = Icon("podcast", "h-7 w-7").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</div><div class=\"min-w-0\"><p class=\"font-semibold leading-snug line-clamp-2\" style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(feed.DisplayTitle())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 184, Col: 111}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</p><p class=\"text-xs mt-0.5\" style=\"color: var(--text-secondary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(feed.TypeLabel())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 186, Col: 23}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, " ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if feed.Host != "" {
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(" · " + feed.Host)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 188, Col: 26}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</p></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if feed.HasContinue {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<p class=\"text-xs font-medium line-clamp-1\" style=\"color: var(--accent)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("book-open", "h-3.5 w-3.5 inline").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(" " + podcastInt(feed.ContinueEpisode) + ". " + feed.ContinueTitle)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 196, Col: 72}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else if feed.Description != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<p class=\"text-xs line-clamp-2\" style=\"color: var(--text-secondary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var23 string
|
||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(feed.Description)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 199, Col: 90}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<div class=\"mt-auto flex flex-wrap items-center gap-1.5 text-xs\"><span class=\"badge\" style=\"background-color: var(--surface-secondary); color: var(--text-secondary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var24 string
|
||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(feed.DownloadedN) + " / " + podcastInt(feed.EpisodeTotal) + " downloaded")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 203, Col: 90}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if feed.EpisodeMatches > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<span class=\"badge\" style=\"background-color: var(--accent-muted); color: var(--accent)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var25 string
|
||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(feed.EpisodeMatches) + " matching episode" + podcastPlural(feed.EpisodeMatches))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 207, Col: 97}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if feed.NewEpisodes {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<span class=\"badge\" style=\"background-color: var(--accent-muted); color: var(--accent)\">new episodes</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if feed.IsComplete {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<span class=\"badge\" style=\"background-color: var(--surface-secondary); color: var(--text-secondary)\">complete</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if feed.Explicit {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<span class=\"badge\" style=\"background-color: var(--surface-secondary); color: var(--text-secondary)\">explicit</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</div></a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func PodcastAddFeedModal(libraries []LibraryData) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var26 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var26 == nil {
|
||||
templ_7745c5c3_Var26 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<div id=\"podcast-add-feed-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card w-full max-w-lg\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center p-6 border-b\" style=\"border-color: var(--border);\"><h2 class=\"text-lg font-bold\" style=\"color: var(--text-primary)\">Add Podcast Feed</h2><button class=\"icon-btn\" aria-label=\"Close\" @click=\"hideAddFeed()\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("close", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</button></div><form hx-post=\"/api/podcasts/feeds\" hx-swap=\"none\" class=\"p-6 flex flex-col gap-4\"><div><label class=\"text-sm font-medium mb-1 block\" for=\"podcast-feed-url\" style=\"color: var(--text-secondary)\">RSS feed URL</label> <input type=\"url\" id=\"podcast-feed-url\" name=\"url\" placeholder=\"https://example.com/feed.xml\" class=\"input w-full\" required></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if len(libraries) > 1 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<div><label class=\"text-sm font-medium mb-1 block\" for=\"podcast-feed-library\" style=\"color: var(--text-secondary)\">Library</label> <select id=\"podcast-feed-library\" name=\"library_id\" class=\"input w-full\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, lib := range libraries {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<option value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var27 string
|
||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 257, Col: 30}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var28 string
|
||||
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 257, Col: 43}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</option>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</select></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else if len(libraries) == 1 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<input type=\"hidden\" name=\"library_id\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var29 string
|
||||
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraries[0].ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 262, Col: 67}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "<div class=\"flex flex-wrap items-center gap-x-3 gap-y-2 text-sm\"><input type=\"checkbox\" id=\"podcast-download-all\" name=\"download_all\" value=\"true\" class=\"cursor-pointer\"> <label for=\"podcast-download-all\" style=\"color: var(--text-secondary)\">Download all episodes now</label><!-- Download order (§0 item 5c): revealed by pure CSS when\n\t\t\t\t\t the checkbox is checked — #podcast-download-all:checked ~\n\t\t\t\t\t .podcast-add-order. The checkbox and this row MUST stay\n\t\t\t\t\t siblings under this wrapper for the selector to fire. --><div class=\"podcast-add-order flex items-center gap-2 pl-6\"><label for=\"podcast-download-order\" style=\"color: var(--text-secondary)\">Download order</label> <select id=\"podcast-download-order\" name=\"download_order\" class=\"input w-auto py-1.5 cursor-pointer\" title=\"Listen/download order for this feed — chapters land in this order\"><option value=\"feed\" selected>Feed order</option> <option value=\"reverse\">Feed order (reversed)</option> <option value=\"oldest\">Oldest first (pub date)</option> <option value=\"newest\">Newest first (pub date)</option></select></div></div><div id=\"podcast-add-feed-error\" class=\"text-sm hidden\" style=\"color: var(--status-danger)\"></div><div class=\"flex justify-end gap-2\"><button type=\"button\" class=\"btn btn-secondary\" @click=\"hideAddFeed()\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("plus", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<span>Add & Subscribe</span></button></div></form></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
@@ -22,6 +22,10 @@ type User struct {
|
||||
DefaultAudioSpeed float64
|
||||
// Whether the web reader/player should hold a Screen Wake Lock.
|
||||
KeepScreenAwake bool
|
||||
// Sidebar /podcasts entry gate (plan D13): the user can see >=1
|
||||
// podcasts-type library. Resolved centrally in
|
||||
// getTemplateUserWithTheme so every page's Header is correct.
|
||||
ShowPodcastsNav bool
|
||||
}
|
||||
|
||||
// SpeedOption is one entry of the default-playback-speed select — the
|
||||
@@ -308,6 +312,24 @@ type ReaderMetadata struct {
|
||||
// KeepScreenAwake: user preference — the reader holds a Screen Wake
|
||||
// Lock while open when true.
|
||||
KeepScreenAwake bool `json:"keep_screen_awake"`
|
||||
// PodcastSequence (plan D7/Phase 2): set ONLY for podcast episodes —
|
||||
// the neighboring downloaded episodes in the caller's effective
|
||||
// order. Powers chain-by-default playback (auto-advance across
|
||||
// episodes) and the player's episode prev/next. nil for books.
|
||||
PodcastSequence *PodcastSequenceContext `json:"podcast_sequence,omitempty"`
|
||||
}
|
||||
|
||||
// PodcastSequenceContext is the episode-level chain payload rendered
|
||||
// into the audio reader's config JSON.
|
||||
type PodcastSequenceContext struct {
|
||||
FeedURL string `json:"feed_url,omitempty"`
|
||||
FeedTitle string `json:"feed_title,omitempty"`
|
||||
PrevURL string `json:"prev_url,omitempty"`
|
||||
NextURL string `json:"next_url,omitempty"`
|
||||
NextTitle string `json:"next_title,omitempty"`
|
||||
// EpisodeIndex/IndexTotal render the "Ep N of M" indicator (D7).
|
||||
EpisodeIndex int `json:"episode_index,omitempty"`
|
||||
IndexTotal int `json:"index_total,omitempty"`
|
||||
}
|
||||
|
||||
type ReadingProgress struct {
|
||||
|
||||
+11
-4
@@ -117,10 +117,10 @@ func MediaBadgeIcon(badge string) string {
|
||||
func TotalMediaCount(libs []LibraryData) int64 {
|
||||
var total int64
|
||||
for _, l := range libs {
|
||||
// Unmounted storage contributes nothing visible: an offline
|
||||
// library's holdings stay in the database but out of the UI
|
||||
// until its folders return.
|
||||
if l.Offline {
|
||||
// Podcasts libraries are feed-managed (plan D13): not book-
|
||||
// library inventory — excluded here so switcher totals stay
|
||||
// consistent with the switcher's podcast-filtered option list.
|
||||
if l.Offline || l.TypeName == "podcasts" {
|
||||
continue
|
||||
}
|
||||
total += l.MediaCount
|
||||
@@ -128,6 +128,13 @@ func TotalMediaCount(libs []LibraryData) int64 {
|
||||
return total
|
||||
}
|
||||
|
||||
// IsBookLibrary reports whether a library type participates in the
|
||||
// generic book surfaces (switcher, All Books, series). Podcasts are
|
||||
// feed-managed and surface via /podcasts only (plan §9/D13).
|
||||
func IsBookLibrary(typeName string) bool {
|
||||
return typeName != "podcasts"
|
||||
}
|
||||
|
||||
func uuidToString(id pgtype.UUID) string {
|
||||
if !id.Valid {
|
||||
return ""
|
||||
|
||||
@@ -22,3 +22,33 @@ Gate: every line below executes for BOTH personas unless marked
|
||||
| 15 | Upload: EPUB generated at runtime, upload → appears → delete | 🔲 | n/a |
|
||||
| 16 | Auth edges: restricted → admin URL; anonymous → media URL | 🔲 | ✅ (detail/404) |
|
||||
| 17 | OPDS / KOReader | API-covered (invariant test) | API-covered |
|
||||
| 18 | Podcasts: page renders (empty state before any feed) | 🔲 | 🔲 |
|
||||
| 19 | Podcasts: add-feed modal → feed page (member flow) | 🔲 | 🔲 |
|
||||
| 20 | Podcasts: feed detail — episode list lifecycle (download/queue/failed+retry states inline), Download All, gap banner | 🔲 | 🔲 |
|
||||
| 21 | Podcasts: cross-user invisibility — own feed only (RLS §20 surfaces) | 🔲 | 🔲 |
|
||||
| 22 | Podcasts: order selector re-renders episode list; serial suggestion shown only for serial/audiobook feeds | 🔲 | 🔲 |
|
||||
| 23 | Podcasts: audio reader "Ep N/M" + prev/next episode; chain-advance to next episode | 🔲 | 🔲 |
|
||||
|
||||
### Podcast-specific sweep notes (plan task 1.10)
|
||||
|
||||
- The download lifecycle needs a fetchable enclosure: sweep with a local
|
||||
feed server fixture (the integration tests use `swapFeedServer`; the
|
||||
Playwright run needs the same trick) — otherwise episodes stay
|
||||
pending and rows 20's queue/failed states can't render.
|
||||
- Row 21 rides `TestPodcastRLSSurfaces` (API level) until the sweep
|
||||
lands; the page-level check is the 404 render for a foreign feed id.
|
||||
- Row 23 chains by page navigation — assert the URL becomes the next
|
||||
episode's reader and progress carries over.
|
||||
|
||||
## 2026-10-30 — round-3 regression probes (live-server, env-gated)
|
||||
|
||||
Two specs added against a RUNNING dev server (skip unless
|
||||
`BH_FEED=<feed uuid>` is set for the first):
|
||||
|
||||
- `confirm_probe.spec.ts` — every `hx-confirm` must open the GLOBAL
|
||||
confirm modal, never a native dialog. Pins the htmx-2 detail-shape
|
||||
fix (`detail.question`, not `detail.element`) and the modal's
|
||||
presence on signed-in pages (it used to live inside SidebarSignIn).
|
||||
- `add_order_probe.spec.ts` — the add-modal "Download order" select
|
||||
reveals when "Download all episodes now" is checked (CSS sibling
|
||||
selector; the checkbox must stay a sibling of `.podcast-add-order`).
|
||||
@@ -0,0 +1,27 @@
|
||||
// One-off probe: the add-modal "Download order" select must reveal when
|
||||
// "Download all episodes now" is checked (pure-CSS sibling selector —
|
||||
// the checkbox was nested, so the selector never fired; 2026-10-30).
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
const BASE = process.env.BH_BASE ?? "http://localhost:8765";
|
||||
|
||||
test("download-order select reveals with the download-all checkbox", async ({ page }) => {
|
||||
await page.request.post(`${BASE}/api/auth/login`, {
|
||||
data: { login: "testuser", password: "Test@Pass123!" },
|
||||
});
|
||||
await page.goto(`${BASE}/podcasts`);
|
||||
|
||||
await page.getByRole("button", { name: "Add Feed" }).click();
|
||||
const modal = page.locator("#podcast-add-feed-modal");
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
const checkbox = page.locator("#podcast-download-all");
|
||||
const orderRow = page.locator("#podcast-add-modal .podcast-add-order, form .podcast-add-order").first();
|
||||
const orderSelect = page.locator("#podcast-download-order");
|
||||
|
||||
await expect(orderSelect).toBeAttached();
|
||||
await expect(orderRow).toBeHidden(); // unchecked → hidden
|
||||
await checkbox.check();
|
||||
await expect(orderRow).toBeVisible(); // checked → revealed
|
||||
await expect(orderSelect).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
// One-off probe: does clicking an hx-confirm button open the CUSTOM
|
||||
// modal (#confirm-modal) or a native dialog? Clicks "Remove Feed" on a
|
||||
// hard-coded feed, then CANCELS whichever appears (nothing is deleted).
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
const BASE = process.env.BH_BASE ?? "http://localhost:8765";
|
||||
const FEED = process.env.BH_FEED ?? "";
|
||||
|
||||
test.skip(!FEED, "set BH_FEED=<feed uuid> against a running dev server");
|
||||
|
||||
test("hx-confirm uses the custom modal", async ({ page }) => {
|
||||
const nativeDialogs: string[] = [];
|
||||
page.on("console", (m) => {
|
||||
if (m.type() === "error" || m.type() === "warning") console.log("[console]", m.type(), m.text().slice(0, 300));
|
||||
});
|
||||
page.on("pageerror", (e) => console.log("[pageerror]", String(e).slice(0, 400)));
|
||||
page.on("dialog", async (d) => {
|
||||
nativeDialogs.push(d.message());
|
||||
await d.dismiss(); // never accept
|
||||
});
|
||||
|
||||
// Login via the API form (session cookie sticks to the context).
|
||||
await page.request.post(`${BASE}/api/auth/login`, {
|
||||
data: { login: "testuser", password: "Test@Pass123!" },
|
||||
});
|
||||
await page.goto(`${BASE}/podcasts/${FEED}`);
|
||||
|
||||
const modal = page.locator("#confirm-modal");
|
||||
await expect(modal).toHaveCount(1); // the modal must exist on the page
|
||||
await expect(modal).toBeHidden();
|
||||
|
||||
await page.getByRole("button", { name: "Remove Feed" }).click();
|
||||
|
||||
if (nativeDialogs.length > 0) {
|
||||
throw new Error(
|
||||
`NATIVE dialog opened (${nativeDialogs[0]}) — htmx:confirm interceptor did not preventDefault`,
|
||||
);
|
||||
}
|
||||
await expect(modal).toBeVisible({ timeout: 3000 });
|
||||
await expect(page.locator("#confirm-modal-message")).toContainText("remove this feed");
|
||||
|
||||
// Cancel — no request fires.
|
||||
await page.locator("#confirm-modal-cancel").click();
|
||||
await expect(modal).toBeHidden();
|
||||
});
|
||||
+7
-6
@@ -1,3 +1,4 @@
|
||||
import { confirmDialog } from "./confirm";
|
||||
import { Alpine } from "./alpine";
|
||||
import { showToast } from "./toast";
|
||||
|
||||
@@ -423,9 +424,9 @@ async function purgeArchivedItems(): Promise<void> {
|
||||
if (!token) return;
|
||||
|
||||
if (
|
||||
!window.confirm(
|
||||
!(await confirmDialog(
|
||||
"Permanently delete all archived items? Their reading progress, notes, and highlights will be lost.",
|
||||
)
|
||||
))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -475,9 +476,9 @@ async function deleteArchivedItem(id: string): Promise<void> {
|
||||
const token = requireToken();
|
||||
if (!token) return;
|
||||
if (
|
||||
!window.confirm(
|
||||
!(await confirmDialog(
|
||||
"Permanently delete this item? Its reading progress, notes, and highlights will be lost.",
|
||||
)
|
||||
))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -504,9 +505,9 @@ async function mergeArchivedItem(sourceId: string, targetId: string): Promise<vo
|
||||
const token = requireToken();
|
||||
if (!token) return;
|
||||
if (
|
||||
!window.confirm(
|
||||
!(await confirmDialog(
|
||||
"Merge this archived copy's reading progress, notes, and highlights into the active copy? The archived entry is removed.",
|
||||
)
|
||||
))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ export const KEYMAP: readonly KeyActionSpec[] = [
|
||||
{
|
||||
action: "prev-chapter",
|
||||
label: "Previous chapter",
|
||||
description: "Previous chapter",
|
||||
description: "Back — chapter, or episode on podcasts",
|
||||
keys: [
|
||||
{ key: "k", label: "K" },
|
||||
{ key: "PageUp", label: "PgUp" },
|
||||
@@ -95,7 +95,7 @@ export const KEYMAP: readonly KeyActionSpec[] = [
|
||||
{
|
||||
action: "next-chapter",
|
||||
label: "Next chapter",
|
||||
description: "Next chapter",
|
||||
description: "Forward — chapter, or episode on podcasts",
|
||||
keys: [
|
||||
{ key: "j", label: "J" },
|
||||
{ key: "PageDown", label: "PgDn" },
|
||||
@@ -195,14 +195,14 @@ export const KEYMAP: readonly KeyActionSpec[] = [
|
||||
{
|
||||
action: "media-next",
|
||||
label: "Media next key",
|
||||
description: "Hardware media next (chapter step, like the notification)",
|
||||
description: "Hardware media next (chapter step; episode on podcasts)",
|
||||
keys: [{ key: "MediaTrackNext", label: "Media ⏭" }],
|
||||
hidden: true,
|
||||
},
|
||||
{
|
||||
action: "media-prev",
|
||||
label: "Media previous key",
|
||||
description: "Hardware media previous (chapter step, like the notification)",
|
||||
description: "Hardware media previous (chapter step; episode on podcasts)",
|
||||
keys: [{ key: "MediaTrackPrevious", label: "Media ⏮" }],
|
||||
hidden: true,
|
||||
},
|
||||
|
||||
+59
-1
@@ -171,9 +171,20 @@ export function endTimeLabel(
|
||||
|
||||
const PROGRESS_INTERVAL_MS = 30_000;
|
||||
|
||||
interface PodcastSequenceConfig {
|
||||
feed_url?: string;
|
||||
feed_title?: string;
|
||||
prev_url?: string;
|
||||
next_url?: string;
|
||||
next_title?: string;
|
||||
episode_index?: number;
|
||||
index_total?: number;
|
||||
}
|
||||
|
||||
interface AudioConfig {
|
||||
chapterEndpoint: string;
|
||||
progressEndpoint: string;
|
||||
podcastSequence?: PodcastSequenceConfig;
|
||||
}
|
||||
|
||||
function readConfig(): AudioConfig {
|
||||
@@ -419,10 +430,14 @@ Alpine.data("audioReader", () => ({
|
||||
});
|
||||
audio.addEventListener("ended", () => {
|
||||
// Natural end of track n: roll into n+1 and keep playing. At the
|
||||
// last track the book is done — nothing to advance into.
|
||||
// last track the book is done — nothing to advance into... unless
|
||||
// this is a podcast episode with a sequence (D7): chain into the
|
||||
// next downloaded episode's reader page.
|
||||
if (this.activeTrack < this.tracks.length - 1) {
|
||||
this.setActiveTrack(this.activeTrack + 1, 0, true);
|
||||
return;
|
||||
}
|
||||
this.goToNextEpisode(true);
|
||||
});
|
||||
audio.addEventListener("play", () => {
|
||||
this.playing = true;
|
||||
@@ -649,6 +664,16 @@ Alpine.data("audioReader", () => ({
|
||||
},
|
||||
|
||||
stepChapter(delta: 1 | -1) {
|
||||
// Podcast sequence context: the chapter-step buttons AND every
|
||||
// route that funnels through here (arrow-key/media-key shortcuts,
|
||||
// hardware headset prev/next track) navigate EPISODES — one set of
|
||||
// big buttons does the big navigation (round-3 user design,
|
||||
// 2026-10-30). Edge episodes are server-gated (no url → no-op).
|
||||
if (readConfig().podcastSequence) {
|
||||
if (delta === -1) this.goToPrevEpisode();
|
||||
else this.goToNextEpisode(false);
|
||||
return;
|
||||
}
|
||||
const next = this.currentChapterIdx + delta;
|
||||
if (next < 0 || next >= this.chapters.length) return;
|
||||
this.jumpToChapter(next);
|
||||
@@ -760,9 +785,42 @@ Alpine.data("audioReader", () => ({
|
||||
|
||||
backToDetail() {
|
||||
this.pushProgress(true);
|
||||
// Podcast episodes exit to their feed page (D7 sequence context);
|
||||
// books keep the book-detail exit.
|
||||
const seq = readConfig().podcastSequence;
|
||||
if (seq && seq.feed_url) {
|
||||
window.location.href = seq.feed_url;
|
||||
return;
|
||||
}
|
||||
window.location.href = `/media/${this.mediaItemId}`;
|
||||
},
|
||||
|
||||
// ---- podcast sequence chaining (D7) ----
|
||||
// Auto-advance across episodes: the last track's `ended` rolls into
|
||||
// the next episode's reader page (progress already pushed on
|
||||
// timeupdate; a final push fires before navigating). Books are
|
||||
// untouched — no sequence context, no behavior change.
|
||||
goToNextEpisode(finalPush) {
|
||||
const seq = readConfig().podcastSequence;
|
||||
if (!seq || !seq.next_url) return false;
|
||||
const go = () => {
|
||||
window.location.href = seq.next_url as string;
|
||||
};
|
||||
if (finalPush) {
|
||||
Promise.resolve(this.pushProgress(true)).then(go).catch(go);
|
||||
} else {
|
||||
go();
|
||||
}
|
||||
return true;
|
||||
},
|
||||
goToPrevEpisode() {
|
||||
const seq = readConfig().podcastSequence;
|
||||
if (seq && seq.prev_url) {
|
||||
this.pushProgress(true);
|
||||
window.location.href = seq.prev_url;
|
||||
}
|
||||
},
|
||||
|
||||
// ---- volume (settings strip) ----
|
||||
|
||||
volumeIcon(): "muted" | "low" | "mid" | "high" {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { confirmDialog } from "./confirm";
|
||||
import { Alpine } from "./alpine";
|
||||
import { showToast } from "./toast";
|
||||
import { searchTagSuggestions, type TagSuggestion } from "./tag-dropdown";
|
||||
@@ -531,9 +532,9 @@ Alpine.data("bookDetail", () => {
|
||||
|
||||
async purgeDeletedAnnotation(annotationType: string, annotationId: string) {
|
||||
if (
|
||||
!confirm(
|
||||
!(await confirmDialog(
|
||||
"Permanently delete this annotation? This cannot be undone and it will not reappear on any device.",
|
||||
)
|
||||
))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -1002,9 +1003,9 @@ Alpine.data("bookDetail", () => {
|
||||
async resetMetadata() {
|
||||
if (this.resetting) return;
|
||||
if (
|
||||
!window.confirm(
|
||||
!(await confirmDialog(
|
||||
"Reset all metadata to scanned defaults? Manual edits and custom covers will be discarded.",
|
||||
)
|
||||
))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -366,8 +366,21 @@ function clearForm(): void {
|
||||
if (testResultsDiv) testResultsDiv.classList.add("hidden");
|
||||
}
|
||||
|
||||
function logout(): void {
|
||||
// Rules-page sign-out: same contract as the header's — the API clears
|
||||
// the HttpOnly session cookie and revokes the refresh token; local
|
||||
// cleanup and redirect are best-effort.
|
||||
async function logout(): Promise<void> {
|
||||
try {
|
||||
await fetch("/api/auth/logout", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ refresh_token: localStorage.getItem("refresh_token") ?? "" }),
|
||||
});
|
||||
} catch {
|
||||
// redirect regardless
|
||||
}
|
||||
localStorage.removeItem("token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
window.location.href = "/login";
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// Global confirm-dialog replacement (issue #5): ONE styled modal
|
||||
// answers every hx-confirm sitewide (via the htmx:confirm event) and
|
||||
// exposes a promise API for the legacy JS call sites. No more native
|
||||
// window.confirm anywhere.
|
||||
|
||||
let activeResolve: ((ok: boolean) => void) | null = null;
|
||||
|
||||
function modalElements(): { overlay: HTMLElement | null; message: HTMLElement | null } {
|
||||
return {
|
||||
overlay: document.getElementById("confirm-modal"),
|
||||
message: document.getElementById("confirm-modal-message"),
|
||||
};
|
||||
}
|
||||
|
||||
function hide(ok: boolean): void {
|
||||
const { overlay } = modalElements();
|
||||
if (overlay) overlay.classList.add("hidden");
|
||||
const resolve = activeResolve;
|
||||
activeResolve = null;
|
||||
if (resolve) resolve(ok);
|
||||
}
|
||||
|
||||
/**
|
||||
* confirmDialog shows the global modal and resolves with the answer.
|
||||
* Safe to call from any module (import this instead of window.confirm).
|
||||
*/
|
||||
export function confirmDialog(message: string): Promise<boolean> {
|
||||
const { overlay, message: messageEl } = modalElements();
|
||||
if (!overlay || !messageEl) {
|
||||
// Header missing (bare layouts): degrade to the native dialog
|
||||
// rather than silently swallowing the confirmation.
|
||||
return Promise.resolve(window.confirm(message));
|
||||
}
|
||||
messageEl.textContent = message;
|
||||
overlay.classList.remove("hidden");
|
||||
return new Promise<boolean>((resolve) => {
|
||||
activeResolve = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
// Wire the static buttons once.
|
||||
function wireButtons(): void {
|
||||
const { overlay } = modalElements();
|
||||
const accept = document.getElementById("confirm-modal-accept");
|
||||
const dismiss = document.getElementById("confirm-modal-cancel");
|
||||
if (accept && !accept.hasAttribute("data-wired")) {
|
||||
accept.setAttribute("data-wired", "1");
|
||||
accept.addEventListener("click", () => hide(true));
|
||||
}
|
||||
if (dismiss && !dismiss.hasAttribute("data-wired")) {
|
||||
dismiss.setAttribute("data-wired", "1");
|
||||
dismiss.addEventListener("click", () => hide(false));
|
||||
}
|
||||
// Backdrop click cancels.
|
||||
if (overlay && !overlay.hasAttribute("data-wired")) {
|
||||
overlay.setAttribute("data-wired", "1");
|
||||
overlay.addEventListener("click", (e) => {
|
||||
if (e.target === overlay) hide(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// htmx:confirm interceptor: every element with hx-confirm gets the
|
||||
// modal instead of the browser dialog; confirming issues the request.
|
||||
// (Canonical htmx pattern — see htmx docs, "confirming with a modal".)
|
||||
//
|
||||
// htmx 2 detail shape: { elt, question, issueRequest, ... } — question
|
||||
// is the CLOSEST hx-confirm value htmx already resolved. (The original
|
||||
// read detail.element, which htmx 2 does not provide; the handler
|
||||
// bailed before preventDefault and EVERY hx-confirm fell through to
|
||||
// the native dialog — user report 2026-10-30, headless-proven.)
|
||||
document.body.addEventListener("htmx:confirm", function (evt) {
|
||||
const custom = evt as CustomEvent<{
|
||||
elt?: HTMLElement;
|
||||
question?: string;
|
||||
issueRequest: (skipConfirmation?: boolean) => void;
|
||||
}>;
|
||||
const question = custom.detail.question;
|
||||
if (!question) return; // not a confirm-gated request — normal flow
|
||||
evt.preventDefault();
|
||||
wireButtons();
|
||||
confirmDialog(question).then((ok) => {
|
||||
if (ok) custom.detail.issueRequest(true);
|
||||
});
|
||||
});
|
||||
+17
-2
@@ -12,12 +12,27 @@ import {
|
||||
revealCurrentTheme,
|
||||
updateWoodPanelingIndicators,
|
||||
} from "./theme";
|
||||
import { getSelectedLibrary } from "./storage";
|
||||
import { getSelectedLibrary, getRefreshToken, removeRefreshToken } from "./storage";
|
||||
import { addListener, removeListener } from "./websocket";
|
||||
|
||||
const logout = (): void => {
|
||||
// Real sign-out: the HttpOnly session cookie can only be cleared by a
|
||||
// server response, and the refresh token (30d) must be revoked —
|
||||
// localStorage-only "logout" left the cookie session alive for days
|
||||
// (the fake-signout bug). Best effort: local state clears and the
|
||||
// redirect happens even if the API call fails.
|
||||
const logout = async (): Promise<void> => {
|
||||
try {
|
||||
await fetch("/api/auth/logout", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ refresh_token: getRefreshToken() ?? "" }),
|
||||
});
|
||||
} catch {
|
||||
// keep going: local cleanup + redirect must never be blocked
|
||||
}
|
||||
localStorage.removeItem("token");
|
||||
localStorage.removeItem("user");
|
||||
removeRefreshToken();
|
||||
window.location.href = "/";
|
||||
};
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ import "./linking";
|
||||
import "./login";
|
||||
import "./password_validation";
|
||||
import "./processing_issues";
|
||||
import "./podcasts";
|
||||
import "./confirm";
|
||||
import "./profile";
|
||||
import "./profile-modal";
|
||||
import "./register";
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
// Podcasts UI islands (plan task 1.9): the Add-Feed modal toggle and
|
||||
// the htmx glue the modal needs (error surfacing). All episode-list /
|
||||
// download behavior is htmx-native (hx-post + podcast:changed trigger +
|
||||
// server-driven polling) — this module only owns pure client-local
|
||||
// presentation state, per the rendering-ownership rule.
|
||||
import { Alpine } from "./alpine";
|
||||
|
||||
Alpine.data("podcastsPage", () => ({
|
||||
showAddFeed(): void {
|
||||
const modal = document.getElementById("podcast-add-feed-modal");
|
||||
if (modal) modal.classList.remove("hidden");
|
||||
},
|
||||
hideAddFeed(): void {
|
||||
const modal = document.getElementById("podcast-add-feed-modal");
|
||||
if (modal) modal.classList.add("hidden");
|
||||
const err = document.getElementById("podcast-add-feed-error");
|
||||
if (err) err.classList.add("hidden");
|
||||
},
|
||||
}));
|
||||
|
||||
// The detail page's actions are pure htmx; Alpine hosts the x-data the
|
||||
// body declares (modal visibility) so CSP-bound Alpine stays happy.
|
||||
Alpine.data("podcastDetail", () => ({
|
||||
unsubOpen: false,
|
||||
filterListenerInstalled: false,
|
||||
|
||||
// Admin bulk file selection (§0 item 8 follow-up): ids of CHECKED
|
||||
// episode rows. Survives the list's htmx swaps (rows re-render fresh
|
||||
// and unchecked → reapplied on afterSwap); cleared after a successful
|
||||
// bulk delete. Plain array — deliberately NOT reactive state beyond
|
||||
// bulkCount, which the toolbar binds.
|
||||
bulkIds: [] as string[],
|
||||
bulkCount: 0,
|
||||
|
||||
bulkSelectionLabel(): string {
|
||||
return this.bulkCount === 0 ? "" : this.bulkCount + " selected";
|
||||
},
|
||||
|
||||
bulkSelectionClass(): string {
|
||||
return this.bulkCount === 0 ? "opacity-40 pointer-events-none" : "";
|
||||
},
|
||||
|
||||
toggleBulkSelection(target: HTMLInputElement): void {
|
||||
const id = target.value;
|
||||
if (!id) return;
|
||||
if (target.checked) {
|
||||
if (!this.bulkIds.includes(id)) this.bulkIds.push(id);
|
||||
} else {
|
||||
this.bulkIds = this.bulkIds.filter((v) => v !== id);
|
||||
}
|
||||
this.bulkCount = this.bulkIds.length;
|
||||
},
|
||||
|
||||
// After each swap the list re-renders unchecked; restore ticks for
|
||||
// ids still selected. Ids whose rows vanished (deleted / state
|
||||
// flipped) silently drop out.
|
||||
reapplyBulkSelection(): void {
|
||||
if (this.bulkIds.length === 0) return;
|
||||
document
|
||||
.querySelectorAll<HTMLInputElement>("input[name='episode-file-select']")
|
||||
.forEach((box) => {
|
||||
box.checked = this.bulkIds.includes(box.value);
|
||||
});
|
||||
// Drop ids whose checkboxes no longer exist.
|
||||
const present = new Set(
|
||||
Array.from(
|
||||
document.querySelectorAll<HTMLInputElement>("input[name='episode-file-select']"),
|
||||
).map((b) => b.value),
|
||||
);
|
||||
this.bulkIds = this.bulkIds.filter((id) => present.has(id));
|
||||
this.bulkCount = this.bulkIds.length;
|
||||
},
|
||||
|
||||
clearBulkSelection(): void {
|
||||
this.bulkIds = [];
|
||||
this.bulkCount = 0;
|
||||
document
|
||||
.querySelectorAll<HTMLInputElement>("input[name='episode-file-select']:checked")
|
||||
.forEach((box) => {
|
||||
box.checked = false;
|
||||
});
|
||||
},
|
||||
|
||||
// Episode-list filters (§0 item 6, 2026-10-30): hide played/
|
||||
// downloaded rows and/or substring-filter titles+descriptions. Pure
|
||||
// presentation — the DOM IS the data (rows carry data-* attributes
|
||||
// rendered by templ); no query changes, no server round trip.
|
||||
// finishedThreshold 99.5% matches the server's finished rule.
|
||||
applyEpisodeFilter(): void {
|
||||
const input = (this as any).$refs.episodeFilterInput as HTMLInputElement | undefined;
|
||||
const box = (this as any).$refs.hideFinished as HTMLInputElement | undefined;
|
||||
const emptyNote = (this as any).$refs.filterEmptyNote as HTMLElement | undefined;
|
||||
const q = (input?.value ?? "").trim().toLowerCase();
|
||||
const hideFinished = box?.checked ?? false;
|
||||
|
||||
let visible = 0;
|
||||
let total = 0;
|
||||
document.querySelectorAll<HTMLElement>("#episode-list [data-episode-row]").forEach((row) => {
|
||||
total++;
|
||||
const downloaded = row.dataset.downloaded === "true";
|
||||
const pct = parseFloat(row.dataset.listenPct ?? "0");
|
||||
const text = row.dataset.searchText ?? "";
|
||||
const hide =
|
||||
(hideFinished && (downloaded || pct >= 99.5)) ||
|
||||
(q !== "" && !text.toLowerCase().includes(q));
|
||||
row.classList.toggle("hidden", hide);
|
||||
if (!hide) visible++;
|
||||
});
|
||||
if (emptyNote) emptyNote.classList.toggle("hidden", visible > 0 || total === 0);
|
||||
},
|
||||
|
||||
// Re-apply after every htmx swap of the list (polling + action
|
||||
// responses swap #episode-list wholesale): fresh rows must honor the
|
||||
// active filter AND restored bulk-selection ticks; a successful bulk
|
||||
// delete clears the selection. Installed once per page.
|
||||
initPodcastFilterPersistence(): void {
|
||||
if (this.filterListenerInstalled) return;
|
||||
this.filterListenerInstalled = true;
|
||||
document.addEventListener("htmx:afterSwap", () => {
|
||||
this.reapplyBulkSelection();
|
||||
this.applyEpisodeFilter();
|
||||
});
|
||||
// Successful bulk delete → the list swaps fresh; drop the state.
|
||||
document.addEventListener("htmx:afterRequest", (evt) => {
|
||||
const custom = evt as CustomEvent<{ successful: boolean; elt: HTMLElement }>;
|
||||
const elt = custom.detail.elt as HTMLElement | undefined;
|
||||
if (custom.detail.successful && elt && elt.id === "episode-bulk-delete") {
|
||||
this.clearBulkSelection();
|
||||
}
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
// Add-feed form: surface the server's error text inline (the JSON error
|
||||
// body arrives on non-2xx; htmx swaps nothing). Success redirects via
|
||||
// HX-Redirect — nothing to do here.
|
||||
export function initPodcastAddFeedForm(): void {
|
||||
const form = document.querySelector<HTMLFormElement>(
|
||||
'#podcast-add-feed-modal form[hx-post="/api/podcasts/feeds"], #podcast-add-feed-modal form',
|
||||
);
|
||||
if (!form) return;
|
||||
form.addEventListener("htmx:afterRequest", (evt) => {
|
||||
const custom = evt as CustomEvent<{
|
||||
successful: boolean;
|
||||
xhr: XMLHttpRequest;
|
||||
}>;
|
||||
if (custom.detail.successful) return;
|
||||
let message = "Could not add the feed — check the URL and try again.";
|
||||
try {
|
||||
const parsed = JSON.parse(custom.detail.xhr.responseText) as {
|
||||
error?: string;
|
||||
};
|
||||
if (parsed.error) message = parsed.error;
|
||||
} catch {
|
||||
// keep the default text
|
||||
}
|
||||
const err = document.getElementById("podcast-add-feed-error");
|
||||
if (err) {
|
||||
err.textContent = message;
|
||||
err.classList.remove("hidden");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", initPodcastAddFeedForm);
|
||||
} else {
|
||||
initPodcastAddFeedForm();
|
||||
}
|
||||
+1
-1
@@ -229,7 +229,7 @@ const setupFetchInterceptor = (): void => {
|
||||
if (response.status === 401) {
|
||||
// Clear invalid tokens from localStorage
|
||||
localStorage.removeItem("token");
|
||||
localStorage.removeItem("refreshToken");
|
||||
localStorage.removeItem("refresh_token");
|
||||
localStorage.removeItem("user");
|
||||
|
||||
// Check if this was a page navigation (not API call)
|
||||
|
||||
+21
-1
@@ -486,7 +486,7 @@
|
||||
* which Alpine flips through `mobileMenuOpen`) and pinned on >= lg.
|
||||
*/
|
||||
:root {
|
||||
--sidebar-w: 16rem;
|
||||
--sidebar-w: 14rem;
|
||||
--topbar-h: 4rem;
|
||||
}
|
||||
|
||||
@@ -2127,3 +2127,23 @@
|
||||
border-color: var(--status-danger) !important;
|
||||
}
|
||||
|
||||
/* ---------- Podcast download indeterminate bar (sliding shimmer) ----------
|
||||
Podcast feeds without Content-Length (e.g. Scribl) get an
|
||||
indeterminate progress bar; animate-pulse fades in place and looks
|
||||
frozen to the user, so a sliver slides across instead (2026-10-30,
|
||||
§0 item 1). */
|
||||
@keyframes podcast-indeterminate {
|
||||
from { transform: translateX(-100%); }
|
||||
to { transform: translateX(300%); }
|
||||
}
|
||||
.podcast-progress-indeterminate > div {
|
||||
width: 33.33%;
|
||||
animation: podcast-indeterminate 1.3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ---------- Add-feed modal: order dropdown reveals with the
|
||||
"Download all" checkbox (§0 item 5c) — pure CSS, no JS island. The
|
||||
select is a sibling of the checkbox label's input. */
|
||||
.podcast-add-order { display: none; }
|
||||
#podcast-download-all:checked ~ .podcast-add-order { display: flex; }
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }
|
||||
/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath Line truncated
|
||||
/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath Line truncated
|
||||
Reference in new issue
Block a user