Compare commits
5 Commits
v4.4.4
...
v3-cleanup
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51adf0e7a5 | ||
|
|
5bc69ad9ce | ||
|
|
c09d9d0ca2 | ||
|
|
b4595d3f64 | ||
|
|
75bfff5a74 |
@@ -130,7 +130,8 @@ jobs:
|
|||||||
'set -euo pipefail
|
'set -euo pipefail
|
||||||
cd /opt/libnovel
|
cd /opt/libnovel
|
||||||
doppler run -- docker compose pull backend runner ui caddy pocketbase
|
doppler run -- docker compose pull backend runner ui caddy pocketbase
|
||||||
doppler run -- docker compose up -d --no-deps --remove-orphans backend runner ui caddy pocketbase'
|
doppler run -- docker compose stop backend runner ui caddy pocketbase
|
||||||
|
doppler run -- docker compose up -d --remove-orphans backend runner ui caddy pocketbase'
|
||||||
|
|
||||||
# ── deploy homelab runner ─────────────────────────────────────────────────────
|
# ── deploy homelab runner ─────────────────────────────────────────────────────
|
||||||
# Syncs the homelab runner compose file and restarts the runner service.
|
# Syncs the homelab runner compose file and restarts the runner service.
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -29,3 +29,6 @@ Thumbs.db
|
|||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
.opencode/
|
.opencode/
|
||||||
|
|
||||||
|
# ── Playwright MCP browser session data ────────────────────────────────────────
|
||||||
|
.playwright-mcp/
|
||||||
|
|||||||
@@ -559,6 +559,11 @@ func (s *Server) handleAdminImageGenAsync(w http.ResponseWriter, r *http.Request
|
|||||||
go func() {
|
go func() {
|
||||||
defer deregisterCancelJob(jobID)
|
defer deregisterCancelJob(jobID)
|
||||||
defer jobCancel()
|
defer jobCancel()
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
logger.Error("admin: image-gen goroutine panic", "job_id", jobID, "recover", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
if jobCtx.Err() != nil {
|
if jobCtx.Err() != nil {
|
||||||
_ = store.UpdateAIJob(context.Background(), jobID, map[string]any{
|
_ = store.UpdateAIJob(context.Background(), jobID, map[string]any{
|
||||||
@@ -625,13 +630,19 @@ func (s *Server) handleAdminImageGenAsync(w http.ResponseWriter, r *http.Request
|
|||||||
Guidance: capturedReq.Guidance,
|
Guidance: capturedReq.Guidance,
|
||||||
})
|
})
|
||||||
|
|
||||||
_ = store.UpdateAIJob(context.Background(), jobID, map[string]any{
|
if err := store.UpdateAIJob(context.Background(), jobID, map[string]any{
|
||||||
"status": string(domain.TaskStatusDone),
|
"status": string(domain.TaskStatusDone),
|
||||||
"items_done": 1,
|
"items_done": 1,
|
||||||
"items_total": 1,
|
"items_total": 1,
|
||||||
"payload": string(resultJSON),
|
"finished": time.Now().Format(time.RFC3339),
|
||||||
"finished": time.Now().Format(time.RFC3339),
|
}); err != nil {
|
||||||
})
|
logger.Error("admin: image-gen failed to mark job done", "job_id", jobID, "err", err)
|
||||||
|
}
|
||||||
|
if err := store.UpdateAIJob(context.Background(), jobID, map[string]any{
|
||||||
|
"payload": string(resultJSON),
|
||||||
|
}); err != nil {
|
||||||
|
logger.Error("admin: image-gen failed to write job payload", "job_id", jobID, "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
logger.Info("admin: image-gen async done",
|
logger.Info("admin: image-gen async done",
|
||||||
"job_id", jobID, "slug", capturedReq.Slug,
|
"job_id", jobID, "slug", capturedReq.Slug,
|
||||||
|
|||||||
524
backend/internal/backend/handlers_podcast.go
Normal file
524
backend/internal/backend/handlers_podcast.go
Normal file
@@ -0,0 +1,524 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
// handlers_podcast.go — Podcast RSS feed generation and serving.
|
||||||
|
//
|
||||||
|
// Public endpoints (no auth required, suitable for podcast clients):
|
||||||
|
//
|
||||||
|
// GET /podcast/{slug} — RSS 2.0 feed (slug may end in .xml)
|
||||||
|
// ?voice=<voice-id> (optional; defaults to DefaultVoice)
|
||||||
|
// GET /podcast/audio/{slug}/{n}/{voice} — redirects to a presigned MinIO URL
|
||||||
|
//
|
||||||
|
// Admin endpoints (Bearer-token protected):
|
||||||
|
//
|
||||||
|
// POST /api/admin/podcast — start a podcast generation job
|
||||||
|
// GET /api/admin/podcast/{slug} — list podcast jobs for slug
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/libnovel/backend/internal/cfai"
|
||||||
|
"github.com/libnovel/backend/internal/domain"
|
||||||
|
"github.com/libnovel/backend/internal/kokoro"
|
||||||
|
"github.com/libnovel/backend/internal/pockettts"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── RSS data structures ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type podcastFeed struct {
|
||||||
|
XMLName xml.Name `xml:"rss"`
|
||||||
|
Version string `xml:"version,attr"`
|
||||||
|
ItunesNS string `xml:"xmlns:itunes,attr"`
|
||||||
|
Channel podcastChannel `xml:"channel"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type podcastChannel struct {
|
||||||
|
Title string `xml:"title"`
|
||||||
|
Link string `xml:"link"`
|
||||||
|
Description string `xml:"description"`
|
||||||
|
Language string `xml:"language"`
|
||||||
|
Author string `xml:"itunes:author"`
|
||||||
|
Image *podcastImage `xml:"image,omitempty"`
|
||||||
|
ItunesImg *itunesCover `xml:"itunes:image,omitempty"`
|
||||||
|
Items []podcastItem `xml:"item"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type podcastImage struct {
|
||||||
|
URL string `xml:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type itunesCover struct {
|
||||||
|
Href string `xml:"href,attr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type podcastItem struct {
|
||||||
|
Title string `xml:"title"`
|
||||||
|
GUID string `xml:"guid"`
|
||||||
|
PubDate string `xml:"pubDate,omitempty"`
|
||||||
|
Enclosure *podcastEnclosure `xml:"enclosure,omitempty"`
|
||||||
|
Episode int `xml:"itunes:episode,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type podcastEnclosure struct {
|
||||||
|
URL string `xml:"url,attr"`
|
||||||
|
Type string `xml:"type,attr"`
|
||||||
|
Length string `xml:"length,attr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public: RSS feed ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// handlePodcastFeed handles GET /podcast/{slug}.
|
||||||
|
// Slug may optionally end with ".xml" (podcast clients expect it).
|
||||||
|
// Query param: voice (optional; defaults to DefaultVoice).
|
||||||
|
func (s *Server) handlePodcastFeed(w http.ResponseWriter, r *http.Request) {
|
||||||
|
slug := strings.TrimSuffix(r.PathValue("slug"), ".xml")
|
||||||
|
if slug == "" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
voice := r.URL.Query().Get("voice")
|
||||||
|
if voice == "" {
|
||||||
|
voice = s.cfg.DefaultVoice
|
||||||
|
}
|
||||||
|
|
||||||
|
meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), slug)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "read metadata: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
chapters, err := s.deps.BookReader.ListChapters(r.Context(), slug)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "list chapters: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine base URL from request headers (Caddy sets X-Forwarded-Proto/Host).
|
||||||
|
scheme := "https"
|
||||||
|
if r.Header.Get("X-Forwarded-Proto") == "http" || (r.TLS == nil && r.Header.Get("X-Forwarded-Proto") == "") {
|
||||||
|
scheme = "http"
|
||||||
|
}
|
||||||
|
host := r.Host
|
||||||
|
if fh := r.Header.Get("X-Forwarded-Host"); fh != "" {
|
||||||
|
host = fh
|
||||||
|
}
|
||||||
|
baseURL := scheme + "://" + host
|
||||||
|
|
||||||
|
var items []podcastItem
|
||||||
|
for _, ch := range chapters {
|
||||||
|
audioKey := s.deps.AudioStore.AudioObjectKey(slug, ch.Number, voice)
|
||||||
|
size := s.deps.AudioStore.AudioObjectSize(r.Context(), audioKey)
|
||||||
|
if size < 0 {
|
||||||
|
continue // audio not generated yet
|
||||||
|
}
|
||||||
|
title := ch.Title
|
||||||
|
if title == "" {
|
||||||
|
title = fmt.Sprintf("Chapter %d", ch.Number)
|
||||||
|
}
|
||||||
|
audioURL := fmt.Sprintf("%s/podcast/audio/%s/%d/%s", baseURL, slug, ch.Number, voice)
|
||||||
|
|
||||||
|
// pubDate: use chapter creation date if available, else fallback to now.
|
||||||
|
pubDate := time.Now().Format(time.RFC1123Z)
|
||||||
|
if ch.Date != "" {
|
||||||
|
if t, err := time.Parse(time.RFC3339, ch.Date); err == nil {
|
||||||
|
pubDate = t.Format(time.RFC1123Z)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items = append(items, podcastItem{
|
||||||
|
Title: title,
|
||||||
|
GUID: fmt.Sprintf("%s/podcast/%s/%d/%s", baseURL, slug, ch.Number, voice),
|
||||||
|
PubDate: pubDate,
|
||||||
|
Enclosure: &podcastEnclosure{
|
||||||
|
URL: audioURL,
|
||||||
|
Type: "audio/mpeg",
|
||||||
|
Length: fmt.Sprintf("%d", size),
|
||||||
|
},
|
||||||
|
Episode: ch.Number,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
desc := meta.Summary
|
||||||
|
if desc == "" {
|
||||||
|
desc = meta.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
feed := podcastFeed{
|
||||||
|
Version: "2.0",
|
||||||
|
ItunesNS: "http://www.itunes.com/dtds/podcast-1.0.dtd",
|
||||||
|
Channel: podcastChannel{
|
||||||
|
Title: meta.Title,
|
||||||
|
Link: fmt.Sprintf("%s/books/%s", baseURL, slug),
|
||||||
|
Description: desc,
|
||||||
|
Language: "en",
|
||||||
|
Author: meta.Author,
|
||||||
|
Items: items,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if meta.Cover != "" {
|
||||||
|
feed.Channel.Image = &podcastImage{URL: meta.Cover}
|
||||||
|
feed.Channel.ItunesImg = &itunesCover{Href: meta.Cover}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8")
|
||||||
|
_, _ = w.Write([]byte(xml.Header))
|
||||||
|
enc := xml.NewEncoder(w)
|
||||||
|
enc.Indent("", " ")
|
||||||
|
if err := enc.Encode(feed); err != nil {
|
||||||
|
s.deps.Log.Error("podcast: encode RSS failed", "slug", slug, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePodcastAudio handles GET /podcast/audio/{slug}/{n}/{voice}.
|
||||||
|
// Redirects to a 4-hour presigned MinIO URL. No auth required.
|
||||||
|
func (s *Server) handlePodcastAudio(w http.ResponseWriter, r *http.Request) {
|
||||||
|
slug := r.PathValue("slug")
|
||||||
|
voice := r.PathValue("voice")
|
||||||
|
n, err := strconv.Atoi(r.PathValue("n"))
|
||||||
|
if err != nil || n < 1 {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
audioKey := s.deps.AudioStore.AudioObjectKey(slug, n, voice)
|
||||||
|
if !s.deps.AudioStore.AudioExists(r.Context(), audioKey) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
url, err := s.deps.PresignStore.PresignAudio(r.Context(), audioKey, 4*time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
s.deps.Log.Error("podcast: presign audio failed", "slug", slug, "n", n, "err", err)
|
||||||
|
http.Error(w, "presign failed", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, url, http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Admin: create podcast generation job ──────────────────────────────────────
|
||||||
|
|
||||||
|
type podcastJobRequest struct {
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Voice string `json:"voice"`
|
||||||
|
FromChapter int `json:"from_chapter"` // 0 = chapter 1
|
||||||
|
ToChapter int `json:"to_chapter"` // 0 = all chapters
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAdminPodcast handles POST /api/admin/podcast.
|
||||||
|
// Creates an ai_job of kind="podcast" and spawns a background goroutine that
|
||||||
|
// generates TTS for any chapters that do not yet have audio.
|
||||||
|
func (s *Server) handleAdminPodcast(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.deps.AIJobStore == nil {
|
||||||
|
jsonError(w, http.StatusServiceUnavailable, "ai job store not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req podcastJobRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.Slug) == "" {
|
||||||
|
jsonError(w, http.StatusBadRequest, "slug is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.Voice) == "" {
|
||||||
|
req.Voice = s.cfg.DefaultVoice
|
||||||
|
}
|
||||||
|
|
||||||
|
meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), req.Slug)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "read metadata: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
jsonError(w, http.StatusNotFound, fmt.Sprintf("book %q not found", req.Slug))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
chapters, err := s.deps.BookReader.ListChapters(r.Context(), req.Slug)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "list chapters: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(chapters) == 0 {
|
||||||
|
jsonError(w, http.StatusBadRequest, "book has no chapters")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
from := req.FromChapter
|
||||||
|
if from <= 0 {
|
||||||
|
from = 1
|
||||||
|
}
|
||||||
|
to := req.ToChapter
|
||||||
|
if to <= 0 || to > chapters[len(chapters)-1].Number {
|
||||||
|
to = chapters[len(chapters)-1].Number
|
||||||
|
}
|
||||||
|
if from > to {
|
||||||
|
jsonError(w, http.StatusBadRequest, "from_chapter must be ≤ to_chapter")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
paramsJSON, _ := json.Marshal(map[string]any{
|
||||||
|
"voice": req.Voice,
|
||||||
|
"from_chapter": from,
|
||||||
|
"to_chapter": to,
|
||||||
|
})
|
||||||
|
|
||||||
|
jobID, createErr := s.deps.AIJobStore.CreateAIJob(r.Context(), domain.AIJob{
|
||||||
|
Kind: "podcast",
|
||||||
|
Slug: req.Slug,
|
||||||
|
Status: domain.TaskStatusPending,
|
||||||
|
Model: req.Voice,
|
||||||
|
FromItem: from,
|
||||||
|
ToItem: to,
|
||||||
|
ItemsTotal: to - from + 1,
|
||||||
|
Payload: string(paramsJSON),
|
||||||
|
Started: time.Now(),
|
||||||
|
})
|
||||||
|
if createErr != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "create job: "+createErr.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jobCtx, jobCancel := context.WithCancel(context.Background())
|
||||||
|
registerCancelJob(jobID, jobCancel)
|
||||||
|
|
||||||
|
_ = s.deps.AIJobStore.UpdateAIJob(r.Context(), jobID, map[string]any{
|
||||||
|
"status": string(domain.TaskStatusRunning),
|
||||||
|
})
|
||||||
|
|
||||||
|
s.deps.Log.Info("admin: podcast job started",
|
||||||
|
"job_id", jobID, "slug", req.Slug, "voice", req.Voice,
|
||||||
|
"from", from, "to", to)
|
||||||
|
|
||||||
|
// Capture dependencies for goroutine.
|
||||||
|
store := s.deps.AIJobStore
|
||||||
|
bookReader := s.deps.BookReader
|
||||||
|
audioStore := s.deps.AudioStore
|
||||||
|
kokoroClient := s.deps.Kokoro
|
||||||
|
pocketTTSClient := s.deps.PocketTTS
|
||||||
|
cfaiClient := s.deps.CFAI
|
||||||
|
logger := s.deps.Log
|
||||||
|
capturedMeta := meta
|
||||||
|
capturedChapters := chapters
|
||||||
|
capturedReq := req
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer deregisterCancelJob(jobID)
|
||||||
|
defer jobCancel()
|
||||||
|
|
||||||
|
done := 0
|
||||||
|
var lastErr string
|
||||||
|
|
||||||
|
for _, ch := range capturedChapters {
|
||||||
|
if jobCtx.Err() != nil {
|
||||||
|
_ = store.UpdateAIJob(context.Background(), jobID, map[string]any{
|
||||||
|
"status": string(domain.TaskStatusCancelled),
|
||||||
|
"items_done": done,
|
||||||
|
"error_message": "cancelled",
|
||||||
|
"finished": time.Now().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ch.Number < from || ch.Number > to {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
audioKey := audioStore.AudioObjectKey(capturedMeta.Slug, ch.Number, capturedReq.Voice)
|
||||||
|
if audioStore.AudioExists(jobCtx, audioKey) {
|
||||||
|
// Already generated — count it and move on.
|
||||||
|
done++
|
||||||
|
_ = store.UpdateAIJob(context.Background(), jobID, map[string]any{
|
||||||
|
"items_done": done,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, readErr := bookReader.ReadChapter(jobCtx, capturedMeta.Slug, ch.Number)
|
||||||
|
if readErr != nil {
|
||||||
|
lastErr = fmt.Sprintf("ch.%d read: %v", ch.Number, readErr)
|
||||||
|
logger.Warn("podcast: read chapter failed",
|
||||||
|
"slug", capturedMeta.Slug, "chapter", ch.Number, "err", readErr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
text := stripMarkdown(raw)
|
||||||
|
if text == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
audioData, genErr := podcastGenerateAudio(jobCtx, text, capturedReq.Voice,
|
||||||
|
kokoroClient, pocketTTSClient, cfaiClient, logger)
|
||||||
|
if genErr != nil {
|
||||||
|
lastErr = fmt.Sprintf("ch.%d tts: %v", ch.Number, genErr)
|
||||||
|
logger.Warn("podcast: tts failed",
|
||||||
|
"slug", capturedMeta.Slug, "chapter", ch.Number,
|
||||||
|
"voice", capturedReq.Voice, "err", genErr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if putErr := audioStore.PutAudio(jobCtx, audioKey, audioData); putErr != nil {
|
||||||
|
lastErr = fmt.Sprintf("ch.%d put: %v", ch.Number, putErr)
|
||||||
|
logger.Warn("podcast: put audio failed",
|
||||||
|
"slug", capturedMeta.Slug, "chapter", ch.Number, "err", putErr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
done++
|
||||||
|
_ = store.UpdateAIJob(context.Background(), jobID, map[string]any{
|
||||||
|
"items_done": done,
|
||||||
|
})
|
||||||
|
logger.Info("podcast: chapter audio ready",
|
||||||
|
"slug", capturedMeta.Slug, "chapter", ch.Number, "voice", capturedReq.Voice)
|
||||||
|
}
|
||||||
|
|
||||||
|
finalStatus := string(domain.TaskStatusDone)
|
||||||
|
if lastErr != "" && done == 0 {
|
||||||
|
finalStatus = string(domain.TaskStatusFailed)
|
||||||
|
}
|
||||||
|
_ = store.UpdateAIJob(context.Background(), jobID, map[string]any{
|
||||||
|
"status": finalStatus,
|
||||||
|
"items_done": done,
|
||||||
|
"error_message": lastErr,
|
||||||
|
"finished": time.Now().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
logger.Info("podcast: job finished",
|
||||||
|
"slug", capturedMeta.Slug, "voice", capturedReq.Voice,
|
||||||
|
"done", done, "status", finalStatus)
|
||||||
|
}()
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusAccepted, map[string]any{
|
||||||
|
"job_id": jobID,
|
||||||
|
"slug": req.Slug,
|
||||||
|
"voice": req.Voice,
|
||||||
|
"from": from,
|
||||||
|
"to": to,
|
||||||
|
"items_total": to - from + 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAdminPodcastStatus handles GET /api/admin/podcast/{slug}.
|
||||||
|
// Returns all podcast ai_jobs for slug, newest first.
|
||||||
|
func (s *Server) handleAdminPodcastStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.deps.AIJobStore == nil {
|
||||||
|
jsonError(w, http.StatusServiceUnavailable, "ai job store not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slug := r.PathValue("slug")
|
||||||
|
|
||||||
|
all, err := s.deps.AIJobStore.ListAIJobs(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "list jobs: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var jobs []domain.AIJob
|
||||||
|
for _, j := range all {
|
||||||
|
if j.Kind == "podcast" && j.Slug == slug {
|
||||||
|
jobs = append(jobs, j)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, 0, map[string]any{"jobs": jobs})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TTS helper ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// podcastGenerateAudio generates audio for a full chapter text using the
|
||||||
|
// appropriate TTS engine for voice. For Kokoro it splits text into ~1 000-char
|
||||||
|
// sentence-boundary chunks and concatenates the resulting MP3 frames, matching
|
||||||
|
// the runner's behaviour (see runner.kokoroGenerateChunked).
|
||||||
|
func podcastGenerateAudio(
|
||||||
|
ctx context.Context,
|
||||||
|
text, voice string,
|
||||||
|
kokoroClient kokoro.Client,
|
||||||
|
pocketTTSClient pockettts.Client,
|
||||||
|
cfaiClient cfai.Client,
|
||||||
|
logger interface{ Warn(string, ...any); Info(string, ...any) },
|
||||||
|
) ([]byte, error) {
|
||||||
|
switch {
|
||||||
|
case pockettts.IsPocketTTSVoice(voice):
|
||||||
|
if pocketTTSClient == nil {
|
||||||
|
return nil, fmt.Errorf("pocket-tts client not configured")
|
||||||
|
}
|
||||||
|
return pocketTTSClient.GenerateAudio(ctx, text, voice)
|
||||||
|
|
||||||
|
case cfai.IsCFAIVoice(voice):
|
||||||
|
if cfaiClient == nil {
|
||||||
|
return nil, fmt.Errorf("cloudflare AI client not configured")
|
||||||
|
}
|
||||||
|
return cfaiClient.GenerateAudio(ctx, text, voice)
|
||||||
|
|
||||||
|
default: // Kokoro
|
||||||
|
if kokoroClient == nil {
|
||||||
|
return nil, fmt.Errorf("kokoro client not configured")
|
||||||
|
}
|
||||||
|
return podcastKokoroChunked(ctx, kokoroClient, text, voice, logger)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// podcastKokoroChunked mirrors runner.kokoroGenerateChunked: splits text into
|
||||||
|
// ~1 000-character sentence-boundary chunks and concatenates raw MP3 bytes.
|
||||||
|
func podcastKokoroChunked(
|
||||||
|
ctx context.Context,
|
||||||
|
k kokoro.Client,
|
||||||
|
text, voice string,
|
||||||
|
logger interface{ Warn(string, ...any); Info(string, ...any) },
|
||||||
|
) ([]byte, error) {
|
||||||
|
const chunkSize = 1000
|
||||||
|
chunks := podcastChunkText(text, chunkSize)
|
||||||
|
logger.Info("podcast: kokoro chunked generation", "chunks", len(chunks), "total_chars", len(text))
|
||||||
|
|
||||||
|
var combined []byte
|
||||||
|
for i, chunk := range chunks {
|
||||||
|
data, err := k.GenerateAudio(ctx, chunk, voice)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("chunk %d/%d: %w", i+1, len(chunks), err)
|
||||||
|
}
|
||||||
|
combined = append(combined, data...)
|
||||||
|
}
|
||||||
|
return combined, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// podcastChunkText mirrors runner.chunkText.
|
||||||
|
func podcastChunkText(text string, maxChars int) []string {
|
||||||
|
if len(text) <= maxChars {
|
||||||
|
return []string{text}
|
||||||
|
}
|
||||||
|
delimiters := []string{".\n", "!\n", "?\n", ". ", "! ", "? ", "\n\n", "\n"}
|
||||||
|
var chunks []string
|
||||||
|
remaining := text
|
||||||
|
for len(remaining) > 0 {
|
||||||
|
if len(remaining) <= maxChars {
|
||||||
|
chunks = append(chunks, strings.TrimSpace(remaining))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
window := remaining[:maxChars]
|
||||||
|
cutAt := -1
|
||||||
|
for _, d := range delimiters {
|
||||||
|
idx := strings.LastIndex(window, d)
|
||||||
|
if idx > 0 && idx+len(d) > cutAt {
|
||||||
|
cutAt = idx + len(d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cutAt <= 0 {
|
||||||
|
cutAt = maxChars
|
||||||
|
}
|
||||||
|
if chunk := strings.TrimSpace(remaining[:cutAt]); chunk != "" {
|
||||||
|
chunks = append(chunks, chunk)
|
||||||
|
}
|
||||||
|
remaining = strings.TrimSpace(remaining[cutAt:])
|
||||||
|
}
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
@@ -516,6 +516,11 @@ func (s *Server) handleAdminTextGenChapterNamesAsync(w http.ResponseWriter, r *h
|
|||||||
go func() {
|
go func() {
|
||||||
defer deregisterCancelJob(jobID)
|
defer deregisterCancelJob(jobID)
|
||||||
defer jobCancel()
|
defer jobCancel()
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
logger.Error("admin: text-gen chapter-names goroutine panic", "job_id", jobID, "recover", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
var allResults []proposedChapterTitle
|
var allResults []proposedChapterTitle
|
||||||
chaptersDone := 0
|
chaptersDone := 0
|
||||||
@@ -574,12 +579,18 @@ func (s *Server) handleAdminTextGenChapterNamesAsync(w http.ResponseWriter, r *h
|
|||||||
if jobCtx.Err() != nil {
|
if jobCtx.Err() != nil {
|
||||||
status = domain.TaskStatusCancelled
|
status = domain.TaskStatusCancelled
|
||||||
}
|
}
|
||||||
_ = store.UpdateAIJob(context.Background(), jobID, map[string]any{
|
if err := store.UpdateAIJob(context.Background(), jobID, map[string]any{
|
||||||
"status": string(status),
|
"status": string(status),
|
||||||
"items_done": chaptersDone,
|
"items_done": chaptersDone,
|
||||||
"finished": time.Now().Format(time.RFC3339),
|
"finished": time.Now().Format(time.RFC3339),
|
||||||
"payload": finalPayload,
|
}); err != nil {
|
||||||
})
|
logger.Error("admin: text-gen chapter-names failed to mark job done", "job_id", jobID, "err", err)
|
||||||
|
}
|
||||||
|
if err := store.UpdateAIJob(context.Background(), jobID, map[string]any{
|
||||||
|
"payload": finalPayload,
|
||||||
|
}); err != nil {
|
||||||
|
logger.Error("admin: text-gen chapter-names failed to write job payload", "job_id", jobID, "err", err)
|
||||||
|
}
|
||||||
logger.Info("admin: text-gen chapter-names async done",
|
logger.Info("admin: text-gen chapter-names async done",
|
||||||
"job_id", jobID, "slug", capturedSlug,
|
"job_id", jobID, "slug", capturedSlug,
|
||||||
"results", len(allResults), "status", string(status))
|
"results", len(allResults), "status", string(status))
|
||||||
@@ -902,6 +913,11 @@ func (s *Server) handleAdminTextGenDescriptionAsync(w http.ResponseWriter, r *ht
|
|||||||
go func() {
|
go func() {
|
||||||
defer deregisterCancelJob(jobID)
|
defer deregisterCancelJob(jobID)
|
||||||
defer jobCancel()
|
defer jobCancel()
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
logger.Error("admin: text-gen description goroutine panic", "job_id", jobID, "recover", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
if jobCtx.Err() != nil {
|
if jobCtx.Err() != nil {
|
||||||
_ = store.UpdateAIJob(context.Background(), jobID, map[string]any{
|
_ = store.UpdateAIJob(context.Background(), jobID, map[string]any{
|
||||||
@@ -955,13 +971,19 @@ func (s *Server) handleAdminTextGenDescriptionAsync(w http.ResponseWriter, r *ht
|
|||||||
NewDescription: strings.TrimSpace(newDesc),
|
NewDescription: strings.TrimSpace(newDesc),
|
||||||
})
|
})
|
||||||
|
|
||||||
_ = store.UpdateAIJob(context.Background(), jobID, map[string]any{
|
if err := store.UpdateAIJob(context.Background(), jobID, map[string]any{
|
||||||
"status": string(domain.TaskStatusDone),
|
"status": string(domain.TaskStatusDone),
|
||||||
"items_done": 1,
|
"items_done": 1,
|
||||||
"items_total": 1,
|
"items_total": 1,
|
||||||
"payload": string(resultJSON),
|
|
||||||
"finished": time.Now().Format(time.RFC3339),
|
"finished": time.Now().Format(time.RFC3339),
|
||||||
})
|
}); err != nil {
|
||||||
|
logger.Error("admin: text-gen description failed to mark job done", "job_id", jobID, "err", err)
|
||||||
|
}
|
||||||
|
if err := store.UpdateAIJob(context.Background(), jobID, map[string]any{
|
||||||
|
"payload": string(resultJSON),
|
||||||
|
}); err != nil {
|
||||||
|
logger.Error("admin: text-gen description failed to write job payload", "job_id", jobID, "err", err)
|
||||||
|
}
|
||||||
logger.Info("admin: text-gen description async done", "job_id", jobID, "slug", capturedMeta.Slug)
|
logger.Info("admin: text-gen description async done", "job_id", jobID, "slug", capturedMeta.Slug)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|||||||
@@ -301,6 +301,14 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
|||||||
admin("GET /api/admin/import", s.handleAdminImportList)
|
admin("GET /api/admin/import", s.handleAdminImportList)
|
||||||
admin("GET /api/admin/import/{id}", s.handleAdminImportStatus)
|
admin("GET /api/admin/import/{id}", s.handleAdminImportStatus)
|
||||||
|
|
||||||
|
// Podcast RSS feed generation and serving
|
||||||
|
// Public endpoints — no auth; podcast clients subscribe to these URLs.
|
||||||
|
mux.HandleFunc("GET /podcast/audio/{slug}/{n}/{voice}", s.handlePodcastAudio)
|
||||||
|
mux.HandleFunc("GET /podcast/{slug}", s.handlePodcastFeed)
|
||||||
|
// Admin endpoints — trigger generation job and check status.
|
||||||
|
admin("POST /api/admin/podcast", s.handleAdminPodcast)
|
||||||
|
admin("GET /api/admin/podcast/{slug}", s.handleAdminPodcastStatus)
|
||||||
|
|
||||||
// Notifications
|
// Notifications
|
||||||
mux.HandleFunc("GET /api/notifications", s.handleListNotifications)
|
mux.HandleFunc("GET /api/notifications", s.handleListNotifications)
|
||||||
mux.HandleFunc("PATCH /api/notifications", s.handleMarkAllNotificationsRead)
|
mux.HandleFunc("PATCH /api/notifications", s.handleMarkAllNotificationsRead)
|
||||||
|
|||||||
@@ -99,6 +99,10 @@ type AudioStore interface {
|
|||||||
// PutAudio stores raw audio bytes under the given MinIO object key.
|
// PutAudio stores raw audio bytes under the given MinIO object key.
|
||||||
PutAudio(ctx context.Context, key string, data []byte) error
|
PutAudio(ctx context.Context, key string, data []byte) error
|
||||||
|
|
||||||
|
// AudioObjectSize returns the byte size of the audio object at key,
|
||||||
|
// or -1 if the object does not exist or size cannot be determined.
|
||||||
|
AudioObjectSize(ctx context.Context, key string) int64
|
||||||
|
|
||||||
// PutAudioStream uploads audio from r to MinIO under key.
|
// PutAudioStream uploads audio from r to MinIO under key.
|
||||||
// size must be the exact byte length of r, or -1 to use multipart upload.
|
// size must be the exact byte length of r, or -1 to use multipart upload.
|
||||||
// contentType should be "audio/mpeg" or "audio/wav".
|
// contentType should be "audio/mpeg" or "audio/wav".
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ func (m *mockStore) AudioObjectKey(_ string, _ int, _ string) string { ret
|
|||||||
func (m *mockStore) AudioObjectKeyExt(_ string, _ int, _, _ string) string { return "" }
|
func (m *mockStore) AudioObjectKeyExt(_ string, _ int, _, _ string) string { return "" }
|
||||||
func (m *mockStore) AudioExists(_ context.Context, _ string) bool { return false }
|
func (m *mockStore) AudioExists(_ context.Context, _ string) bool { return false }
|
||||||
func (m *mockStore) PutAudio(_ context.Context, _ string, _ []byte) error { return nil }
|
func (m *mockStore) PutAudio(_ context.Context, _ string, _ []byte) error { return nil }
|
||||||
|
func (m *mockStore) AudioObjectSize(_ context.Context, _ string) int64 { return -1 }
|
||||||
func (m *mockStore) PutAudioStream(_ context.Context, _ string, _ io.Reader, _ int64, _ string) error {
|
func (m *mockStore) PutAudioStream(_ context.Context, _ string, _ io.Reader, _ int64, _ string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -237,14 +237,18 @@ func (c *textGenHTTPClient) Generate(ctx context.Context, req TextRequest) (stri
|
|||||||
if err := json.Unmarshal(wrapper.Result.Response, &text); err == nil {
|
if err := json.Unmarshal(wrapper.Result.Response, &text); err == nil {
|
||||||
return text, nil
|
return text, nil
|
||||||
}
|
}
|
||||||
// Fall back: array of objects with a "generated_text" field.
|
// Fall back: array of objects with a "generated_text" field
|
||||||
|
// (older CF AI models return [{"generated_text":"..."}]).
|
||||||
var arr []struct {
|
var arr []struct {
|
||||||
GeneratedText string `json:"generated_text"`
|
GeneratedText string `json:"generated_text"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(wrapper.Result.Response, &arr); err == nil && len(arr) > 0 {
|
if err := json.Unmarshal(wrapper.Result.Response, &arr); err == nil && len(arr) > 0 && arr[0].GeneratedText != "" {
|
||||||
return arr[0].GeneratedText, nil
|
return arr[0].GeneratedText, nil
|
||||||
}
|
}
|
||||||
return "", fmt.Errorf("cfai/text: model %s: unrecognised response shape: %s", req.Model, wrapper.Result.Response)
|
// Final fallback: model returned the result directly as a JSON value
|
||||||
|
// (e.g. Llama 4 Scout returns [{"number":1,"title":"..."},...] directly).
|
||||||
|
// Return the raw JSON bytes as a string so callers can parse it themselves.
|
||||||
|
return string(wrapper.Result.Response), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Models returns all supported text generation model metadata.
|
// Models returns all supported text generation model metadata.
|
||||||
|
|||||||
@@ -142,7 +142,8 @@ func (s *stubAudioStore) AudioObjectKey(slug string, n int, voice string) string
|
|||||||
func (s *stubAudioStore) AudioObjectKeyExt(slug string, n int, voice, ext string) string {
|
func (s *stubAudioStore) AudioObjectKeyExt(slug string, n int, voice, ext string) string {
|
||||||
return slug + "/" + string(rune('0'+n)) + "/" + voice + "." + ext
|
return slug + "/" + string(rune('0'+n)) + "/" + voice + "." + ext
|
||||||
}
|
}
|
||||||
func (s *stubAudioStore) AudioExists(_ context.Context, _ string) bool { return false }
|
func (s *stubAudioStore) AudioExists(_ context.Context, _ string) bool { return false }
|
||||||
|
func (s *stubAudioStore) AudioObjectSize(_ context.Context, _ string) int64 { return -1 }
|
||||||
func (s *stubAudioStore) PutAudio(_ context.Context, _ string, _ []byte) error {
|
func (s *stubAudioStore) PutAudio(_ context.Context, _ string, _ []byte) error {
|
||||||
s.putCalled.Add(1)
|
s.putCalled.Add(1)
|
||||||
return s.putErr
|
return s.putErr
|
||||||
|
|||||||
@@ -190,6 +190,14 @@ func (m *minioClient) objectExists(ctx context.Context, bucket, key string) bool
|
|||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *minioClient) objectSize(ctx context.Context, bucket, key string) int64 {
|
||||||
|
info, err := m.client.StatObject(ctx, bucket, key, minio.StatObjectOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return info.Size
|
||||||
|
}
|
||||||
|
|
||||||
func (m *minioClient) presignGet(ctx context.Context, bucket, key string, expires time.Duration) (string, error) {
|
func (m *minioClient) presignGet(ctx context.Context, bucket, key string, expires time.Duration) (string, error) {
|
||||||
u, err := m.pubClient.PresignedGetObject(ctx, bucket, key, expires, nil)
|
u, err := m.pubClient.PresignedGetObject(ctx, bucket, key, expires, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -370,11 +370,16 @@ func (s *Store) ListChapters(ctx context.Context, slug string) ([]domain.Chapter
|
|||||||
chapters := make([]domain.ChapterInfo, 0, len(items))
|
chapters := make([]domain.ChapterInfo, 0, len(items))
|
||||||
for _, raw := range items {
|
for _, raw := range items {
|
||||||
var rec struct {
|
var rec struct {
|
||||||
Number int `json:"number"`
|
Number int `json:"number"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
|
Created string `json:"created"`
|
||||||
}
|
}
|
||||||
json.Unmarshal(raw, &rec)
|
json.Unmarshal(raw, &rec)
|
||||||
chapters = append(chapters, domain.ChapterInfo{Number: rec.Number, Title: rec.Title})
|
chapters = append(chapters, domain.ChapterInfo{
|
||||||
|
Number: rec.Number,
|
||||||
|
Title: rec.Title,
|
||||||
|
Date: rec.Created,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return chapters, nil
|
return chapters, nil
|
||||||
}
|
}
|
||||||
@@ -606,6 +611,10 @@ func (s *Store) AudioExists(ctx context.Context, key string) bool {
|
|||||||
return s.mc.objectExists(ctx, s.mc.bucketAudio, key)
|
return s.mc.objectExists(ctx, s.mc.bucketAudio, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) AudioObjectSize(ctx context.Context, key string) int64 {
|
||||||
|
return s.mc.objectSize(ctx, s.mc.bucketAudio, key)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) PutAudio(ctx context.Context, key string, data []byte) error {
|
func (s *Store) PutAudio(ctx context.Context, key string, data []byte) error {
|
||||||
return s.mc.putObject(ctx, s.mc.bucketAudio, key, "audio/mpeg", data)
|
return s.mc.putObject(ctx, s.mc.bucketAudio, key, "audio/mpeg", data)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,11 @@
|
|||||||
label: () => m.admin_nav_catalogue_tools(),
|
label: () => m.admin_nav_catalogue_tools(),
|
||||||
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />`
|
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />`
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: '/admin/podcast',
|
||||||
|
label: () => 'Podcast',
|
||||||
|
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />`
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: '/admin/changelog',
|
href: '/admin/changelog',
|
||||||
label: () => m.admin_nav_changelog(),
|
label: () => m.admin_nav_changelog(),
|
||||||
|
|||||||
30
ui/src/routes/admin/podcast/+page.server.ts
Normal file
30
ui/src/routes/admin/podcast/+page.server.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { listBooks, listAIJobs, type Book, type AIJob } from '$lib/server/pocketbase';
|
||||||
|
import { backendFetch } from '$lib/server/scraper';
|
||||||
|
import { log } from '$lib/server/logger';
|
||||||
|
|
||||||
|
export type { Book, AIJob };
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async () => {
|
||||||
|
const [books, allJobs, voicesRes] = await Promise.all([
|
||||||
|
listBooks().catch((e): Book[] => {
|
||||||
|
log.warn('admin/podcast', 'failed to load books', { err: String(e) });
|
||||||
|
return [];
|
||||||
|
}),
|
||||||
|
listAIJobs().catch((e): AIJob[] => {
|
||||||
|
log.warn('admin/podcast', 'failed to load ai jobs', { err: String(e) });
|
||||||
|
return [];
|
||||||
|
}),
|
||||||
|
backendFetch('/api/voices').catch(() => null)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const podcastJobs = allJobs.filter((j) => j.kind === 'podcast');
|
||||||
|
|
||||||
|
let voices: { id: string; engine: string; lang: string; gender: string }[] = [];
|
||||||
|
if (voicesRes?.ok) {
|
||||||
|
const body = await voicesRes.json().catch(() => null);
|
||||||
|
if (Array.isArray(body?.voices)) voices = body.voices;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { books, podcastJobs, voices };
|
||||||
|
};
|
||||||
312
ui/src/routes/admin/podcast/+page.svelte
Normal file
312
ui/src/routes/admin/podcast/+page.svelte
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
import type { AIJob } from '$lib/server/pocketbase';
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
// ── Form state ────────────────────────────────────────────────────────────────
|
||||||
|
let selectedSlug = $state('');
|
||||||
|
let selectedVoice = $state('');
|
||||||
|
let fromChapter = $state(0);
|
||||||
|
let toChapter = $state(0);
|
||||||
|
let busy = $state(false);
|
||||||
|
let error = $state('');
|
||||||
|
let successMsg = $state('');
|
||||||
|
|
||||||
|
// ── Job list ──────────────────────────────────────────────────────────────────
|
||||||
|
let jobs = $state<AIJob[]>(data.podcastJobs);
|
||||||
|
|
||||||
|
const hasInFlight = $derived(
|
||||||
|
jobs.some((j) => j.status === 'pending' || j.status === 'running')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auto-refresh while jobs are running.
|
||||||
|
$effect(() => {
|
||||||
|
if (!hasInFlight) return;
|
||||||
|
const id = setInterval(async () => {
|
||||||
|
const res = await fetch('/api/admin/ai-jobs').catch(() => null);
|
||||||
|
if (res?.ok) {
|
||||||
|
const body = await res.json().catch(() => null);
|
||||||
|
if (Array.isArray(body?.jobs)) {
|
||||||
|
jobs = (body.jobs as AIJob[]).filter((j) => j.kind === 'podcast');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
function statusColor(status: string) {
|
||||||
|
switch (status) {
|
||||||
|
case 'done': return 'text-green-400';
|
||||||
|
case 'running': return 'text-(--color-brand) animate-pulse';
|
||||||
|
case 'pending': return 'text-sky-400 animate-pulse';
|
||||||
|
case 'failed': return 'text-(--color-danger)';
|
||||||
|
case 'cancelled': return 'text-(--color-muted)';
|
||||||
|
default: return 'text-(--color-text)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(s: string) {
|
||||||
|
if (!s) return '—';
|
||||||
|
return new Date(s).toLocaleString(undefined, {
|
||||||
|
month: 'short', day: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function progress(job: AIJob) {
|
||||||
|
if (!job.items_total) return '';
|
||||||
|
const pct = Math.round((job.items_done / job.items_total) * 100);
|
||||||
|
return `${job.items_done}/${job.items_total} (${pct}%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function feedURL(job: AIJob) {
|
||||||
|
const origin = page.url.origin;
|
||||||
|
return `${origin}/podcast/${job.slug}.xml?voice=${encodeURIComponent(job.model)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyFeedURL(job: AIJob) {
|
||||||
|
await navigator.clipboard.writeText(feedURL(job)).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Job submission ────────────────────────────────────────────────────────────
|
||||||
|
async function startJob() {
|
||||||
|
if (busy || !selectedSlug) return;
|
||||||
|
error = '';
|
||||||
|
successMsg = '';
|
||||||
|
busy = true;
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
slug: selectedSlug,
|
||||||
|
voice: selectedVoice || undefined
|
||||||
|
};
|
||||||
|
if (fromChapter > 0) body.from_chapter = fromChapter;
|
||||||
|
if (toChapter > 0) body.to_chapter = toChapter;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/podcast', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
const json = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
error = json.error ?? `HTTP ${res.status}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
successMsg = `Job ${json.job_id} started — generating audio for chapters ${json.from}–${json.to}`;
|
||||||
|
// Optimistically prepend new job placeholder.
|
||||||
|
jobs = [
|
||||||
|
{
|
||||||
|
id: json.job_id,
|
||||||
|
kind: 'podcast',
|
||||||
|
slug: selectedSlug,
|
||||||
|
status: 'running',
|
||||||
|
model: json.voice,
|
||||||
|
items_done: 0,
|
||||||
|
items_total: json.items_total,
|
||||||
|
from_item: json.from,
|
||||||
|
to_item: json.to,
|
||||||
|
started: new Date().toISOString(),
|
||||||
|
finished: '',
|
||||||
|
heartbeat_at: '',
|
||||||
|
error_message: '',
|
||||||
|
payload: ''
|
||||||
|
} as AIJob,
|
||||||
|
...jobs
|
||||||
|
];
|
||||||
|
} catch (e) {
|
||||||
|
error = String(e);
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelJob(id: string) {
|
||||||
|
await fetch(`/api/admin/ai-jobs/${id}/cancel`, { method: 'POST' }).catch(() => {});
|
||||||
|
jobs = jobs.map((j) => (j.id === id ? { ...j, status: 'cancelled' as const } : j));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Podcast — Admin</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="max-w-3xl">
|
||||||
|
<h1 class="text-xl font-bold text-(--color-text) mb-1">Podcast Feed</h1>
|
||||||
|
<p class="text-sm text-(--color-muted) mb-6">
|
||||||
|
Generate TTS audio for a book and publish it as a podcast RSS feed that any podcast app can subscribe to.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- ── Generation form ─────────────────────────────────────────────────── -->
|
||||||
|
<div class="bg-(--color-surface-2) border border-(--color-border) rounded-xl p-5 mb-8 space-y-4">
|
||||||
|
<h2 class="text-sm font-semibold text-(--color-text)">Generate audiobook</h2>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<!-- Book -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-(--color-muted) mb-1" for="podcast-book">Book</label>
|
||||||
|
<select
|
||||||
|
id="podcast-book"
|
||||||
|
bind:value={selectedSlug}
|
||||||
|
class="w-full bg-(--color-surface) border border-(--color-border) rounded-lg px-3 py-2 text-sm text-(--color-text) focus:outline-none focus:border-(--color-brand)"
|
||||||
|
>
|
||||||
|
<option value="">— select a book —</option>
|
||||||
|
{#each data.books as book}
|
||||||
|
<option value={book.slug}>{book.title}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Voice -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-(--color-muted) mb-1" for="podcast-voice">Voice</label>
|
||||||
|
<select
|
||||||
|
id="podcast-voice"
|
||||||
|
bind:value={selectedVoice}
|
||||||
|
class="w-full bg-(--color-surface) border border-(--color-border) rounded-lg px-3 py-2 text-sm text-(--color-text) focus:outline-none focus:border-(--color-brand)"
|
||||||
|
>
|
||||||
|
<option value="">— default —</option>
|
||||||
|
{#each data.voices as v}
|
||||||
|
<option value={v.id}>{v.id} ({v.engine} · {v.lang} · {v.gender})</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- From chapter -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-(--color-muted) mb-1" for="podcast-from">From chapter</label>
|
||||||
|
<input
|
||||||
|
id="podcast-from"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
bind:value={fromChapter}
|
||||||
|
placeholder="1 (default)"
|
||||||
|
class="w-full bg-(--color-surface) border border-(--color-border) rounded-lg px-3 py-2 text-sm text-(--color-text) focus:outline-none focus:border-(--color-brand)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- To chapter -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-(--color-muted) mb-1" for="podcast-to">To chapter</label>
|
||||||
|
<input
|
||||||
|
id="podcast-to"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
bind:value={toChapter}
|
||||||
|
placeholder="all (default)"
|
||||||
|
class="w-full bg-(--color-surface) border border-(--color-border) rounded-lg px-3 py-2 text-sm text-(--color-text) focus:outline-none focus:border-(--color-brand)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<p class="text-sm text-(--color-danger)">{error}</p>
|
||||||
|
{/if}
|
||||||
|
{#if successMsg}
|
||||||
|
<p class="text-sm text-green-400">{successMsg}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy || !selectedSlug}
|
||||||
|
onclick={startJob}
|
||||||
|
class="px-4 py-2 rounded-lg text-sm font-medium bg-(--color-brand) text-(--color-surface) hover:opacity-90 transition-opacity disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{#if busy}
|
||||||
|
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
Generate podcast
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Job history ──────────────────────────────────────────────────────── -->
|
||||||
|
<h2 class="text-sm font-semibold text-(--color-text) mb-3">Job history</h2>
|
||||||
|
|
||||||
|
{#if jobs.length === 0}
|
||||||
|
<p class="text-sm text-(--color-muted)">No podcast jobs yet.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each jobs as job (job.id)}
|
||||||
|
<div class="bg-(--color-surface-2) border border-(--color-border) rounded-xl p-4 flex flex-col gap-2">
|
||||||
|
<div class="flex items-start justify-between gap-2 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<span class="text-sm font-medium text-(--color-text)">{job.slug}</span>
|
||||||
|
<span class="text-xs text-(--color-muted) ml-2">voice: {job.model}</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs font-semibold {statusColor(job.status)}">{job.status}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Progress bar -->
|
||||||
|
{#if job.items_total > 0}
|
||||||
|
<div class="w-full bg-(--color-surface) rounded-full h-1.5">
|
||||||
|
<div
|
||||||
|
class="bg-(--color-brand) h-1.5 rounded-full transition-all"
|
||||||
|
style="width: {Math.round((job.items_done / job.items_total) * 100)}%"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-(--color-muted)">{progress(job)}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if job.error_message}
|
||||||
|
<p class="text-xs text-(--color-danger)">{job.error_message}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2 flex-wrap text-xs text-(--color-muted)">
|
||||||
|
<span>Started: {fmtDate(job.started ?? '')}</span>
|
||||||
|
{#if job.finished}
|
||||||
|
<span>·</span>
|
||||||
|
<span>Finished: {fmtDate(job.finished)}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2 flex-wrap mt-1">
|
||||||
|
<!-- Feed URL (only useful once job is done/in-progress) -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => copyFeedURL(job)}
|
||||||
|
class="text-xs px-2.5 py-1 rounded-lg bg-(--color-surface) border border-(--color-border) text-(--color-muted) hover:text-(--color-text) transition-colors flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
|
||||||
|
</svg>
|
||||||
|
Copy feed URL
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Cancel button for running/pending jobs -->
|
||||||
|
{#if job.status === 'running' || job.status === 'pending'}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => cancelJob(job.id)}
|
||||||
|
class="text-xs px-2.5 py-1 rounded-lg bg-red-500/10 text-red-400 border border-red-500/30 hover:bg-red-500/20 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- ── How to subscribe ─────────────────────────────────────────────────── -->
|
||||||
|
<div class="mt-8 bg-(--color-surface-2) border border-(--color-border) rounded-xl p-5">
|
||||||
|
<h2 class="text-sm font-semibold text-(--color-text) mb-2">How to subscribe</h2>
|
||||||
|
<ol class="text-sm text-(--color-muted) list-decimal list-inside space-y-1">
|
||||||
|
<li>Click <strong class="text-(--color-text)">Generate podcast</strong> above to create audio for your book.</li>
|
||||||
|
<li>Once running, copy the feed URL with the button above.</li>
|
||||||
|
<li>Open your podcast app (Apple Podcasts, Spotify, Pocket Casts, etc.).</li>
|
||||||
|
<li>Add the feed URL as a custom RSS feed / podcast URL.</li>
|
||||||
|
<li>As more chapters are generated they will appear automatically on the next feed refresh.</li>
|
||||||
|
</ol>
|
||||||
|
<p class="text-xs text-(--color-muted) mt-3">
|
||||||
|
Feed URL format: <code class="bg-(--color-surface) px-1 py-0.5 rounded text-xs">/podcast/<slug>.xml?voice=<voice-id></code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
34
ui/src/routes/api/admin/podcast/+server.ts
Normal file
34
ui/src/routes/api/admin/podcast/+server.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* POST /api/admin/podcast
|
||||||
|
*
|
||||||
|
* Admin-only proxy to the Go backend's podcast generation endpoint.
|
||||||
|
* Body: { slug, voice?, from_chapter?, to_chapter? }
|
||||||
|
* Response 202: { job_id, slug, voice, from, to, items_total }
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { log } from '$lib/server/logger';
|
||||||
|
import { backendFetch } from '$lib/server/scraper';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||||
|
if (!locals.user || locals.user.role !== 'admin') {
|
||||||
|
throw error(403, 'Forbidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.text();
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await backendFetch('/api/admin/podcast', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
log.error('admin/podcast', 'backend proxy error', { err: String(e) });
|
||||||
|
throw error(502, 'Could not reach backend');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
return json(data, { status: res.status });
|
||||||
|
};
|
||||||
@@ -15,5 +15,5 @@ export const GET: RequestHandler = async ({ params }) => {
|
|||||||
const task = await getScrapingTask(id).catch(() => null);
|
const task = await getScrapingTask(id).catch(() => null);
|
||||||
if (!task) throw error(404, 'Task not found');
|
if (!task) throw error(404, 'Task not found');
|
||||||
|
|
||||||
return json({ id: task.id, status: task.status, error_message: task.error_message ?? '' });
|
return json({ id: task.id, status: task.status, chapters_scraped: task.chapters_scraped ?? 0, error_message: task.error_message ?? '' });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -167,17 +167,24 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
|
|||||||
|
|
||||||
// ── Original content path ──────────────────────────────────────────────
|
// ── Original content path ──────────────────────────────────────────────
|
||||||
let html = '';
|
let html = '';
|
||||||
|
let contentMissing = false;
|
||||||
try {
|
try {
|
||||||
const res = await backendFetch(`/api/chapter-markdown/${encodeURIComponent(slug)}/${n}`);
|
const res = await backendFetch(`/api/chapter-markdown/${encodeURIComponent(slug)}/${n}`);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
log.error('chapter', 'chapter-markdown returned error', { slug, n, status: res.status });
|
log.error('chapter', 'chapter-markdown returned error', { slug, n, status: res.status });
|
||||||
error(res.status === 404 ? 404 : 502, res.status === 404 ? `Chapter ${n} not found` : 'Could not fetch chapter content');
|
if (res.status === 404) {
|
||||||
|
// Content file missing from storage — render the page shell with an
|
||||||
|
// empty body so admins can trigger a re-scrape from the page itself.
|
||||||
|
contentMissing = true;
|
||||||
|
} else {
|
||||||
|
error(502, 'Could not fetch chapter content');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const markdown = await res.text();
|
||||||
|
html = await marked(markdown);
|
||||||
}
|
}
|
||||||
const markdown = await res.text();
|
|
||||||
html = await marked(markdown);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof Error && 'status' in e) throw e;
|
if (e instanceof Error && 'status' in e) throw e;
|
||||||
// Don't hard-fail — show empty content with error message
|
|
||||||
log.error('chapter', 'failed to fetch chapter content', { slug, n, err: String(e) });
|
log.error('chapter', 'failed to fetch chapter content', { slug, n, err: String(e) });
|
||||||
error(502, 'Could not fetch chapter content');
|
error(502, 'Could not fetch chapter content');
|
||||||
}
|
}
|
||||||
@@ -202,9 +209,10 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
|
|||||||
const nextChapter = chapters.find((c) => c.number === n + 1) ?? null;
|
const nextChapter = chapters.find((c) => c.number === n + 1) ?? null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
book: { slug: book.slug, title: book.title, cover: book.cover ?? '' },
|
book: { slug: book.slug, title: book.title, cover: book.cover ?? '', source_url: book.source_url ?? '' },
|
||||||
chapter: chapterIdx,
|
chapter: chapterIdx,
|
||||||
html,
|
html,
|
||||||
|
contentMissing,
|
||||||
voices,
|
voices,
|
||||||
prev: prevChapter ? prevChapter.number : null,
|
prev: prevChapter ? prevChapter.number : null,
|
||||||
next: nextChapter ? nextChapter.number : null,
|
next: nextChapter ? nextChapter.number : null,
|
||||||
@@ -214,6 +222,7 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
|
|||||||
lang: useTranslation ? lang : '',
|
lang: useTranslation ? lang : '',
|
||||||
translationStatus,
|
translationStatus,
|
||||||
isPro: locals.isPro,
|
isPro: locals.isPro,
|
||||||
|
isAdmin: locals.user?.role === 'admin',
|
||||||
chapterImageUrl,
|
chapterImageUrl,
|
||||||
audioReady,
|
audioReady,
|
||||||
availableVoice
|
availableVoice
|
||||||
|
|||||||
@@ -374,7 +374,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If the normal path returned no content, fall back to live preview scrape
|
// If the normal path returned no content, fall back to live preview scrape
|
||||||
if (!data.isPreview && !data.html) {
|
// (but only when contentMissing is false — if the file is genuinely absent
|
||||||
|
// from storage the preview scrape won't help either and admins can re-queue)
|
||||||
|
if (!data.isPreview && !data.html && !data.contentMissing) {
|
||||||
fetchingContent = true;
|
fetchingContent = true;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -406,6 +408,62 @@
|
|||||||
html ? (html.replace(/<[^>]*>/g, '').match(/\S+/g)?.length ?? 0) : 0
|
html ? (html.replace(/<[^>]*>/g, '').match(/\S+/g)?.length ?? 0) : 0
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── Admin chapter tools ────────────────────────────────────────────────────
|
||||||
|
let adminOpen = $state(false);
|
||||||
|
let scrapeStatus = $state<'idle' | 'busy' | 'queued' | 'error'>('idle');
|
||||||
|
let scrapeTaskId = $state('');
|
||||||
|
let scrapeProgress = $state('');
|
||||||
|
let pollTimer = 0;
|
||||||
|
|
||||||
|
async function scrapeThisChapter() {
|
||||||
|
if (scrapeStatus === 'busy' || !data.book.source_url) return;
|
||||||
|
scrapeStatus = 'busy';
|
||||||
|
scrapeProgress = '';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/scrape/range', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url: data.book.source_url, from: data.chapter.number, to: data.chapter.number })
|
||||||
|
});
|
||||||
|
const d = await res.json().catch(() => ({}));
|
||||||
|
if (res.ok) {
|
||||||
|
scrapeStatus = 'queued';
|
||||||
|
scrapeTaskId = d.task_id ?? '';
|
||||||
|
if (scrapeTaskId) startPollTask(scrapeTaskId);
|
||||||
|
} else if (res.status === 409) {
|
||||||
|
scrapeStatus = 'error';
|
||||||
|
scrapeProgress = 'A scrape job is already running — try again shortly.';
|
||||||
|
} else {
|
||||||
|
scrapeStatus = 'error';
|
||||||
|
scrapeProgress = d.message ?? `Error ${res.status}`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
scrapeStatus = 'error';
|
||||||
|
scrapeProgress = String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPollTask(id: string) {
|
||||||
|
clearTimeout(pollTimer);
|
||||||
|
pollTimer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/scrape/task/${id}`);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const d = await res.json() as { status: string; chapters_scraped?: number; error_message?: string };
|
||||||
|
if (d.status === 'done') {
|
||||||
|
scrapeProgress = `Done — ${d.chapters_scraped ?? 0} chapter(s) scraped. Reload to see content.`;
|
||||||
|
scrapeStatus = 'queued';
|
||||||
|
} else if (d.status === 'failed') {
|
||||||
|
scrapeProgress = `Failed: ${d.error_message ?? 'unknown error'}`;
|
||||||
|
scrapeStatus = 'error';
|
||||||
|
} else {
|
||||||
|
scrapeProgress = `Status: ${d.status}${d.chapters_scraped ? ` (${d.chapters_scraped} done)` : ''}`;
|
||||||
|
startPollTask(id); // keep polling
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, 3000) as unknown as number;
|
||||||
|
}
|
||||||
|
|
||||||
// Strip scraper artifacts from chapter titles:
|
// Strip scraper artifacts from chapter titles:
|
||||||
// - Leading digit(s) prefixed before "Chapter" (e.g. "6Chapter 6 : ...")
|
// - Leading digit(s) prefixed before "Chapter" (e.g. "6Chapter 6 : ...")
|
||||||
// - Everything after the first newline (often includes a scraped date)
|
// - Everything after the first newline (often includes a scraped date)
|
||||||
@@ -717,8 +775,115 @@
|
|||||||
{m.reader_fetching_chapter()}
|
{m.reader_fetching_chapter()}
|
||||||
</div>
|
</div>
|
||||||
{:else if !html}
|
{:else if !html}
|
||||||
<div class="text-(--color-muted) text-center py-16">
|
<!-- ── Content unavailable ─────────────────────────────────────────── -->
|
||||||
<p>{fetchError || m.reader_audio_error()}</p>
|
<div class="mt-8 rounded-xl border border-(--color-border) bg-(--color-surface-2) overflow-hidden">
|
||||||
|
<!-- Banner — visible to all users -->
|
||||||
|
<div class="flex items-start gap-4 p-6">
|
||||||
|
<div class="w-10 h-10 rounded-full bg-(--color-surface-3) flex items-center justify-center shrink-0 mt-0.5">
|
||||||
|
<svg class="w-5 h-5 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold text-(--color-text) mb-1">Chapter content not available</p>
|
||||||
|
<p class="text-sm text-(--color-muted)">
|
||||||
|
{#if data.contentMissing}
|
||||||
|
This chapter exists in the index but its content hasn't been stored yet.
|
||||||
|
{#if !data.isAdmin}It should appear soon — try refreshing in a few minutes.{/if}
|
||||||
|
{:else}
|
||||||
|
{fetchError || 'Could not load this chapter. Please try again.'}
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Admin toolbar — only visible to admins -->
|
||||||
|
{#if data.isAdmin}
|
||||||
|
<div class="border-t border-(--color-border)">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (adminOpen = !adminOpen)}
|
||||||
|
class="w-full flex items-center gap-2 px-5 py-3 text-xs font-semibold text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3)/40 transition-colors text-left"
|
||||||
|
>
|
||||||
|
<svg class="w-3.5 h-3.5 shrink-0 text-(--color-brand)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||||
|
</svg>
|
||||||
|
Admin Tools
|
||||||
|
<svg class="w-3 h-3 ml-auto transition-transform {adminOpen ? 'rotate-180' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if adminOpen}
|
||||||
|
<div class="px-5 pb-5 flex flex-col gap-4 border-t border-(--color-border)">
|
||||||
|
|
||||||
|
<!-- Source URL info -->
|
||||||
|
{#if data.book.source_url}
|
||||||
|
<div class="mt-3">
|
||||||
|
<p class="text-xs text-(--color-muted) mb-1">Source URL</p>
|
||||||
|
<a href={data.book.source_url} target="_blank" rel="noopener noreferrer"
|
||||||
|
class="text-xs text-(--color-brand) hover:underline break-all">
|
||||||
|
{data.book.source_url}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Scrape this chapter -->
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">Scrape Chapter {data.chapter.number}</p>
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={scrapeThisChapter}
|
||||||
|
disabled={scrapeStatus === 'busy' || !data.book.source_url}
|
||||||
|
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-xs hover:bg-(--color-brand-dim) disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{#if scrapeStatus === 'busy'}
|
||||||
|
<svg class="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
|
</svg>
|
||||||
|
Scraping…
|
||||||
|
{:else}
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||||
|
</svg>
|
||||||
|
Re-scrape ch.{data.chapter.number}
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{#if !data.book.source_url}
|
||||||
|
<span class="text-xs text-(--color-muted)">No source URL on this book.</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if scrapeProgress}
|
||||||
|
<p class="text-xs {scrapeStatus === 'error' ? 'text-red-400' : 'text-green-400'}">{scrapeProgress}</p>
|
||||||
|
{:else if scrapeStatus === 'queued'}
|
||||||
|
<p class="text-xs text-(--color-muted)">Queued — polling for completion…</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick links -->
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">Quick links</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<a href="/admin/scrape" class="text-xs px-2.5 py-1 rounded-md bg-(--color-surface-3) text-(--color-muted) hover:text-(--color-text) transition-colors">
|
||||||
|
Scrape dashboard
|
||||||
|
</a>
|
||||||
|
<a href="/books/{data.book.slug}" class="text-xs px-2.5 py-1 rounded-md bg-(--color-surface-3) text-(--color-muted) hover:text-(--color-text) transition-colors">
|
||||||
|
Book page
|
||||||
|
</a>
|
||||||
|
<a href="https://pb.libnovel.cc/_/" target="_blank" rel="noopener noreferrer"
|
||||||
|
class="text-xs px-2.5 py-1 rounded-md bg-(--color-surface-3) text-(--color-muted) hover:text-(--color-text) transition-colors">
|
||||||
|
PocketBase ↗
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Chapter illustration hero (if generated, hidden in focus mode) -->
|
<!-- Chapter illustration hero (if generated, hidden in focus mode) -->
|
||||||
|
|||||||
Reference in New Issue
Block a user