Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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.
|
# Declared after `COPY . .` so a version bump only invalidates this layer.
|
||||||
# The release workflow passes the git tag, e.g. APP_VERSION=v1.5.
|
# The release workflow passes the git tag, e.g. APP_VERSION=v1.5.
|
||||||
ARG APP_VERSION=dev
|
ARG APP_VERSION=dev
|
||||||
|
# Short commit hash of the built source (stale-container drift guard).
|
||||||
|
ARG GIT_COMMIT=none
|
||||||
|
|
||||||
# Build Go binary (cached unless Go files or generated code changes)
|
# Build Go binary (cached unless Go files or generated code changes)
|
||||||
RUN --mount=type=cache,target=/root/go/pkg/mod \
|
RUN --mount=type=cache,target=/root/go/pkg/mod \
|
||||||
--mount=type=cache,target=/root/.cache/go-build \
|
--mount=type=cache,target=/root/.cache/go-build \
|
||||||
CGO_ENABLED=0 GOOS=linux go build -installsuffix cgo \
|
CGO_ENABLED=0 GOOS=linux go build -installsuffix cgo \
|
||||||
-ldflags "-X bookhoard/internal/version.Version=${APP_VERSION}" \
|
-ldflags "-X bookhoard/internal/version.Version=${APP_VERSION} -X bookhoard/internal/version.Commit=${GIT_COMMIT}" \
|
||||||
-o main ./cmd/server
|
-o main ./cmd/server
|
||||||
|
|
||||||
# Materialize the Go module cache into an image layer so the test-runner
|
# Materialize the Go module cache into an image layer so the test-runner
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ endif
|
|||||||
# Override with: CONTAINER_RUNTIME=podman make rebuild-app
|
# Override with: CONTAINER_RUNTIME=podman make rebuild-app
|
||||||
CONTAINER_RUNTIME ?= $(shell command -v docker 2>/dev/null || command -v podman 2>/dev/null)
|
CONTAINER_RUNTIME ?= $(shell command -v docker 2>/dev/null || command -v podman 2>/dev/null)
|
||||||
|
|
||||||
|
# Build stamp (stale-container drift guard): the rebuilt image carries
|
||||||
|
# the source commit; the app logs it at boot and /api/health reports it.
|
||||||
|
GIT_COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo none)
|
||||||
|
|
||||||
# Dev compose stack: base prod file merged with the dev override (local build + tests).
|
# Dev compose stack: base prod file merged with the dev override (local build + tests).
|
||||||
# Prod deploy does NOT use this — it runs plain `docker compose` against the base file only.
|
# Prod deploy does NOT use this — it runs plain `docker compose` against the base file only.
|
||||||
COMPOSE := $(CONTAINER_RUNTIME) compose -f docker-compose.yml -f docker-compose.dev.yml
|
COMPOSE := $(CONTAINER_RUNTIME) compose -f docker-compose.yml -f docker-compose.dev.yml
|
||||||
|
|||||||
@@ -60,3 +60,13 @@ variables:
|
|||||||
name: book_sha256
|
name: book_sha256
|
||||||
- secret: true
|
- secret: true
|
||||||
name: book_uuid
|
name: book_uuid
|
||||||
|
- secret: true
|
||||||
|
name: podcasts_library_id
|
||||||
|
- name: podcast_feed_url
|
||||||
|
value: https://example.com/feed.xml
|
||||||
|
- secret: true
|
||||||
|
name: podcast_feed_id
|
||||||
|
- secret: true
|
||||||
|
name: podcast_episode_id
|
||||||
|
- name: podcast_search_query
|
||||||
|
value: episode
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
info:
|
||||||
|
name: Add Podcast Feed
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: POST
|
||||||
|
url: '{{base_url}}/api/podcasts/feeds'
|
||||||
|
auth: inherit
|
||||||
|
body:
|
||||||
|
type: json
|
||||||
|
data: |-
|
||||||
|
{
|
||||||
|
"library_id": "{{podcasts_library_id}}",
|
||||||
|
"url": "{{podcast_feed_url}}",
|
||||||
|
"download_all": false
|
||||||
|
}
|
||||||
|
settings:
|
||||||
|
encodeUrl: true
|
||||||
|
timeout: 30000
|
||||||
|
followRedirects: true
|
||||||
|
maxRedirects: 5
|
||||||
|
|
||||||
|
runtime:
|
||||||
|
scripts:
|
||||||
|
- type: tests
|
||||||
|
code: |-
|
||||||
|
test("status is 201", function() {
|
||||||
|
expect(res.status).to.eql(201);
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
info:
|
||||||
|
name: Delete Feed (admin; ?files=false keeps files)
|
||||||
|
type: http
|
||||||
|
seq: 12
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: DELETE
|
||||||
|
url: '{{base_url}}/api/podcasts/feeds/{{podcast_feed_id}}'
|
||||||
|
auth: inherit
|
||||||
|
|
||||||
|
runtime:
|
||||||
|
scripts:
|
||||||
|
- type: tests
|
||||||
|
code: |-
|
||||||
|
test("status is 200", function() {
|
||||||
|
expect(res.status).to.eql(200);
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
info:
|
||||||
|
name: Download All (every non-downloaded episode)
|
||||||
|
type: http
|
||||||
|
seq: 8
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: POST
|
||||||
|
url: '{{base_url}}/api/podcasts/feeds/{{podcast_feed_id}}/episodes/all/download'
|
||||||
|
auth: inherit
|
||||||
|
|
||||||
|
runtime:
|
||||||
|
scripts:
|
||||||
|
- type: tests
|
||||||
|
code: |-
|
||||||
|
test("status is 202", function() {
|
||||||
|
expect(res.status).to.eql(202);
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
info:
|
||||||
|
name: Download / Retry Episode (idempotent state machine)
|
||||||
|
type: http
|
||||||
|
seq: 7
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: POST
|
||||||
|
url: '{{base_url}}/api/podcasts/feeds/{{podcast_feed_id}}/episodes/{{podcast_episode_id}}/download'
|
||||||
|
auth: inherit
|
||||||
|
|
||||||
|
runtime:
|
||||||
|
scripts:
|
||||||
|
- type: tests
|
||||||
|
code: |-
|
||||||
|
test("status is 202", function() {
|
||||||
|
expect(res.status).to.eql(202);
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
info:
|
||||||
|
name: Feed Detail (episodes + download states)
|
||||||
|
type: http
|
||||||
|
seq: 3
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: GET
|
||||||
|
url: '{{base_url}}/api/podcasts/feeds/{{podcast_feed_id}}'
|
||||||
|
auth: inherit
|
||||||
|
|
||||||
|
runtime:
|
||||||
|
scripts:
|
||||||
|
- type: tests
|
||||||
|
code: |-
|
||||||
|
test("status is 200 (404 would be scoping/RLS)", function() {
|
||||||
|
expect(res.status).to.eql(200);
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
info:
|
||||||
|
name: List My Podcast Feeds
|
||||||
|
type: http
|
||||||
|
seq: 2
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: GET
|
||||||
|
url: '{{base_url}}/api/podcasts/feeds'
|
||||||
|
auth: inherit
|
||||||
|
|
||||||
|
runtime:
|
||||||
|
scripts:
|
||||||
|
- type: tests
|
||||||
|
code: |-
|
||||||
|
test("status is 200", function() {
|
||||||
|
expect(res.status).to.eql(200);
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
info:
|
||||||
|
name: Manual Refresh (poll one feed)
|
||||||
|
type: http
|
||||||
|
seq: 6
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: POST
|
||||||
|
url: '{{base_url}}/api/podcasts/feeds/{{podcast_feed_id}}/refresh'
|
||||||
|
auth: inherit
|
||||||
|
|
||||||
|
runtime:
|
||||||
|
scripts:
|
||||||
|
- type: tests
|
||||||
|
code: |-
|
||||||
|
test("status is 200", function() {
|
||||||
|
expect(res.status).to.eql(200);
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
info:
|
||||||
|
name: Effective Sequence (override applied, downloaded-only, gaps)
|
||||||
|
type: http
|
||||||
|
seq: 9
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: GET
|
||||||
|
url: '{{base_url}}/api/podcasts/feeds/{{podcast_feed_id}}/sequence'
|
||||||
|
auth: inherit
|
||||||
|
|
||||||
|
runtime:
|
||||||
|
scripts:
|
||||||
|
- type: tests
|
||||||
|
code: |-
|
||||||
|
test("status is 200", function() {
|
||||||
|
expect(res.status).to.eql(200);
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
info:
|
||||||
|
name: Set Order Override (native | oldest | newest)
|
||||||
|
type: http
|
||||||
|
seq: 10
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: PUT
|
||||||
|
url: '{{base_url}}/api/podcasts/feeds/{{podcast_feed_id}}/order'
|
||||||
|
auth: inherit
|
||||||
|
body:
|
||||||
|
type: json
|
||||||
|
data: |-
|
||||||
|
{
|
||||||
|
"order": "oldest"
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime:
|
||||||
|
scripts:
|
||||||
|
- type: tests
|
||||||
|
code: |-
|
||||||
|
test("status is 200", function() {
|
||||||
|
expect(res.status).to.eql(200);
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
info:
|
||||||
|
name: Subscription-scoped Search (covers un-downloaded + historical)
|
||||||
|
type: http
|
||||||
|
seq: 11
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: GET
|
||||||
|
url: '{{base_url}}/api/podcasts/search?q={{podcast_search_query}}'
|
||||||
|
auth: inherit
|
||||||
|
|
||||||
|
runtime:
|
||||||
|
scripts:
|
||||||
|
- type: tests
|
||||||
|
code: |-
|
||||||
|
test("status is 200", function() {
|
||||||
|
expect(res.status).to.eql(200);
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
info:
|
||||||
|
name: Unsubscribe (files retained)
|
||||||
|
type: http
|
||||||
|
seq: 5
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: DELETE
|
||||||
|
url: '{{base_url}}/api/podcasts/feeds/{{podcast_feed_id}}/subscription'
|
||||||
|
auth: inherit
|
||||||
|
|
||||||
|
runtime:
|
||||||
|
scripts:
|
||||||
|
- type: tests
|
||||||
|
code: |-
|
||||||
|
test("status is 200", function() {
|
||||||
|
expect(res.status).to.eql(200);
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
info:
|
||||||
|
name: Update Subscription (display name + auto-download)
|
||||||
|
type: http
|
||||||
|
seq: 4
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: PUT
|
||||||
|
url: '{{base_url}}/api/podcasts/feeds/{{podcast_feed_id}}/subscription'
|
||||||
|
auth: inherit
|
||||||
|
body:
|
||||||
|
type: json
|
||||||
|
data: |-
|
||||||
|
{
|
||||||
|
"display_name": "My Serialized Feed",
|
||||||
|
"auto_download": "newest",
|
||||||
|
"auto_download_count": 3
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime:
|
||||||
|
scripts:
|
||||||
|
- type: tests
|
||||||
|
code: |-
|
||||||
|
test("status is 200", function() {
|
||||||
|
expect(res.status).to.eql(200);
|
||||||
|
});
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"bookhoard/internal/router"
|
"bookhoard/internal/router"
|
||||||
"bookhoard/internal/services"
|
"bookhoard/internal/services"
|
||||||
"bookhoard/internal/sync"
|
"bookhoard/internal/sync"
|
||||||
|
"bookhoard/internal/version"
|
||||||
"bookhoard/templates"
|
"bookhoard/templates"
|
||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
@@ -33,6 +34,7 @@ func (cv *CustomValidator) Validate(i interface{}) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
log.Printf("Bookhoard %s (commit %s) starting", version.Version, version.Commit)
|
||||||
cfg := config.LoadConfig()
|
cfg := config.LoadConfig()
|
||||||
|
|
||||||
dbPool, err := pgxpool.New(context.Background(), cfg.DatabaseURL())
|
dbPool, err := pgxpool.New(context.Background(), cfg.DatabaseURL())
|
||||||
@@ -177,6 +179,12 @@ func main() {
|
|||||||
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
|
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
|
||||||
jobsHandler := handlers.NewJobsHandler(queries, worker)
|
jobsHandler := handlers.NewJobsHandler(queries, worker)
|
||||||
|
|
||||||
|
// Podcast subsystem: fetcher service + sequential download worker +
|
||||||
|
// poller. One instance trio per server (per-feed locks dedupe).
|
||||||
|
podcastService := services.NewPodcastService(queries)
|
||||||
|
podcastWorker := services.NewPodcastDownloadWorker(queries, podcastService)
|
||||||
|
podcastHandler := handlers.NewPodcastHandler(queries, podcastService, podcastWorker)
|
||||||
|
|
||||||
e := echo.New()
|
e := echo.New()
|
||||||
|
|
||||||
// Set up validator
|
// Set up validator
|
||||||
@@ -237,6 +245,9 @@ func main() {
|
|||||||
CollectionHandler: collectionHandler,
|
CollectionHandler: collectionHandler,
|
||||||
FiltersHandler: filtersHandler,
|
FiltersHandler: filtersHandler,
|
||||||
DashboardHandler: dashboardHandler,
|
DashboardHandler: dashboardHandler,
|
||||||
|
PodcastHandler: podcastHandler,
|
||||||
|
PodcastService: podcastService,
|
||||||
|
PodcastWorker: podcastWorker,
|
||||||
DashboardService: dashboardService,
|
DashboardService: dashboardService,
|
||||||
SeriesHandler: seriesHandler,
|
SeriesHandler: seriesHandler,
|
||||||
OPDSHandler: opdsHandler,
|
OPDSHandler: opdsHandler,
|
||||||
|
|||||||
@@ -752,6 +752,15 @@ func TestContractPin_MediaListBothBranches(t *testing.T) {
|
|||||||
"tags_search", "contributors_search", "narrators_search", "file_sha256",
|
"tags_search", "contributors_search", "narrators_search", "file_sha256",
|
||||||
"opf_identifier", "opf_uuid", "hash_confidence", "library_name",
|
"opf_identifier", "opf_uuid", "hash_confidence", "library_name",
|
||||||
"library_type_name_2",
|
"library_type_name_2",
|
||||||
|
// Podcast episode columns (2026-10-29 additive widening — plan
|
||||||
|
// podcast-integration.md tasks 1.1/1.10): columns on
|
||||||
|
// media_items, NULL for scan rows. See docs/contract_pins.md
|
||||||
|
// changelog.
|
||||||
|
"source", "source_feed_id", "episode_guid", "feed_position",
|
||||||
|
"season_number", "episode_number", "enclosure_url",
|
||||||
|
"declared_duration_seconds", "transcript_url", "chapters_url",
|
||||||
|
"hosts", "guests", "download_state", "download_error",
|
||||||
|
"download_attempts",
|
||||||
}
|
}
|
||||||
resolvedKeys := []string{
|
resolvedKeys := []string{
|
||||||
"id", "library_id", "title", "subtitle", "author", "authors", "isbn", "description",
|
"id", "library_id", "title", "subtitle", "author", "authors", "isbn", "description",
|
||||||
|
|||||||
@@ -0,0 +1,884 @@
|
|||||||
|
// Podcast integration tests (plan task 1.10): RLS surfaces (the
|
||||||
|
// auditor — hidden-library + cross-user invisibility through every
|
||||||
|
// podcast surface), add-feed flow with allow_member_feeds gating,
|
||||||
|
// upsert-without-delete (shrunken feeds), edit segregation, search
|
||||||
|
// scoping, and the download worker state machine (ffmpeg-generated
|
||||||
|
// audio at runtime — no binary fixtures in the repo).
|
||||||
|
//
|
||||||
|
// Doctrine: shared dev DB — test libraries/feeds named "test*"; the
|
||||||
|
// library delete at cleanup cascades feeds/episodes/subscriptions.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"bookhoard/internal/database"
|
||||||
|
"bookhoard/internal/rls"
|
||||||
|
"bookhoard/internal/services"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// podcastFixture builds a minimal RSS 2.0 doc with the given episodes
|
||||||
|
// (guid/title pairs) — committable-text fixtures only, generated inline.
|
||||||
|
func podcastFixture(channelTitle string, episodes map[string]string) string {
|
||||||
|
items := ""
|
||||||
|
for guid, title := range episodes {
|
||||||
|
items += fmt.Sprintf(`
|
||||||
|
<item>
|
||||||
|
<guid>%s</guid>
|
||||||
|
<title>%s</title>
|
||||||
|
<itunes:subtitle>sub of %s</itunes:subtitle>
|
||||||
|
<description>Description of %s</description>
|
||||||
|
<itunes:duration>600</itunes:duration>
|
||||||
|
<pubDate>Mon, 26 Oct 2026 08:00:00 +0000</pubDate>
|
||||||
|
<enclosure url="https://cdn.invalid/%s.mp3" length="65536" type="audio/mpeg"/>
|
||||||
|
</item>`, guid, title, guid, title, guid)
|
||||||
|
}
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
|
||||||
|
<channel>
|
||||||
|
<title>` + channelTitle + `</title>
|
||||||
|
<language>en-us</language>
|
||||||
|
<description>Integration test feed</description>
|
||||||
|
<itunes:author>Test Host</itunes:author>
|
||||||
|
<itunes:type>serial</itunes:type>` + items + `
|
||||||
|
</channel>
|
||||||
|
</rss>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// swapFeedServer hosts feed XML over httptest TLS and wires the
|
||||||
|
// fetcher's test client (the production client refuses loopback by
|
||||||
|
// design — the SSRF boundary; restored on cleanup).
|
||||||
|
func swapFeedServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
srv := httptest.NewTLSServer(handler)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
services.SetPodcastHTTPClientForTests(nil)
|
||||||
|
srv.Close()
|
||||||
|
})
|
||||||
|
services.SetPodcastHTTPClientForTests(srv.Client())
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
// createPodcastsLibrary provisions a podcasts-type library with a
|
||||||
|
// download root in a temp dir (test-prefixed name; cleanup cascades).
|
||||||
|
// Returns the download root for file-layout assertions.
|
||||||
|
func createPodcastsLibrary(t *testing.T, setup *TestServerSetup, name string) (database.Libraries, string) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := seedCtx()
|
||||||
|
libType, err := setup.DB.GetLibraryTypeByName(context.Background(), "podcasts")
|
||||||
|
require.NoError(t, err, "podcasts library type must be seeded")
|
||||||
|
lib, err := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{
|
||||||
|
Name: name,
|
||||||
|
Description: pgtype.Text{String: "podcast integration test library", Valid: true},
|
||||||
|
LibraryTypeID: libType.ID,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
root := t.TempDir() // download root
|
||||||
|
_, err = setup.DB.AddLibraryFolder(ctx, database.AddLibraryFolderParams{
|
||||||
|
LibraryID: lib.ID,
|
||||||
|
FolderPath: root,
|
||||||
|
Purpose: services.FolderPurposePodcast,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = setup.DB.DeleteLibrary(ctx, lib.ID) })
|
||||||
|
return lib, root
|
||||||
|
}
|
||||||
|
|
||||||
|
// upsertTestEpisode seeds one metadata-only episode row.
|
||||||
|
func upsertTestEpisode(t *testing.T, setup *TestServerSetup, feed database.PodcastFeeds, guid, title string, position int) database.MediaItems {
|
||||||
|
t.Helper()
|
||||||
|
ep, err := setup.DB.UpsertPodcastEpisode(adminSeedCtx(t, setup), database.UpsertPodcastEpisodeParams{
|
||||||
|
LibraryID: feed.LibraryID,
|
||||||
|
Title: title,
|
||||||
|
Authors: []string{},
|
||||||
|
AuthorsSearch: []string{},
|
||||||
|
FilePath: fmt.Sprintf("test-feed/%04d-%s.mp3", position, guid),
|
||||||
|
Tags: []string{},
|
||||||
|
SourceFeedID: feed.ID,
|
||||||
|
EpisodeGuid: pgtype.Text{String: guid, Valid: true},
|
||||||
|
FeedPosition: pgtype.Int4{Int32: int32(position), Valid: true},
|
||||||
|
EnclosureUrl: pgtype.Text{String: "https://cdn.invalid/" + guid + ".mp3", Valid: true},
|
||||||
|
Hosts: []string{},
|
||||||
|
Guests: []string{},
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "seed episode %q", title)
|
||||||
|
return ep
|
||||||
|
}
|
||||||
|
|
||||||
|
// subscribeRaw inserts a subscription row (podcast_subscriptions is
|
||||||
|
// ungoverned by design — the read policy's lookup table).
|
||||||
|
func subscribeRaw(t *testing.T, setup *TestServerSetup, userID uuid.UUID, feedID pgtype.UUID) {
|
||||||
|
t.Helper()
|
||||||
|
_, err := setup.DBPool.Exec(context.Background(),
|
||||||
|
`INSERT INTO podcast_subscriptions (user_id, feed_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||||
|
userID, feedID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminSeedCtx declares the FIRST-ADMIN identity — the same one the
|
||||||
|
// fetcher/worker use (invariant 12). The harness seed identity has
|
||||||
|
// neither a subscription nor a users row, so podcast INSERT..RETURNING
|
||||||
|
// (which evaluates the SELECT policy under FORCE) 42501s for it; real
|
||||||
|
// admin writes are exactly what production pods run as.
|
||||||
|
func adminSeedCtx(t *testing.T, setup *TestServerSetup) context.Context {
|
||||||
|
t.Helper()
|
||||||
|
admin, err := setup.DB.GetFirstAdmin(context.Background())
|
||||||
|
require.NoError(t, err, "podcast tests need an admin identity")
|
||||||
|
return rls.WithUser(context.Background(), admin)
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminCtxValue is the raw pgtype.UUID behind adminSeedCtx (for
|
||||||
|
// ScopedDB.Exec calls that want the same identity).
|
||||||
|
func adminCtxValue(t *testing.T, setup *TestServerSetup) pgtype.UUID {
|
||||||
|
t.Helper()
|
||||||
|
admin, err := setup.DB.GetFirstAdmin(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
return admin
|
||||||
|
}
|
||||||
|
|
||||||
|
func podcastAuthed(t *testing.T, setup *TestServerSetup, token, method, path, body string) (int, []byte) {
|
||||||
|
t.Helper()
|
||||||
|
var req *http.Request
|
||||||
|
if body == "" {
|
||||||
|
req, _ = http.NewRequest(method, setup.Server.URL+path, nil)
|
||||||
|
} else {
|
||||||
|
req, _ = http.NewRequest(method, setup.Server.URL+path, bytes.NewReader([]byte(body)))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
_, _ = buf.ReadFrom(resp.Body)
|
||||||
|
return resp.StatusCode, buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the RLS auditor for podcast surfaces (plan task 1.10 / invariant 13) ----
|
||||||
|
|
||||||
|
func TestPodcastRLSSurfaces(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
regularID := getRegularUserID(t, setup.DB)
|
||||||
|
marker := "PODRLS" + uuid.New().String()[:8]
|
||||||
|
|
||||||
|
lib, _ := createPodcastsLibrary(t, setup, "test RLS Podcasts "+marker)
|
||||||
|
feed, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{
|
||||||
|
LibraryID: lib.ID,
|
||||||
|
FeedUrl: "https://feed.invalid/" + marker + ".xml",
|
||||||
|
Title: "RLS Probe Feed " + marker,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
ep := upsertTestEpisode(t, setup, feed, "guid-1", marker+" Episode One", 1)
|
||||||
|
|
||||||
|
adminToken := setup.Token
|
||||||
|
regularToken := setup.RegularToken
|
||||||
|
|
||||||
|
t.Run("non_subscriber_gets_404_everywhere", func(t *testing.T) {
|
||||||
|
code, _ := podcastAuthed(t, setup, regularToken, "GET", "/api/podcasts/feeds/"+uuid.UUID(feed.ID.Bytes).String(), "")
|
||||||
|
assert.Equal(t, http.StatusNotFound, code, "unsubscribed feed must be invisible (existence leak)")
|
||||||
|
|
||||||
|
code, _ = podcastAuthed(t, setup, regularToken, "GET", "/api/podcasts/search?q="+marker, "")
|
||||||
|
require.Equal(t, http.StatusOK, code)
|
||||||
|
// empty via body check below in subscriber test's mirror
|
||||||
|
|
||||||
|
code, _ = podcastAuthed(t, setup, regularToken, "GET", "/podcasts/"+uuid.UUID(feed.ID.Bytes).String(), "")
|
||||||
|
assert.Equal(t, http.StatusNotFound, code, "detail page must 404 for non-subscribers")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("episode_hidden_from_generic_media_reads", func(t *testing.T) {
|
||||||
|
code, body := podcastAuthed(t, setup, regularToken, "GET", "/api/media-items?limit=500", "")
|
||||||
|
require.Equal(t, http.StatusOK, code)
|
||||||
|
assert.NotContains(t, string(body), marker, "LEAK: podcast episode surfaced through generic media list")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("admin_sees_unsubscribed_feed", func(t *testing.T) {
|
||||||
|
code, body := podcastAuthed(t, setup, adminToken, "GET", "/api/podcasts/feeds/"+uuid.UUID(feed.ID.Bytes).String(), "")
|
||||||
|
assert.Equal(t, http.StatusOK, code, "admin escape must see any feed")
|
||||||
|
assert.Contains(t, string(body), marker)
|
||||||
|
_ = ep
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("subscriber_sees_feed_then_hidden_library_hides_it", func(t *testing.T) {
|
||||||
|
feedIDStr := uuid.UUID(feed.ID.Bytes).String()
|
||||||
|
subscribeRaw(t, setup, regularID, feed.ID)
|
||||||
|
|
||||||
|
code, body := podcastAuthed(t, setup, regularToken, "GET", "/api/podcasts/feeds/"+feedIDStr, "")
|
||||||
|
require.Equal(t, http.StatusOK, code)
|
||||||
|
assert.Contains(t, string(body), marker, "subscriber must see their feed")
|
||||||
|
|
||||||
|
code, body = podcastAuthed(t, setup, regularToken, "GET", "/api/podcasts/search?q="+marker, "")
|
||||||
|
require.Equal(t, http.StatusOK, code)
|
||||||
|
assert.Contains(t, string(body), marker, "subscription-scoped search covers the subscriber's feed")
|
||||||
|
|
||||||
|
// Hide the library → the engine hides the feed row again (the
|
||||||
|
// feed policy carries the library-visibility join).
|
||||||
|
_, err := setup.DB.SetLibraryVisibility(context.Background(), database.SetLibraryVisibilityParams{
|
||||||
|
UserID: pgtype.UUID{Bytes: regularID, Valid: true},
|
||||||
|
LibraryID: lib.ID,
|
||||||
|
IsVisible: false,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, _ = setup.DB.SetLibraryVisibility(context.Background(), database.SetLibraryVisibilityParams{
|
||||||
|
UserID: pgtype.UUID{Bytes: regularID, Valid: true},
|
||||||
|
LibraryID: lib.ID,
|
||||||
|
IsVisible: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
code, _ = podcastAuthed(t, setup, regularToken, "GET", "/api/podcasts/feeds/"+feedIDStr, "")
|
||||||
|
assert.Equal(t, http.StatusNotFound, code, "hidden library must hide its feeds even from subscribers")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- add-feed flow: fetch+parse+upsert+subscribe; allow_member_feeds gate ----
|
||||||
|
|
||||||
|
func TestPodcastAddFeedFlow(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
regularID := getRegularUserID(t, setup.DB)
|
||||||
|
lib, _ := createPodcastsLibrary(t, setup, "test AddFeed Podcasts")
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
feedXML := podcastFixture("AddFlow Feed", map[string]string{"ep-a": "Episode A", "ep-b": "Episode B"})
|
||||||
|
mux.HandleFunc("/feed.xml", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/rss+xml")
|
||||||
|
_, _ = w.Write([]byte(feedXML))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/other.xml", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/rss+xml")
|
||||||
|
_, _ = w.Write([]byte(podcastFixture("Other Feed", map[string]string{"other-1": "Other Episode"})))
|
||||||
|
})
|
||||||
|
srv := swapFeedServer(t, mux.ServeHTTP)
|
||||||
|
feedURL := srv.URL + "/feed.xml"
|
||||||
|
|
||||||
|
libID := uuid.UUID(lib.ID.Bytes).String()
|
||||||
|
addBody := fmt.Sprintf(`{"library_id":%q,"url":%q}`, libID, feedURL)
|
||||||
|
regularPG := pgtype.UUID{Bytes: regularID, Valid: true}
|
||||||
|
|
||||||
|
t.Run("member_adds_feed_subscribes_and_episodes_upsert_metadata_only", func(t *testing.T) {
|
||||||
|
resp, body := podcastAuthed(t, setup, setup.RegularToken, "POST", "/api/podcasts/feeds", addBody)
|
||||||
|
require.Equal(t, http.StatusCreated, resp, "member add should succeed: %s", string(body))
|
||||||
|
|
||||||
|
// The subscription exists; the feed is on the member's list.
|
||||||
|
code, body := podcastAuthed(t, setup, setup.RegularToken, "GET", "/api/podcasts/feeds", "")
|
||||||
|
require.Equal(t, http.StatusOK, code)
|
||||||
|
assert.Contains(t, string(body), "AddFlow Feed")
|
||||||
|
|
||||||
|
// Episode rows are metadata-only: pending, no file facts.
|
||||||
|
feeds, err := setup.DB.ListMyPodcastFeeds(rls.WithUser(context.Background(), regularPG), regularPG)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, feeds, 1)
|
||||||
|
eps, err := setup.DB.ListPodcastEpisodes(rls.WithUser(context.Background(), pgtype.UUID{Bytes: regularID, Valid: true}), feeds[0].ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, eps, 2)
|
||||||
|
for _, ep := range eps {
|
||||||
|
assert.Equal(t, "pending", ep.DownloadState, "metadata-only rows stay pending until a download")
|
||||||
|
assert.NotEmpty(t, ep.EnclosureUrl.String, "enclosure URL persisted for the download worker")
|
||||||
|
assert.NotZero(t, ep.FeedPosition.Int32)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("duplicate_url_attaches_without_new_row", func(t *testing.T) {
|
||||||
|
resp, _ := podcastAuthed(t, setup, setup.RegularToken, "POST", "/api/podcasts/feeds", addBody)
|
||||||
|
assert.Equal(t, http.StatusCreated, resp)
|
||||||
|
feeds, err := setup.DB.ListMyPodcastFeeds(rls.WithUser(context.Background(), regularPG), regularPG)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, feeds, 1, "URL dedupe: no second feed row")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("allow_member_feeds_false_rejects_members_allows_admins", func(t *testing.T) {
|
||||||
|
_, err := setup.DBPool.Exec(context.Background(),
|
||||||
|
`UPDATE libraries SET allow_member_feeds = false WHERE id = $1`, lib.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, _ = setup.DBPool.Exec(context.Background(),
|
||||||
|
`UPDATE libraries SET allow_member_feeds = true WHERE id = $1`, lib.ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
// A second feed URL from a member → rejected.
|
||||||
|
otherURL := srv.URL + "/other.xml"
|
||||||
|
resp, body := podcastAuthed(t, setup, setup.RegularToken, "POST", "/api/podcasts/feeds",
|
||||||
|
fmt.Sprintf(`{"library_id":%q,"url":%q}`, libID, otherURL))
|
||||||
|
assert.Equal(t, http.StatusForbidden, resp, "member add must be refused: %s", string(body))
|
||||||
|
|
||||||
|
// Admin keeps the right.
|
||||||
|
resp, _ = podcastAuthed(t, setup, setup.Token, "POST", "/api/podcasts/feeds",
|
||||||
|
fmt.Sprintf(`{"library_id":%q,"url":%q}`, libID, otherURL))
|
||||||
|
assert.Equal(t, http.StatusCreated, resp, "admin add must succeed")
|
||||||
|
|
||||||
|
// Subscribing to an EXISTING feed stays allowed for members
|
||||||
|
// (the gate governs ADDING feeds, not subscribing).
|
||||||
|
code, _ := podcastAuthed(t, setup, setup.RegularToken, "GET", "/api/podcasts/feeds", "")
|
||||||
|
require.Equal(t, http.StatusOK, code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- upsert-without-delete: shrunken feeds keep their history (D14) ----
|
||||||
|
|
||||||
|
func TestPodcastShrunkenFeedRetention(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
lib, _ := createPodcastsLibrary(t, setup, "test Shrink Podcasts")
|
||||||
|
|
||||||
|
current := podcastFixture("Shrink Feed", map[string]string{"ep-1": "Kept One", "ep-2": "Doomed Two"})
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/feed.xml", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/rss+xml")
|
||||||
|
_, _ = w.Write([]byte(current))
|
||||||
|
})
|
||||||
|
srv := swapFeedServer(t, mux.ServeHTTP)
|
||||||
|
|
||||||
|
libID := uuid.UUID(lib.ID.Bytes).String()
|
||||||
|
resp, body := podcastAuthed(t, setup, setup.Token, "POST", "/api/podcasts/feeds",
|
||||||
|
fmt.Sprintf(`{"library_id":%q,"url":%q}`, libID, srv.URL+"/feed.xml"))
|
||||||
|
require.Equal(t, http.StatusCreated, resp, string(body))
|
||||||
|
|
||||||
|
adminCtx := adminSeedCtx(t, setup)
|
||||||
|
allFeeds, err := setup.DB.ListPodcastFeedsForPolling(adminCtx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
var feedID pgtype.UUID
|
||||||
|
for _, f := range allFeeds {
|
||||||
|
if f.Title == "Shrink Feed" {
|
||||||
|
feedID = f.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
feedIDStr := uuid.UUID(feedID.Bytes).String()
|
||||||
|
|
||||||
|
eps, err := setup.DB.ListPodcastEpisodes(adminSeedCtx(t, setup), feedID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, eps, 2)
|
||||||
|
|
||||||
|
// The publisher purges an episode from the XML.
|
||||||
|
current = podcastFixture("Shrink Feed", map[string]string{"ep-1": "Kept One"})
|
||||||
|
resp, _ = podcastAuthed(t, setup, setup.Token, "POST", "/api/podcasts/feeds/"+feedIDStr+"/refresh", "")
|
||||||
|
require.Equal(t, http.StatusOK, resp)
|
||||||
|
|
||||||
|
eps, err = setup.DB.ListPodcastEpisodes(adminCtx, feedID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, eps, 2, "PIN: shrunken feeds NEVER delete rows — history accumulates for search")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- edit segregation (D10) ----
|
||||||
|
|
||||||
|
func TestPodcastEditSegregation(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
lib, _ := createPodcastsLibrary(t, setup, "test EditSeg Podcasts")
|
||||||
|
feed, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{
|
||||||
|
LibraryID: lib.ID,
|
||||||
|
FeedUrl: "https://feed.invalid/edit-seg.xml",
|
||||||
|
Title: "EditSeg Feed",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
ep := upsertTestEpisode(t, setup, feed, "seg-1", "Segregated Episode", 1)
|
||||||
|
epID := uuid.UUID(ep.ID.Bytes).String()
|
||||||
|
|
||||||
|
t.Run("generic_edit_rejects_podcast_episodes", func(t *testing.T) {
|
||||||
|
resp, body := podcastAuthed(t, setup, setup.Token, "PUT", "/api/media-items/"+epID,
|
||||||
|
`{"title":"Hacked Title","description":"clobbered"}`)
|
||||||
|
assert.Equal(t, http.StatusBadRequest, resp, "PIN: generic edit must reject episodes: %s", string(body))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("write_to_file_rejects_podcast_episodes", func(t *testing.T) {
|
||||||
|
resp, body := podcastAuthed(t, setup, setup.Token, "POST", "/api/media-items/"+epID+"/write-metadata", "")
|
||||||
|
assert.NotEqual(t, http.StatusOK, resp, "PIN: metadata write-to-file must refuse episodes: %s", string(body))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("book_edit_still_works", func(t *testing.T) {
|
||||||
|
// Control: the rejection is podcast-specific, not a broken route.
|
||||||
|
libType, err := setup.DB.GetLibraryTypeByName(seedCtx(), "audiobooks")
|
||||||
|
require.NoError(t, err)
|
||||||
|
book, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||||
|
LibraryID: func() pgtype.UUID {
|
||||||
|
lib2, cerr := setup.DB.CreateLibrary(seedCtx(), database.CreateLibraryParams{
|
||||||
|
Name: "test EditSeg Books",
|
||||||
|
LibraryTypeID: libType.ID,
|
||||||
|
})
|
||||||
|
require.NoError(t, cerr)
|
||||||
|
t.Cleanup(func() { _ = setup.DB.DeleteLibrary(seedCtx(), lib2.ID) })
|
||||||
|
return lib2.ID
|
||||||
|
}(),
|
||||||
|
Title: "Edit Control Book",
|
||||||
|
FilePath: "control-" + uuid.New().String() + ".m4b",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
resp, body := podcastAuthed(t, setup, setup.Token, "PUT", "/api/media-items/"+uuid.UUID(book.ID.Bytes).String(),
|
||||||
|
`{"title":"Edited Control"}`)
|
||||||
|
assert.True(t, resp == http.StatusOK || resp == http.StatusBadRequest,
|
||||||
|
"control book edit must not 404/500 (podcast-specific gate): %d %s", resp, string(body))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- download worker: state machine, idempotency, file layout ----
|
||||||
|
|
||||||
|
func TestPodcastDownloadWorker(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||||
|
if _, err := os.Stat("/usr/bin/ffmpeg"); err != nil {
|
||||||
|
t.Skip("ffmpeg unavailable: worker pipeline test needs runtime-generated audio")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lib, root := createPodcastsLibrary(t, setup, "test Download Podcasts")
|
||||||
|
|
||||||
|
// Generate a small valid MP3 (1s tone) at runtime — no binary fixtures.
|
||||||
|
mp3Path := filepath.Join(t.TempDir(), "tone.mp3")
|
||||||
|
gen := exec.Command("ffmpeg", "-y", "-v", "error", "-f", "lavfi",
|
||||||
|
"-i", "sine=frequency=440:duration=1", "-b:a", "32k", mp3Path)
|
||||||
|
out, err := gen.CombinedOutput()
|
||||||
|
require.NoError(t, err, "ffmpeg generate: %s", string(out))
|
||||||
|
audio, err := os.ReadFile(mp3Path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var enclosureHits int
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/audio/ep.mp3", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
enclosureHits++
|
||||||
|
w.Header().Set("Content-Type", "audio/mpeg")
|
||||||
|
_, _ = w.Write(audio)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/feed.xml", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/rss+xml")
|
||||||
|
_, _ = w.Write([]byte(podcastFixture("Download Feed", map[string]string{"dl-1": "Downloaded Episode"})))
|
||||||
|
})
|
||||||
|
srv := swapFeedServer(t, mux.ServeHTTP)
|
||||||
|
|
||||||
|
libID := uuid.UUID(lib.ID.Bytes).String()
|
||||||
|
resp, body := podcastAuthed(t, setup, setup.Token, "POST", "/api/podcasts/feeds",
|
||||||
|
fmt.Sprintf(`{"library_id":%q,"url":%q}`, libID, srv.URL+"/feed.xml"))
|
||||||
|
require.Equal(t, http.StatusCreated, resp, string(body))
|
||||||
|
|
||||||
|
feeds, err := setup.DB.ListPodcastFeedsForPolling(adminSeedCtx(t, setup))
|
||||||
|
require.NoError(t, err)
|
||||||
|
var feedRow database.PodcastFeeds
|
||||||
|
for _, f := range feeds {
|
||||||
|
if f.Title == "Download Feed" {
|
||||||
|
feedRow = f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NotZero(t, feedRow.ID, "feed must exist after add")
|
||||||
|
eps, err := setup.DB.ListPodcastEpisodes(adminSeedCtx(t, setup), feedRow.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, eps, 1)
|
||||||
|
ep := eps[0]
|
||||||
|
|
||||||
|
// Point the enclosure at the TEST server (fixtures use cdn.invalid).
|
||||||
|
// The write goes through an ADMIN-declared ScopedDB — the episode
|
||||||
|
// read policy hides such rows from the raw pool's seed identity, and
|
||||||
|
// onUpdate UPDATE FOR UPDATE reads the target row first.
|
||||||
|
adminCtx := adminSeedCtx(t, setup)
|
||||||
|
adminScope := database.NewScopedDB(setup.DBPool)
|
||||||
|
_, err = adminScope.Exec(rls.WithUser(context.Background(), adminCtxValue(t, setup)),
|
||||||
|
`UPDATE media_items SET enclosure_url = $2 WHERE id = $1`,
|
||||||
|
ep.ID, srv.URL+"/audio/ep.mp3")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
updated, err := setup.DB.GetPodcastEpisode(adminCtx, ep.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, srv.URL+"/audio/ep.mp3", updated.EnclosureUrl.String, "enclosure re-pointed at the test server")
|
||||||
|
|
||||||
|
worker := services.NewPodcastDownloadWorker(setup.DB, services.NewPodcastService(setup.DB))
|
||||||
|
workerCtx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
worker.Start(workerCtx)
|
||||||
|
|
||||||
|
feedIDStr := uuid.UUID(feedRow.ID.Bytes).String()
|
||||||
|
epIDStr := uuid.UUID(ep.ID.Bytes).String()
|
||||||
|
resp, _ = podcastAuthed(t, setup, setup.Token, "POST",
|
||||||
|
"/api/podcasts/feeds/"+feedIDStr+"/episodes/"+epIDStr+"/download", "")
|
||||||
|
require.Equal(t, http.StatusAccepted, resp)
|
||||||
|
|
||||||
|
// Terminal state within the worker's drain cadence.
|
||||||
|
var done database.MediaItems
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
row, err := setup.DB.GetPodcastEpisode(adminCtx, ep.ID)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
done = row
|
||||||
|
return row.DownloadState == "downloaded" || row.DownloadState == "failed"
|
||||||
|
}, 30*time.Second, 250*time.Millisecond, "download must reach a terminal state (got %s)", done.DownloadState)
|
||||||
|
|
||||||
|
require.Equal(t, "downloaded", done.DownloadState, "inline reason (if failed): %s", done.DownloadError.String)
|
||||||
|
assert.True(t, done.FileSize.Int64 > 0)
|
||||||
|
assert.NotEmpty(t, done.AudioMetadata, "ffprobe facts must land in audio_metadata")
|
||||||
|
assert.FileExists(t, filepath.Join(root, done.FilePath), "episode lands under <download-root>/<feed-slug>/ per the pinned layout")
|
||||||
|
|
||||||
|
t.Run("redownload_of_downloaded_episode_is_a_noop", func(t *testing.T) {
|
||||||
|
hitsBefore := enclosureHits
|
||||||
|
resp, _ := podcastAuthed(t, setup, setup.Token, "POST",
|
||||||
|
"/api/podcasts/feeds/"+feedIDStr+"/episodes/"+epIDStr+"/download", "")
|
||||||
|
require.Equal(t, http.StatusAccepted, resp)
|
||||||
|
time.Sleep(1500 * time.Millisecond)
|
||||||
|
assert.Equal(t, hitsBefore, enclosureHits, "PIN: re-download of a downloaded episode must not re-fetch")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("failed_download_persists_reason_inline", func(t *testing.T) {
|
||||||
|
// A dead enclosure: fetch fails, state+reason persist, existing
|
||||||
|
// metadata is never cleared. Writes run under the acting admin
|
||||||
|
// (the seed identity would read-match zero rows under the
|
||||||
|
// subscription policy).
|
||||||
|
// The failure path only exercises with the file ABSENT — an
|
||||||
|
// on-disk, probe-clean file re-marks downloaded without fetching
|
||||||
|
// (the idempotency reconciliation, already pinned by the no-op
|
||||||
|
// subtest above).
|
||||||
|
require.NoError(t, os.Remove(filepath.Join(root, done.FilePath)))
|
||||||
|
_, err := adminScope.Exec(rls.WithUser(context.Background(), adminCtxValue(t, setup)),
|
||||||
|
`UPDATE media_items SET enclosure_url = 'https://127.0.0.1:1/none.mp3',
|
||||||
|
download_state = 'pending', download_error = NULL, download_attempts = 0
|
||||||
|
WHERE id = $1`, ep.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
resp, _ := podcastAuthed(t, setup, setup.Token, "POST",
|
||||||
|
"/api/podcasts/feeds/"+feedIDStr+"/episodes/"+epIDStr+"/download", "")
|
||||||
|
require.Equal(t, http.StatusAccepted, resp)
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
r, err := setup.DB.GetPodcastEpisode(adminCtx, ep.ID)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if r.DownloadState != "failed" {
|
||||||
|
t.Logf("still not failed: state=%s enclosure=%q attempts=%d err=%q",
|
||||||
|
r.DownloadState, r.EnclosureUrl.String, r.DownloadAttempts, r.DownloadError.String)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}, 30*time.Second, 2*time.Second, "dead enclosure must end failed")
|
||||||
|
|
||||||
|
r, err := setup.DB.GetPodcastEpisode(adminCtx, ep.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, r.DownloadError.String, "PIN: the reason text persists (renders inline in the episode list)")
|
||||||
|
assert.Equal(t, "Downloaded Episode", r.Title, "PIN: failures never clear episode metadata")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- invariant 4 pin: the scanner never sees podcasts folders ----
|
||||||
|
|
||||||
|
func TestPodcastScannerExclusion(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
lib, root := createPodcastsLibrary(t, setup, "test ScanSkip Podcasts")
|
||||||
|
|
||||||
|
// A stray audio file inside the download root must never ingest.
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(root, "some-feed"), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(root, "some-feed", "01-ep.mp3"), []byte("not really audio"), 0o644))
|
||||||
|
|
||||||
|
scanner := services.NewMediaScanner(setup.DB)
|
||||||
|
err := scanner.SetFolders([]string{root}, false)
|
||||||
|
if err != nil {
|
||||||
|
// Zero scannable folders errors at the watcher (watch=true) —
|
||||||
|
// scan jobs (watch=false) still configure; assert either way.
|
||||||
|
assert.Contains(t, err.Error(), "feed-managed")
|
||||||
|
}
|
||||||
|
assert.Empty(t, scanner.FolderCount(), "PIN: podcasts download roots are never scanner folders")
|
||||||
|
assert.NotZero(t, lib.ID, "library must exist for the pin to be meaningful")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- invariant 3 / task 2.6: order flips never move progress ----
|
||||||
|
|
||||||
|
func TestPodcastReorderSafety(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
adminCtx := adminSeedCtx(t, setup)
|
||||||
|
lib, _ := createPodcastsLibrary(t, setup, "test Reorder Podcasts")
|
||||||
|
|
||||||
|
feed, err := setup.DB.CreatePodcastFeed(adminCtx, database.CreatePodcastFeedParams{
|
||||||
|
LibraryID: lib.ID,
|
||||||
|
FeedUrl: "https://feed.invalid/reorder.xml",
|
||||||
|
Title: "Reorder Feed",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
e1 := upsertTestEpisode(t, setup, feed, "ro-1", "Reorder One", 1)
|
||||||
|
e2 := upsertTestEpisode(t, setup, feed, "ro-2", "Reorder Two", 2)
|
||||||
|
|
||||||
|
// Mark both DOWNLOADED so they enter the sequence (the lifecycle
|
||||||
|
// itself is pinned by TestPodcastDownloadWorker).
|
||||||
|
adminScopeExec(t, setup, adminCtxValue(t, setup),
|
||||||
|
`UPDATE media_items SET download_state = 'downloaded', file_size = 1000
|
||||||
|
WHERE id IN ($1, $2) AND source = 'podcast_episode'`, e1.ID, e2.ID)
|
||||||
|
|
||||||
|
adminID := adminCtxValue(t, setup)
|
||||||
|
|
||||||
|
// Listener is 40% through episode TWO (direct row write — the pin
|
||||||
|
// is about the row's LIFE under renumbering, not the write API).
|
||||||
|
adminScopeExec(t, setup, adminID,
|
||||||
|
`INSERT INTO reading_progress (media_item_id, user_id, percentage, chapter, character_offset)
|
||||||
|
VALUES ($1, $2, 0.4, 0, 0)
|
||||||
|
ON CONFLICT (media_item_id, user_id) DO UPDATE SET percentage = 0.4, last_read_at = NOW()`,
|
||||||
|
e2.ID, adminID)
|
||||||
|
|
||||||
|
before, err := setup.DB.GetReadingProgress(adminCtx, database.GetReadingProgressParams{
|
||||||
|
MediaItemID: e2.ID, UserID: adminID,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
pctBefore := before.Percentage.Float64
|
||||||
|
|
||||||
|
// FLIP the feed's native positions (the fetcher renumbering case:
|
||||||
|
// the publisher reorders; positions renumber on the next fetch).
|
||||||
|
adminScopeExec(t, setup, adminID,
|
||||||
|
`UPDATE media_items SET feed_position = CASE id WHEN $1 THEN 2 WHEN $2 THEN 1 END
|
||||||
|
WHERE id IN ($1, $2)`, e1.ID, e2.ID)
|
||||||
|
|
||||||
|
after, err := setup.DB.GetReadingProgress(adminCtx, database.GetReadingProgressParams{
|
||||||
|
MediaItemID: e2.ID, UserID: adminID,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, pctBefore, after.Percentage.Float64,
|
||||||
|
"PIN: progress anchors on the episode (guid/identity), never the ordinal — reordering must not move it")
|
||||||
|
|
||||||
|
// The sequence still lists both downloaded episodes; the override
|
||||||
|
// only changes ORDER, never membership.
|
||||||
|
memberID := getRegularUserID(t, setup.DB)
|
||||||
|
subscribeRaw(t, setup, memberID, feed.ID)
|
||||||
|
_, body := podcastAuthed(t, setup, setup.Token, "GET",
|
||||||
|
"/api/podcasts/feeds/"+uuid.UUID(feed.ID.Bytes).String()+"/sequence", "")
|
||||||
|
var seq struct {
|
||||||
|
Episodes []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"episodes"`
|
||||||
|
Gaps []interface{} `json:"gaps"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal(body, &seq))
|
||||||
|
assert.Len(t, seq.Episodes, 2, "sequence membership survives renumbering")
|
||||||
|
assert.Empty(t, seq.Gaps, "no un-downloaded rows → no gaps")
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminScopeExec runs raw SQL under the first-admin identity (helper
|
||||||
|
// for pins that need direct row surgery the Queries API lacks).
|
||||||
|
func adminScopeExec(t *testing.T, setup *TestServerSetup, adminID pgtype.UUID, sql string, args ...any) {
|
||||||
|
t.Helper()
|
||||||
|
scope := database.NewScopedDB(setup.DBPool)
|
||||||
|
_, err := scope.Exec(rls.WithUser(context.Background(), adminID), sql, args...)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ---- task 1.9 residue: the htmx wire format is FORM-encoded (issue #2) ----
|
||||||
|
|
||||||
|
func TestPodcastOrderFormEncoding(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
lib, _ := createPodcastsLibrary(t, setup, "test OrderForm Podcasts")
|
||||||
|
feed, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{
|
||||||
|
LibraryID: lib.ID,
|
||||||
|
FeedUrl: "https://feed.invalid/order-form.xml",
|
||||||
|
Title: "Order Form Feed",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
feedIDStr := uuid.UUID(feed.ID.Bytes).String()
|
||||||
|
|
||||||
|
// The UI's hx-put submits urlencoded form bodies (htmx non-GET
|
||||||
|
// default) — the binder must map them (form tag), not just JSON.
|
||||||
|
formPut := func(body string) int {
|
||||||
|
req, _ := http.NewRequest(http.MethodPut,
|
||||||
|
setup.Server.URL+"/api/podcasts/feeds/"+feedIDStr+"/order",
|
||||||
|
strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
return resp.StatusCode
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, formPut("order=oldest"), "form-encoded order must bind")
|
||||||
|
assert.Equal(t, http.StatusOK, formPut("order=native"), "native binds too")
|
||||||
|
assert.Equal(t, http.StatusBadRequest, formPut(""), "missing order stays rejected (required)")
|
||||||
|
assert.Equal(t, http.StatusBadRequest, formPut("order=sideways"), "invalid order stays rejected")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- issue #8: page search hits EPISODE titles/descriptions too ----
|
||||||
|
|
||||||
|
func TestPodcastPageSearchEpisodes(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
lib, _ := createPodcastsLibrary(t, setup, "test PageSearch Podcasts")
|
||||||
|
// Feed title deliberately does NOT contain the needle.
|
||||||
|
feed, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{
|
||||||
|
LibraryID: lib.ID,
|
||||||
|
FeedUrl: "https://feed.invalid/page-search.xml",
|
||||||
|
Title: "Trader Tales Show",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
upsertTestEpisode(t, setup, feed, "ps-1", "Zarkorrin Blaster", 1)
|
||||||
|
|
||||||
|
subscribeRaw(t, setup, getRegularUserID(t, setup.DB), feed.ID)
|
||||||
|
|
||||||
|
// SSR page search: the needle exists only in an EPISODE title.
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, setup.Server.URL+"/podcasts?q=Zarkorrin", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
_, _ = buf.ReadFrom(resp.Body)
|
||||||
|
body := buf.String()
|
||||||
|
|
||||||
|
assert.Contains(t, body, "Trader Tales Show",
|
||||||
|
"PIN: a feed surfaces when an EPISODE matches the query, even when the feed's own metadata does not")
|
||||||
|
assert.Contains(t, body, "matching episode",
|
||||||
|
"the match count renders on the card")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- issue #3: Download All flips the CALLER's auto-download to all ----
|
||||||
|
|
||||||
|
func TestPodcastDownloadAllPolicyFlip(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
regularID := getRegularUserID(t, setup.DB)
|
||||||
|
lib, _ := createPodcastsLibrary(t, setup, "test Flip Podcasts")
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/feed.xml", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/rss+xml")
|
||||||
|
_, _ = w.Write([]byte(podcastFixture("Flip Feed", map[string]string{"f-1": "Flip One"})))
|
||||||
|
})
|
||||||
|
srv := swapFeedServer(t, mux.ServeHTTP)
|
||||||
|
|
||||||
|
libID := uuid.UUID(lib.ID.Bytes).String()
|
||||||
|
regularPG := pgtype.UUID{Bytes: regularID, Valid: true}
|
||||||
|
|
||||||
|
t.Run("add_feed_with_download_all_sets_policy_all", func(t *testing.T) {
|
||||||
|
resp, body := podcastAuthed(t, setup, setup.RegularToken, "POST", "/api/podcasts/feeds",
|
||||||
|
fmt.Sprintf(`{"library_id":%q,"url":%q,"download_all":true}`, libID, srv.URL+"/feed.xml"))
|
||||||
|
require.Equal(t, http.StatusCreated, resp, string(body))
|
||||||
|
|
||||||
|
sub, err := setup.DB.GetPodcastSubscription(rls.WithUser(context.Background(), regularPG), database.GetPodcastSubscriptionParams{
|
||||||
|
UserID: regularPG, FeedID: func() pgtype.UUID {
|
||||||
|
feeds, _ := setup.DB.ListPodcastFeedsForPolling(rls.WithUser(context.Background(), regularPG))
|
||||||
|
for _, f := range feeds {
|
||||||
|
if f.Title == "Flip Feed" {
|
||||||
|
return f.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pgtype.UUID{}
|
||||||
|
}(),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "all", sub.AutoDownload, "PIN: the add-feed checkbox flips the caller's policy to all")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("download_all_button_flips_policy_and_returns_oob_select", func(t *testing.T) {
|
||||||
|
// Set the policy to newest first: the flip must override it.
|
||||||
|
feeds, _ := setup.DB.ListPodcastFeedsForPolling(rls.WithUser(context.Background(), regularPG))
|
||||||
|
var feedID pgtype.UUID
|
||||||
|
for _, f := range feeds {
|
||||||
|
if f.Title == "Flip Feed" {
|
||||||
|
feedID = f.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err := setup.DB.UpdatePodcastSubscription(rls.WithUser(context.Background(), regularPG), database.UpdatePodcastSubscriptionParams{
|
||||||
|
UserID: regularPG, FeedID: feedID,
|
||||||
|
DisplayName: pgtype.Text{}, AutoDownload: "newest",
|
||||||
|
AutoDownloadCount: pgtype.Int4{Int32: 3, Valid: true},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
feedIDStr := uuid.UUID(feedID.Bytes).String()
|
||||||
|
req, _ := http.NewRequest(http.MethodPost,
|
||||||
|
setup.Server.URL+"/api/podcasts/feeds/"+feedIDStr+"/episodes/all/download", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
|
||||||
|
req.Header.Set("HX-Request", "true")
|
||||||
|
hxResp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = hxResp.Body.Close() }()
|
||||||
|
require.Equal(t, http.StatusOK, hxResp.StatusCode, "HX action answers with the refreshed partial")
|
||||||
|
var buf bytes.Buffer
|
||||||
|
_, _ = buf.ReadFrom(hxResp.Body)
|
||||||
|
body := buf.String()
|
||||||
|
assert.Contains(t, body, `id="episode-list"`, "direct swap: the episode list partial IS the response")
|
||||||
|
assert.Contains(t, body, `hx-swap-oob="true"`, "issue #3: the policy select rides along out-of-band")
|
||||||
|
|
||||||
|
sub, err := setup.DB.GetPodcastSubscription(rls.WithUser(context.Background(), regularPG), database.GetPodcastSubscriptionParams{
|
||||||
|
UserID: regularPG, FeedID: feedID,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "all", sub.AutoDownload, "PIN: Download All flips newest → all (dynamic + visible)")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- logout actually signs out (fake-signout bug, 2026-10-30) ----
|
||||||
|
|
||||||
|
func TestLogoutClearsSession(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
|
||||||
|
// Real login: capture the session cookie AND the refresh token.
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, setup.Server.URL+"/api/auth/login",
|
||||||
|
strings.NewReader(`{"login":"testuser@tests.bookhoard.internal","password":"Test@Pass123!"}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
loginResp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = loginResp.Body.Close() }()
|
||||||
|
require.Equal(t, http.StatusOK, loginResp.StatusCode)
|
||||||
|
|
||||||
|
var cookies []*http.Cookie
|
||||||
|
for _, c := range loginResp.Cookies() {
|
||||||
|
if c.Name == "token" && c.Value != "" {
|
||||||
|
cookies = append(cookies, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NotEmpty(t, cookies, "login must set the session cookie")
|
||||||
|
var loginBody struct {
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.NewDecoder(loginResp.Body).Decode(&loginBody))
|
||||||
|
require.NotEmpty(t, loginBody.RefreshToken, "login must hand back a refresh token")
|
||||||
|
|
||||||
|
// Logout WITH the session cookie + refresh token.
|
||||||
|
logoutReq, _ := http.NewRequest(http.MethodPost, setup.Server.URL+"/api/auth/logout",
|
||||||
|
strings.NewReader(`{"refresh_token":"`+loginBody.RefreshToken+`"}`))
|
||||||
|
logoutReq.Header.Set("Content-Type", "application/json")
|
||||||
|
for _, c := range cookies {
|
||||||
|
logoutReq.AddCookie(c)
|
||||||
|
}
|
||||||
|
logoutResp, err := http.DefaultClient.Do(logoutReq)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = logoutResp.Body.Close() }()
|
||||||
|
require.Equal(t, http.StatusOK, logoutResp.StatusCode)
|
||||||
|
|
||||||
|
// PIN 1: the response deletes the session cookie.
|
||||||
|
var cleared bool
|
||||||
|
for _, c := range logoutResp.Cookies() {
|
||||||
|
if c.Name == "token" && c.Value == "" && c.MaxAge < 0 {
|
||||||
|
cleared = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.True(t, cleared, "PIN: logout must Set-Cookie an empty/expired token (HttpOnly — the browser is the only eraser)")
|
||||||
|
|
||||||
|
// PIN 2: the refresh token is revoked server-side.
|
||||||
|
refreshReq, _ := http.NewRequest(http.MethodPost, setup.Server.URL+"/api/auth/refresh",
|
||||||
|
strings.NewReader(`{"refresh_token":"`+loginBody.RefreshToken+`"}`))
|
||||||
|
refreshReq.Header.Set("Content-Type", "application/json")
|
||||||
|
refreshResp, err := http.DefaultClient.Do(refreshReq)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = refreshResp.Body.Close() }()
|
||||||
|
assert.NotEqual(t, http.StatusOK, refreshResp.StatusCode,
|
||||||
|
"PIN: a logged-out refresh token must not mint new access tokens")
|
||||||
|
|
||||||
|
// Logout WITHOUT a body still clears the cookie (malformed/absent
|
||||||
|
// payloads must never leave the browser signed in).
|
||||||
|
logoutReq2, _ := http.NewRequest(http.MethodPost, setup.Server.URL+"/api/auth/logout", nil)
|
||||||
|
for _, c := range cookies {
|
||||||
|
logoutReq2.AddCookie(c)
|
||||||
|
}
|
||||||
|
logoutResp2, err := http.DefaultClient.Do(logoutReq2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = logoutResp2.Body.Close() }()
|
||||||
|
cleared = false
|
||||||
|
for _, c := range logoutResp2.Cookies() {
|
||||||
|
if c.Name == "token" && c.Value == "" && c.MaxAge < 0 {
|
||||||
|
cleared = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.True(t, cleared, "cookie clear must not depend on the request body")
|
||||||
|
}
|
||||||
@@ -610,6 +610,14 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
|||||||
conversionService := services.NewConversionService(queries, getCachePath())
|
conversionService := services.NewConversionService(queries, getCachePath())
|
||||||
opdsHandler := handlers.NewOPDSHandler(queries, libraryService, conversionService)
|
opdsHandler := handlers.NewOPDSHandler(queries, libraryService, conversionService)
|
||||||
|
|
||||||
|
// Podcast subsystem (mirrors main.go wiring): fetcher service +
|
||||||
|
// download worker + handler. The poller is NOT started in tests
|
||||||
|
// (no background ticks against the shared dev DB); the worker loop
|
||||||
|
// only drains when a test queues work.
|
||||||
|
podcastService := services.NewPodcastService(queries)
|
||||||
|
podcastWorker := services.NewPodcastDownloadWorker(queries, podcastService)
|
||||||
|
podcastHandler := handlers.NewPodcastHandler(queries, podcastService, podcastWorker)
|
||||||
|
|
||||||
// Create Echo instance
|
// Create Echo instance
|
||||||
e := echo.New()
|
e := echo.New()
|
||||||
|
|
||||||
@@ -672,6 +680,9 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
|||||||
// hands the kobo/kepub CFI converter a typed-nil (kobo markup
|
// hands the kobo/kepub CFI converter a typed-nil (kobo markup
|
||||||
// panic-recovered as 500s in TestProgressWeb_KoboIntegration).
|
// panic-recovered as 500s in TestProgressWeb_KoboIntegration).
|
||||||
LibraryService: libraryService,
|
LibraryService: libraryService,
|
||||||
|
PodcastHandler: podcastHandler,
|
||||||
|
PodcastService: podcastService,
|
||||||
|
PodcastWorker: podcastWorker,
|
||||||
}
|
}
|
||||||
|
|
||||||
router.RegisterRoutes(routerConfig)
|
router.RegisterRoutes(routerConfig)
|
||||||
|
|||||||
+198
-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']),
|
('ebooks', 'Ebook files including EPUB, PDF, MOBI, etc.', ARRAY['.epub', '.pdf', '.mobi', '.azw', '.azw3', '.txt', '.rtf', '.doc', '.docx', '.lit', '.fb2', '.pdb']),
|
||||||
('comics', 'Comic book archives and image formats', ARRAY['.cbz', '.cbr', '.cb7', '.cbt', '.epub', '.pdf']),
|
('comics', 'Comic book archives and image formats', ARRAY['.cbz', '.cbr', '.cb7', '.cbt', '.epub', '.pdf']),
|
||||||
('manga', 'Manga files including archives and image folders', ARRAY['.cbz', '.cbr', '.epub', '.pdf', '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.avif', '.tiff', '.tif']),
|
('manga', 'Manga files including archives and image folders', ARRAY['.cbz', '.cbr', '.epub', '.pdf', '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.avif', '.tiff', '.tif']),
|
||||||
('audiobooks', 'Audiobook files; m4b first, breadth formats registered from day one', ARRAY['.m4b', '.m4a', '.mp3', '.flac'])
|
('audiobooks', 'Audiobook files; m4b first, breadth formats registered from day one', ARRAY['.m4b', '.m4a', '.mp3', '.flac']),
|
||||||
|
('podcasts', 'RSS podcast/feed libraries: feeds subscribe-and-download; the scanner never walks these folders', ARRAY[]::TEXT[])
|
||||||
ON CONFLICT (name) DO NOTHING;
|
ON CONFLICT (name) DO NOTHING;
|
||||||
|
|
||||||
-- Create users table
|
-- Create users table
|
||||||
@@ -99,7 +100,9 @@ INSERT INTO system_settings (setting_key, setting_value, description, setting_ty
|
|||||||
('sync_queue_interval_seconds', '5', 'How often the sync queue flushes', 'int', '1', '3600', TRUE, 'sync'),
|
('sync_queue_interval_seconds', '5', 'How often the sync queue flushes', 'int', '1', '3600', TRUE, 'sync'),
|
||||||
('sync_queue_batch_size', '50', 'Maximum items processed per sync queue flush', 'int', '1', '10000', TRUE, 'sync'),
|
('sync_queue_batch_size', '50', 'Maximum items processed per sync queue flush', 'int', '1', '10000', TRUE, 'sync'),
|
||||||
('worker_pool_size', '3', 'Number of background worker goroutines', 'int', '1', '100', TRUE, 'performance'),
|
('worker_pool_size', '3', 'Number of background worker goroutines', 'int', '1', '100', TRUE, 'performance'),
|
||||||
('worker_queue_cap', '100', 'Background worker job queue capacity', 'int', '1', '10000', TRUE, 'performance')
|
('worker_queue_cap', '100', 'Background worker job queue capacity', 'int', '1', '10000', TRUE, 'performance'),
|
||||||
|
-- podcasts
|
||||||
|
('podcast_poll_interval_seconds', '3600', 'How often to poll podcast feeds for new episodes (seconds; complete feeds back off)', 'int', '300', '604800', FALSE, 'podcasts')
|
||||||
ON CONFLICT (setting_key) DO NOTHING;
|
ON CONFLICT (setting_key) DO NOTHING;
|
||||||
|
|
||||||
-- Create refresh_tokens table
|
-- Create refresh_tokens table
|
||||||
@@ -1878,6 +1881,126 @@ CREATE INDEX IF NOT EXISTS idx_hash_conflicts_status ON hash_conflicts(status);
|
|||||||
-- Widen for databases created before the resolution format settled (no-op otherwise)
|
-- Widen for databases created before the resolution format settled (no-op otherwise)
|
||||||
ALTER TABLE hash_conflicts ALTER COLUMN resolution TYPE VARCHAR(50);
|
ALTER TABLE hash_conflicts ALTER COLUMN resolution TYPE VARCHAR(50);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- PODCASTS / RSS FEEDS
|
||||||
|
-- Plan: ~/.opencode/plan/podcast-integration.md (§6).
|
||||||
|
-- Feeds are per-library entities (UNIQUE(library_id, feed_url)); users
|
||||||
|
-- subscribe personally (podcast_subscriptions). Episodes are
|
||||||
|
-- media_items rows with source='podcast_episode' — per-episode
|
||||||
|
-- progress/stats for free — grouped by source_feed_id. Visibility is
|
||||||
|
-- RLS-enforced (see the policy block at the end of this file): an
|
||||||
|
-- episode/feed row is SELECT-visible only to subscribers of the feed
|
||||||
|
-- and to admins. The fetcher/download worker is the SOLE writer of
|
||||||
|
-- feed/episode metadata; users own only their subscription row and the
|
||||||
|
-- reader_settings order override. ALL DDL IDEMPOTENT: schema.sql
|
||||||
|
-- re-executes on EVERY boot.
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- First per-library boolean knob (plain column, not KV): when false,
|
||||||
|
-- only admins may add feeds to the library (members keep subscribe
|
||||||
|
-- rights to existing feeds).
|
||||||
|
ALTER TABLE libraries ADD COLUMN IF NOT EXISTS allow_member_feeds BOOLEAN NOT NULL DEFAULT TRUE;
|
||||||
|
|
||||||
|
-- Folder purpose: 'scan' (default; the FS walker's root) vs 'podcast'
|
||||||
|
-- (a podcasts library's download root — scanner skips it wholesale,
|
||||||
|
-- belt-and-braces on top of the library-type skip).
|
||||||
|
ALTER TABLE library_folders ADD COLUMN IF NOT EXISTS purpose VARCHAR(20) NOT NULL DEFAULT 'scan';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS podcast_feeds (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
library_id UUID NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
|
||||||
|
feed_url TEXT NOT NULL,
|
||||||
|
-- podcast:guid — the feed's canonical identity; dedupes across URL
|
||||||
|
-- migrations (fetcher honors itunes:new-feed-url + redirects).
|
||||||
|
podcast_guid TEXT,
|
||||||
|
title VARCHAR(500) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
-- Channel itunes:author (semantically the host) and itunes:owner.
|
||||||
|
host VARCHAR(500),
|
||||||
|
publisher VARCHAR(500),
|
||||||
|
owner_name VARCHAR(500),
|
||||||
|
owner_email VARCHAR(500),
|
||||||
|
language VARCHAR(50),
|
||||||
|
tags TEXT[] DEFAULT '{}',
|
||||||
|
explicit BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
artwork_path VARCHAR(500),
|
||||||
|
-- itunes:type: 'serial' | 'episodic'
|
||||||
|
feed_type VARCHAR(20),
|
||||||
|
-- podcast:medium: 'audiobook' | 'podcast' | 'music' | ...
|
||||||
|
medium VARCHAR(20),
|
||||||
|
-- itunes:complete: poller backs off once the publisher ends the feed.
|
||||||
|
is_complete BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
-- Conditional-GET validators (opaque; echoed verbatim).
|
||||||
|
etag VARCHAR(500),
|
||||||
|
last_modified VARCHAR(500),
|
||||||
|
last_fetched_at TIMESTAMPTZ,
|
||||||
|
last_build_date TIMESTAMPTZ,
|
||||||
|
created_by_admin_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE(library_id, feed_url)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_podcast_feeds_library ON podcast_feeds(library_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_podcast_feeds_guid ON podcast_feeds(podcast_guid);
|
||||||
|
|
||||||
|
-- User-owned subscription row: the ONLY podcast state a user writes.
|
||||||
|
-- display_name NULL renders as the feed title. auto_download:
|
||||||
|
-- 'off' | 'all' | 'newest' (+ auto_download_count for newest N).
|
||||||
|
CREATE TABLE IF NOT EXISTS podcast_subscriptions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
feed_id UUID NOT NULL REFERENCES podcast_feeds(id) ON DELETE CASCADE,
|
||||||
|
display_name VARCHAR(500),
|
||||||
|
auto_download VARCHAR(20) NOT NULL DEFAULT 'off',
|
||||||
|
auto_download_count INT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE(user_id, feed_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_podcast_subs_user ON podcast_subscriptions(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_podcast_subs_feed ON podcast_subscriptions(feed_id);
|
||||||
|
|
||||||
|
-- Episode columns (NULL for scan-sourced items). See the plan §5 for
|
||||||
|
-- the full RSS/iTunes/Podcasting-2.0 field mapping — existing columns
|
||||||
|
-- (title, subtitle, description, authors, explicit, date_published,
|
||||||
|
-- cover_image_path, file_size, mime_type, language, genre/tags) carry
|
||||||
|
-- the shared fields; only genuinely podcast-only storage lives here.
|
||||||
|
ALTER TABLE media_items
|
||||||
|
ADD COLUMN IF NOT EXISTS source VARCHAR(20) NOT NULL DEFAULT 'scan',
|
||||||
|
ADD COLUMN IF NOT EXISTS source_feed_id UUID REFERENCES podcast_feeds(id) ON DELETE CASCADE,
|
||||||
|
-- Stable reorder-proof addressing key (invariant 3): progress
|
||||||
|
-- anchors on guid, never ordinal.
|
||||||
|
ADD COLUMN IF NOT EXISTS episode_guid TEXT,
|
||||||
|
-- Native order as of the latest fetch (renumbered per fetch).
|
||||||
|
ADD COLUMN IF NOT EXISTS feed_position INTEGER,
|
||||||
|
ADD COLUMN IF NOT EXISTS season_number INTEGER,
|
||||||
|
ADD COLUMN IF NOT EXISTS episode_number INTEGER,
|
||||||
|
ADD COLUMN IF NOT EXISTS enclosure_url TEXT,
|
||||||
|
-- itunes:duration claim (often HH:MM:SS or prose); display until
|
||||||
|
-- ffprobe reconciles after download.
|
||||||
|
ADD COLUMN IF NOT EXISTS declared_duration_seconds INTEGER,
|
||||||
|
ADD COLUMN IF NOT EXISTS transcript_url TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS chapters_url TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS hosts TEXT[] DEFAULT '{}',
|
||||||
|
ADD COLUMN IF NOT EXISTS guests TEXT[] DEFAULT '{}',
|
||||||
|
-- Download state machine: pending | queued | downloading |
|
||||||
|
-- downloaded | failed (+ reason text + attempt count). The episode
|
||||||
|
-- list renders this inline (D8/D19) — never Processing Issues.
|
||||||
|
ADD COLUMN IF NOT EXISTS download_state VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||||
|
ADD COLUMN IF NOT EXISTS download_error TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS download_attempts INT NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- 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';
|
||||||
|
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- RLS: PER-USER LIBRARY VISIBILITY (ONE SOURCE OF TRUTH)
|
-- RLS: PER-USER LIBRARY VISIBILITY (ONE SOURCE OF TRUTH)
|
||||||
-- Plan: ~/.opencode/plan/server-rls-library-visibility.md REV 2.
|
-- Plan: ~/.opencode/plan/server-rls-library-visibility.md REV 2.
|
||||||
@@ -1912,6 +2035,13 @@ ALTER TABLE media_items FORCE ROW LEVEL SECURITY;
|
|||||||
-- semantics — row absent -> visible; row true -> visible; row false
|
-- semantics — row absent -> visible; row true -> visible; row false
|
||||||
-- -> hidden. No new index needed: library_visibility's
|
-- -> hidden. No new index needed: library_visibility's
|
||||||
-- UNIQUE(user_id, library_id) constraint already indexes the join.
|
-- UNIQUE(user_id, library_id) constraint already indexes the join.
|
||||||
|
-- PODCAST EPISODES (plan D20): source='podcast_episode' rows carry the
|
||||||
|
-- ADDITIONAL subscription predicate — visible only to subscribers of
|
||||||
|
-- source_feed_id, or to admins (admins manage feeds regardless of
|
||||||
|
-- subscription; the escape also satisfies background jobs, which
|
||||||
|
-- declare a first-admin identity). Scan rows pass unchanged via the
|
||||||
|
-- IS DISTINCT FROM guard — the predicate costs them one NULL compare.
|
||||||
|
-- Unset identity FAILS LOUD, never fails open (see the block header).
|
||||||
CREATE POLICY p_media_items_visible ON media_items
|
CREATE POLICY p_media_items_visible ON media_items
|
||||||
FOR SELECT
|
FOR SELECT
|
||||||
USING (
|
USING (
|
||||||
@@ -1922,6 +2052,19 @@ CREATE POLICY p_media_items_visible ON media_items
|
|||||||
AND lv.library_id = media_items.library_id
|
AND lv.library_id = media_items.library_id
|
||||||
AND lv.is_visible = false
|
AND lv.is_visible = false
|
||||||
)
|
)
|
||||||
|
AND (
|
||||||
|
media_items.source IS DISTINCT FROM 'podcast_episode'
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM podcast_subscriptions s
|
||||||
|
WHERE s.user_id = current_setting('app.user_id')::uuid
|
||||||
|
AND s.feed_id = media_items.source_feed_id
|
||||||
|
)
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM users u
|
||||||
|
WHERE u.id = current_setting('app.user_id')::uuid
|
||||||
|
AND u.role = 'admin'
|
||||||
|
)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
-- WRITE policies: PERMISSIVE STAND-INS for the deferred scoped write
|
-- WRITE policies: PERMISSIVE STAND-INS for the deferred scoped write
|
||||||
@@ -1942,7 +2085,59 @@ CREATE POLICY p_media_items_update ON media_items
|
|||||||
CREATE POLICY p_media_items_delete ON media_items
|
CREATE POLICY p_media_items_delete ON media_items
|
||||||
FOR DELETE USING (true);
|
FOR DELETE USING (true);
|
||||||
|
|
||||||
-- Other tables: NONE. collection_items reaches media only via INNER
|
-- Other tables: collection_items reaches media only via INNER
|
||||||
-- JOIN media_items (the join drops filtered rows). collections
|
-- JOIN media_items (the join drops filtered rows). collections
|
||||||
-- metadata (book_count) is a separate counts question — plan A13.
|
-- metadata (book_count) is a separate counts question — plan A13.
|
||||||
|
|
||||||
|
-- PODCAST FEEDS (plan D20): governed the same way. SELECT-visible to
|
||||||
|
-- subscribers of the feed (their subscription IS the grant — this is
|
||||||
|
-- the predicate's lookup table, podcast_subscriptions itself stays
|
||||||
|
-- UNGOVERNED) plus the library-visibility join (a hidden library
|
||||||
|
-- hides its feeds too), and to admins. WRITE policies are permissive
|
||||||
|
-- stand-ins for the same reason as media_items above: mandatory at
|
||||||
|
-- ENABLE time, and the fetcher/worker (first-admin identity) plus the
|
||||||
|
-- add-feed upsert (ON CONFLICT DO UPDATE needs no separate SELECT)
|
||||||
|
-- cover every legitimate writer.
|
||||||
|
DROP POLICY IF EXISTS p_podcast_feeds_visible ON podcast_feeds;
|
||||||
|
DROP POLICY IF EXISTS p_podcast_feeds_insert ON podcast_feeds;
|
||||||
|
DROP POLICY IF EXISTS p_podcast_feeds_update ON podcast_feeds;
|
||||||
|
DROP POLICY IF EXISTS p_podcast_feeds_delete ON podcast_feeds;
|
||||||
|
|
||||||
|
ALTER TABLE podcast_feeds ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE podcast_feeds FORCE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY p_podcast_feeds_visible ON podcast_feeds
|
||||||
|
FOR SELECT
|
||||||
|
USING (
|
||||||
|
current_setting('app.user_id') IS NOT NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM library_visibility lv
|
||||||
|
WHERE lv.user_id = current_setting('app.user_id')::uuid
|
||||||
|
AND lv.library_id = podcast_feeds.library_id
|
||||||
|
AND lv.is_visible = false
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM podcast_subscriptions s
|
||||||
|
WHERE s.user_id = current_setting('app.user_id')::uuid
|
||||||
|
AND s.feed_id = podcast_feeds.id
|
||||||
|
)
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM users u
|
||||||
|
WHERE u.id = current_setting('app.user_id')::uuid
|
||||||
|
AND u.role = 'admin'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY p_podcast_feeds_insert ON podcast_feeds
|
||||||
|
FOR INSERT WITH CHECK (true);
|
||||||
|
CREATE POLICY p_podcast_feeds_update ON podcast_feeds
|
||||||
|
FOR UPDATE USING (true) WITH CHECK (true);
|
||||||
|
CREATE POLICY p_podcast_feeds_delete ON podcast_feeds
|
||||||
|
FOR DELETE USING (true);
|
||||||
|
|
||||||
|
-- podcast_subscriptions: UNGOVERNED, deliberately — it IS the read
|
||||||
|
-- policy's lookup table, and a user must always see their own
|
||||||
|
-- subscription rows (subscribe -> visibility is a single INSERT).
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
@@ -12,6 +12,8 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: ./Dockerfile
|
dockerfile: ./Dockerfile
|
||||||
|
args:
|
||||||
|
GIT_COMMIT: ${GIT_COMMIT:-none}
|
||||||
|
|
||||||
# Integration Tests - runs against containerized app and db (dev only)
|
# Integration Tests - runs against containerized app and db (dev only)
|
||||||
tests:
|
tests:
|
||||||
|
|||||||
@@ -443,3 +443,19 @@ Both answers are gated: 403 (Go-level explicit-library checks remain on
|
|||||||
listing endpoints) and 404 (row-level invisibility — no existence leak).
|
listing endpoints) and 404 (row-level invisibility — no existence leak).
|
||||||
Pins asserting 403 on item-scoped content endpoints widen to accept
|
Pins asserting 403 on item-scoped content endpoints widen to accept
|
||||||
{403, 404}. Listing/scoped endpoints keep the strict 403 contract.
|
{403, 404}. Listing/scoped endpoints keep the strict 403 contract.
|
||||||
|
|
||||||
|
## 2026-10-29 — Podcast episode columns join media_items (additive widening)
|
||||||
|
|
||||||
|
The podcast integration (plan podcast-integration.md tasks 1.1/1.10) adds
|
||||||
|
fifteen nullable/no-null-default columns to `media_items` — they surface in
|
||||||
|
raw sqlc rows ONLY (`library-scoped list emits raw sqlc rows`), never in the
|
||||||
|
resolved unscoped map:
|
||||||
|
|
||||||
|
`source` ('scan' default), `source_feed_id`, `episode_guid`, `feed_position`,
|
||||||
|
`season_number`, `episode_number`, `enclosure_url`,
|
||||||
|
`declared_duration_seconds`, `transcript_url`, `chapters_url`, `hosts`,
|
||||||
|
`guests`, `download_state`, `download_error`, `download_attempts`.
|
||||||
|
|
||||||
|
Scan-sourced rows carry the inert defaults ('scan' / NULLs / 'pending');
|
||||||
|
raw-row consumers that filter `source='scan'` (scanner paths) are unaffected.
|
||||||
|
Nothing removed, nothing renamed.
|
||||||
@@ -150,6 +150,7 @@ type Libraries struct {
|
|||||||
CreatedByAdminID pgtype.UUID `db:"created_by_admin_id" json:"created_by_admin_id"`
|
CreatedByAdminID pgtype.UUID `db:"created_by_admin_id" json:"created_by_admin_id"`
|
||||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||||
|
AllowMemberFeeds bool `db:"allow_member_feeds" json:"allow_member_feeds"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type LibraryFolders struct {
|
type LibraryFolders struct {
|
||||||
@@ -157,6 +158,7 @@ type LibraryFolders struct {
|
|||||||
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||||
FolderPath string `db:"folder_path" json:"folder_path"`
|
FolderPath string `db:"folder_path" json:"folder_path"`
|
||||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||||
|
Purpose string `db:"purpose" json:"purpose"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type LibraryTypes struct {
|
type LibraryTypes struct {
|
||||||
@@ -338,6 +340,21 @@ type MediaItems struct {
|
|||||||
OpfIdentifier pgtype.Text `db:"opf_identifier" json:"opf_identifier"`
|
OpfIdentifier pgtype.Text `db:"opf_identifier" json:"opf_identifier"`
|
||||||
OpfUuid pgtype.Text `db:"opf_uuid" json:"opf_uuid"`
|
OpfUuid pgtype.Text `db:"opf_uuid" json:"opf_uuid"`
|
||||||
HashConfidence pgtype.Text `db:"hash_confidence" json:"hash_confidence"`
|
HashConfidence pgtype.Text `db:"hash_confidence" json:"hash_confidence"`
|
||||||
|
Source string `db:"source" json:"source"`
|
||||||
|
SourceFeedID pgtype.UUID `db:"source_feed_id" json:"source_feed_id"`
|
||||||
|
EpisodeGuid pgtype.Text `db:"episode_guid" json:"episode_guid"`
|
||||||
|
FeedPosition pgtype.Int4 `db:"feed_position" json:"feed_position"`
|
||||||
|
SeasonNumber pgtype.Int4 `db:"season_number" json:"season_number"`
|
||||||
|
EpisodeNumber pgtype.Int4 `db:"episode_number" json:"episode_number"`
|
||||||
|
EnclosureUrl pgtype.Text `db:"enclosure_url" json:"enclosure_url"`
|
||||||
|
DeclaredDurationSeconds pgtype.Int4 `db:"declared_duration_seconds" json:"declared_duration_seconds"`
|
||||||
|
TranscriptUrl pgtype.Text `db:"transcript_url" json:"transcript_url"`
|
||||||
|
ChaptersUrl pgtype.Text `db:"chapters_url" json:"chapters_url"`
|
||||||
|
Hosts []string `db:"hosts" json:"hosts"`
|
||||||
|
Guests []string `db:"guests" json:"guests"`
|
||||||
|
DownloadState string `db:"download_state" json:"download_state"`
|
||||||
|
DownloadError pgtype.Text `db:"download_error" json:"download_error"`
|
||||||
|
DownloadAttempts int32 `db:"download_attempts" json:"download_attempts"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MediaNotes struct {
|
type MediaNotes struct {
|
||||||
@@ -405,6 +422,43 @@ type PanelData struct {
|
|||||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PodcastFeeds struct {
|
||||||
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||||
|
FeedUrl string `db:"feed_url" json:"feed_url"`
|
||||||
|
PodcastGuid pgtype.Text `db:"podcast_guid" json:"podcast_guid"`
|
||||||
|
Title string `db:"title" json:"title"`
|
||||||
|
Description pgtype.Text `db:"description" json:"description"`
|
||||||
|
Host pgtype.Text `db:"host" json:"host"`
|
||||||
|
Publisher pgtype.Text `db:"publisher" json:"publisher"`
|
||||||
|
OwnerName pgtype.Text `db:"owner_name" json:"owner_name"`
|
||||||
|
OwnerEmail pgtype.Text `db:"owner_email" json:"owner_email"`
|
||||||
|
Language pgtype.Text `db:"language" json:"language"`
|
||||||
|
Tags []string `db:"tags" json:"tags"`
|
||||||
|
Explicit bool `db:"explicit" json:"explicit"`
|
||||||
|
ArtworkPath pgtype.Text `db:"artwork_path" json:"artwork_path"`
|
||||||
|
FeedType pgtype.Text `db:"feed_type" json:"feed_type"`
|
||||||
|
Medium pgtype.Text `db:"medium" json:"medium"`
|
||||||
|
IsComplete bool `db:"is_complete" json:"is_complete"`
|
||||||
|
Etag pgtype.Text `db:"etag" json:"etag"`
|
||||||
|
LastModified pgtype.Text `db:"last_modified" json:"last_modified"`
|
||||||
|
LastFetchedAt pgtype.Timestamptz `db:"last_fetched_at" json:"last_fetched_at"`
|
||||||
|
LastBuildDate pgtype.Timestamptz `db:"last_build_date" json:"last_build_date"`
|
||||||
|
CreatedByAdminID pgtype.UUID `db:"created_by_admin_id" json:"created_by_admin_id"`
|
||||||
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PodcastSubscriptions struct {
|
||||||
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||||
|
FeedID pgtype.UUID `db:"feed_id" json:"feed_id"`
|
||||||
|
DisplayName pgtype.Text `db:"display_name" json:"display_name"`
|
||||||
|
AutoDownload string `db:"auto_download" json:"auto_download"`
|
||||||
|
AutoDownloadCount pgtype.Int4 `db:"auto_download_count" json:"auto_download_count"`
|
||||||
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
// Tracks media that failed processing in a library: item-backed issues (library-type mismatches, metadata extraction failures) and row-less import failures (no media_items row exists; keyed by library + file_path). Auto-resolved by later successful scans.
|
// Tracks media that failed processing in a library: item-backed issues (library-type mismatches, metadata extraction failures) and row-less import failures (no media_items row exists; keyed by library + file_path). Auto-resolved by later successful scans.
|
||||||
type ProcessingIssues struct {
|
type ProcessingIssues struct {
|
||||||
ID pgtype.UUID `db:"id" json:"id"`
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ type Querier interface {
|
|||||||
// ============================================
|
// ============================================
|
||||||
AddBookToKoboShelf(ctx context.Context, arg AddBookToKoboShelfParams) (KoboShelves, error)
|
AddBookToKoboShelf(ctx context.Context, arg AddBookToKoboShelfParams) (KoboShelves, error)
|
||||||
// Library Folders queries
|
// Library Folders queries
|
||||||
|
// purpose: 'scan' (FS-walker root, default) | 'podcast' (download root;
|
||||||
|
// scanner-skipped, written by the podcast download worker instead).
|
||||||
AddLibraryFolder(ctx context.Context, arg AddLibraryFolderParams) (LibraryFolders, error)
|
AddLibraryFolder(ctx context.Context, arg AddLibraryFolderParams) (LibraryFolders, error)
|
||||||
// Second consecutive missing scan: hide the item from all browsing while
|
// Second consecutive missing scan: hide the item from all browsing while
|
||||||
// preserving reading history in case the file returns.
|
// preserving reading history in case the file returns.
|
||||||
@@ -53,11 +55,19 @@ type Querier interface {
|
|||||||
// collections page); same live filter as GetCollectionItems, plus the
|
// collections page); same live filter as GetCollectionItems, plus the
|
||||||
// future-hybrid excluded exclusion and an optional library scope.
|
// future-hybrid excluded exclusion and an optional library scope.
|
||||||
CountCollectionLiveItems(ctx context.Context, arg CountCollectionLiveItemsParams) (int32, error)
|
CountCollectionLiveItems(ctx context.Context, arg CountCollectionLiveItemsParams) (int32, error)
|
||||||
|
CountDownloadedEpisodes(ctx context.Context, sourceFeedID pgtype.UUID) (int64, error)
|
||||||
// Sibling-book count for sidecar naming (MetadataWriter): a dir-scoped
|
// Sibling-book count for sidecar naming (MetadataWriter): a dir-scoped
|
||||||
// metadata.opf may only be written when NO other non-archived item
|
// metadata.opf may only be written when NO other non-archived item
|
||||||
// shares the directory; otherwise the sidecar goes to
|
// shares the directory; otherwise the sidecar goes to
|
||||||
// <basename>.opf so one book's metadata can never poison dir-mates.
|
// <basename>.opf so one book's metadata can never poison dir-mates.
|
||||||
CountOtherMediaItemsInDir(ctx context.Context, arg CountOtherMediaItemsInDirParams) (int32, error)
|
CountOtherMediaItemsInDir(ctx context.Context, arg CountOtherMediaItemsInDirParams) (int32, error)
|
||||||
|
CountPodcastEpisodesForFeed(ctx context.Context, sourceFeedID pgtype.UUID) (int64, error)
|
||||||
|
CountPodcastFeedSubscribers(ctx context.Context, feedID pgtype.UUID) (int64, error)
|
||||||
|
// Sidebar/page gate (D13): >=1 podcasts-type library the user can see
|
||||||
|
// (COALESCE(is_visible,true) semantics — mirrors GetUserVisibleLibraries).
|
||||||
|
CountPodcastLibrariesForUser(ctx context.Context, userID pgtype.UUID) (int64, error)
|
||||||
|
CountQueuedEpisodes(ctx context.Context) (int64, error)
|
||||||
|
CountUndownloadedEpisodes(ctx context.Context, sourceFeedID pgtype.UUID) (int64, error)
|
||||||
// Count unlinked books for a device (ignored rows stay hidden)
|
// Count unlinked books for a device (ignored rows stay hidden)
|
||||||
CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error)
|
CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error)
|
||||||
// Banner count: unresolved, non-ignored queue rows across the caller's own
|
// Banner count: unresolved, non-ignored queue rows across the caller's own
|
||||||
@@ -116,6 +126,22 @@ type Querier interface {
|
|||||||
// ============================================
|
// ============================================
|
||||||
CreateOrUpdateKoboEntitlement(ctx context.Context, arg CreateOrUpdateKoboEntitlementParams) (KoboEntitlements, error)
|
CreateOrUpdateKoboEntitlement(ctx context.Context, arg CreateOrUpdateKoboEntitlementParams) (KoboEntitlements, error)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
// PODCASTS / RSS FEEDS (plan ~/.opencode/plan/podcast-integration.md §6/§8)
|
||||||
|
// Visibility is RLS-enforced (schema policy block) — NO query here carries
|
||||||
|
// visibility clauses. Feed reads run under the acting user's identity
|
||||||
|
// (subscriber-or-admin policy); the fetcher/worker run as first-admin.
|
||||||
|
// The fetcher/download worker is the SOLE writer of feed/episode metadata;
|
||||||
|
// users write only podcast_subscriptions rows.
|
||||||
|
// ============================================================================
|
||||||
|
// Add-feed upsert keyed on the per-library UNIQUE(library_id, feed_url):
|
||||||
|
// a second subscriber adding the same URL attaches to the existing row
|
||||||
|
// instead of erroring. DO UPDATE (not DO NOTHING) so the RETURNING works
|
||||||
|
// even when the conflicting row is invisible to the actor under RLS
|
||||||
|
// (non-admin creators cannot SELECT other users' feeds). Metadata columns
|
||||||
|
// are filled by the fetcher right after; this only establishes identity.
|
||||||
|
CreatePodcastFeed(ctx context.Context, arg CreatePodcastFeedParams) (PodcastFeeds, error)
|
||||||
|
CreatePodcastLibraryFolder(ctx context.Context, arg CreatePodcastLibraryFolderParams) (LibraryFolders, error)
|
||||||
|
// ============================================================================
|
||||||
// PROCESSING ISSUES QUERIES
|
// PROCESSING ISSUES QUERIES
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Widened upsert (faceted plan Phase 11): media_item_id and file_path
|
// Widened upsert (faceted plan Phase 11): media_item_id and file_path
|
||||||
@@ -160,6 +186,11 @@ type Querier interface {
|
|||||||
DeleteMediaItemFormat(ctx context.Context, id pgtype.UUID) error
|
DeleteMediaItemFormat(ctx context.Context, id pgtype.UUID) error
|
||||||
DeleteMediaRating(ctx context.Context, arg DeleteMediaRatingParams) error
|
DeleteMediaRating(ctx context.Context, arg DeleteMediaRatingParams) error
|
||||||
DeleteMediaReview(ctx context.Context, arg DeleteMediaReviewParams) error
|
DeleteMediaReview(ctx context.Context, arg DeleteMediaReviewParams) error
|
||||||
|
// Admin-only removal (handler checks role); episodes cascade via
|
||||||
|
// media_items.source_feed_id ON DELETE CASCADE.
|
||||||
|
DeletePodcastFeed(ctx context.Context, id pgtype.UUID) (int64, error)
|
||||||
|
// Unsubscribe NEVER deletes feed/episode/file state (D6).
|
||||||
|
DeletePodcastSubscription(ctx context.Context, arg DeletePodcastSubscriptionParams) (int64, error)
|
||||||
DeleteProcessingIssue(ctx context.Context, id pgtype.UUID) (ProcessingIssues, error)
|
DeleteProcessingIssue(ctx context.Context, id pgtype.UUID) (ProcessingIssues, error)
|
||||||
DeleteReadingProgress(ctx context.Context, arg DeleteReadingProgressParams) error
|
DeleteReadingProgress(ctx context.Context, arg DeleteReadingProgressParams) error
|
||||||
DeleteSavedFilter(ctx context.Context, arg DeleteSavedFilterParams) (SavedFilters, error)
|
DeleteSavedFilter(ctx context.Context, arg DeleteSavedFilterParams) (SavedFilters, error)
|
||||||
@@ -170,6 +201,7 @@ type Querier interface {
|
|||||||
// Delete unlinked book
|
// Delete unlinked book
|
||||||
DeleteUnlinkedBook(ctx context.Context, id pgtype.UUID) error
|
DeleteUnlinkedBook(ctx context.Context, id pgtype.UUID) error
|
||||||
DeleteUser(ctx context.Context, id pgtype.UUID) error
|
DeleteUser(ctx context.Context, id pgtype.UUID) error
|
||||||
|
DeleteUserSettingByKey(ctx context.Context, arg DeleteUserSettingByKeyParams) error
|
||||||
DeleteUserSystemCollection(ctx context.Context, arg DeleteUserSystemCollectionParams) error
|
DeleteUserSystemCollection(ctx context.Context, arg DeleteUserSystemCollectionParams) error
|
||||||
// Device deletion (deregister on logout): detach the device's reading
|
// Device deletion (deregister on logout): detach the device's reading
|
||||||
// history instead of deleting it — these rows are real captured stats
|
// history instead of deleting it — these rows are real captured stats
|
||||||
@@ -414,6 +446,8 @@ type Querier interface {
|
|||||||
// GetMediaScanState reads one item's manifest (per-item rescan paths
|
// GetMediaScanState reads one item's manifest (per-item rescan paths
|
||||||
// and the manifest integration tests).
|
// and the manifest integration tests).
|
||||||
GetMediaScanState(ctx context.Context, mediaItemID pgtype.UUID) (MediaScanState, error)
|
GetMediaScanState(ctx context.Context, mediaItemID pgtype.UUID) (MediaScanState, error)
|
||||||
|
// Sequential download worker's claim source (single worker, day one).
|
||||||
|
GetNextQueuedEpisode(ctx context.Context) (MediaItems, error)
|
||||||
GetNextRetryTime(ctx context.Context) (interface{}, error)
|
GetNextRetryTime(ctx context.Context) (interface{}, error)
|
||||||
GetNotStartedItems(ctx context.Context, arg GetNotStartedItemsParams) ([]MediaItems, error)
|
GetNotStartedItems(ctx context.Context, arg GetNotStartedItemsParams) ([]MediaItems, error)
|
||||||
// Get OPDS token
|
// Get OPDS token
|
||||||
@@ -421,6 +455,25 @@ type Querier interface {
|
|||||||
// Get OPDS tokens by device
|
// Get OPDS tokens by device
|
||||||
GetOpdsTokensByDevice(ctx context.Context, deviceID pgtype.UUID) ([]OpdsTokens, error)
|
GetOpdsTokensByDevice(ctx context.Context, deviceID pgtype.UUID) ([]OpdsTokens, error)
|
||||||
GetPanelData(ctx context.Context, arg GetPanelDataParams) (PanelData, error)
|
GetPanelData(ctx context.Context, arg GetPanelDataParams) (PanelData, error)
|
||||||
|
// Continue aggregation (plan 2.5): the caller's IN-PROGRESS episodes
|
||||||
|
// (0 < pct < finishedThreshold) across ALL subscribed feeds, most
|
||||||
|
// recent first. Go groups per feed — the first row per feed IS the
|
||||||
|
// continue target.
|
||||||
|
GetPodcastContinueForUser(ctx context.Context, userID pgtype.UUID) ([]GetPodcastContinueForUserRow, error)
|
||||||
|
GetPodcastEpisode(ctx context.Context, id pgtype.UUID) (MediaItems, error)
|
||||||
|
// Per-user progress for a feed's episodes (row badges + the Phase-2
|
||||||
|
// continue fields). reading_progress is UNIQUE per (media_item, user).
|
||||||
|
GetPodcastEpisodeProgress(ctx context.Context, arg GetPodcastEpisodeProgressParams) ([]GetPodcastEpisodeProgressRow, error)
|
||||||
|
GetPodcastFeed(ctx context.Context, id pgtype.UUID) (PodcastFeeds, error)
|
||||||
|
// URL-migration dedupe: same canonical guid within the library reuses
|
||||||
|
// the existing row.
|
||||||
|
GetPodcastFeedByGuid(ctx context.Context, arg GetPodcastFeedByGuidParams) (PodcastFeeds, error)
|
||||||
|
// The download root (purpose='podcast') of a podcasts library.
|
||||||
|
GetPodcastLibraryFolder(ctx context.Context, libraryID pgtype.UUID) ([]LibraryFolders, error)
|
||||||
|
GetPodcastSubscription(ctx context.Context, arg GetPodcastSubscriptionParams) (PodcastSubscriptions, error)
|
||||||
|
// The podcasts library/ies visible to the user (add-feed target list;
|
||||||
|
// a self-hosted instance typically has exactly one).
|
||||||
|
GetPodcastsLibraryForUser(ctx context.Context, userID pgtype.UUID) ([]GetPodcastsLibraryForUserRow, error)
|
||||||
// Get most popular books for a user
|
// Get most popular books for a user
|
||||||
GetPopularBooks(ctx context.Context, arg GetPopularBooksParams) ([]GetPopularBooksRow, error)
|
GetPopularBooks(ctx context.Context, arg GetPopularBooksParams) ([]GetPopularBooksRow, error)
|
||||||
// Single-issue read for the resolve/delete endpoints' family-lens gate.
|
// Single-issue read for the resolve/delete endpoints' family-lens gate.
|
||||||
@@ -521,6 +574,12 @@ type Querier interface {
|
|||||||
// END instant (the push time; NULL falls back to created_at) — the same
|
// END instant (the push time; NULL falls back to created_at) — the same
|
||||||
// value the capture stamps every write with.
|
// value the capture stamps every write with.
|
||||||
GetUserReadingSessionsRange(ctx context.Context, arg GetUserReadingSessionsRangeParams) ([]GetUserReadingSessionsRangeRow, error)
|
GetUserReadingSessionsRange(ctx context.Context, arg GetUserReadingSessionsRangeParams) ([]GetUserReadingSessionsRangeRow, error)
|
||||||
|
// ============================================================================
|
||||||
|
// GENERIC reader_settings KV (podcast order override, plan D5: key
|
||||||
|
// podcast_order:<feed_id>). The pre-existing reader_settings queries are
|
||||||
|
// hard-keyed to 'reader_settings'; these two are the generic form.
|
||||||
|
// ============================================================================
|
||||||
|
GetUserSettingByKey(ctx context.Context, arg GetUserSettingByKeyParams) ([]byte, error)
|
||||||
GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]GetUserVisibleLibrariesRow, error)
|
GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]GetUserVisibleLibrariesRow, error)
|
||||||
GetVisibleLibraryMediaCounts(ctx context.Context, userID pgtype.UUID) ([]GetVisibleLibraryMediaCountsRow, error)
|
GetVisibleLibraryMediaCounts(ctx context.Context, userID pgtype.UUID) ([]GetVisibleLibraryMediaCountsRow, error)
|
||||||
// Smart ("dynamic") collections: rules are evaluated LIVE at render, so
|
// Smart ("dynamic") collections: rules are evaluated LIVE at render, so
|
||||||
@@ -552,6 +611,9 @@ type Querier interface {
|
|||||||
ListDeletedAnnotationsForBook(ctx context.Context, arg ListDeletedAnnotationsForBookParams) ([]ListDeletedAnnotationsForBookRow, error)
|
ListDeletedAnnotationsForBook(ctx context.Context, arg ListDeletedAnnotationsForBookParams) ([]ListDeletedAnnotationsForBookRow, error)
|
||||||
ListDevicesByType(ctx context.Context, deviceType string) ([]Devices, error)
|
ListDevicesByType(ctx context.Context, deviceType string) ([]Devices, error)
|
||||||
ListDevicesByUser(ctx context.Context, userID pgtype.UUID) ([]Devices, error)
|
ListDevicesByUser(ctx context.Context, userID pgtype.UUID) ([]Devices, error)
|
||||||
|
// Download-worker policy union (D6): most aggressive policy wins for
|
||||||
|
// what arrives. Runs under an admin identity (background job).
|
||||||
|
ListFeedSubscribers(ctx context.Context, feedID pgtype.UUID) ([]ListFeedSubscribersRow, error)
|
||||||
// Items hidden from libraries: either missing from disk (waiting on the
|
// Items hidden from libraries: either missing from disk (waiting on the
|
||||||
// second scan that archives them) or already archived and pending purge.
|
// second scan that archives them) or already archived and pending purge.
|
||||||
// Feeds the admin archived-items page. match_* columns identify an active
|
// Feeds the admin archived-items page. match_* columns identify an active
|
||||||
@@ -563,6 +625,9 @@ type Querier interface {
|
|||||||
// collapsible "Ignored" section so a misclick is recoverable.
|
// collapsible "Ignored" section so a misclick is recoverable.
|
||||||
ListIgnoredUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) ([]ListIgnoredUnlinkedBooksForUserRow, error)
|
ListIgnoredUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) ([]ListIgnoredUnlinkedBooksForUserRow, error)
|
||||||
ListLibraries(ctx context.Context) ([]ListLibrariesRow, error)
|
ListLibraries(ctx context.Context) ([]ListLibrariesRow, error)
|
||||||
|
// Podcasts exclusion (plan §9): generic book surfaces never list
|
||||||
|
// podcast episodes (library_type_name is the denormalized, trigger/
|
||||||
|
// fetcher-maintained discriminator; NULL rows predate it and stay).
|
||||||
ListMediaItems(ctx context.Context, arg ListMediaItemsParams) ([]ListMediaItemsRow, error)
|
ListMediaItems(ctx context.Context, arg ListMediaItemsParams) ([]ListMediaItemsRow, error)
|
||||||
ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryRow, error)
|
ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryRow, error)
|
||||||
ListMediaItemsByLibraryIncludingArchived(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryIncludingArchivedRow, error)
|
ListMediaItemsByLibraryIncludingArchived(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryIncludingArchivedRow, error)
|
||||||
@@ -571,11 +636,21 @@ type Querier interface {
|
|||||||
// List media items that have no stored SHA-256 (imported before hashing existed)
|
// List media items that have no stored SHA-256 (imported before hashing existed)
|
||||||
ListMediaItemsMissingHash(ctx context.Context) ([]MediaItems, error)
|
ListMediaItemsMissingHash(ctx context.Context) ([]MediaItems, error)
|
||||||
ListMediaItemsSorted(ctx context.Context, arg ListMediaItemsSortedParams) ([]ListMediaItemsSortedRow, error)
|
ListMediaItemsSorted(ctx context.Context, arg ListMediaItemsSortedParams) ([]ListMediaItemsSortedRow, error)
|
||||||
|
// The podcasts page: the acting user's subscriptions with feed metadata
|
||||||
|
// and the user-owned subscription fields. Facet values derive in Go.
|
||||||
|
ListMyPodcastFeeds(ctx context.Context, userID pgtype.UUID) ([]ListMyPodcastFeedsRow, error)
|
||||||
ListPendingHashConflicts(ctx context.Context) ([]ListPendingHashConflictsRow, error)
|
ListPendingHashConflicts(ctx context.Context) ([]ListPendingHashConflictsRow, error)
|
||||||
// DeviceID NULL = every device's pending items (the queue processor's
|
// DeviceID NULL = every device's pending items (the queue processor's
|
||||||
// enumerator passes NULL; a bare `device_id = NULL` never matches, which
|
// enumerator passes NULL; a bare `device_id = NULL` never matches, which
|
||||||
// silently disabled the offline flush until 2026-10-17).
|
// silently disabled the offline flush until 2026-10-17).
|
||||||
ListPendingSyncQueueItems(ctx context.Context, arg ListPendingSyncQueueItemsParams) ([]SyncQueue, error)
|
ListPendingSyncQueueItems(ctx context.Context, arg ListPendingSyncQueueItemsParams) ([]SyncQueue, error)
|
||||||
|
// Feed detail / sequence source. RLS scopes to subscriber-or-admin;
|
||||||
|
// ordering: native position, then pub date, then guid (stable across
|
||||||
|
// re-fetches for historical rows whose position went stale).
|
||||||
|
ListPodcastEpisodes(ctx context.Context, sourceFeedID pgtype.UUID) ([]MediaItems, error)
|
||||||
|
// Poller worklist (admin identity): every feed with its validators and
|
||||||
|
// the back-off flag (is_complete feeds poll rarely, never disappear).
|
||||||
|
ListPodcastFeedsForPolling(ctx context.Context) ([]PodcastFeeds, error)
|
||||||
// LEFT JOIN (Phase 11): row-less import failures have NO media_items
|
// LEFT JOIN (Phase 11): row-less import failures have NO media_items
|
||||||
// row — an inner join would silently drop the ledger's newest kind.
|
// row — an inner join would silently drop the ledger's newest kind.
|
||||||
// Item-side projections COALESCE to keep them non-nullable strings;
|
// Item-side projections COALESCE to keep them non-nullable strings;
|
||||||
@@ -583,6 +658,8 @@ type Querier interface {
|
|||||||
ListProcessingIssuesByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListProcessingIssuesByLibraryRow, error)
|
ListProcessingIssuesByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListProcessingIssuesByLibraryRow, error)
|
||||||
ListSyncConflictsByMediaItem(ctx context.Context, arg ListSyncConflictsByMediaItemParams) ([]SyncConflicts, error)
|
ListSyncConflictsByMediaItem(ctx context.Context, arg ListSyncConflictsByMediaItemParams) ([]SyncConflicts, error)
|
||||||
ListSyncConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListSyncConflictsByUserRow, error)
|
ListSyncConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListSyncConflictsByUserRow, error)
|
||||||
|
// Download All + policy union worklist.
|
||||||
|
ListUndownloadedEpisodes(ctx context.Context, sourceFeedID pgtype.UUID) ([]MediaItems, error)
|
||||||
// List unresolved unlinked books with pagination
|
// List unresolved unlinked books with pagination
|
||||||
ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnresolvedUnlinkedBooksParams) ([]ListUnresolvedUnlinkedBooksRow, error)
|
ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnresolvedUnlinkedBooksParams) ([]ListUnresolvedUnlinkedBooksRow, error)
|
||||||
// Page list: unresolved queue rows for the caller's own devices (ignored
|
// Page list: unresolved queue rows for the caller's own devices (ignored
|
||||||
@@ -591,6 +668,14 @@ type Querier interface {
|
|||||||
ListUsers(ctx context.Context) ([]ListUsersRow, error)
|
ListUsers(ctx context.Context) ([]ListUsersRow, error)
|
||||||
// First consecutive scan that cannot find the file on disk.
|
// First consecutive scan that cannot find the file on disk.
|
||||||
MarkMediaItemMissing(ctx context.Context, id pgtype.UUID) error
|
MarkMediaItemMissing(ctx context.Context, id pgtype.UUID) error
|
||||||
|
// SUCCESS-only write (invariant 7): the file is on disk and probed.
|
||||||
|
// ffprobe facts (real duration via audio_metadata, precise size/mime)
|
||||||
|
// reconcile the feed's declared values.
|
||||||
|
MarkPodcastEpisodeDownloaded(ctx context.Context, arg MarkPodcastEpisodeDownloadedParams) (MediaItems, error)
|
||||||
|
// Failure/rejection write: state + reason + whether a human already
|
||||||
|
// intervened (manual_retry resets the auto-retry budget). Existing
|
||||||
|
// metadata/file facts are never cleared.
|
||||||
|
MarkPodcastEpisodeFailed(ctx context.Context, arg MarkPodcastEpisodeFailedParams) error
|
||||||
// Content reappeared at a new location while the old path vanished: treat as
|
// Content reappeared at a new location while the old path vanished: treat as
|
||||||
// a move. Repoints the row (and refreshes file-level fields) so reading
|
// a move. Repoints the row (and refreshes file-level fields) so reading
|
||||||
// history follows the book; archive/missing state is cleared separately.
|
// history follows the book; archive/missing state is cleared separately.
|
||||||
@@ -654,14 +739,30 @@ type Querier interface {
|
|||||||
// Search Media Items queries
|
// Search Media Items queries
|
||||||
SearchMediaItems(ctx context.Context, arg SearchMediaItemsParams) ([]SearchMediaItemsRow, error)
|
SearchMediaItems(ctx context.Context, arg SearchMediaItemsParams) ([]SearchMediaItemsRow, error)
|
||||||
SearchMediaItemsUnified(ctx context.Context, arg SearchMediaItemsUnifiedParams) ([]SearchMediaItemsUnifiedRow, error)
|
SearchMediaItemsUnified(ctx context.Context, arg SearchMediaItemsUnifiedParams) ([]SearchMediaItemsUnifiedRow, error)
|
||||||
|
// Subscription-scoped episode search (D14): title + description cover
|
||||||
|
// un-downloaded AND historical rows (fetcher persists metadata-only).
|
||||||
|
// RLS already restricts to the caller's subscriptions — the explicit
|
||||||
|
// source filter keeps scan items out of podcast search.
|
||||||
|
SearchMyPodcastEpisodes(ctx context.Context, searchQuery string) ([]MediaItems, error)
|
||||||
|
// Feed-title AND subscription-display-name search (D14) + the facet
|
||||||
|
// fields the podcasts page filters on.
|
||||||
|
SearchMyPodcastFeeds(ctx context.Context, arg SearchMyPodcastFeedsParams) ([]SearchMyPodcastFeedsRow, error)
|
||||||
SearchNarratorValues(ctx context.Context, arg SearchNarratorValuesParams) ([]SearchNarratorValuesRow, error)
|
SearchNarratorValues(ctx context.Context, arg SearchNarratorValuesParams) ([]SearchNarratorValuesRow, error)
|
||||||
SearchSeriesValues(ctx context.Context, arg SearchSeriesValuesParams) ([]SearchSeriesValuesRow, error)
|
SearchSeriesValues(ctx context.Context, arg SearchSeriesValuesParams) ([]SearchSeriesValuesRow, error)
|
||||||
SearchTagsValues(ctx context.Context, arg SearchTagsValuesParams) ([]SearchTagsValuesRow, error)
|
SearchTagsValues(ctx context.Context, arg SearchTagsValuesParams) ([]SearchTagsValuesRow, error)
|
||||||
SetDeviceSyncEnabled(ctx context.Context, arg SetDeviceSyncEnabledParams) (Devices, error)
|
SetDeviceSyncEnabled(ctx context.Context, arg SetDeviceSyncEnabledParams) (Devices, error)
|
||||||
// Library Visibility queries
|
// Library Visibility queries
|
||||||
SetLibraryVisibility(ctx context.Context, arg SetLibraryVisibilityParams) (LibraryVisibility, error)
|
SetLibraryVisibility(ctx context.Context, arg SetLibraryVisibilityParams) (LibraryVisibility, error)
|
||||||
|
// Worker claim: pending|failed -> queued. Attempts incremented at
|
||||||
|
// claim time so a crashed worker still counts the try.
|
||||||
|
SetPodcastEpisodeDownloadQueued(ctx context.Context, id pgtype.UUID) (int64, error)
|
||||||
|
SetPodcastEpisodeDownloading(ctx context.Context, id pgtype.UUID) error
|
||||||
|
// Manual retry (same endpoint as download): reset to pending so the
|
||||||
|
// worker re-claims it; the reason text clears with the fresh attempt.
|
||||||
|
SetPodcastEpisodePending(ctx context.Context, id pgtype.UUID) error
|
||||||
// Set system config
|
// Set system config
|
||||||
SetSystemConfig(ctx context.Context, arg SetSystemConfigParams) (SystemConfig, error)
|
SetSystemConfig(ctx context.Context, arg SetSystemConfigParams) (SystemConfig, error)
|
||||||
|
SubscribePodcastFeed(ctx context.Context, arg SubscribePodcastFeedParams) (PodcastSubscriptions, error)
|
||||||
SyncLibraryTypeExtensions(ctx context.Context, arg SyncLibraryTypeExtensionsParams) error
|
SyncLibraryTypeExtensions(ctx context.Context, arg SyncLibraryTypeExtensionsParams) error
|
||||||
TombstoneMediaBookmarkByDedupKey(ctx context.Context, arg TombstoneMediaBookmarkByDedupKeyParams) error
|
TombstoneMediaBookmarkByDedupKey(ctx context.Context, arg TombstoneMediaBookmarkByDedupKeyParams) error
|
||||||
TombstoneMediaBookmarkByID(ctx context.Context, arg TombstoneMediaBookmarkByIDParams) error
|
TombstoneMediaBookmarkByID(ctx context.Context, arg TombstoneMediaBookmarkByIDParams) error
|
||||||
@@ -726,6 +827,20 @@ type Querier interface {
|
|||||||
UpdateMediaNoteForSync(ctx context.Context, arg UpdateMediaNoteForSyncParams) (MediaNotes, error)
|
UpdateMediaNoteForSync(ctx context.Context, arg UpdateMediaNoteForSyncParams) (MediaNotes, error)
|
||||||
UpdateMediaRating(ctx context.Context, arg UpdateMediaRatingParams) (MediaRatings, error)
|
UpdateMediaRating(ctx context.Context, arg UpdateMediaRatingParams) (MediaRatings, error)
|
||||||
UpdatePassword(ctx context.Context, arg UpdatePasswordParams) error
|
UpdatePassword(ctx context.Context, arg UpdatePasswordParams) error
|
||||||
|
UpdatePodcastFeedArtwork(ctx context.Context, arg UpdatePodcastFeedArtworkParams) error
|
||||||
|
// Post-fetch bookkeeping: validators for conditional GETs, the fetch
|
||||||
|
// timestamp, and the feed's own last-build marker.
|
||||||
|
UpdatePodcastFeedFetch(ctx context.Context, arg UpdatePodcastFeedFetchParams) (PodcastFeeds, error)
|
||||||
|
// Fetcher-only full metadata refresh (parse result). podcast_guid is
|
||||||
|
// set once a feed declares it; new_feed_url handling is a dedicated
|
||||||
|
// query below.
|
||||||
|
UpdatePodcastFeedMetadata(ctx context.Context, arg UpdatePodcastFeedMetadataParams) (PodcastFeeds, error)
|
||||||
|
// itunes:new-feed-url (or a permanent HTTP redirect chain): the feed has
|
||||||
|
// MOVED. Rewrite feed_url so future polls hit the new location; the row
|
||||||
|
// (and every subscription/episode) stays put.
|
||||||
|
UpdatePodcastFeedRedirect(ctx context.Context, arg UpdatePodcastFeedRedirectParams) error
|
||||||
|
// User-owned fields only (D9): display name + auto-download policy.
|
||||||
|
UpdatePodcastSubscription(ctx context.Context, arg UpdatePodcastSubscriptionParams) (PodcastSubscriptions, error)
|
||||||
UpdateReadingProgress(ctx context.Context, arg UpdateReadingProgressParams) (ReadingProgress, error)
|
UpdateReadingProgress(ctx context.Context, arg UpdateReadingProgressParams) (ReadingProgress, error)
|
||||||
UpdateReadingSpeed(ctx context.Context, arg UpdateReadingSpeedParams) (ReadingSpeed, error)
|
UpdateReadingSpeed(ctx context.Context, arg UpdateReadingSpeedParams) (ReadingSpeed, error)
|
||||||
UpdateSavedFilter(ctx context.Context, arg UpdateSavedFilterParams) (SavedFilters, error)
|
UpdateSavedFilter(ctx context.Context, arg UpdateSavedFilterParams) (SavedFilters, error)
|
||||||
@@ -751,11 +866,23 @@ type Querier interface {
|
|||||||
// content identical), and after a manifest-gated re-extract.
|
// content identical), and after a manifest-gated re-extract.
|
||||||
UpsertMediaScanState(ctx context.Context, arg UpsertMediaScanStateParams) error
|
UpsertMediaScanState(ctx context.Context, arg UpsertMediaScanStateParams) error
|
||||||
UpsertPanelData(ctx context.Context, arg UpsertPanelDataParams) (PanelData, error)
|
UpsertPanelData(ctx context.Context, arg UpsertPanelDataParams) (PanelData, error)
|
||||||
|
// ============================================================================
|
||||||
|
// Podcast episodes — media_items rows with source='podcast_episode'
|
||||||
|
// ============================================================================
|
||||||
|
// Fetcher-only upsert keyed on the partial unique index
|
||||||
|
// (source_feed_id, episode_guid). DO UPDATE refreshes FETCH-OWNED
|
||||||
|
// metadata only. Write-after-success guard (AGENTS.md gotcha #7):
|
||||||
|
// download state/error/attempts are NEVER touched here, and the
|
||||||
|
// downloaded file's facts (path/size/mime/cover) survive renumbering —
|
||||||
|
// only un-downloaded rows follow the re-derived file_path.
|
||||||
|
// authors_search mirrors authors (trigram search column).
|
||||||
|
UpsertPodcastEpisode(ctx context.Context, arg UpsertPodcastEpisodeParams) (MediaItems, error)
|
||||||
UpsertReaderSettings(ctx context.Context, arg UpsertReaderSettingsParams) (ReaderSettings, error)
|
UpsertReaderSettings(ctx context.Context, arg UpsertReaderSettingsParams) (ReaderSettings, error)
|
||||||
UpsertSystemSetting(ctx context.Context, arg UpsertSystemSettingParams) (SystemSettings, error)
|
UpsertSystemSetting(ctx context.Context, arg UpsertSystemSettingParams) (SystemSettings, error)
|
||||||
// Ingest: insert or refresh the queue row for a device file that could not
|
// Ingest: insert or refresh the queue row for a device file that could not
|
||||||
// be identified. One row per (device, content_id); re-syncs bump last_seen.
|
// be identified. One row per (device, content_id); re-syncs bump last_seen.
|
||||||
UpsertUnlinkedBook(ctx context.Context, arg UpsertUnlinkedBookParams) (UnlinkedBooks, error)
|
UpsertUnlinkedBook(ctx context.Context, arg UpsertUnlinkedBookParams) (UnlinkedBooks, error)
|
||||||
|
UpsertUserSettingByKey(ctx context.Context, arg UpsertUserSettingByKeyParams) error
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ Querier = (*Queries)(nil)
|
var _ Querier = (*Queries)(nil)
|
||||||
+2805
-49
File diff suppressed because it is too large.
Load diff
@@ -97,7 +97,9 @@ DELETE FROM libraries WHERE id = $1;
|
|||||||
|
|
||||||
-- Library Folders queries
|
-- Library Folders queries
|
||||||
-- name: AddLibraryFolder :one
|
-- name: AddLibraryFolder :one
|
||||||
INSERT INTO library_folders (library_id, folder_path) VALUES ($1, $2) RETURNING *;
|
-- purpose: 'scan' (FS-walker root, default) | 'podcast' (download root;
|
||||||
|
-- scanner-skipped, written by the podcast download worker instead).
|
||||||
|
INSERT INTO library_folders (library_id, folder_path, purpose) VALUES ($1, $2, $3) RETURNING *;
|
||||||
|
|
||||||
-- name: GetLibraryFolders :many
|
-- name: GetLibraryFolders :many
|
||||||
SELECT * FROM library_folders WHERE library_id = $1 ORDER BY created_at;
|
SELECT * FROM library_folders WHERE library_id = $1 ORDER BY created_at;
|
||||||
@@ -178,12 +180,16 @@ SELECT * FROM media_items WHERE library_id = $1 AND file_path = $2;
|
|||||||
SELECT * FROM media_items WHERE id = $1;
|
SELECT * FROM media_items WHERE id = $1;
|
||||||
|
|
||||||
-- name: ListMediaItems :many
|
-- name: ListMediaItems :many
|
||||||
|
-- Podcasts exclusion (plan §9): generic book surfaces never list
|
||||||
|
-- podcast episodes (library_type_name is the denormalized, trigger/
|
||||||
|
-- fetcher-maintained discriminator; NULL rows predate it and stay).
|
||||||
SELECT mi.*, l.name as library_name, lt.name as library_type_name
|
SELECT mi.*, l.name as library_name, lt.name as library_type_name
|
||||||
FROM media_items mi
|
FROM media_items mi
|
||||||
JOIN libraries l ON mi.library_id = l.id
|
JOIN libraries l ON mi.library_id = l.id
|
||||||
JOIN library_types lt ON l.library_type_id = lt.id
|
JOIN library_types lt ON l.library_type_id = lt.id
|
||||||
WHERE mi.archived_at IS NULL
|
WHERE mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts')
|
||||||
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
||||||
ORDER BY mi.created_at DESC LIMIT $1 OFFSET $2;
|
ORDER BY mi.created_at DESC LIMIT $1 OFFSET $2;
|
||||||
|
|
||||||
@@ -212,6 +218,7 @@ JOIN libraries l ON mi.library_id = l.id
|
|||||||
JOIN library_types lt ON l.library_type_id = lt.id
|
JOIN library_types lt ON l.library_type_id = lt.id
|
||||||
WHERE mi.archived_at IS NULL
|
WHERE mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND mi.library_id = sqlc.narg('library_id')
|
AND mi.library_id = sqlc.narg('library_id')
|
||||||
ORDER BY
|
ORDER BY
|
||||||
CASE
|
CASE
|
||||||
@@ -658,6 +665,7 @@ LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.na
|
|||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
AND (
|
AND (
|
||||||
mi.title ILIKE sqlc.narg('search_pattern') OR
|
mi.title ILIKE sqlc.narg('search_pattern') OR
|
||||||
@@ -748,6 +756,7 @@ LEFT JOIN reading_progress rp ON rp.media_item_id = mi.id AND rp.user_id = sqlc.
|
|||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND (sqlc.narg('library_id')::uuid IS NULL
|
AND (sqlc.narg('library_id')::uuid IS NULL
|
||||||
OR mi.library_id = sqlc.narg('library_id')::uuid)
|
OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
-- Fuzzy author filter — per-element against the authors[] array (A2c
|
-- Fuzzy author filter — per-element against the authors[] array (A2c
|
||||||
@@ -932,6 +941,7 @@ FROM media_items mi
|
|||||||
JOIN libraries l ON mi.library_id = l.id
|
JOIN libraries l ON mi.library_id = l.id
|
||||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
@@ -951,6 +961,7 @@ FROM media_items mi
|
|||||||
JOIN libraries l ON mi.library_id = l.id
|
JOIN libraries l ON mi.library_id = l.id
|
||||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
@@ -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')
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||||
CROSS JOIN LATERAL unnest(mi.tags_search) AS tag
|
CROSS JOIN LATERAL unnest(mi.tags_search) AS tag
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
AND tag IS NOT NULL
|
AND tag IS NOT NULL
|
||||||
AND word_similarity(sqlc.narg('search_query'), tag) > 0.3
|
AND word_similarity(sqlc.narg('search_query'), tag) > 0.3
|
||||||
@@ -988,6 +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')
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||||
CROSS JOIN LATERAL unnest(mi.narrators) AS narrator
|
CROSS JOIN LATERAL unnest(mi.narrators) AS narrator
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
AND narrator IS NOT NULL
|
AND narrator IS NOT NULL
|
||||||
AND word_similarity(sqlc.narg('search_query'), narrator) > 0.3
|
AND word_similarity(sqlc.narg('search_query'), narrator) > 0.3
|
||||||
@@ -1004,6 +1017,7 @@ FROM media_items mi
|
|||||||
JOIN libraries l ON mi.library_id = l.id
|
JOIN libraries l ON mi.library_id = l.id
|
||||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
@@ -1023,6 +1037,7 @@ FROM media_items mi
|
|||||||
JOIN libraries l ON mi.library_id = l.id
|
JOIN libraries l ON mi.library_id = l.id
|
||||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
@@ -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')
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||||
CROSS JOIN LATERAL unnest(mi.authors) AS a
|
CROSS JOIN LATERAL unnest(mi.authors) AS a
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
@@ -1129,6 +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')
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||||
CROSS JOIN LATERAL unnest(mi.publishers) AS p
|
CROSS JOIN LATERAL unnest(mi.publishers) AS p
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
@@ -1196,6 +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')
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||||
CROSS JOIN LATERAL unnest(mi.narrators) AS n
|
CROSS JOIN LATERAL unnest(mi.narrators) AS n
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
@@ -1265,6 +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')
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||||
CROSS JOIN LATERAL unnest(mi.tags) AS t
|
CROSS JOIN LATERAL unnest(mi.tags) AS t
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
@@ -1333,6 +1352,7 @@ FROM media_items mi
|
|||||||
JOIN libraries l ON mi.library_id = l.id
|
JOIN libraries l ON mi.library_id = l.id
|
||||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
@@ -1404,6 +1424,7 @@ FROM media_items mi
|
|||||||
JOIN libraries l ON mi.library_id = l.id
|
JOIN libraries l ON mi.library_id = l.id
|
||||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
@@ -1475,6 +1496,7 @@ FROM media_items mi
|
|||||||
JOIN libraries l ON mi.library_id = l.id
|
JOIN libraries l ON mi.library_id = l.id
|
||||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
@@ -1546,6 +1568,7 @@ FROM media_items mi
|
|||||||
JOIN libraries l ON mi.library_id = l.id
|
JOIN libraries l ON mi.library_id = l.id
|
||||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
@@ -1613,6 +1636,7 @@ FROM media_items mi
|
|||||||
JOIN libraries l ON mi.library_id = l.id
|
JOIN libraries l ON mi.library_id = l.id
|
||||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
@@ -1680,6 +1704,7 @@ FROM media_items mi
|
|||||||
JOIN libraries l ON mi.library_id = l.id
|
JOIN libraries l ON mi.library_id = l.id
|
||||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
@@ -1747,6 +1772,7 @@ FROM media_items mi
|
|||||||
JOIN libraries l ON mi.library_id = l.id
|
JOIN libraries l ON mi.library_id = l.id
|
||||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
@@ -1818,6 +1844,7 @@ FROM media_items mi
|
|||||||
JOIN libraries l ON mi.library_id = l.id
|
JOIN libraries l ON mi.library_id = l.id
|
||||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
@@ -1889,6 +1916,7 @@ FROM media_items mi
|
|||||||
JOIN libraries l ON mi.library_id = l.id
|
JOIN libraries l ON mi.library_id = l.id
|
||||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
@@ -1960,6 +1988,7 @@ FROM media_items mi
|
|||||||
JOIN libraries l ON mi.library_id = l.id
|
JOIN libraries l ON mi.library_id = l.id
|
||||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
@@ -2604,6 +2633,7 @@ SELECT
|
|||||||
FROM media_items mi
|
FROM media_items mi
|
||||||
JOIN reading_history rh ON rh.media_item_id = mi.id
|
JOIN reading_history rh ON rh.media_item_id = mi.id
|
||||||
WHERE rh.user_id = $1
|
WHERE rh.user_id = $1
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
GROUP BY mi.id, mi.title, mi.author
|
GROUP BY mi.id, mi.title, mi.author
|
||||||
ORDER BY read_count DESC
|
ORDER BY read_count DESC
|
||||||
LIMIT $2;
|
LIMIT $2;
|
||||||
@@ -2666,6 +2696,7 @@ SELECT
|
|||||||
FROM reading_history rh
|
FROM reading_history rh
|
||||||
JOIN media_items mi ON mi.id = rh.media_item_id
|
JOIN media_items mi ON mi.id = rh.media_item_id
|
||||||
WHERE rh.user_id = sqlc.arg('user_id')
|
WHERE rh.user_id = sqlc.arg('user_id')
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND COALESCE(rh.reading_session_end, rh.created_at) >= sqlc.arg('start_time')
|
AND COALESCE(rh.reading_session_end, rh.created_at) >= sqlc.arg('start_time')
|
||||||
AND COALESCE(rh.reading_session_end, rh.created_at) <= sqlc.arg('end_time')
|
AND COALESCE(rh.reading_session_end, rh.created_at) <= sqlc.arg('end_time')
|
||||||
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
||||||
@@ -3962,6 +3993,7 @@ INNER JOIN (
|
|||||||
ORDER BY media_item_id, last_read_at DESC
|
ORDER BY media_item_id, last_read_at DESC
|
||||||
) rp ON rp.media_item_id = mi.id
|
) rp ON rp.media_item_id = mi.id
|
||||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
ORDER BY rp.last_read_at DESC
|
ORDER BY rp.last_read_at DESC
|
||||||
@@ -3970,6 +4002,7 @@ LIMIT sqlc.narg('limit');
|
|||||||
-- name: GetRecentlyAddedItems :many
|
-- name: GetRecentlyAddedItems :many
|
||||||
SELECT mi.* FROM media_items mi
|
SELECT mi.* FROM media_items mi
|
||||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
ORDER BY mi.imported_at DESC NULLS LAST, mi.created_at DESC
|
ORDER BY mi.imported_at DESC NULLS LAST, mi.created_at DESC
|
||||||
@@ -3985,6 +4018,7 @@ INNER JOIN (
|
|||||||
ORDER BY media_item_id, last_read_at DESC
|
ORDER BY media_item_id, last_read_at DESC
|
||||||
) rp ON rp.media_item_id = mi.id
|
) rp ON rp.media_item_id = mi.id
|
||||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
ORDER BY rp.last_read_at DESC
|
ORDER BY rp.last_read_at DESC
|
||||||
@@ -3993,6 +4027,7 @@ LIMIT sqlc.narg('limit');
|
|||||||
-- name: GetNotStartedItems :many
|
-- name: GetNotStartedItems :many
|
||||||
SELECT mi.* FROM media_items mi
|
SELECT mi.* FROM media_items mi
|
||||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
@@ -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)
|
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9: collections picker)
|
||||||
ORDER BY mi.created_at DESC;
|
ORDER BY mi.created_at DESC;
|
||||||
|
|
||||||
-- name: GetDistinctSeries :many
|
-- name: GetDistinctSeries :many
|
||||||
@@ -4111,6 +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 (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
||||||
AND archived_at IS NULL
|
AND archived_at IS NULL
|
||||||
AND missing_scan_count = 0
|
AND missing_scan_count = 0
|
||||||
|
AND (library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9: series pages)
|
||||||
AND series IS NOT NULL AND series != ''
|
AND series IS NOT NULL AND series != ''
|
||||||
-- Multi-field search (series page): the query matches the series
|
-- Multi-field search (series page): the query matches the series
|
||||||
-- NAME, any book's AUTHOR, or any book's TITLE in the group. It is
|
-- NAME, any book's AUTHOR, or any book's TITLE in the group. It is
|
||||||
@@ -4171,6 +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 (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
||||||
AND archived_at IS NULL
|
AND archived_at IS NULL
|
||||||
AND missing_scan_count = 0
|
AND missing_scan_count = 0
|
||||||
|
AND (library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9: series pages)
|
||||||
AND series IS NOT NULL AND series != ''
|
AND series IS NOT NULL AND series != ''
|
||||||
AND (sqlc.narg('series_query')::text IS NULL OR
|
AND (sqlc.narg('series_query')::text IS NULL OR
|
||||||
series ILIKE '%' || sqlc.narg('series_query')::text || '%'
|
series ILIKE '%' || sqlc.narg('series_query')::text || '%'
|
||||||
@@ -4590,3 +4628,403 @@ SET file_size = $2,
|
|||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, abridged, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence;
|
RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, abridged, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- PODCASTS / RSS FEEDS (plan ~/.opencode/plan/podcast-integration.md §6/§8)
|
||||||
|
-- Visibility is RLS-enforced (schema policy block) — NO query here carries
|
||||||
|
-- visibility clauses. Feed reads run under the acting user's identity
|
||||||
|
-- (subscriber-or-admin policy); the fetcher/worker run as first-admin.
|
||||||
|
-- The fetcher/download worker is the SOLE writer of feed/episode metadata;
|
||||||
|
-- users write only podcast_subscriptions rows.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- name: CreatePodcastFeed :one
|
||||||
|
-- Add-feed upsert keyed on the per-library UNIQUE(library_id, feed_url):
|
||||||
|
-- a second subscriber adding the same URL attaches to the existing row
|
||||||
|
-- instead of erroring. DO UPDATE (not DO NOTHING) so the RETURNING works
|
||||||
|
-- even when the conflicting row is invisible to the actor under RLS
|
||||||
|
-- (non-admin creators cannot SELECT other users' feeds). Metadata columns
|
||||||
|
-- are filled by the fetcher right after; this only establishes identity.
|
||||||
|
INSERT INTO podcast_feeds (library_id, feed_url, title, created_by_admin_id)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (library_id, feed_url) DO UPDATE SET updated_at = NOW()
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: GetPodcastFeed :one
|
||||||
|
SELECT * FROM podcast_feeds WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: ListMyPodcastFeeds :many
|
||||||
|
-- The podcasts page: the acting user's subscriptions with feed metadata
|
||||||
|
-- and the user-owned subscription fields. Facet values derive in Go.
|
||||||
|
SELECT f.*,
|
||||||
|
s.display_name,
|
||||||
|
s.auto_download,
|
||||||
|
s.auto_download_count,
|
||||||
|
s.created_at AS subscribed_at
|
||||||
|
FROM podcast_subscriptions s
|
||||||
|
JOIN podcast_feeds f ON f.id = s.feed_id
|
||||||
|
WHERE s.user_id = $1
|
||||||
|
ORDER BY COALESCE(s.display_name, f.title) ASC;
|
||||||
|
|
||||||
|
-- name: GetPodcastSubscription :one
|
||||||
|
SELECT * FROM podcast_subscriptions WHERE user_id = $1 AND feed_id = $2;
|
||||||
|
|
||||||
|
-- name: SubscribePodcastFeed :one
|
||||||
|
INSERT INTO podcast_subscriptions (user_id, feed_id)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (user_id, feed_id) DO NOTHING
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: UpdatePodcastSubscription :one
|
||||||
|
-- User-owned fields only (D9): display name + auto-download policy.
|
||||||
|
UPDATE podcast_subscriptions
|
||||||
|
SET display_name = $3,
|
||||||
|
auto_download = $4,
|
||||||
|
auto_download_count = $5
|
||||||
|
WHERE user_id = $1 AND feed_id = $2
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: DeletePodcastSubscription :execrows
|
||||||
|
-- Unsubscribe NEVER deletes feed/episode/file state (D6).
|
||||||
|
DELETE FROM podcast_subscriptions WHERE user_id = $1 AND feed_id = $2;
|
||||||
|
|
||||||
|
-- name: ListFeedSubscribers :many
|
||||||
|
-- Download-worker policy union (D6): most aggressive policy wins for
|
||||||
|
-- what arrives. Runs under an admin identity (background job).
|
||||||
|
SELECT user_id, auto_download, auto_download_count
|
||||||
|
FROM podcast_subscriptions
|
||||||
|
WHERE feed_id = $1;
|
||||||
|
|
||||||
|
-- name: CountPodcastFeedSubscribers :one
|
||||||
|
SELECT COUNT(*) FROM podcast_subscriptions WHERE feed_id = $1;
|
||||||
|
|
||||||
|
-- name: 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
|
||||||
|
-- Worker claim: pending|failed -> queued. Attempts incremented at
|
||||||
|
-- claim time so a crashed worker still counts the try.
|
||||||
|
UPDATE media_items
|
||||||
|
SET download_state = 'queued',
|
||||||
|
download_error = NULL,
|
||||||
|
download_attempts = download_attempts + 1,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
AND source = 'podcast_episode'
|
||||||
|
AND download_state IN ('pending', 'failed');
|
||||||
|
|
||||||
|
-- name: SetPodcastEpisodeDownloading :exec
|
||||||
|
UPDATE media_items
|
||||||
|
SET download_state = 'downloading', updated_at = NOW()
|
||||||
|
WHERE id = $1 AND source = 'podcast_episode';
|
||||||
|
|
||||||
|
-- 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: GetNextQueuedEpisode :one
|
||||||
|
-- Sequential download worker's claim source (single worker, day one).
|
||||||
|
SELECT * FROM media_items
|
||||||
|
WHERE source = 'podcast_episode' AND download_state = 'queued'
|
||||||
|
ORDER BY updated_at ASC
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- 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;
|
||||||
@@ -82,6 +82,7 @@ var SettingDefaults = []SettingDefault{
|
|||||||
{Key: "sync_queue_batch_size", Value: "50", Type: SettingTypeInt, Min: "1", Max: "10000", RequiresRestart: true, Category: "sync", Group: "Sync Queue", Description: "Maximum items processed per sync queue flush"},
|
{Key: "sync_queue_batch_size", Value: "50", Type: SettingTypeInt, Min: "1", Max: "10000", RequiresRestart: true, Category: "sync", Group: "Sync Queue", Description: "Maximum items processed per sync queue flush"},
|
||||||
{Key: "worker_pool_size", Value: "3", Type: SettingTypeInt, Min: "1", Max: "100", RequiresRestart: true, Category: "performance", Group: "Worker Pool", Description: "Number of background worker goroutines"},
|
{Key: "worker_pool_size", Value: "3", Type: SettingTypeInt, Min: "1", Max: "100", RequiresRestart: true, Category: "performance", Group: "Worker Pool", Description: "Number of background worker goroutines"},
|
||||||
{Key: "worker_queue_cap", Value: "100", Type: SettingTypeInt, Min: "1", Max: "10000", RequiresRestart: true, Category: "performance", Group: "Worker Pool", Description: "Background worker job queue capacity"},
|
{Key: "worker_queue_cap", Value: "100", Type: SettingTypeInt, Min: "1", Max: "10000", RequiresRestart: true, Category: "performance", Group: "Worker Pool", Description: "Background worker job queue capacity"},
|
||||||
|
{Key: "podcast_poll_interval_seconds", Value: "3600", Type: SettingTypeInt, Min: "300", Max: "604800", Category: "podcasts", Group: "Podcasts", Description: "How often to poll podcast feeds for new episodes (seconds; complete feeds back off)"},
|
||||||
}
|
}
|
||||||
|
|
||||||
// defaultBy indexes SettingDefaults by key for O(1) lookup.
|
// defaultBy indexes SettingDefaults by key for O(1) lookup.
|
||||||
@@ -188,6 +189,13 @@ func (r *SettingsRegistry) ScanPollInterval() time.Duration {
|
|||||||
return time.Duration(r.getInt("scan_poll_interval_seconds")) * time.Second
|
return time.Duration(r.getInt("scan_poll_interval_seconds")) * time.Second
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PodcastPollInterval is the feed-poller tick (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
|
||||||
|
}
|
||||||
|
|
||||||
// AutoScanEnabled reports whether auto-scanning is on.
|
// AutoScanEnabled reports whether auto-scanning is on.
|
||||||
func (r *SettingsRegistry) AutoScanEnabled() bool { return r.getBool("auto_scan_enabled") }
|
func (r *SettingsRegistry) AutoScanEnabled() bool { return r.getBool("auto_scan_enabled") }
|
||||||
|
|
||||||
|
|||||||
@@ -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"})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "media item not found"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Edit segregation (plan D10, task 1.8): podcast episodes are
|
||||||
|
// fetcher-owned (D9 — the sole writer); the generic edit box would
|
||||||
|
// clobber feed metadata that the next refresh overwrites anyway.
|
||||||
|
if existing.Source == "podcast_episode" {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "podcast episodes are managed by their feed and cannot be edited here"})
|
||||||
|
}
|
||||||
|
|
||||||
coverPath := existing.CoverImagePath.String
|
coverPath := existing.CoverImagePath.String
|
||||||
coverUploaded := false
|
coverUploaded := false
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
"bookhoard/internal/opds"
|
"bookhoard/internal/opds"
|
||||||
@@ -18,6 +17,24 @@ import (
|
|||||||
"github.com/labstack/echo/v5"
|
"github.com/labstack/echo/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// opdsVisibleLibraries is GetUserVisibleLibraries minus podcasts-type
|
||||||
|
// libraries (plan D16): feeds are not OPDS inventory day one — they
|
||||||
|
// surface via the /podcasts endpoints only.
|
||||||
|
func opdsVisibleLibraries(ctx context.Context, q *database.Queries, userID pgtype.UUID) ([]database.GetUserVisibleLibrariesRow, error) {
|
||||||
|
libs, err := q.GetUserVisibleLibraries(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]database.GetUserVisibleLibrariesRow, 0, len(libs))
|
||||||
|
for _, lib := range libs {
|
||||||
|
if lib.TypeName == services.LibraryTypePodcasts {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, lib)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
type OPDSHandler struct {
|
type OPDSHandler struct {
|
||||||
db *database.Queries
|
db *database.Queries
|
||||||
libraryService *services.LibraryService
|
libraryService *services.LibraryService
|
||||||
@@ -209,7 +226,7 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
|
|||||||
|
|
||||||
// Get user's visible libraries
|
// Get user's visible libraries
|
||||||
userID := device.UserID.Bytes
|
userID := device.UserID.Bytes
|
||||||
libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
|
libraries, err := opdsVisibleLibraries(c.Request().Context(), h.db, pgtype.UUID{Bytes: userID, Valid: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries"))
|
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries"))
|
||||||
}
|
}
|
||||||
@@ -318,7 +335,7 @@ func (h *OPDSHandler) serveAllBooksCatalog(c *echo.Context) error {
|
|||||||
|
|
||||||
// Get user's visible libraries
|
// Get user's visible libraries
|
||||||
userID := device.UserID.Bytes
|
userID := device.UserID.Bytes
|
||||||
libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
|
libraries, err := opdsVisibleLibraries(c.Request().Context(), h.db, pgtype.UUID{Bytes: userID, Valid: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries"))
|
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries"))
|
||||||
}
|
}
|
||||||
@@ -570,7 +587,7 @@ func (h *OPDSHandler) GetLibraryCatalog(c *echo.Context) error {
|
|||||||
|
|
||||||
// The library must be visible to the device owner.
|
// The library must be visible to the device owner.
|
||||||
userID := device.UserID.Bytes
|
userID := device.UserID.Bytes
|
||||||
libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
|
libraries, err := opdsVisibleLibraries(c.Request().Context(), h.db, pgtype.UUID{Bytes: userID, Valid: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries"))
|
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries"))
|
||||||
}
|
}
|
||||||
@@ -677,7 +694,7 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
|
|||||||
|
|
||||||
// Get user's visible libraries
|
// Get user's visible libraries
|
||||||
userID := device.UserID.Bytes
|
userID := device.UserID.Bytes
|
||||||
libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
|
libraries, err := opdsVisibleLibraries(c.Request().Context(), h.db, pgtype.UUID{Bytes: userID, Valid: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries"))
|
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries"))
|
||||||
}
|
}
|
||||||
@@ -818,7 +835,7 @@ func (h *OPDSHandler) DownloadBook(c *echo.Context) error {
|
|||||||
|
|
||||||
// Get user's visible libraries
|
// Get user's visible libraries
|
||||||
userID := device.UserID.Bytes
|
userID := device.UserID.Bytes
|
||||||
libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
|
libraries, err := opdsVisibleLibraries(c.Request().Context(), h.db, pgtype.UUID{Bytes: userID, Valid: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get libraries"})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get libraries"})
|
||||||
}
|
}
|
||||||
@@ -974,7 +991,7 @@ func (h *OPDSHandler) GetCoverImage(c *echo.Context) error {
|
|||||||
|
|
||||||
// Get user's visible libraries
|
// Get user's visible libraries
|
||||||
userID := device.UserID.Bytes
|
userID := device.UserID.Bytes
|
||||||
libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
|
libraries, err := opdsVisibleLibraries(c.Request().Context(), h.db, pgtype.UUID{Bytes: userID, Valid: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get libraries"})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get libraries"})
|
||||||
}
|
}
|
||||||
@@ -1115,7 +1132,7 @@ func (h *OPDSHandler) ListFormats(c *echo.Context) error {
|
|||||||
|
|
||||||
// Get user's visible libraries
|
// Get user's visible libraries
|
||||||
userID := device.UserID.Bytes
|
userID := device.UserID.Bytes
|
||||||
libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
|
libraries, err := opdsVisibleLibraries(c.Request().Context(), h.db, pgtype.UUID{Bytes: userID, Valid: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get libraries"})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get libraries"})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,610 @@
|
|||||||
|
// 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"
|
||||||
|
"fmt"
|
||||||
|
"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).
|
||||||
|
type EpisodeListRenderer func(c *echo.Context, feedID string, includePolicySelect 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 bool) error {
|
||||||
|
if h.episodeListRenderer != nil {
|
||||||
|
return h.episodeListRenderer(c, feedID, includePolicySelect)
|
||||||
|
}
|
||||||
|
return hxAccepted(c, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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 bool `json:"download_all" form:"download_all"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
if _, err := h.worker.QueueFeedDownloads(c.Request().Context(), 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)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
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).
|
||||||
|
queued, err := h.worker.QueueFeedDownloads(ctx, 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)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
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) | oldest | newest.
|
||||||
|
func podcastOrderValid(o string) bool {
|
||||||
|
return o == "native" || o == "oldest" || o == "newest"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 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)
|
||||||
|
}
|
||||||
|
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]))
|
||||||
|
}
|
||||||
|
sortSlice(views, func(a, b PodcastEpisodeView) bool {
|
||||||
|
switch order {
|
||||||
|
case "oldest":
|
||||||
|
return episodeSortKey(a) < episodeSortKey(b)
|
||||||
|
case "newest":
|
||||||
|
return episodeSortKey(a) > episodeSortKey(b)
|
||||||
|
default: // native: feed position (fetch-maintained)
|
||||||
|
if a.FeedPosition != b.FeedPosition {
|
||||||
|
return a.FeedPosition < b.FeedPosition
|
||||||
|
}
|
||||||
|
return a.Date < b.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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// episodeSortKey: chronological key for oldest/newest (date, then
|
||||||
|
// native position as the same-day tiebreaker).
|
||||||
|
func episodeSortKey(v PodcastEpisodeView) string {
|
||||||
|
return v.Date + fmt.Sprintf("%06d", v.FeedPosition)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortSlice: sort.Slice with a controller-friendly wrapper.
|
||||||
|
func sortSlice[T any](s []T, less func(a, b T) bool) {
|
||||||
|
sort.Slice(s, func(i, j int) bool { return less(s[i], s[j]) })
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
// Podcast view types + mappers shared by the JSON API and the SSR
|
||||||
|
// partials (plan §8/§9). One shape per concept so the htmx partials and
|
||||||
|
// the API can never drift.
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"bookhoard/internal/database"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Feed is the API-facing feed shape (subscription fields attached when
|
||||||
|
// the caller has one).
|
||||||
|
type Feed struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
LibraryID string `json:"library_id"`
|
||||||
|
FeedUrl string `json:"feed_url"`
|
||||||
|
PodcastGuid string `json:"podcast_guid,omitempty"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Host string `json:"host,omitempty"`
|
||||||
|
Publisher string `json:"publisher,omitempty"`
|
||||||
|
Language string `json:"language,omitempty"`
|
||||||
|
Tags []string `json:"tags,omitempty"`
|
||||||
|
Explicit bool `json:"explicit"`
|
||||||
|
ArtworkPath string `json:"artwork_path,omitempty"`
|
||||||
|
FeedType string `json:"feed_type,omitempty"`
|
||||||
|
Medium string `json:"medium,omitempty"`
|
||||||
|
IsComplete bool `json:"is_complete"`
|
||||||
|
LastFetchedAt string `json:"last_fetched_at,omitempty"`
|
||||||
|
|
||||||
|
// Subscription-owned (rendered when present).
|
||||||
|
DisplayName string `json:"display_name,omitempty"`
|
||||||
|
AutoDownload string `json:"auto_download,omitempty"`
|
||||||
|
AutoDownloadCount int `json:"auto_download_count,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PodcastEpisodeView is the episode-list row: media facts + the
|
||||||
|
// download lifecycle the list renders inline (D8/D19).
|
||||||
|
type PodcastEpisodeView struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
FeedID string `json:"feed_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Subtitle string `json:"subtitle,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Authors []string `json:"authors,omitempty"`
|
||||||
|
Hosts []string `json:"hosts,omitempty"`
|
||||||
|
Guests []string `json:"guests,omitempty"`
|
||||||
|
Date string `json:"date_published,omitempty"`
|
||||||
|
FeedPosition int `json:"feed_position"`
|
||||||
|
Season int `json:"season_number,omitempty"`
|
||||||
|
Episode int `json:"episode_number,omitempty"`
|
||||||
|
Duration int `json:"declared_duration_seconds,omitempty"`
|
||||||
|
MimeType string `json:"mime_type,omitempty"`
|
||||||
|
CoverImage string `json:"cover_image_path,omitempty"`
|
||||||
|
EnclosureURL string `json:"enclosure_url,omitempty"`
|
||||||
|
|
||||||
|
// Download lifecycle.
|
||||||
|
DownloadState string `json:"download_state"`
|
||||||
|
DownloadError string `json:"download_error,omitempty"`
|
||||||
|
DownloadAttempts int `json:"download_attempts"`
|
||||||
|
Downloaded bool `json:"downloaded"`
|
||||||
|
FileSize int64 `json:"file_size,omitempty"`
|
||||||
|
|
||||||
|
// Progress (from the shared reading_progress machinery).
|
||||||
|
ProgressPercent float64 `json:"progress_percent,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// episodeView maps a media_items row (podcast episode) onto the view.
|
||||||
|
func episodeView(ep *database.MediaItems) PodcastEpisodeView {
|
||||||
|
v := PodcastEpisodeView{
|
||||||
|
ID: uuidString(ep.ID),
|
||||||
|
FeedID: uuidString(ep.SourceFeedID),
|
||||||
|
Title: ep.Title,
|
||||||
|
Subtitle: ep.Subtitle.String,
|
||||||
|
Description: ep.Description.String,
|
||||||
|
Authors: ep.Authors,
|
||||||
|
Hosts: ep.Hosts,
|
||||||
|
Guests: ep.Guests,
|
||||||
|
FeedPosition: int(ep.FeedPosition.Int32),
|
||||||
|
Season: int(ep.SeasonNumber.Int32),
|
||||||
|
Episode: int(ep.EpisodeNumber.Int32),
|
||||||
|
Duration: int(ep.DeclaredDurationSeconds.Int32),
|
||||||
|
MimeType: ep.MimeType.String,
|
||||||
|
CoverImage: ep.CoverImagePath.String,
|
||||||
|
EnclosureURL: ep.EnclosureUrl.String,
|
||||||
|
|
||||||
|
DownloadState: ep.DownloadState,
|
||||||
|
DownloadError: ep.DownloadError.String,
|
||||||
|
DownloadAttempts: int(ep.DownloadAttempts),
|
||||||
|
Downloaded: ep.DownloadState == "downloaded",
|
||||||
|
FileSize: ep.FileSize.Int64,
|
||||||
|
}
|
||||||
|
if ep.DatePublished.Valid {
|
||||||
|
v.Date = ep.DatePublished.Time.Format("2006-01-02")
|
||||||
|
}
|
||||||
|
if !ep.FileSize.Valid {
|
||||||
|
v.FileSize = 0
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// feedWithSubscription merges the feed row + the caller's subscription
|
||||||
|
// (empty sub → bare feed).
|
||||||
|
func feedWithSubscription(feed *database.PodcastFeeds, sub database.PodcastSubscriptions, subFound bool) Feed {
|
||||||
|
f := Feed{
|
||||||
|
ID: uuidString(feed.ID),
|
||||||
|
LibraryID: uuidString(feed.LibraryID),
|
||||||
|
FeedUrl: feed.FeedUrl,
|
||||||
|
PodcastGuid: feed.PodcastGuid.String,
|
||||||
|
Title: feed.Title,
|
||||||
|
Description: feed.Description.String,
|
||||||
|
Host: feed.Host.String,
|
||||||
|
Publisher: feed.Publisher.String,
|
||||||
|
Language: feed.Language.String,
|
||||||
|
Tags: feed.Tags,
|
||||||
|
Explicit: feed.Explicit,
|
||||||
|
ArtworkPath: feed.ArtworkPath.String,
|
||||||
|
FeedType: feed.FeedType.String,
|
||||||
|
Medium: feed.Medium.String,
|
||||||
|
IsComplete: feed.IsComplete,
|
||||||
|
LastFetchedAt: timeString(feed.LastFetchedAt),
|
||||||
|
}
|
||||||
|
if subFound {
|
||||||
|
f.DisplayName = sub.DisplayName.String
|
||||||
|
f.AutoDownload = sub.AutoDownload
|
||||||
|
f.AutoDownloadCount = int(sub.AutoDownloadCount.Int32)
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// countGaps counts un-downloaded episodes that have a downloaded
|
||||||
|
// episode AFTER them in list order — the D8 "middle episodes missing"
|
||||||
|
// definition that drives the gap banner.
|
||||||
|
func countGaps(eps []PodcastEpisodeView) int {
|
||||||
|
seenDownloaded := false
|
||||||
|
gaps := 0
|
||||||
|
for i := len(eps) - 1; i >= 0; i-- {
|
||||||
|
if eps[i].Downloaded {
|
||||||
|
seenDownloaded = true
|
||||||
|
} else if seenDownloaded {
|
||||||
|
gaps++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return gaps
|
||||||
|
}
|
||||||
|
|
||||||
|
func countDownloaded(eps []PodcastEpisodeView) int {
|
||||||
|
n := 0
|
||||||
|
for _, e := range eps {
|
||||||
|
if e.Downloaded {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// facetValuesFor derives the /podcasts facet bar values (D18) from the
|
||||||
|
// subscription list — feed counts are small; no facet SQL.
|
||||||
|
func facetValuesFor(feeds []podcastFeedSummary) map[string][]string {
|
||||||
|
hosts := map[string]bool{}
|
||||||
|
tags := map[string]bool{}
|
||||||
|
langs := map[string]bool{}
|
||||||
|
media := map[string]bool{}
|
||||||
|
for _, f := range feeds {
|
||||||
|
if f.Host != "" {
|
||||||
|
hosts[f.Host] = true
|
||||||
|
}
|
||||||
|
for _, t := range f.Tags {
|
||||||
|
tags[t] = true
|
||||||
|
}
|
||||||
|
if f.Language != "" {
|
||||||
|
langs[f.Language] = true
|
||||||
|
}
|
||||||
|
if f.Medium != "" {
|
||||||
|
media[f.Medium] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map[string][]string{
|
||||||
|
"hosts": sortedKeys(hosts),
|
||||||
|
"tags": sortedKeys(tags),
|
||||||
|
"language": sortedKeys(langs),
|
||||||
|
"medium": sortedKeys(media),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedKeys(set map[string]bool) []string {
|
||||||
|
out := make([]string, 0, len(set))
|
||||||
|
for k := range set {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// uuidString renders a pgtype.UUID (empty string when NULL).
|
||||||
|
func uuidString(id pgtype.UUID) string {
|
||||||
|
if !id.Valid {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return uuid.UUID(id.Bytes).String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// timeString renders a timestamp (RFC3339; empty when NULL).
|
||||||
|
func timeString(t pgtype.Timestamptz) string {
|
||||||
|
if !t.Valid {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return t.Time.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pgtypeText (handlers-local) — service has its own.
|
||||||
|
func pgtypeText(s string) pgtype.Text {
|
||||||
|
if s == "" {
|
||||||
|
return pgtype.Text{}
|
||||||
|
}
|
||||||
|
return pgtype.Text{String: s, Valid: true}
|
||||||
|
}
|
||||||
@@ -74,6 +74,23 @@ func (h *AuthHandler) RefreshAccessToken(c *echo.Context) error {
|
|||||||
|
|
||||||
// Logout handles POST /api/auth/logout
|
// Logout handles POST /api/auth/logout
|
||||||
func (h *AuthHandler) Logout(c *echo.Context) error {
|
func (h *AuthHandler) Logout(c *echo.Context) error {
|
||||||
|
// Clear the HttpOnly session cookie — the SSR pages authenticate
|
||||||
|
// with it, and JavaScript cannot clear an HttpOnly cookie by
|
||||||
|
// design. Without this, "logout" wiped localStorage but left the
|
||||||
|
// cookie session alive for up to session_duration_seconds (the
|
||||||
|
// fake-signout bug: users roamed every cookie-authed page). The
|
||||||
|
// cookie is cleared BEFORE anything else — even a malformed body
|
||||||
|
// must sign the browser out.
|
||||||
|
c.SetCookie(&http.Cookie{
|
||||||
|
Name: "token",
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: secure == "true",
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
MaxAge: -1, // delete
|
||||||
|
})
|
||||||
|
|
||||||
var req RefreshTokenRequest
|
var req RefreshTokenRequest
|
||||||
if err := c.Bind(&req); err != nil {
|
if err := c.Bind(&req); err != nil {
|
||||||
return c.JSON(http.StatusOK, map[string]string{"message": "logged out successfully"})
|
return c.JSON(http.StatusOK, map[string]string{"message": "logged out successfully"})
|
||||||
|
|||||||
@@ -85,6 +85,11 @@ func (h *Handler) ScanLibrary(c *echo.Context) error {
|
|||||||
for _, folder := range libraryFolders {
|
for _, folder := range libraryFolders {
|
||||||
if folder.FolderPath == req.FolderPath {
|
if folder.FolderPath == req.FolderPath {
|
||||||
registered = true
|
registered = true
|
||||||
|
// Podcast download roots are feed-managed (invariant
|
||||||
|
// 4): never scanner-ingestible, even scoped.
|
||||||
|
if folder.Purpose == services.FolderPurposePodcast {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "podcast download roots are feed-managed and cannot be scanned"})
|
||||||
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1984,6 +1984,7 @@ func (cfg *Config) GetHealth(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
"status": "healthy",
|
"status": "healthy",
|
||||||
"version": version.Version,
|
"version": version.Version,
|
||||||
|
"commit": version.Commit,
|
||||||
"database": "connected",
|
"database": "connected",
|
||||||
"scan": map[string]interface{}{
|
"scan": map[string]interface{}{
|
||||||
"scan_in_progress": cfg.Worker.HasActiveScans(),
|
"scan_in_progress": cfg.Worker.HasActiveScans(),
|
||||||
|
|||||||
@@ -55,6 +55,14 @@ func getTemplateUserWithTheme(c *echo.Context, cfg *Config) (templates.User, err
|
|||||||
token = cookie.Value
|
token = cookie.Value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sidebar /podcasts gate (plan D13): >=1 visible podcasts library.
|
||||||
|
// One indexed COUNT per page render — the same class of per-request
|
||||||
|
// lookup the live-user guard already does.
|
||||||
|
showPodcasts := false
|
||||||
|
if n, err := cfg.Queries.CountPodcastLibrariesForUser(c.Request().Context(), uuidToPGType(userUUID)); err == nil && n > 0 {
|
||||||
|
showPodcasts = true
|
||||||
|
}
|
||||||
|
|
||||||
return templates.User{
|
return templates.User{
|
||||||
ID: userID,
|
ID: userID,
|
||||||
Email: userEmail,
|
Email: userEmail,
|
||||||
@@ -68,6 +76,7 @@ func getTemplateUserWithTheme(c *echo.Context, cfg *Config) (templates.User, err
|
|||||||
CreatedAt: userDB.CreatedAt.Time,
|
CreatedAt: userDB.CreatedAt.Time,
|
||||||
DefaultAudioSpeed: float64(userDB.DefaultAudioSpeed),
|
DefaultAudioSpeed: float64(userDB.DefaultAudioSpeed),
|
||||||
KeepScreenAwake: userDB.KeepScreenAwake,
|
KeepScreenAwake: userDB.KeepScreenAwake,
|
||||||
|
ShowPodcastsNav: showPodcasts,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
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/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 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,467 @@
|
|||||||
|
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/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 := ""
|
||||||
|
if ep.DatePublished.Valid {
|
||||||
|
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,
|
||||||
|
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).
|
||||||
|
if order == "oldest" || order == "newest" {
|
||||||
|
sort.SliceStable(rows, func(a, b int) bool {
|
||||||
|
ka := rows[a].Date + itoaPad(rows[a].Position)
|
||||||
|
kb := rows[b].Date + itoaPad(rows[b].Position)
|
||||||
|
if order == "newest" {
|
||||||
|
return ka > kb
|
||||||
|
}
|
||||||
|
return ka < kb
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
switch v.Order {
|
||||||
|
case "oldest", "newest":
|
||||||
|
return v.Order
|
||||||
|
}
|
||||||
|
return "native"
|
||||||
|
}
|
||||||
|
|
||||||
|
// facetValuesFromCards derives the filter bar values.
|
||||||
|
func facetValuesFromCards(feeds []templates.PodcastFeedCard) map[string][]string {
|
||||||
|
hosts, tags, langs := map[string]bool{}, map[string]bool{}, 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map[string][]string{
|
||||||
|
"hosts": sortedSet(hosts),
|
||||||
|
"tags": sortedSet(tags),
|
||||||
|
"language": sortedSet(langs),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedSet(set map[string]bool) []string {
|
||||||
|
out := make([]string, 0, len(set))
|
||||||
|
for k := range set {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstLine(s string, max int) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if i := strings.IndexAny(s, "\n\r"); i > 0 {
|
||||||
|
s = s[:i]
|
||||||
|
}
|
||||||
|
if len(s) > max {
|
||||||
|
s = s[:max] + "…"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func pgTimeDisplay(t pgtype.Timestamptz, tz string) string {
|
||||||
|
if !t.Valid {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return utils.FormatTimeInTimezone(t.Time, tz)
|
||||||
|
}
|
||||||
|
|
||||||
|
func itoaPad(n int) string {
|
||||||
|
return fmt.Sprintf("%06d", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// parsePodcastUUID parses an id path param (handlers.parseUUID is
|
||||||
|
// package-private).
|
||||||
|
func parsePodcastUUID(s string) (pgtype.UUID, error) {
|
||||||
|
id, err := uuid.Parse(strings.TrimSpace(s))
|
||||||
|
if err != nil {
|
||||||
|
return pgtype.UUID{}, err
|
||||||
|
}
|
||||||
|
return pgtype.UUID{Bytes: id, Valid: true}, nil
|
||||||
|
}
|
||||||
@@ -1,13 +1,17 @@
|
|||||||
package router
|
package router
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bookhoard/internal/database"
|
||||||
"bookhoard/internal/handlers"
|
"bookhoard/internal/handlers"
|
||||||
"bookhoard/internal/services"
|
"bookhoard/internal/services"
|
||||||
"bookhoard/internal/sync"
|
"bookhoard/internal/sync"
|
||||||
"bookhoard/internal/utils"
|
"bookhoard/internal/utils"
|
||||||
"bookhoard/templates"
|
"bookhoard/templates"
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
@@ -102,6 +106,18 @@ func registerReaderRoutes(cfg *Config) {
|
|||||||
TreatAsComic: handlers.ShouldTreatAsComic(libraryWithType.TypeName, mediaItem.FormatGroup),
|
TreatAsComic: handlers.ShouldTreatAsComic(libraryWithType.TypeName, mediaItem.FormatGroup),
|
||||||
KeepScreenAwake: user.KeepScreenAwake,
|
KeepScreenAwake: user.KeepScreenAwake,
|
||||||
}
|
}
|
||||||
|
// Podcast chaining (plan D7): episodes render the audio reader
|
||||||
|
// with sequence context — the neighboring DOWNLOADED episodes in
|
||||||
|
// the caller's effective order (override applied). RLS already
|
||||||
|
// scoped the episode read; the subscription predicate covers the
|
||||||
|
// neighbors the same way. ?single=1 escapes chaining (the "play
|
||||||
|
// single episode" affordance).
|
||||||
|
if mediaItem.Source == "podcast_episode" && mediaItem.SourceFeedID.Valid && c.QueryParam("single") != "1" {
|
||||||
|
userPG := uuidToPGType(userUUID)
|
||||||
|
if seq := buildPodcastSequenceContext(c, cfg, &mediaItem, userPG, libraryWithType.TypeName); seq != nil {
|
||||||
|
metadata.PodcastSequence = seq
|
||||||
|
}
|
||||||
|
}
|
||||||
// Render template
|
// Render template
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
// Audiobooks get the audio player page — the foliate document
|
// Audiobooks get the audio player page — the foliate document
|
||||||
@@ -144,3 +160,96 @@ func registerReaderRoutes(cfg *Config) {
|
|||||||
reader.GET("/settings", cfg.ReaderHandler.GetSettings)
|
reader.GET("/settings", cfg.ReaderHandler.GetSettings)
|
||||||
reader.PUT("/settings", cfg.ReaderHandler.UpdateSettings)
|
reader.PUT("/settings", cfg.ReaderHandler.UpdateSettings)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildPodcastSequenceContext computes the episode chain payload (plan
|
||||||
|
// D7): effective order — the D5 override applied, feed-native fallback —
|
||||||
|
// DOWNLOADED episodes only (gaps never enter the queue), the current
|
||||||
|
// episode's index, and prev/next reader URLs. RLS scopes the episode
|
||||||
|
// reads; non-subscribers never reach this path in the first place.
|
||||||
|
func buildPodcastSequenceContext(c *echo.Context, cfg *Config, item *database.MediaItems, userID pgtype.UUID, libraryType string) *templates.PodcastSequenceContext {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
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 && (v.Order == "oldest" || v.Order == "newest") {
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.SliceStable(ordered, func(a, b int) bool {
|
||||||
|
ka := ordered[a].date + fmt.Sprintf("%06d", ordered[a].pos)
|
||||||
|
kb := ordered[b].date + fmt.Sprintf("%06d", ordered[b].pos)
|
||||||
|
if order == "newest" {
|
||||||
|
return ka > kb
|
||||||
|
}
|
||||||
|
if order == "oldest" {
|
||||||
|
return ka < kb
|
||||||
|
}
|
||||||
|
return ordered[a].pos < ordered[b].pos
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
SidecarHandler *handlers.SidecarHandler
|
||||||
ReaderHandler *handlers.ReaderHandler
|
ReaderHandler *handlers.ReaderHandler
|
||||||
LibraryService *services.LibraryService
|
LibraryService *services.LibraryService
|
||||||
|
PodcastHandler *handlers.PodcastHandler
|
||||||
|
PodcastService *services.PodcastService
|
||||||
|
PodcastWorker *services.PodcastDownloadWorker
|
||||||
}
|
}
|
||||||
|
|
||||||
// getBaseURL returns the configured base URL from the database, falling back to
|
// getBaseURL returns the configured base URL from the database, falling back to
|
||||||
@@ -344,12 +347,14 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
|
|||||||
registerConflictRoutes(cfg)
|
registerConflictRoutes(cfg)
|
||||||
registerAnalyticsRoutes(cfg)
|
registerAnalyticsRoutes(cfg)
|
||||||
registerQueueRoutes(cfg)
|
registerQueueRoutes(cfg)
|
||||||
|
registerPodcastRoutes(cfg)
|
||||||
registerJobRoutes(cfg)
|
registerJobRoutes(cfg)
|
||||||
registerFiltersRoutes(cfg)
|
registerFiltersRoutes(cfg)
|
||||||
registerOPDSRoutes(cfg)
|
registerOPDSRoutes(cfg)
|
||||||
registerReaderRoutes(cfg)
|
registerReaderRoutes(cfg)
|
||||||
registerWebSocketRoutes(cfg)
|
registerWebSocketRoutes(cfg)
|
||||||
registerFrontendRoutes(cfg)
|
registerFrontendRoutes(cfg)
|
||||||
|
registerPodcastPages(cfg)
|
||||||
registerDocumentationRoutes(cfg)
|
registerDocumentationRoutes(cfg)
|
||||||
|
|
||||||
// Static bundles are served unhashed (reader.js, index.js), so the
|
// Static bundles are served unhashed (reader.js, index.js), so the
|
||||||
@@ -394,6 +399,14 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
|
|||||||
// Start background tasks (queue processor and connection cleanup)
|
// Start background tasks (queue processor and connection cleanup)
|
||||||
scannerHandler.StartBackgroundTasks()
|
scannerHandler.StartBackgroundTasks()
|
||||||
|
|
||||||
|
// Podcast subsystem background loops: sequential download worker +
|
||||||
|
// feed poller (plan §7). The poller re-reads its interval setting
|
||||||
|
// every tick; both exit with the server context.
|
||||||
|
if cfg.PodcastWorker != nil && cfg.PodcastService != nil {
|
||||||
|
cfg.PodcastWorker.Start(context.Background())
|
||||||
|
services.NewPodcastPoller(cfg.Queries, cfg.PodcastService, cfg.PodcastWorker, cfg.Settings).Start(context.Background())
|
||||||
|
}
|
||||||
|
|
||||||
// Start watch mode for all libraries (after 2 second delay for DB)
|
// Start watch mode for all libraries (after 2 second delay for DB)
|
||||||
go func() {
|
go func() {
|
||||||
time.Sleep(2 * time.Second)
|
time.Sleep(2 * time.Second)
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ const (
|
|||||||
LibraryTypeComics = "comics"
|
LibraryTypeComics = "comics"
|
||||||
LibraryTypeManga = "manga"
|
LibraryTypeManga = "manga"
|
||||||
LibraryTypeAudiobooks = "audiobooks"
|
LibraryTypeAudiobooks = "audiobooks"
|
||||||
|
// Podcasts libraries are feed containers (RSS subscribe+download),
|
||||||
|
// not FS-scanned media: AllowedExtensions is deliberately empty —
|
||||||
|
// the scanner skips these folders wholesale (podcast downloads are
|
||||||
|
// written by the fetcher's download worker, never discovered by
|
||||||
|
// the walker). See podcast_service.go.
|
||||||
|
LibraryTypePodcasts = "podcasts"
|
||||||
)
|
)
|
||||||
|
|
||||||
var AllowedExtensions = map[string][]string{
|
var AllowedExtensions = map[string][]string{
|
||||||
@@ -43,6 +49,9 @@ var AllowedExtensions = map[string][]string{
|
|||||||
// browser-playable lossless/next-gen containers join for Batch B
|
// browser-playable lossless/next-gen containers join for Batch B
|
||||||
// metadata parity (.wma deliberately absent: browsers can't play it).
|
// metadata parity (.wma deliberately absent: browsers can't play it).
|
||||||
LibraryTypeAudiobooks: {".m4b", ".m4a", ".mp3", ".flac", ".zip", ".opus", ".ogg", ".oga", ".aac", ".wav"},
|
LibraryTypeAudiobooks: {".m4b", ".m4a", ".mp3", ".flac", ".zip", ".opus", ".ogg", ".oga", ".aac", ".wav"},
|
||||||
|
// Empty BY DESIGN: nothing in a podcasts library is scanner-facing.
|
||||||
|
// The scanner additionally skips these folders outright.
|
||||||
|
LibraryTypePodcasts: {},
|
||||||
}
|
}
|
||||||
|
|
||||||
var MimeTypes = map[string]string{
|
var MimeTypes = map[string]string{
|
||||||
@@ -149,10 +158,39 @@ func (s *LibraryService) DeleteLibrary(ctx context.Context, libraryID pgtype.UUI
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AddLibraryFolder adds a folder to a library
|
// AddLibraryFolder adds a folder to a library
|
||||||
|
// Folder purposes (library_folders.purpose): 'scan' roots feed the FS
|
||||||
|
// walker; 'podcast' roots are download targets written only by the
|
||||||
|
// podcast download worker.
|
||||||
|
const (
|
||||||
|
FolderPurposeScan = "scan"
|
||||||
|
FolderPurposePodcast = "podcast"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AddLibraryFolder registers a folder against a library. The purpose is
|
||||||
|
// derived from the library type: podcasts libraries register their
|
||||||
|
// download root (purpose='podcast', exactly ONE folder — feeds lay out
|
||||||
|
// <download-root>/<feed-slug>/...); every other type registers a
|
||||||
|
// scanner root (purpose='scan').
|
||||||
func (s *LibraryService) AddLibraryFolder(ctx context.Context, libraryID pgtype.UUID, folderPath string) (*database.LibraryFolders, error) {
|
func (s *LibraryService) AddLibraryFolder(ctx context.Context, libraryID pgtype.UUID, folderPath string) (*database.LibraryFolders, error) {
|
||||||
|
purpose := FolderPurposeScan
|
||||||
|
|
||||||
|
lib, err := s.db.GetLibrary(ctx, libraryID)
|
||||||
|
if err == nil && lib.TypeName == LibraryTypePodcasts {
|
||||||
|
// Podcasts libraries own exactly one folder: the download root.
|
||||||
|
existing, err := s.db.GetLibraryFolders(ctx, libraryID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(existing) > 0 {
|
||||||
|
return nil, fmt.Errorf("podcasts libraries have a single download root; %s is already registered", existing[0].FolderPath)
|
||||||
|
}
|
||||||
|
purpose = FolderPurposePodcast
|
||||||
|
}
|
||||||
|
|
||||||
folder, err := s.db.AddLibraryFolder(ctx, database.AddLibraryFolderParams{
|
folder, err := s.db.AddLibraryFolder(ctx, database.AddLibraryFolderParams{
|
||||||
LibraryID: libraryID,
|
LibraryID: libraryID,
|
||||||
FolderPath: folderPath,
|
FolderPath: folderPath,
|
||||||
|
Purpose: purpose,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -504,6 +504,12 @@ func (s *MediaScanner) GetStats() (int, int, int) {
|
|||||||
return s.totalFiles, s.newItems, s.errors
|
return s.totalFiles, s.newItems, s.errors
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FolderCount reports the active root-folder count (test/audit helper:
|
||||||
|
// the podcast-exclusion pin asserts it stays 0 for podcasts roots).
|
||||||
|
func (s *MediaScanner) FolderCount() int {
|
||||||
|
return len(s.folders)
|
||||||
|
}
|
||||||
|
|
||||||
// SetFolders configures the scanner's folders and (optionally) sets up an
|
// SetFolders configures the scanner's folders and (optionally) sets up an
|
||||||
// fsnotify watcher over the full directory tree.
|
// fsnotify watcher over the full directory tree.
|
||||||
//
|
//
|
||||||
@@ -512,9 +518,34 @@ func (s *MediaScanner) GetStats() (int, int, int) {
|
|||||||
// watcher (and thus no fd/inotify watches) is allocated — the watcher is never
|
// watcher (and thus no fd/inotify watches) is allocated — the watcher is never
|
||||||
// read by scan jobs and previously leaked one watcher per job.
|
// read by scan jobs and previously leaked one watcher per job.
|
||||||
func (s *MediaScanner) SetFolders(folders []string, watch bool) error {
|
func (s *MediaScanner) SetFolders(folders []string, watch bool) error {
|
||||||
s.folders = folders
|
// Podcasts libraries are feed-managed (plan: podcast-integration.md
|
||||||
|
// task 1.2, invariant 4): their download roots are written by the
|
||||||
|
// download worker, never discovered by the walker — including
|
||||||
|
// scoped single-folder scans (0f1d0abb), which all funnel through
|
||||||
|
// here. Filter them out before anything else touches the list.
|
||||||
|
scannable := folders[:0:0]
|
||||||
|
for _, folder := range folders {
|
||||||
|
lib, err := s.db.GetLibraryByFolder(context.Background(), folder)
|
||||||
|
if err != nil {
|
||||||
|
// Unresolvable folders keep the old behavior (scan walk
|
||||||
|
// skips them per-file; watch adds them harmlessly).
|
||||||
|
scannable = append(scannable, folder)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
libType, err := s.db.GetLibraryType(context.Background(), lib.LibraryTypeID)
|
||||||
|
if err != nil {
|
||||||
|
scannable = append(scannable, folder)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if libType.Name == LibraryTypePodcasts {
|
||||||
|
fmt.Printf("Scanner: skipping %s (podcasts library %q — feed-managed, not scanned)\n", folder, lib.Name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
scannable = append(scannable, folder)
|
||||||
|
}
|
||||||
|
folders = scannable
|
||||||
|
|
||||||
// Always close any previously-owned watcher so reconfiguration doesn't leak.
|
s.folders = folders // Always close any previously-owned watcher so reconfiguration doesn't leak.
|
||||||
if s.watcher != nil {
|
if s.watcher != nil {
|
||||||
if err := s.watcher.Close(); err != nil {
|
if err := s.watcher.Close(); err != nil {
|
||||||
fmt.Printf("Warning: failed to close old watcher during folder reconfiguration: %v\n", err)
|
fmt.Printf("Warning: failed to close old watcher during folder reconfiguration: %v\n", err)
|
||||||
@@ -524,6 +555,12 @@ func (s *MediaScanner) SetFolders(folders []string, watch bool) error {
|
|||||||
|
|
||||||
// Create + populate a fresh watcher only when the caller intends to read events.
|
// Create + populate a fresh watcher only when the caller intends to read events.
|
||||||
if watch {
|
if watch {
|
||||||
|
if len(folders) == 0 {
|
||||||
|
// All requested folders filtered (e.g. a podcasts library:
|
||||||
|
// nothing scannable, nothing to watch). Fail loudly at the
|
||||||
|
// API boundary instead of panicking on folders[0] below.
|
||||||
|
return fmt.Errorf("no scannable folders (podcasts libraries are feed-managed and cannot be scanned)")
|
||||||
|
}
|
||||||
watcher, err := fsnotify.NewWatcher()
|
watcher, err := fsnotify.NewWatcher()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Return an error instead of panicking so a failed watcher can't
|
// Return an error instead of panicking so a failed watcher can't
|
||||||
|
|||||||
@@ -110,6 +110,14 @@ func (w *MetadataWriter) WriteMetadataToFile(ctx context.Context, itemID uuid.UU
|
|||||||
return nil, fmt.Errorf("media item not found: %w", err)
|
return nil, fmt.Errorf("media item not found: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Edit segregation (plan D10/D9, task 1.8): podcast episodes are
|
||||||
|
// feed-managed; writing their metadata into the file would be
|
||||||
|
// clobbered by the next download/refresh and violates the
|
||||||
|
// fetcher's sole-writer rule.
|
||||||
|
if item.Source == "podcast_episode" {
|
||||||
|
return nil, fmt.Errorf("podcast episodes are managed by their feed; metadata write-to-file is not available")
|
||||||
|
}
|
||||||
|
|
||||||
absPath, err := w.libraries.ResolveMediaPath(ctx, item.LibraryID, item.FilePath)
|
absPath, err := w.libraries.ResolveMediaPath(ctx, item.LibraryID, item.FilePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("cannot resolve library folder: %w", err)
|
return nil, fmt.Errorf("cannot resolve library folder: %w", err)
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
// SSRF-guarded HTTP fetching for the podcast fetcher (plan §7).
|
||||||
|
//
|
||||||
|
// This is the code path that turns USER-SUPPLIED URLs into server
|
||||||
|
// fetches — treat it as a security boundary, not a convenience wrapper:
|
||||||
|
//
|
||||||
|
// - https-only schemes (feed URL, enclosure, artwork, EVERY redirect hop)
|
||||||
|
// - private/loopback/link-local/multicast/unspecified IP ranges blocked
|
||||||
|
// at DIAL time via Dialer.Control — the check sees the RESOLVED
|
||||||
|
// address, so a DNS name that rebinding-resolves to 127.0.0.1 (or a
|
||||||
|
// redirect into the internal network) is refused at the socket, not
|
||||||
|
// merely at URL-parse time
|
||||||
|
// - hard response size caps on every body (feed XML, artwork, enclosures)
|
||||||
|
//
|
||||||
|
// Go's redirect follower re-dials through the same dialer per hop, so
|
||||||
|
// the IP guard re-applies automatically; the scheme guard re-applies
|
||||||
|
// via CheckRedirect.
|
||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Response size caps (bytes). Feeds are XML; artwork is an image;
|
||||||
|
// enclosures are audio episodes — the enclosure cap is deliberately the
|
||||||
|
// large one (audiobook chapters run long) but still bounded.
|
||||||
|
const (
|
||||||
|
maxFeedSizeBytes = 25 << 20 // 25 MiB
|
||||||
|
maxArtworkSizeBytes = 20 << 20 // 20 MiB
|
||||||
|
maxEnclosureCapBytes = 1 << 30 // 1 GiB
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrBlockedPrivateAddress marks SSRF-guard rejections (dial-time IP or
|
||||||
|
// scheme checks). Handlers surface it as a validation failure, and the
|
||||||
|
// download worker records it as the download_error text.
|
||||||
|
var ErrBlockedPrivateAddress = errors.New("address blocked: private, loopback, or otherwise non-public")
|
||||||
|
|
||||||
|
// podcastHTTPClient builds the shared guard client. Timeout is generous
|
||||||
|
// (podcast servers are slow) but bounded; enclosures stream through a
|
||||||
|
// LimitReader so a lying Content-Length cannot exhaust disk.
|
||||||
|
func podcastHTTPClient() *http.Client {
|
||||||
|
dialer := &net.Dialer{
|
||||||
|
Timeout: 15 * time.Second,
|
||||||
|
Control: func(network, address string, _ syscall.RawConn) error {
|
||||||
|
return guardDialAddress(address)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
transport := &http.Transport{
|
||||||
|
DialContext: dialer.DialContext,
|
||||||
|
TLSHandshakeTimeout: 15 * time.Second,
|
||||||
|
ResponseHeaderTimeout: 60 * time.Second,
|
||||||
|
// Enclosure downloads are long; idle conn reuse still applies.
|
||||||
|
IdleConnTimeout: 90 * time.Second,
|
||||||
|
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
|
||||||
|
Proxy: nil, // never route through a user-controllable proxy
|
||||||
|
}
|
||||||
|
return &http.Client{
|
||||||
|
Transport: transport,
|
||||||
|
Timeout: 15 * time.Minute, // outer bound (biggest enclosures on slow lines)
|
||||||
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
|
if len(via) >= 10 {
|
||||||
|
return fmt.Errorf("stopped after 10 redirects")
|
||||||
|
}
|
||||||
|
if err := guardURLScheme(req.URL); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// guardDialAddress validates the RESOLVED host:port at dial time.
|
||||||
|
func guardDialAddress(address string) error {
|
||||||
|
host, _, err := net.SplitHostPort(address)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("dial guard: %w", err)
|
||||||
|
}
|
||||||
|
ip := net.ParseIP(host)
|
||||||
|
if ip == nil {
|
||||||
|
return fmt.Errorf("dial guard: unparseable address %q", host)
|
||||||
|
}
|
||||||
|
if !isPublicIP(ip) {
|
||||||
|
return ErrBlockedPrivateAddress
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// guardURLScheme enforces https on any URL we are about to touch.
|
||||||
|
func guardURLScheme(u *url.URL) error {
|
||||||
|
if u == nil || u.Scheme != "https" {
|
||||||
|
return fmt.Errorf("only https feed/enclosure URLs are supported")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isPublicIP reports whether ip is a routable public unicast address.
|
||||||
|
// Everything loopback/private/link-local/multicast/unspecified —
|
||||||
|
// including IPv4-mapped IPv6 spellings of those — is rejected.
|
||||||
|
func isPublicIP(ip net.IP) bool {
|
||||||
|
if ip == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Normalize IPv4-mapped IPv6 (::ffff:10.0.0.1) to its v4 form so
|
||||||
|
// the v4 range checks below actually fire.
|
||||||
|
if ip4 := ip.To4(); ip4 != nil {
|
||||||
|
ip = ip4
|
||||||
|
}
|
||||||
|
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
|
||||||
|
ip.IsLinkLocalMulticast() || ip.IsInterfaceLocalMulticast() ||
|
||||||
|
ip.IsMulticast() || ip.IsUnspecified() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// guardFetch performs a GET with the scheme/IP guards, conditional-GET
|
||||||
|
// validators, and a hard body cap. Returns the body bytes, the FINAL
|
||||||
|
// URL after redirects (new-feed-url / migration detection), validators
|
||||||
|
// to persist, and whether the server answered 304 Not Modified.
|
||||||
|
type fetchResult struct {
|
||||||
|
Body []byte
|
||||||
|
FinalURL string
|
||||||
|
ETag string
|
||||||
|
LastModified string
|
||||||
|
NotModified bool
|
||||||
|
StatusCode int
|
||||||
|
}
|
||||||
|
|
||||||
|
// podcastHTTPClientFactory is the indirection TESTS use: the SSRF suite
|
||||||
|
// exercises the REAL client; fetch-driven integration tests swap in a
|
||||||
|
// client that trusts httptest's loopback TLS cert. Never reassign
|
||||||
|
// outside tests (use SetPodcastHTTPClientForTests).
|
||||||
|
var podcastHTTPClientFactory = podcastHTTPClient
|
||||||
|
|
||||||
|
// SetPodcastHTTPClientForTests swaps the fetch client. nil restores the
|
||||||
|
// production client (SSRF guards intact).
|
||||||
|
func SetPodcastHTTPClientForTests(c *http.Client) {
|
||||||
|
if c == nil {
|
||||||
|
podcastHTTPClientFactory = podcastHTTPClient
|
||||||
|
return
|
||||||
|
}
|
||||||
|
podcastHTTPClientFactory = func() *http.Client { return c }
|
||||||
|
}
|
||||||
|
|
||||||
|
func guardFetch(ctx context.Context, rawURL, etag, lastModified string, maxSize int64) (*fetchResult, error) {
|
||||||
|
u, err := url.Parse(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid URL: %w", err)
|
||||||
|
}
|
||||||
|
if err := guardURLScheme(u); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// A recognizable UA; some podcast hosts 403 empty/default agents.
|
||||||
|
req.Header.Set("User-Agent", "Bookhoard/1.0 (podcast fetcher; +https://github.com/bookhoard)")
|
||||||
|
req.Header.Set("Accept", "*/*")
|
||||||
|
if etag != "" {
|
||||||
|
req.Header.Set("If-None-Match", etag)
|
||||||
|
}
|
||||||
|
if lastModified != "" {
|
||||||
|
req.Header.Set("If-Modified-Since", lastModified)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := podcastHTTPClientFactory().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
// Unwrap redirect/scheme guard errors so callers can classify.
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
res := &fetchResult{
|
||||||
|
FinalURL: resp.Request.URL.String(),
|
||||||
|
ETag: resp.Header.Get("ETag"),
|
||||||
|
LastModified: resp.Header.Get("Last-Modified"),
|
||||||
|
StatusCode: resp.StatusCode,
|
||||||
|
}
|
||||||
|
if resp.StatusCode == http.StatusNotModified {
|
||||||
|
res.NotModified = true
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return res, fmt.Errorf("fetch %s: HTTP %d", rawURL, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, maxSize+1))
|
||||||
|
if err != nil {
|
||||||
|
return res, fmt.Errorf("fetch %s: %w", rawURL, err)
|
||||||
|
}
|
||||||
|
if int64(len(body)) > maxSize {
|
||||||
|
return res, fmt.Errorf("fetch %s: response exceeds %d byte cap", rawURL, maxSize)
|
||||||
|
}
|
||||||
|
res.Body = body
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,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,785 @@
|
|||||||
|
// 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)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,627 @@
|
|||||||
|
// Podcast download worker (plan §7 / D6 / D8 / D19): ONE sequential
|
||||||
|
// worker (day one) 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).
|
||||||
|
//
|
||||||
|
// 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"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"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
|
||||||
|
wake chan struct{}
|
||||||
|
|
||||||
|
// 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),
|
||||||
|
progress: make(map[string]*episodeProgress),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// drain processes queued episodes until the queue is empty.
|
||||||
|
func (w *PodcastDownloadWorker) drain(ctx context.Context) {
|
||||||
|
for {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
acted, err := w.processNext(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[PODCAST-DL] worker step: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !acted {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueueFeedDownloads implements "Download All" (D8): queue exactly the
|
||||||
|
// non-downloaded set — pending AND failed rows (retry is the same path).
|
||||||
|
func (w *PodcastDownloadWorker) QueueFeedDownloads(ctx context.Context, 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
|
||||||
|
}
|
||||||
|
queued := 0
|
||||||
|
for _, ep := range eps {
|
||||||
|
if _, err := w.db.SetPodcastEpisodeDownloadQueued(actx, ep.ID); err == nil {
|
||||||
|
queued++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.Wake()
|
||||||
|
return queued, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// processNext claims and processes one queued episode. Returns false
|
||||||
|
// when the queue was empty.
|
||||||
|
func (w *PodcastDownloadWorker) processNext(ctx context.Context) (bool, error) {
|
||||||
|
actx, err := w.svc.adminCtx(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
ep, err := w.db.GetNextQueuedEpisode(actx)
|
||||||
|
if err != nil {
|
||||||
|
return false, nil // empty queue (or read failure — logged upstream)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = w.db.SetPodcastEpisodeDownloading(actx, ep.ID)
|
||||||
|
|
||||||
|
if err := w.runPipeline(actx, ep); err != nil {
|
||||||
|
// failed/rejected: state + reason persist; metadata/file facts
|
||||||
|
// untouched (invariant 7 — write-after-success only).
|
||||||
|
_ = 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)
|
||||||
|
}
|
||||||
|
return true, 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
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])
|
||||||
|
}
|
||||||
@@ -296,6 +296,13 @@ func (s *StatisticsService) UserSummary(ctx context.Context, userID pgtype.UUID,
|
|||||||
entry.Sessions++
|
entry.Sessions++
|
||||||
summary.SessionCount++
|
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])
|
mediaUUID, err := uuid.FromBytes(sess.MediaItemID.Bytes[0:16])
|
||||||
if err == nil {
|
if err == nil {
|
||||||
key := mediaUUID.String()
|
key := mediaUUID.String()
|
||||||
|
|||||||
@@ -8,3 +8,11 @@ package version
|
|||||||
// The release workflow passes the git tag (e.g. v1.5) as the APP_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.
|
// Docker build arg, which the Dockerfile forwards to this flag.
|
||||||
var Version = "dev"
|
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"
|
||||||
@@ -7,7 +7,32 @@ package templates
|
|||||||
// <audio> element streams the m4b through the existing cookie-authed
|
// <audio> element streams the m4b through the existing cookie-authed
|
||||||
// Range-serving /uploads path.
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
templ AudioReader(user User, metadata ReaderMetadata) {
|
templ AudioReader(user User, metadata ReaderMetadata) {
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
@@ -41,9 +66,7 @@ templ AudioReader(user User, metadata ReaderMetadata) {
|
|||||||
data-cover={ metadata.CoverImagePath }
|
data-cover={ metadata.CoverImagePath }
|
||||||
data-file-url={ metadata.FileURL }
|
data-file-url={ metadata.FileURL }
|
||||||
data-keep-awake={ metadata.KeepScreenAwake }
|
data-keep-awake={ metadata.KeepScreenAwake }
|
||||||
data-json={ fmt.Sprintf(`{"chapterEndpoint":"%s","progressEndpoint":"%s"}`,
|
data-json={ AudioReaderConfigJSON(metadata) }
|
||||||
"/api/media-items/"+metadata.MediaItemID+"/chapters",
|
|
||||||
"/api/media-items/"+metadata.MediaItemID+"/progress") }
|
|
||||||
>
|
>
|
||||||
<!-- §17 wind-down dim: the sleep ticker eases this toward 35%
|
<!-- §17 wind-down dim: the sleep ticker eases this toward 35%
|
||||||
black across a countdown's final 30s; pointer-events-none
|
black across a countdown's final 30s; pointer-events-none
|
||||||
@@ -55,6 +78,7 @@ templ AudioReader(user User, metadata ReaderMetadata) {
|
|||||||
<!-- Header: back to the detail page (reader-style exit) and the
|
<!-- Header: back to the detail page (reader-style exit) and the
|
||||||
chapter launcher. Floating drawer, never in-flow (A2b). -->
|
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">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="backToDetail()"
|
@click="backToDetail()"
|
||||||
@@ -67,6 +91,24 @@ templ AudioReader(user User, metadata ReaderMetadata) {
|
|||||||
<path d="m12 19-7-7 7-7"></path>
|
<path d="m12 19-7-7 7-7"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<!-- Podcast sequence (D7): episode prev/next + "Ep N of M".
|
||||||
|
Rendered only when the server supplied sequence context. -->
|
||||||
|
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>
|
||||||
|
if metadata.PodcastSequence.PrevURL != "" {
|
||||||
|
<a href={ metadata.PodcastSequence.PrevURL } class="rounded-full p-2 hover:bg-surface-hover transition-colors" style="color: var(--text-primary);" aria-label="Previous episode" title="Previous episode">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M6 5h2v14H6zM20 5v14l-11-7z"></path></svg>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
if metadata.PodcastSequence.NextURL != "" {
|
||||||
|
<a href={ metadata.PodcastSequence.NextURL } class="rounded-full p-2 hover:bg-surface-hover transition-colors" style="color: var(--text-primary);" aria-label={ "Next episode: " + metadata.PodcastSequence.NextTitle } title={ "Next: " + metadata.PodcastSequence.NextTitle }>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M16 5h2v14h-2zM4 5v14l11-7z"></path></svg>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</span>
|
||||||
<span class="inline-flex items-center gap-2">
|
<span class="inline-flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
+178
-51
@@ -15,7 +15,32 @@ import templruntime "github.com/a-h/templ/runtime"
|
|||||||
// <audio> element streams the m4b through the existing cookie-authed
|
// <audio> element streams the m4b through the existing cookie-authed
|
||||||
// Range-serving /uploads path.
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
||||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
@@ -45,7 +70,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
|||||||
var templ_7745c5c3_Var2 string
|
var templ_7745c5c3_Var2 string
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs("Listening · " + metadata.Title)
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs("Listening · " + metadata.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 16, Col: 44}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 41, Col: 44}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -80,7 +105,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
|||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.MediaItemID)
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.MediaItemID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 39, Col: 44}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 64, Col: 44}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -93,7 +118,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
|||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.Title)
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
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: 65, Col: 30}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -106,7 +131,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
|||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.CoverImagePath)
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.CoverImagePath)
|
||||||
if templ_7745c5c3_Err != nil {
|
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: 66, Col: 39}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -119,7 +144,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
|||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.FileURL)
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.FileURL)
|
||||||
if templ_7745c5c3_Err != nil {
|
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: 67, Col: 35}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -132,7 +157,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
|||||||
var templ_7745c5c3_Var9 string
|
var templ_7745c5c3_Var9 string
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.KeepScreenAwake)
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.KeepScreenAwake)
|
||||||
if templ_7745c5c3_Err != nil {
|
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: 68, Col: 45}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -143,120 +168,222 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf(`{"chapterEndpoint":"%s","progressEndpoint":"%s"}`,
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(AudioReaderConfigJSON(metadata))
|
||||||
"/api/media-items/"+metadata.MediaItemID+"/chapters",
|
|
||||||
"/api/media-items/"+metadata.MediaItemID+"/progress"))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
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: 69, Col: 46}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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, "\"><!-- §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): episode prev/next + \"Ep N of M\".\n\t\t\t\t\t Rendered only when the server supplied sequence context. -->")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
for _, rate := range SpeedOptions {
|
if metadata.PodcastSequence != nil {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<option value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<span class=\"text-xs tabular-nums ml-1\" style=\"color: var(--text-secondary)\">Ep ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var11 string
|
var templ_7745c5c3_Var11 string
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%g", rate))
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", metadata.PodcastSequence.EpisodeIndex))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 169, Col: 41}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 98, Col: 68}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" :selected=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "/")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var12 string
|
var templ_7745c5c3_Var12 string
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("isDefaultSpeed(%g)", rate))
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", metadata.PodcastSequence.IndexTotal))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 170, Col: 61}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 98, Col: 127}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</span> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var13 string
|
if metadata.PodcastSequence.PrevURL != "" {
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%g×", rate))
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<a href=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 171, Col: 37}
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var13 templ.SafeURL
|
||||||
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs(metadata.PodcastSequence.PrevURL)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 101, Col: 49}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</option>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary);\" aria-label=\"Previous episode\" title=\"Previous episode\"><svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\"><path d=\"M6 5h2v14H6zM20 5v14l-11-7z\"></path></svg></a>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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);\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, " ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if metadata.CoverImagePath != "" {
|
if metadata.PodcastSequence.NextURL != "" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<img src=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<a href=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var14 string
|
var templ_7745c5c3_Var14 templ.SafeURL
|
||||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.CoverImagePath)
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinURLErrs(metadata.PodcastSequence.NextURL)
|
||||||
if templ_7745c5c3_Err != nil {
|
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: 106, Col: 49}
|
||||||
}
|
}
|
||||||
_, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" alt=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary);\" aria-label=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var15 string
|
var templ_7745c5c3_Var15 string
|
||||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.Title)
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("Next episode: " + metadata.PodcastSequence.NextTitle)
|
||||||
if templ_7745c5c3_Err != nil {
|
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: 106, Col: 220}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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, 19, "\" title=\"")
|
||||||
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>")
|
|
||||||
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);\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var16 string
|
var templ_7745c5c3_Var16 string
|
||||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title)
|
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("Next: " + metadata.PodcastSequence.NextTitle)
|
||||||
if templ_7745c5c3_Err != nil {
|
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: 106, Col: 276}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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, 20, "\"><svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\"><path d=\"M16 5h2v14h-2zM4 5v14l11-7z\"></path></svg></a>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</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, 22, "<option value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var17 string
|
||||||
|
templ_7745c5c3_Var17, 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: 211, Col: 41}
|
||||||
|
}
|
||||||
|
_, 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, 23, "\" :selected=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var18 string
|
||||||
|
templ_7745c5c3_Var18, 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: 212, Col: 61}
|
||||||
|
}
|
||||||
|
_, 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, "\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var19 string
|
||||||
|
templ_7745c5c3_Var19, 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: 213, Col: 37}
|
||||||
|
}
|
||||||
|
_, 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, 25, "</option>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</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, 27, "<img src=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var20 string
|
||||||
|
templ_7745c5c3_Var20, 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: 227, Col: 36}
|
||||||
|
}
|
||||||
|
_, 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, 28, "\" alt=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var21 string
|
||||||
|
templ_7745c5c3_Var21, 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: 228, Col: 27}
|
||||||
|
}
|
||||||
|
_, 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, 29, "\" 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, 30, "<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, 31, "</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_Var22 string
|
||||||
|
templ_7745c5c3_Var22, 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: 237, Col: 117}
|
||||||
|
}
|
||||||
|
_, 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, 32, "</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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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>
|
<span class="badge ml-1" style="background-color: var(--accent-muted); color: var(--accent);">{ book.NotesCount + book.HighlightsCount }</span>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
if user.Role == "admin" {
|
if user.Role == "admin" && book.LibraryTypeName != "podcasts" {
|
||||||
<button @click="showMetadataEditor()" class="btn btn-secondary px-5 py-2.5">
|
<button @click="showMetadataEditor()" class="btn btn-secondary px-5 py-2.5">
|
||||||
@Icon("edit", "h-4 w-4")
|
@Icon("edit", "h-4 w-4")
|
||||||
<span>Edit</span>
|
<span>Edit</span>
|
||||||
|
|||||||
@@ -566,7 +566,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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\">")
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
|
|||||||
@@ -70,6 +70,12 @@ templ Header(user User, currentPath string) {
|
|||||||
@Icon("layers", "h-5 w-5 shrink-0")
|
@Icon("layers", "h-5 w-5 shrink-0")
|
||||||
<span>Series</span>
|
<span>Series</span>
|
||||||
</a>
|
</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") }>
|
<a href="/collections" class={ activeClass(currentPath, "/collections") }>
|
||||||
@Icon("folder", "h-5 w-5 shrink-0")
|
@Icon("folder", "h-5 w-5 shrink-0")
|
||||||
<span>Collections</span>
|
<span>Collections</span>
|
||||||
@@ -346,4 +352,24 @@ templ SidebarSignIn(currentPath string) {
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 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. -->
|
||||||
|
<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>
|
||||||
}
|
}
|
||||||
+260
-228
@@ -131,12 +131,13 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var8 = []any{activeClass(currentPath, "/collections")}
|
if user.ShowPodcastsNav {
|
||||||
|
var templ_7745c5c3_Var8 = []any{activeClass(currentPath, "/podcasts")}
|
||||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var8...)
|
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var8...)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<a href=\"/collections\" class=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<a href=\"/podcasts\" class=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -153,20 +154,21 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = Icon("folder", "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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<span>Collections</span></a> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<span>Podcasts</span></a> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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...)
|
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var10...)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -183,20 +185,20 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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...)
|
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var12...)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -213,20 +215,20 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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...)
|
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var14...)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -243,24 +245,54 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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)
|
templ_7745c5c3_Err = Icon("device", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var16 string
|
var templ_7745c5c3_Var18 string
|
||||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("sidebarShell('" + string(ThemeModeOf(user.Theme)) + "')")
|
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("sidebarShell('" + string(ThemeModeOf(user.Theme)) + "')")
|
||||||
if templ_7745c5c3_Err != nil {
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -275,7 +307,7 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -283,7 +315,7 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -291,48 +323,14 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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=\"")
|
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_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=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var19 string
|
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 {
|
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)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -342,116 +340,150 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var20 string
|
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 {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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
|
|
||||||
}
|
|
||||||
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var21 string
|
var templ_7745c5c3_Var21 string
|
||||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("themeMode === '" + string(opt.Mode) + "'")
|
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("themeMode === 'light' ? 'mode-toggle-active' : 'text-content-muted hover:text-content'")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 145, Col: 60}
|
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)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" data-theme=\"")
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var22 string
|
var templ_7745c5c3_Var22 string
|
||||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Name)
|
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(len(ThemeOptionsFor(ModeLight)))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 146, Col: 30}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 141, Col: 79}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\" data-mode=\"")
|
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, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var23 string
|
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 {
|
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)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var24 string
|
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 {
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var25 string
|
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 {
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var26 = []any{"theme-check shrink-0" + themeCheckClass(opt.Name, user.Theme)}
|
var templ_7745c5c3_Var26 string
|
||||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var26...)
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var27 string
|
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 {
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -459,94 +491,94 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
for _, w := range WoodOptions {
|
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 + "')")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 163, Col: 55}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
|
|
||||||
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var30 string
|
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;")
|
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue("changeWoodPaneling('" + w.Name + "')")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 171, Col: 191}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 169, Col: 55}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\"></span> ")
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<span class=\"flex-1 text-left\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var31 string
|
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 {
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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, 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, 55, "</div></div><!-- Admin section (visible to admins only) -->")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Role == "admin" {
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -554,46 +586,16 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var32 = []any{activeClass(currentPath, "/admin")}
|
var templ_7745c5c3_Var34 = []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")}
|
|
||||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var34...)
|
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var34...)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -610,20 +612,20 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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...)
|
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var36...)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -640,20 +642,20 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var38 = []any{activeClass(currentPath, "/admin/users")}
|
var templ_7745c5c3_Var38 = []any{activeClass(currentPath, "/admin/hash-conflicts")}
|
||||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var38...)
|
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var38...)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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/hash-conflicts\" class=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -670,20 +672,20 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = Icon("users", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
templ_7745c5c3_Err = Icon("copy", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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>Hash Conflicts</span></a> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var40 = []any{activeClass(currentPath, "/admin/settings")}
|
var templ_7745c5c3_Var40 = []any{activeClass(currentPath, "/admin/users")}
|
||||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var40...)
|
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var40...)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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/users\" class=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -700,16 +702,46 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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, 69, "<span>Users</span></a> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var42 = []any{activeClass(currentPath, "/admin/settings")}
|
||||||
|
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/settings\" 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("gear", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
templ_7745c5c3_Err = Icon("gear", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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, 72, "<span>Settings</span></a></div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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, 73, "</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\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -717,7 +749,7 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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, 74, "</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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -725,20 +757,20 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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, 75, "</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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var42 string
|
var templ_7745c5c3_Var44 string
|
||||||
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.ResolveAttributeValue("/static/main.js?v=" + MainJSVersion())
|
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.ResolveAttributeValue("/static/main.js?v=" + MainJSVersion())
|
||||||
if templ_7745c5c3_Err != nil {
|
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: 278, Col: 68}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var42)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var44)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "\"></script></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "\"></script></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -763,12 +795,12 @@ func SidebarUserMenu(user User) templ.Component {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
ctx = templ.InitializeContext(ctx)
|
ctx = templ.InitializeContext(ctx)
|
||||||
templ_7745c5c3_Var43 := templ.GetChildren(ctx)
|
templ_7745c5c3_Var45 := templ.GetChildren(ctx)
|
||||||
if templ_7745c5c3_Var43 == nil {
|
if templ_7745c5c3_Var45 == nil {
|
||||||
templ_7745c5c3_Var43 = templ.NopComponent
|
templ_7745c5c3_Var45 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
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, 77, "<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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -776,20 +808,20 @@ func SidebarUserMenu(user User) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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, 78, "</span> <span class=\"flex-1 text-left truncate\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var44 string
|
var templ_7745c5c3_Var46 string
|
||||||
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
|
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
|
||||||
if templ_7745c5c3_Err != nil {
|
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: 294, 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_Var46))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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, 79, "</span> <span class=\"h-4 w-4 shrink-0 transition-transform\" :class=\"{ 'rotate-180': openPanel === 'user' }\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -797,7 +829,7 @@ func SidebarUserMenu(user User) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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, 80, "</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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -805,7 +837,7 @@ func SidebarUserMenu(user User) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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, 81, "<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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -813,7 +845,7 @@ func SidebarUserMenu(user User) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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, 82, "<span>Logout</span></button></div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -839,12 +871,12 @@ func SidebarSignIn(currentPath string) templ.Component {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
ctx = templ.InitializeContext(ctx)
|
ctx = templ.InitializeContext(ctx)
|
||||||
templ_7745c5c3_Var45 := templ.GetChildren(ctx)
|
templ_7745c5c3_Var47 := templ.GetChildren(ctx)
|
||||||
if templ_7745c5c3_Var45 == nil {
|
if templ_7745c5c3_Var47 == nil {
|
||||||
templ_7745c5c3_Var45 = templ.NopComponent
|
templ_7745c5c3_Var47 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
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, 83, "<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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -852,7 +884,7 @@ func SidebarSignIn(currentPath string) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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, 84, "</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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -860,30 +892,30 @@ func SidebarSignIn(currentPath string) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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, 85, "</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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var46 string
|
var templ_7745c5c3_Var48 string
|
||||||
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath)
|
templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath)
|
||||||
if templ_7745c5c3_Err != nil {
|
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: 342, Col: 60}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var46)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var48)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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, 86, "\"> <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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if SignupVisible() {
|
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, 87, "<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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</div></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "</div></div><!-- Global confirm modal (issue #5): ONE styled dialog answers every\n\t hx-confirm sitewide via the htmx:confirm interceptor in\n\t web/src/confirm.ts, and exposes the confirmDialog() promise for\n\t legacy JS call sites. Inert until shown. --><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>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,12 @@ templ Icon(name string, extra string) {
|
|||||||
case "library":
|
case "library":
|
||||||
<path d="M3 21h18"></path>
|
<path d="M3 21h18"></path>
|
||||||
<path d="M5 21V7l7-4 7 4v14"></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":
|
case "layers":
|
||||||
<path d="m12 3 9 5-9 5-9-5 9-5z"></path>
|
<path d="m12 3 9 5-9 5-9-5 9-5z"></path>
|
||||||
<path d="m3 13 9 5 9-5"></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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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":
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
case "folder":
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
case "chart":
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
case "device":
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
case "trash":
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
case "save":
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
case "filter":
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
case "check":
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
case "check-circle":
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
case "x-circle":
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
case "circle":
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
case "bookmark":
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
case "star":
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
case "arrow-right":
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
case "arrow-left":
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
case "home":
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
case "logout":
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
case "plus":
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
case "edit":
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
case "info":
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
case "alert":
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
case "clock":
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
case "sync":
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
case "download":
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
case "upload":
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
case "copy":
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
case "external":
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
case "list":
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
case "grid":
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
case "play":
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
case "heart":
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
case "calendar":
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
case "tag":
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
case "globe":
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
case "shield":
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
case "database":
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
case "headphones":
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
case "comic":
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
case "manga":
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
default:
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ templ LibrarySwitcher(libData []LibraryData, currentLibraryID string, title stri
|
|||||||
>
|
>
|
||||||
<option value="">All Libraries ({ TotalMediaCount(libData) })</option>
|
<option value="">All Libraries ({ TotalMediaCount(libData) })</option>
|
||||||
for _, lib := range libData {
|
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.Offline {
|
||||||
if lib.ID == currentLibraryID {
|
if lib.ID == currentLibraryID {
|
||||||
<option value={ lib.ID } selected>{ lib.Name } ({ lib.MediaCount }) — storage offline</option>
|
<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
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
for _, lib := range libData {
|
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.Offline {
|
||||||
if lib.ID == currentLibraryID {
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
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)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
} else {
|
} 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
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)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var9 string
|
var templ_7745c5c3_Var9 string
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if lib.ID == currentLibraryID {
|
} 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
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)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var11 string
|
var templ_7745c5c3_Var11 string
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var12 string
|
var templ_7745c5c3_Var12 string
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
} else {
|
} 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var13 string
|
var templ_7745c5c3_Var13 string
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
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)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var14 string
|
var templ_7745c5c3_Var14 string
|
||||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var15 string
|
var templ_7745c5c3_Var15 string
|
||||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -273,7 +287,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(actions) > 0 {
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -283,12 +297,12 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
|
|||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -317,7 +331,7 @@ func DashboardActions() templ.Component {
|
|||||||
templ_7745c5c3_Var16 = templ.NopComponent
|
templ_7745c5c3_Var16 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -325,7 +339,7 @@ func DashboardActions() templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -333,7 +347,7 @@ func DashboardActions() templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,434 @@
|
|||||||
|
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"
|
||||||
|
|
||||||
|
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" 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="oldest" selected={ data.Order == "oldest" }>Oldest first</option>
|
||||||
|
<option value="newest" selected={ data.Order == "newest" }>Newest first</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- Serial suggestion (D5): one-click, never silent -->
|
||||||
|
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": "oldest"}'
|
||||||
|
hx-target="#episode-list"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
title="Serial feed: publishers list newest first — listen oldest-first"
|
||||||
|
>
|
||||||
|
<span>Serial? Listen oldest-first</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 (self-refreshing htmx partial) -->
|
||||||
|
@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)
|
||||||
|
}
|
||||||
|
</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)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ PodcastEpisodeRowView(ep PodcastEpisodeRow) {
|
||||||
|
<div class="card p-3 flex items-center gap-3">
|
||||||
|
<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="h-1.5 w-full rounded-full overflow-hidden animate-pulse" style="background-color: var(--surface-secondary);">
|
||||||
|
<div class="h-full w-1/3 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>
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,230 @@
|
|||||||
|
// 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).
|
||||||
|
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)
|
||||||
|
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][]string
|
||||||
|
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,256 @@
|
|||||||
|
package templates
|
||||||
|
|
||||||
|
import "net/url"
|
||||||
|
|
||||||
|
// PodcastFacetLink links a facet value back into the podcasts page's
|
||||||
|
// server-side filter (the /bookshelf FacetLink targets the wrong
|
||||||
|
// surface for podcasts).
|
||||||
|
templ PodcastFacetLink(facet string, value string) {
|
||||||
|
<a href={ "/podcasts?" + facet + "=" + url.QueryEscape(value) } class="badge hover:opacity-80 transition-opacity" style="background-color: var(--surface-secondary); color: var(--text-secondary); text-decoration: none;">{ value }</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>
|
||||||
|
for _, h := range data.Facets["hosts"] {
|
||||||
|
@PodcastFacetLink("host", h)
|
||||||
|
}
|
||||||
|
for _, t := range data.Facets["tags"] {
|
||||||
|
@PodcastFacetLink("tag", t)
|
||||||
|
}
|
||||||
|
for _, l := range data.Facets["language"] {
|
||||||
|
@PodcastFacetLink("language", l)
|
||||||
|
}
|
||||||
|
if data.Query != "" {
|
||||||
|
<a href="/podcasts" class="badge" style="background-color: var(--surface-secondary); color: var(--text-secondary)">clear</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 }/>
|
||||||
|
}
|
||||||
|
<label class="flex items-center gap-2 text-sm" style="color: var(--text-secondary)">
|
||||||
|
<input type="checkbox" name="download_all" value="true" class="cursor-pointer"/>
|
||||||
|
Download all episodes now
|
||||||
|
</label>
|
||||||
|
<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,728 @@
|
|||||||
|
// 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"
|
||||||
|
|
||||||
|
// PodcastFacetLink links a facet value back into the podcasts page's
|
||||||
|
// server-side filter (the /bookshelf FacetLink targets the wrong
|
||||||
|
// surface for podcasts).
|
||||||
|
func PodcastFacetLink(facet string, value 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_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(value))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 9, Col: 62}
|
||||||
|
}
|
||||||
|
_, 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(value)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 9, Col: 227}
|
||||||
|
}
|
||||||
|
_, 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, "</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_Var4 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var4 == nil {
|
||||||
|
templ_7745c5c3_Var4 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<!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_Var5 = []any{"theme-" + user.Theme}
|
||||||
|
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, "<body x-data=\"podcastsPage\" class=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var6 string
|
||||||
|
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/podcasts.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, "\">")
|
||||||
|
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, 7, "<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, 8, "</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, 9, "<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, 10, "<span>Add Feed</span></button>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if data.ErrorMsg != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<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_Var7 string
|
||||||
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(data.ErrorMsg)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 53, Col: 21}
|
||||||
|
}
|
||||||
|
_, 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, 13, "</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !data.HasLibrary {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<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, 15, " ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if len(data.Feeds) == 0 {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<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, 17, "</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, 18, "</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_Var8 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var8 == nil {
|
||||||
|
templ_7745c5c3_Var8 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<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, 20, "</span> <input type=\"text\" name=\"q\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var9 string
|
||||||
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(data.Query)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 88, Col: 22}
|
||||||
|
}
|
||||||
|
_, 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, 21, "\" 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, 22, "<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, 23, "<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
|
||||||
|
}
|
||||||
|
for _, h := range data.Facets["hosts"] {
|
||||||
|
templ_7745c5c3_Err = PodcastFacetLink("host", h).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, t := range data.Facets["tags"] {
|
||||||
|
templ_7745c5c3_Err = PodcastFacetLink("tag", t).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, l := range data.Facets["language"] {
|
||||||
|
templ_7745c5c3_Err = PodcastFacetLink("language", l).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, 24, "<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, 25, "</div>")
|
||||||
|
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_Var10 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var10 == nil {
|
||||||
|
templ_7745c5c3_Var10 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
if len(data.ContinueFeeds) > 0 {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<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, 27, "</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_Var11 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var11 == nil {
|
||||||
|
templ_7745c5c3_Var11 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<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, 29, "</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_Var12 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var12 == nil {
|
||||||
|
templ_7745c5c3_Var12 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<a href=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var13 templ.SafeURL
|
||||||
|
templ_7745c5c3_Var13, 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: 144, Col: 31}
|
||||||
|
}
|
||||||
|
_, 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, 31, "\" 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, 32, "<img src=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var14 string
|
||||||
|
templ_7745c5c3_Var14, 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: 151, Col: 73}
|
||||||
|
}
|
||||||
|
_, 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, 33, "\" alt=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var15 string
|
||||||
|
templ_7745c5c3_Var15, 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: 152, Col: 46}
|
||||||
|
}
|
||||||
|
_, 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, 34, "\" 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, 35, "</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_Var16 string
|
||||||
|
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(feed.DisplayTitle())
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 161, Col: 111}
|
||||||
|
}
|
||||||
|
_, 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, 36, "</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_Var17 string
|
||||||
|
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(feed.TypeLabel())
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 163, Col: 23}
|
||||||
|
}
|
||||||
|
_, 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, 37, " ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if feed.Host != "" {
|
||||||
|
var templ_7745c5c3_Var18 string
|
||||||
|
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(" · " + feed.Host)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 165, Col: 26}
|
||||||
|
}
|
||||||
|
_, 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, 38, "</p></div></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if feed.HasContinue {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<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_Var19 string
|
||||||
|
templ_7745c5c3_Var19, 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: 173, Col: 72}
|
||||||
|
}
|
||||||
|
_, 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, 40, "</p>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else if feed.Description != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<p class=\"text-xs line-clamp-2\" 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.Description)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 176, Col: 90}
|
||||||
|
}
|
||||||
|
_, 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, 42, "</p>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<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_Var21 string
|
||||||
|
templ_7745c5c3_Var21, 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: 180, Col: 90}
|
||||||
|
}
|
||||||
|
_, 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, 44, "</span> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if feed.EpisodeMatches > 0 {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<span class=\"badge\" style=\"background-color: var(--accent-muted); color: var(--accent)\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var22 string
|
||||||
|
templ_7745c5c3_Var22, 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: 184, Col: 97}
|
||||||
|
}
|
||||||
|
_, 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, 46, "</span> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if feed.NewEpisodes {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<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, 48, "<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, 49, "<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, 50, "</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_Var23 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var23 == nil {
|
||||||
|
templ_7745c5c3_Var23 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<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, 52, "</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, 53, "<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, 54, "<option value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var24 string
|
||||||
|
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 234, Col: 30}
|
||||||
|
}
|
||||||
|
_, 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, 55, "\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var25 string
|
||||||
|
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 234, Col: 43}
|
||||||
|
}
|
||||||
|
_, 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, 56, "</option>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</select></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else if len(libraries) == 1 {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<input type=\"hidden\" name=\"library_id\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var26 string
|
||||||
|
templ_7745c5c3_Var26, 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: 239, Col: 67}
|
||||||
|
}
|
||||||
|
_, 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, 59, "\"> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<label class=\"flex items-center gap-2 text-sm\" style=\"color: var(--text-secondary)\"><input type=\"checkbox\" name=\"download_all\" value=\"true\" class=\"cursor-pointer\"> Download all episodes now</label><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, 61, "<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
|
DefaultAudioSpeed float64
|
||||||
// Whether the web reader/player should hold a Screen Wake Lock.
|
// Whether the web reader/player should hold a Screen Wake Lock.
|
||||||
KeepScreenAwake bool
|
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
|
// 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
|
// KeepScreenAwake: user preference — the reader holds a Screen Wake
|
||||||
// Lock while open when true.
|
// Lock while open when true.
|
||||||
KeepScreenAwake bool `json:"keep_screen_awake"`
|
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 {
|
type ReadingProgress struct {
|
||||||
|
|||||||
+11
-4
@@ -117,10 +117,10 @@ func MediaBadgeIcon(badge string) string {
|
|||||||
func TotalMediaCount(libs []LibraryData) int64 {
|
func TotalMediaCount(libs []LibraryData) int64 {
|
||||||
var total int64
|
var total int64
|
||||||
for _, l := range libs {
|
for _, l := range libs {
|
||||||
// Unmounted storage contributes nothing visible: an offline
|
// Podcasts libraries are feed-managed (plan D13): not book-
|
||||||
// library's holdings stay in the database but out of the UI
|
// library inventory — excluded here so switcher totals stay
|
||||||
// until its folders return.
|
// consistent with the switcher's podcast-filtered option list.
|
||||||
if l.Offline {
|
if l.Offline || l.TypeName == "podcasts" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
total += l.MediaCount
|
total += l.MediaCount
|
||||||
@@ -128,6 +128,13 @@ func TotalMediaCount(libs []LibraryData) int64 {
|
|||||||
return total
|
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 {
|
func uuidToString(id pgtype.UUID) string {
|
||||||
if !id.Valid {
|
if !id.Valid {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -22,3 +22,20 @@ Gate: every line below executes for BOTH personas unless marked
|
|||||||
| 15 | Upload: EPUB generated at runtime, upload → appears → delete | 🔲 | n/a |
|
| 15 | Upload: EPUB generated at runtime, upload → appears → delete | 🔲 | n/a |
|
||||||
| 16 | Auth edges: restricted → admin URL; anonymous → media URL | 🔲 | ✅ (detail/404) |
|
| 16 | Auth edges: restricted → admin URL; anonymous → media URL | 🔲 | ✅ (detail/404) |
|
||||||
| 17 | OPDS / KOReader | API-covered (invariant test) | API-covered |
|
| 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.
|
||||||
+7
-6
@@ -1,3 +1,4 @@
|
|||||||
|
import { confirmDialog } from "./confirm";
|
||||||
import { Alpine } from "./alpine";
|
import { Alpine } from "./alpine";
|
||||||
import { showToast } from "./toast";
|
import { showToast } from "./toast";
|
||||||
|
|
||||||
@@ -423,9 +424,9 @@ async function purgeArchivedItems(): Promise<void> {
|
|||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!window.confirm(
|
!(await confirmDialog(
|
||||||
"Permanently delete all archived items? Their reading progress, notes, and highlights will be lost.",
|
"Permanently delete all archived items? Their reading progress, notes, and highlights will be lost.",
|
||||||
)
|
))
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -475,9 +476,9 @@ async function deleteArchivedItem(id: string): Promise<void> {
|
|||||||
const token = requireToken();
|
const token = requireToken();
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
if (
|
if (
|
||||||
!window.confirm(
|
!(await confirmDialog(
|
||||||
"Permanently delete this item? Its reading progress, notes, and highlights will be lost.",
|
"Permanently delete this item? Its reading progress, notes, and highlights will be lost.",
|
||||||
)
|
))
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -504,9 +505,9 @@ async function mergeArchivedItem(sourceId: string, targetId: string): Promise<vo
|
|||||||
const token = requireToken();
|
const token = requireToken();
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
if (
|
if (
|
||||||
!window.confirm(
|
!(await confirmDialog(
|
||||||
"Merge this archived copy's reading progress, notes, and highlights into the active copy? The archived entry is removed.",
|
"Merge this archived copy's reading progress, notes, and highlights into the active copy? The archived entry is removed.",
|
||||||
)
|
))
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
+49
-1
@@ -171,9 +171,20 @@ export function endTimeLabel(
|
|||||||
|
|
||||||
const PROGRESS_INTERVAL_MS = 30_000;
|
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 {
|
interface AudioConfig {
|
||||||
chapterEndpoint: string;
|
chapterEndpoint: string;
|
||||||
progressEndpoint: string;
|
progressEndpoint: string;
|
||||||
|
podcastSequence?: PodcastSequenceConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readConfig(): AudioConfig {
|
function readConfig(): AudioConfig {
|
||||||
@@ -419,10 +430,14 @@ Alpine.data("audioReader", () => ({
|
|||||||
});
|
});
|
||||||
audio.addEventListener("ended", () => {
|
audio.addEventListener("ended", () => {
|
||||||
// Natural end of track n: roll into n+1 and keep playing. At the
|
// 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) {
|
if (this.activeTrack < this.tracks.length - 1) {
|
||||||
this.setActiveTrack(this.activeTrack + 1, 0, true);
|
this.setActiveTrack(this.activeTrack + 1, 0, true);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
this.goToNextEpisode(true);
|
||||||
});
|
});
|
||||||
audio.addEventListener("play", () => {
|
audio.addEventListener("play", () => {
|
||||||
this.playing = true;
|
this.playing = true;
|
||||||
@@ -760,9 +775,42 @@ Alpine.data("audioReader", () => ({
|
|||||||
|
|
||||||
backToDetail() {
|
backToDetail() {
|
||||||
this.pushProgress(true);
|
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}`;
|
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) ----
|
// ---- volume (settings strip) ----
|
||||||
|
|
||||||
volumeIcon(): "muted" | "low" | "mid" | "high" {
|
volumeIcon(): "muted" | "low" | "mid" | "high" {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { confirmDialog } from "./confirm";
|
||||||
import { Alpine } from "./alpine";
|
import { Alpine } from "./alpine";
|
||||||
import { showToast } from "./toast";
|
import { showToast } from "./toast";
|
||||||
import { searchTagSuggestions, type TagSuggestion } from "./tag-dropdown";
|
import { searchTagSuggestions, type TagSuggestion } from "./tag-dropdown";
|
||||||
@@ -531,9 +532,9 @@ Alpine.data("bookDetail", () => {
|
|||||||
|
|
||||||
async purgeDeletedAnnotation(annotationType: string, annotationId: string) {
|
async purgeDeletedAnnotation(annotationType: string, annotationId: string) {
|
||||||
if (
|
if (
|
||||||
!confirm(
|
!(await confirmDialog(
|
||||||
"Permanently delete this annotation? This cannot be undone and it will not reappear on any device.",
|
"Permanently delete this annotation? This cannot be undone and it will not reappear on any device.",
|
||||||
)
|
))
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1002,9 +1003,9 @@ Alpine.data("bookDetail", () => {
|
|||||||
async resetMetadata() {
|
async resetMetadata() {
|
||||||
if (this.resetting) return;
|
if (this.resetting) return;
|
||||||
if (
|
if (
|
||||||
!window.confirm(
|
!(await confirmDialog(
|
||||||
"Reset all metadata to scanned defaults? Manual edits and custom covers will be discarded.",
|
"Reset all metadata to scanned defaults? Manual edits and custom covers will be discarded.",
|
||||||
)
|
))
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -366,8 +366,21 @@ function clearForm(): void {
|
|||||||
if (testResultsDiv) testResultsDiv.classList.add("hidden");
|
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("token");
|
||||||
|
localStorage.removeItem("refresh_token");
|
||||||
window.location.href = "/login";
|
window.location.href = "/login";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// 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".)
|
||||||
|
document.body.addEventListener("htmx:confirm", function (evt) {
|
||||||
|
const custom = evt as CustomEvent<{
|
||||||
|
element: HTMLElement;
|
||||||
|
issueRequest: (override?: boolean) => void;
|
||||||
|
}>;
|
||||||
|
const elt = custom.detail.element;
|
||||||
|
if (!elt) return;
|
||||||
|
const question = elt.getAttribute("hx-confirm");
|
||||||
|
if (question === null) return;
|
||||||
|
evt.preventDefault();
|
||||||
|
wireButtons();
|
||||||
|
confirmDialog(question).then((ok) => {
|
||||||
|
if (ok) custom.detail.issueRequest(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
+17
-2
@@ -12,12 +12,27 @@ import {
|
|||||||
revealCurrentTheme,
|
revealCurrentTheme,
|
||||||
updateWoodPanelingIndicators,
|
updateWoodPanelingIndicators,
|
||||||
} from "./theme";
|
} from "./theme";
|
||||||
import { getSelectedLibrary } from "./storage";
|
import { getSelectedLibrary, getRefreshToken, removeRefreshToken } from "./storage";
|
||||||
import { addListener, removeListener } from "./websocket";
|
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("token");
|
||||||
localStorage.removeItem("user");
|
localStorage.removeItem("user");
|
||||||
|
removeRefreshToken();
|
||||||
window.location.href = "/";
|
window.location.href = "/";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import "./linking";
|
|||||||
import "./login";
|
import "./login";
|
||||||
import "./password_validation";
|
import "./password_validation";
|
||||||
import "./processing_issues";
|
import "./processing_issues";
|
||||||
|
import "./podcasts";
|
||||||
|
import "./confirm";
|
||||||
import "./profile";
|
import "./profile";
|
||||||
import "./profile-modal";
|
import "./profile-modal";
|
||||||
import "./register";
|
import "./register";
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// 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,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 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) {
|
if (response.status === 401) {
|
||||||
// Clear invalid tokens from localStorage
|
// Clear invalid tokens from localStorage
|
||||||
localStorage.removeItem("token");
|
localStorage.removeItem("token");
|
||||||
localStorage.removeItem("refreshToken");
|
localStorage.removeItem("refresh_token");
|
||||||
localStorage.removeItem("user");
|
localStorage.removeItem("user");
|
||||||
|
|
||||||
// Check if this was a page navigation (not API call)
|
// Check if this was a page navigation (not API call)
|
||||||
|
|||||||
@@ -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: }
|
*,: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