Files
libnovel/scraper/internal/storage/pocketbase.go
Admin 09cdda2a07
Some checks failed
CI / Scraper / Test (push) Successful in 10s
CI / UI / Build (push) Failing after 9s
CI / Scraper / Lint (pull_request) Successful in 7s
CI / UI / Build (pull_request) Failing after 7s
CI / UI / Docker Push (push) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
CI / Scraper / Lint (push) Successful in 28s
CI / Scraper / Test (pull_request) Successful in 20s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / Scraper / Docker Push (push) Successful in 39s
iOS CI / Build (push) Successful in 2m16s
iOS CI / Test (push) Has been cancelled
iOS CI / Build (pull_request) Successful in 5m35s
iOS CI / Test (pull_request) Successful in 5m50s
feat: add avatars to comments (web + iOS) with replies, delete, sort, and crop fix
- Batch-resolve avatar presign URLs server-side in GET /api/comments/[slug];
  returns avatarUrls map alongside comments and myVotes
- CommentsSection.svelte: show avatar image or initials fallback (24px top-level,
  20px replies) next to each comment/reply username
- iOS CommentsResponse gains avatarUrls field; CommentsViewModel stores and
  populates it on load; CommentRow renders AsyncImage with initials fallback
- Also includes: comment replies (1-level nesting), delete, sort (Top/New),
  parent_id schema migration, and AvatarCropModal cropperjs fix
2026-03-10 20:05:31 +05:00

930 lines
33 KiB
Go

// Package storage — PocketBase REST client.
//
// Collections expected in PocketBase:
//
// books — slug(text,unique), title, author, cover, status, genres(json),
// summary, total_chapters(number), source_url, ranking(number), updated(date)
// chapters_idx — slug(text), number(number), title, date_label, updated(date)
// ranking — rank(number), slug(text,unique), title, author, cover, status,
// genres(json), source_url, updated(date)
// progress — session_id(text), slug(text), chapter(number), updated(date)
// audio_cache — cache_key(text,unique), filename(text), updated(date)
// app_users — username(text,unique), password_hash(text), role(text), created(date)
// scraping_tasks — id(auto), kind(text), target_url(text), status(text),
// books_found(number), chapters_scraped(number),
// chapters_skipped(number), errors(number),
// started(date), finished(date), error_message(text)
// user_sessions — user_id(text), session_id(text,unique), user_agent(text),
// ip(text), created_at(date), last_seen(date)
// book_comments — slug(text), user_id(text), username(text), body(text),
// upvotes(number), downvotes(number), created(date)
// comment_votes — comment_id(text), user_id(text), session_id(text), vote(text: up|down)
package storage
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/libnovel/scraper/internal/scraper"
)
// PocketBaseConfig holds PocketBase connection settings.
type PocketBaseConfig struct {
BaseURL string // e.g. "http://pocketbase:8090"
AdminEmail string
AdminPassword string
}
// pbClient is a minimal PocketBase admin REST client.
type pbClient struct {
cfg PocketBaseConfig
httpClient *http.Client
log *slog.Logger
tokenMu sync.RWMutex
token string
tokenExp time.Time
}
// newPBClient creates a new PocketBase client. It does not authenticate yet;
// authentication happens lazily on the first API call.
func newPBClient(cfg PocketBaseConfig, log *slog.Logger) *pbClient {
return &pbClient{
cfg: cfg,
httpClient: &http.Client{Timeout: 15 * time.Second},
log: log,
}
}
// ─── Auth ─────────────────────────────────────────────────────────────────────
func (p *pbClient) authenticate(ctx context.Context) error {
body, _ := json.Marshal(map[string]string{
"identity": p.cfg.AdminEmail,
"password": p.cfg.AdminPassword,
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
p.cfg.BaseURL+"/api/collections/_superusers/auth-with-password", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := p.httpClient.Do(req)
if err != nil {
return fmt.Errorf("pocketbase: auth: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return fmt.Errorf("pocketbase: auth status %d: %s", resp.StatusCode, b)
}
var result struct {
Token string `json:"token"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return fmt.Errorf("pocketbase: decode auth: %w", err)
}
p.tokenMu.Lock()
p.token = result.Token
p.tokenExp = time.Now().Add(12 * time.Hour)
p.tokenMu.Unlock()
return nil
}
func (p *pbClient) authToken(ctx context.Context) (string, error) {
p.tokenMu.RLock()
tok, exp := p.token, p.tokenExp
p.tokenMu.RUnlock()
if tok != "" && time.Now().Before(exp) {
return tok, nil
}
if err := p.authenticate(ctx); err != nil {
return "", err
}
p.tokenMu.RLock()
defer p.tokenMu.RUnlock()
return p.token, nil
}
// ─── Generic CRUD helpers ──────────────────────────────────────────────────────
func (p *pbClient) do(ctx context.Context, method, path string, body interface{}) (*http.Response, error) {
tok, err := p.authToken(ctx)
if err != nil {
return nil, err
}
var bodyReader io.Reader
if body != nil {
b, _ := json.Marshal(body)
bodyReader = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, p.cfg.BaseURL+path, bodyReader)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+tok)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
return p.httpClient.Do(req)
}
// listOne fetches the first matching record from a collection.
func (p *pbClient) listOne(ctx context.Context, collection, filter string) (map[string]interface{}, error) {
q := url.Values{}
q.Set("filter", filter)
q.Set("perPage", "1")
path := fmt.Sprintf("/api/collections/%s/records?%s", collection, q.Encode())
resp, err := p.do(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, nil
}
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("pocketbase: listOne %s: status %d: %s", collection, resp.StatusCode, b)
}
var result struct {
Items []map[string]interface{} `json:"items"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("pocketbase: listOne %s: decode: %w", collection, err)
}
if len(result.Items) == 0 {
return nil, nil
}
return result.Items[0], nil
}
// listAll returns all records from a collection matching filter by paginating
// through all pages (PocketBase default page size is capped at 500).
func (p *pbClient) listAll(ctx context.Context, collection, filter, sort string) ([]map[string]interface{}, error) {
const perPage = 500
var all []map[string]interface{}
for page := 1; ; page++ {
q := url.Values{}
if filter != "" {
q.Set("filter", filter)
}
if sort != "" {
q.Set("sort", sort)
}
q.Set("perPage", fmt.Sprintf("%d", perPage))
q.Set("page", fmt.Sprintf("%d", page))
path := fmt.Sprintf("/api/collections/%s/records?%s", collection, q.Encode())
resp, err := p.do(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return nil, fmt.Errorf("pocketbase: listAll %s: status %d: %s", collection, resp.StatusCode, b)
}
var result struct {
Page int `json:"page"`
TotalPages int `json:"totalPages"`
Items []map[string]interface{} `json:"items"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
resp.Body.Close()
return nil, fmt.Errorf("pocketbase: listAll %s: decode: %w", collection, err)
}
resp.Body.Close()
all = append(all, result.Items...)
if page >= result.TotalPages || len(result.Items) == 0 {
break
}
}
return all, nil
}
// upsert creates a record; if one matching filter already exists it updates it.
func (p *pbClient) upsert(ctx context.Context, collection, filter string, data map[string]interface{}) error {
existing, err := p.listOne(ctx, collection, filter)
if err != nil {
return err
}
if existing != nil {
id := existing["id"].(string)
resp, err := p.do(ctx, http.MethodPatch,
fmt.Sprintf("/api/collections/%s/records/%s", collection, id), data)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return fmt.Errorf("pocketbase: upsert (patch) %s id=%s: status %d: %s", collection, id, resp.StatusCode, b)
}
return nil
}
resp, err := p.do(ctx, http.MethodPost,
fmt.Sprintf("/api/collections/%s/records", collection), data)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
b, _ := io.ReadAll(resp.Body)
return fmt.Errorf("pocketbase: upsert (create) %s: status %d: %s", collection, resp.StatusCode, b)
}
return nil
}
// deleteWhere deletes all records matching filter in collection.
func (p *pbClient) deleteWhere(ctx context.Context, collection, filter string) error {
items, err := p.listAll(ctx, collection, filter, "")
if err != nil {
return err
}
for _, item := range items {
id, _ := item["id"].(string)
resp, err := p.do(ctx, http.MethodDelete,
fmt.Sprintf("/api/collections/%s/records/%s", collection, id), nil)
if err != nil {
return err
}
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return fmt.Errorf("pocketbase: deleteWhere %s id=%s: status %d: %s", collection, id, resp.StatusCode, b)
}
resp.Body.Close()
}
return nil
}
// ─── PocketBaseStore ──────────────────────────────────────────────────────────
// PocketBaseStore implements the structured-data portion of the Store interface
// backed by PocketBase REST API.
type PocketBaseStore struct {
pb *pbClient
log *slog.Logger
}
// NewPocketBaseStore returns a connected PocketBaseStore.
func NewPocketBaseStore(cfg PocketBaseConfig, log *slog.Logger) *PocketBaseStore {
return &PocketBaseStore{pb: newPBClient(cfg, log), log: log}
}
// Ping verifies connectivity by authenticating.
func (s *PocketBaseStore) Ping(ctx context.Context) error {
_, err := s.pb.authToken(ctx)
return err
}
// ─── Collections schema bootstrap ────────────────────────────────────────────
// CollectionDef maps a collection name to its fields for auto-creation.
// EnsureCollections creates missing collections via the PocketBase API.
// Safe to call on every startup — existing collections are skipped.
func (s *PocketBaseStore) EnsureCollections(ctx context.Context) error {
// We just attempt to create each collection; 400/422 errors for "already
// exists" are silently ignored.
// PocketBase v0.22+ uses "fields"; older versions used "schema".
// We use "fields" which is the current API.
collections := []map[string]interface{}{
{
"name": "books",
"type": "base",
"fields": []map[string]interface{}{
{"name": "slug", "type": "text", "required": true},
{"name": "title", "type": "text", "required": true},
{"name": "author", "type": "text"},
{"name": "cover", "type": "text"},
{"name": "status", "type": "text"},
{"name": "genres", "type": "json"},
{"name": "summary", "type": "text"},
{"name": "total_chapters", "type": "number"},
{"name": "source_url", "type": "text"},
{"name": "ranking", "type": "number"},
{"name": "meta_updated", "type": "date"},
},
},
{
"name": "chapters_idx",
"type": "base",
"fields": []map[string]interface{}{
{"name": "slug", "type": "text", "required": true},
{"name": "number", "type": "number", "required": true},
{"name": "title", "type": "text"},
{"name": "date_label", "type": "text"},
},
},
{
"name": "ranking",
"type": "base",
"fields": []map[string]interface{}{
{"name": "rank", "type": "number", "required": true},
{"name": "slug", "type": "text", "required": true},
{"name": "title", "type": "text"},
{"name": "author", "type": "text"},
{"name": "cover", "type": "text"},
{"name": "status", "type": "text"},
{"name": "genres", "type": "json"},
{"name": "source_url", "type": "text"},
{"name": "updated", "type": "date"},
},
},
{
"name": "progress",
"type": "base",
"fields": []map[string]interface{}{
{"name": "session_id", "type": "text", "required": true},
{"name": "user_id", "type": "text"},
{"name": "slug", "type": "text", "required": true},
{"name": "chapter", "type": "number"},
{"name": "updated", "type": "date"},
},
},
{
"name": "audio_cache",
"type": "base",
"fields": []map[string]interface{}{
{"name": "cache_key", "type": "text", "required": true},
{"name": "filename", "type": "text"},
{"name": "updated", "type": "date"},
},
},
{
"name": "app_users",
"type": "base",
"fields": []map[string]interface{}{
{"name": "username", "type": "text", "required": true},
{"name": "password_hash", "type": "text", "required": true},
{"name": "role", "type": "text"},
{"name": "created", "type": "date"},
},
},
{
"name": "user_library",
"type": "base",
"fields": []map[string]interface{}{
{"name": "session_id", "type": "text", "required": true},
{"name": "user_id", "type": "text"},
{"name": "slug", "type": "text", "required": true},
{"name": "saved_at", "type": "date"},
},
},
{
"name": "scraping_tasks",
"type": "base",
"fields": []map[string]interface{}{
{"name": "kind", "type": "text", "required": true}, // "catalogue" | "book"
{"name": "target_url", "type": "text"}, // set for single-book scrapes
{"name": "status", "type": "text", "required": true}, // "running" | "done" | "failed" | "cancelled"
{"name": "books_found", "type": "number"},
{"name": "chapters_scraped", "type": "number"},
{"name": "chapters_skipped", "type": "number"},
{"name": "errors", "type": "number"},
{"name": "started", "type": "date"},
{"name": "finished", "type": "date"},
{"name": "error_message", "type": "text"},
},
},
{
"name": "audio_jobs",
"type": "base",
"fields": []map[string]interface{}{
{"name": "cache_key", "type": "text", "required": true}, // "slug/chapter/voice"
{"name": "slug", "type": "text", "required": true},
{"name": "chapter", "type": "number"},
{"name": "voice", "type": "text"},
{"name": "status", "type": "text", "required": true}, // "pending" | "generating" | "done" | "failed"
{"name": "error_message", "type": "text"},
{"name": "started", "type": "date"},
{"name": "finished", "type": "date"},
},
},
{
"name": "user_sessions",
"type": "base",
"fields": []map[string]interface{}{
{"name": "user_id", "type": "text", "required": true},
{"name": "session_id", "type": "text", "required": true}, // random ID embedded in auth token
{"name": "user_agent", "type": "text"},
{"name": "ip", "type": "text"},
{"name": "created_at", "type": "date"},
{"name": "last_seen", "type": "date"},
},
},
{
"name": "book_comments",
"type": "base",
"fields": []map[string]interface{}{
{"name": "slug", "type": "text", "required": true},
{"name": "user_id", "type": "text"},
{"name": "username", "type": "text"},
{"name": "body", "type": "text", "required": true},
{"name": "upvotes", "type": "number"},
{"name": "downvotes", "type": "number"},
{"name": "created", "type": "date"},
{"name": "parent_id", "type": "text"}, // empty = top-level; set = reply to that comment ID
},
},
{
"name": "comment_votes",
"type": "base",
"fields": []map[string]interface{}{
{"name": "comment_id", "type": "text", "required": true},
{"name": "user_id", "type": "text"},
{"name": "session_id", "type": "text", "required": true},
{"name": "vote", "type": "text", "required": true}, // "up" | "down"
},
},
}
for _, col := range collections {
name, _ := col["name"].(string)
resp, err := s.pb.do(ctx, http.MethodPost, "/api/collections", col)
if err != nil {
return fmt.Errorf("pocketbase: ensure collection %q: %w", name, err)
}
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK, http.StatusCreated:
s.log.Info("pocketbase: collection created", "collection", name)
case http.StatusBadRequest, http.StatusUnprocessableEntity:
// Already exists or schema mismatch — expected on subsequent startups.
s.log.Debug("pocketbase: collection already exists (skipped)", "collection", name)
default:
s.log.Warn("pocketbase: unexpected status ensuring collection",
"collection", name, "status", resp.StatusCode, "body", string(b))
}
}
return nil
}
// ─── Schema migrations ────────────────────────────────────────────────────────
// migration describes a single field to guarantee exists in a collection.
type migration struct {
collection string
fieldName string
fieldType string
}
// migrations is the ordered list of schema changes applied on every startup.
var migrations = []migration{
// user_id was added to progress after initial deploy.
{"progress", "user_id", "text"},
// avatar_url stores the MinIO presign path for the user's profile picture.
{"app_users", "avatar_url", "text"},
// parent_id enables 1-level comment nesting (replies). Empty = top-level comment.
{"book_comments", "parent_id", "text"},
}
// EnsureMigrations idempotently adds any fields that are missing from existing
// collections. It fetches the current schema, checks for each field by name,
// and PATCHes the collection only when something is absent.
// Safe to call on every startup — no-ops when schema is already up to date.
func (s *PocketBaseStore) EnsureMigrations(ctx context.Context) error {
for _, m := range migrations {
if err := s.ensureField(ctx, m); err != nil {
return err
}
}
return nil
}
func (s *PocketBaseStore) ensureField(ctx context.Context, m migration) error {
// Fetch current collection schema.
resp, err := s.pb.do(ctx, http.MethodGet, "/api/collections/"+m.collection, nil)
if err != nil {
return fmt.Errorf("pocketbase: ensureField %s.%s: fetch schema: %w", m.collection, m.fieldName, err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("pocketbase: ensureField %s.%s: fetch schema status %d: %s", m.collection, m.fieldName, resp.StatusCode, body)
}
var schema struct {
ID string `json:"id"`
Fields []map[string]interface{} `json:"fields"`
}
if err := json.Unmarshal(body, &schema); err != nil {
return fmt.Errorf("pocketbase: ensureField %s.%s: decode schema: %w", m.collection, m.fieldName, err)
}
// Check if field already exists.
for _, f := range schema.Fields {
if name, _ := f["name"].(string); name == m.fieldName {
s.log.Debug("pocketbase: field already exists, skipping migration",
"collection", m.collection, "field", m.fieldName)
return nil
}
}
// Append the new field and PATCH the collection.
newFields := append(schema.Fields, map[string]interface{}{
"name": m.fieldName,
"type": m.fieldType,
})
patch := map[string]interface{}{"fields": newFields}
patchResp, err := s.pb.do(ctx, http.MethodPatch, "/api/collections/"+schema.ID, patch)
if err != nil {
return fmt.Errorf("pocketbase: ensureField %s.%s: patch: %w", m.collection, m.fieldName, err)
}
defer patchResp.Body.Close()
patchBody, _ := io.ReadAll(patchResp.Body)
if patchResp.StatusCode != http.StatusOK {
return fmt.Errorf("pocketbase: ensureField %s.%s: patch status %d: %s", m.collection, m.fieldName, patchResp.StatusCode, patchBody)
}
s.log.Info("pocketbase: schema migration applied", "collection", m.collection, "field", m.fieldName, "type", m.fieldType)
return nil
}
// ─── Book metadata ────────────────────────────────────────────────────────────
func (s *PocketBaseStore) UpsertBook(ctx context.Context, slug, title, author, cover, status, summary, sourceURL string, genres []string, totalChapters, ranking int) error {
genresJSON, _ := json.Marshal(genres)
return s.pb.upsert(ctx, "books", fmt.Sprintf(`slug="%s"`, pbEsc(slug)), map[string]interface{}{
"slug": slug,
"title": title,
"author": author,
"cover": cover,
"status": status,
"genres": string(genresJSON),
"summary": summary,
"total_chapters": totalChapters,
"source_url": sourceURL,
"ranking": ranking,
"meta_updated": time.Now().UTC().Format(time.RFC3339),
})
}
func (s *PocketBaseStore) GetBook(ctx context.Context, slug string) (map[string]interface{}, bool, error) {
rec, err := s.pb.listOne(ctx, "books", fmt.Sprintf(`slug="%s"`, pbEsc(slug)))
if err != nil {
return nil, false, err
}
if rec == nil {
return nil, false, nil
}
return rec, true, nil
}
func (s *PocketBaseStore) ListBooks(ctx context.Context) ([]map[string]interface{}, error) {
return s.pb.listAll(ctx, "books", "", "+title")
}
func (s *PocketBaseStore) BookMetaUpdated(ctx context.Context, slug string) (time.Time, error) {
rec, err := s.pb.listOne(ctx, "books", fmt.Sprintf(`slug="%s"`, pbEsc(slug)))
if err != nil || rec == nil {
return time.Time{}, err
}
if ts, ok := rec["meta_updated"].(string); ok {
t, err := time.Parse(time.RFC3339, ts)
if err == nil {
return t, nil
}
}
return time.Time{}, nil
}
// ─── Chapter index ────────────────────────────────────────────────────────────
func (s *PocketBaseStore) UpsertChapterIdx(ctx context.Context, slug string, number int, title, dateLabel string) error {
return s.pb.upsert(ctx, "chapters_idx",
fmt.Sprintf(`slug="%s"&&number=%d`, pbEsc(slug), number),
map[string]interface{}{
"slug": slug,
"number": number,
"title": title,
"date_label": dateLabel,
})
}
// WriteChapterRefs upserts chapter index rows (number + title) for all refs
// without writing any chapter text. Errors are logged and skipped; the
// operation is best-effort.
func (s *PocketBaseStore) WriteChapterRefs(ctx context.Context, slug string, refs []scraper.ChapterRef) error {
var firstErr error
for _, ref := range refs {
if err := s.UpsertChapterIdx(ctx, slug, ref.Number, ref.Title, ""); err != nil {
s.log.Warn("pocketbase: WriteChapterRefs: upsert failed",
"slug", slug, "chapter", ref.Number, "err", err)
if firstErr == nil {
firstErr = err
}
}
}
return firstErr
}
func (s *PocketBaseStore) ListChapterIdx(ctx context.Context, slug string) ([]map[string]interface{}, error) {
return s.pb.listAll(ctx, "chapters_idx",
fmt.Sprintf(`slug="%s"`, pbEsc(slug)), "+number")
}
func (s *PocketBaseStore) CountChapterIdx(ctx context.Context, slug string) int {
rows, err := s.ListChapterIdx(ctx, slug)
if err != nil {
s.log.Warn("pocketbase: CountChapterIdx failed", "slug", slug, "err", err)
return 0
}
return len(rows)
}
// ─── Ranking (per-item) ───────────────────────────────────────────────────────
func (s *PocketBaseStore) UpsertRankingItem(ctx context.Context, item RankingItem) error {
genresJSON, _ := json.Marshal(item.Genres)
return s.pb.upsert(ctx, "ranking", fmt.Sprintf(`slug="%s"`, pbEsc(item.Slug)), map[string]interface{}{
"rank": item.Rank,
"slug": item.Slug,
"title": item.Title,
"author": item.Author,
"cover": item.Cover,
"status": item.Status,
"genres": string(genresJSON),
"source_url": item.SourceURL,
"updated": time.Now().UTC().Format(time.RFC3339),
})
}
func (s *PocketBaseStore) ListRankingItems(ctx context.Context) ([]RankingItem, error) {
rows, err := s.pb.listAll(ctx, "ranking", "", "+rank")
if err != nil {
return nil, err
}
items := make([]RankingItem, 0, len(rows))
for _, r := range rows {
item := RankingItem{
Rank: int(floatVal(r, "rank")),
Slug: strVal(r, "slug"),
Title: strVal(r, "title"),
Author: strVal(r, "author"),
Cover: strVal(r, "cover"),
Status: strVal(r, "status"),
SourceURL: strVal(r, "source_url"),
}
if ts, ok := r["updated"].(string); ok {
item.Updated, _ = time.Parse(time.RFC3339, ts)
}
switch v := r["genres"].(type) {
case string:
_ = json.Unmarshal([]byte(v), &item.Genres)
case []interface{}:
for _, g := range v {
if s, ok := g.(string); ok {
item.Genres = append(item.Genres, s)
}
}
}
items = append(items, item)
}
return items, nil
}
// RankingLastUpdated returns the most recent Updated time across all ranking rows,
// or the zero time if no rows exist.
func (s *PocketBaseStore) RankingLastUpdated(ctx context.Context) (time.Time, error) {
// listAll with sort "-updated" and perPage=1 is the cheapest approach.
q := url.Values{}
q.Set("sort", "-updated")
q.Set("perPage", "1")
path := fmt.Sprintf("/api/collections/ranking/records?%s", q.Encode())
resp, err := s.pb.do(ctx, http.MethodGet, path, nil)
if err != nil {
return time.Time{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return time.Time{}, fmt.Errorf("pocketbase: RankingLastUpdated: status %d: %s", resp.StatusCode, b)
}
var result struct {
Items []map[string]interface{} `json:"items"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return time.Time{}, fmt.Errorf("pocketbase: RankingLastUpdated: decode: %w", err)
}
if len(result.Items) == 0 {
return time.Time{}, nil
}
ts, _ := result.Items[0]["updated"].(string)
t, _ := time.Parse(time.RFC3339, ts)
return t, nil
}
// ─── Reading progress ─────────────────────────────────────────────────────────
func (s *PocketBaseStore) SetProgress(ctx context.Context, sessionID, slug string, chapter int) error {
return s.pb.upsert(ctx, "progress",
fmt.Sprintf(`session_id="%s"&&slug="%s"`, pbEsc(sessionID), pbEsc(slug)),
map[string]interface{}{
"session_id": sessionID,
"slug": slug,
"chapter": chapter,
"updated": time.Now().UTC().Format(time.RFC3339),
})
}
func (s *PocketBaseStore) GetProgress(ctx context.Context, sessionID, slug string) (int, time.Time, bool, error) {
rec, err := s.pb.listOne(ctx, "progress",
fmt.Sprintf(`session_id="%s"&&slug="%s"`, pbEsc(sessionID), pbEsc(slug)))
if err != nil {
return 0, time.Time{}, false, err
}
if rec == nil {
return 0, time.Time{}, false, nil
}
ch := int(floatVal(rec, "chapter"))
var updated time.Time
if ts, ok := rec["updated"].(string); ok {
updated, _ = time.Parse(time.RFC3339, ts)
}
return ch, updated, true, nil
}
func (s *PocketBaseStore) AllProgress(ctx context.Context, sessionID string) ([]map[string]interface{}, error) {
return s.pb.listAll(ctx, "progress",
fmt.Sprintf(`session_id="%s"`, pbEsc(sessionID)), "-updated")
}
func (s *PocketBaseStore) DeleteProgress(ctx context.Context, sessionID, slug string) error {
return s.pb.deleteWhere(ctx, "progress",
fmt.Sprintf(`session_id="%s"&&slug="%s"`, pbEsc(sessionID), pbEsc(slug)))
}
// ─── Audio cache ──────────────────────────────────────────────────────────────
func (s *PocketBaseStore) SetAudioCache(ctx context.Context, cacheKey, filename string) error {
return s.pb.upsert(ctx, "audio_cache",
fmt.Sprintf(`cache_key="%s"`, pbEsc(cacheKey)),
map[string]interface{}{
"cache_key": cacheKey,
"filename": filename,
"updated": time.Now().UTC().Format(time.RFC3339),
})
}
func (s *PocketBaseStore) GetAudioCache(ctx context.Context, cacheKey string) (string, bool, error) {
rec, err := s.pb.listOne(ctx, "audio_cache",
fmt.Sprintf(`cache_key="%s"`, pbEsc(cacheKey)))
if err != nil {
return "", false, err
}
if rec == nil {
return "", false, nil
}
filename, _ := rec["filename"].(string)
return filename, filename != "", nil
}
// ─── Scraping tasks ───────────────────────────────────────────────────────────
// CreateScrapingTask inserts a new scraping_tasks record with status="running"
// and returns the newly created record's ID.
func (s *PocketBaseStore) CreateScrapingTask(ctx context.Context, kind, targetURL string) (string, error) {
data := map[string]interface{}{
"kind": kind,
"target_url": targetURL,
"status": "running",
"books_found": 0,
"chapters_scraped": 0,
"chapters_skipped": 0,
"errors": 0,
"started": time.Now().UTC().Format(time.RFC3339),
}
resp, err := s.pb.do(ctx, http.MethodPost, "/api/collections/scraping_tasks/records", data)
if err != nil {
return "", err
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return "", fmt.Errorf("pocketbase: CreateScrapingTask: status %d: %s", resp.StatusCode, b)
}
var rec map[string]interface{}
if err := json.Unmarshal(b, &rec); err != nil {
return "", fmt.Errorf("pocketbase: CreateScrapingTask: decode: %w", err)
}
id, _ := rec["id"].(string)
return id, nil
}
// UpdateScrapingTask patches counters on an existing scraping_tasks record.
func (s *PocketBaseStore) UpdateScrapingTask(ctx context.Context, id string, data map[string]interface{}) error {
resp, err := s.pb.do(ctx, http.MethodPatch,
fmt.Sprintf("/api/collections/scraping_tasks/records/%s", id), data)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return fmt.Errorf("pocketbase: UpdateScrapingTask id=%s: status %d: %s", id, resp.StatusCode, b)
}
return nil
}
// ListScrapingTasks returns all scraping_tasks sorted by started descending.
func (s *PocketBaseStore) ListScrapingTasks(ctx context.Context) ([]map[string]interface{}, error) {
return s.pb.listAll(ctx, "scraping_tasks", "", "-started")
}
// ─── Audio jobs ───────────────────────────────────────────────────────────────
// CreateAudioJob inserts a new audio_jobs record with status="pending".
func (s *PocketBaseStore) CreateAudioJob(ctx context.Context, slug string, chapter int, voice string) (string, error) {
cacheKey := fmt.Sprintf("%s/%d/%s", slug, chapter, voice)
data := map[string]interface{}{
"cache_key": cacheKey,
"slug": slug,
"chapter": chapter,
"voice": voice,
"status": "pending",
"started": time.Now().UTC().Format(time.RFC3339),
}
resp, err := s.pb.do(ctx, http.MethodPost, "/api/collections/audio_jobs/records", data)
if err != nil {
return "", err
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return "", fmt.Errorf("pocketbase: CreateAudioJob: status %d: %s", resp.StatusCode, b)
}
var rec map[string]interface{}
if err := json.Unmarshal(b, &rec); err != nil {
return "", fmt.Errorf("pocketbase: CreateAudioJob: decode: %w", err)
}
id, _ := rec["id"].(string)
return id, nil
}
// UpdateAudioJob patches status, error_message, and optionally finished on an audio_jobs record.
func (s *PocketBaseStore) UpdateAudioJob(ctx context.Context, id, status, errMsg string, finished time.Time) error {
data := map[string]interface{}{
"status": status,
"error_message": errMsg,
}
if !finished.IsZero() {
data["finished"] = finished.UTC().Format(time.RFC3339)
}
resp, err := s.pb.do(ctx, http.MethodPatch,
fmt.Sprintf("/api/collections/audio_jobs/records/%s", id), data)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return fmt.Errorf("pocketbase: UpdateAudioJob id=%s: status %d: %s", id, resp.StatusCode, b)
}
return nil
}
// GetAudioJob returns the most recent audio_jobs record for the given cache key.
func (s *PocketBaseStore) GetAudioJob(ctx context.Context, cacheKey string) (map[string]interface{}, bool, error) {
rec, err := s.pb.listOne(ctx, "audio_jobs",
fmt.Sprintf(`cache_key="%s"`, pbEsc(cacheKey)))
if err != nil {
return nil, false, err
}
if rec == nil {
return nil, false, nil
}
return rec, true, nil
}
// ListAudioJobs returns all audio_jobs sorted by started descending.
func (s *PocketBaseStore) ListAudioJobs(ctx context.Context) ([]map[string]interface{}, error) {
return s.pb.listAll(ctx, "audio_jobs", "", "-started")
}
// ─── helpers ──────────────────────────────────────────────────────────────────
// pbEsc escapes a string for use in a PocketBase filter expression.
// Only escapes double-quotes to prevent injection.
func pbEsc(s string) string {
return strings.ReplaceAll(s, `"`, `\"`)
}
func floatVal(m map[string]interface{}, key string) float64 {
if v, ok := m[key].(float64); ok {
return v
}
return 0
}