// 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 — data(json), updated(date) [single row, upserted by slug="_ranking_"] // ranking_html — page(number,unique), html(text), updated(date) // progress — session_id(text), slug(text), chapter(number), updated(date) // audio_cache — cache_key(text,unique), filename(text), updated(date) package storage import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "os" "strings" "sync" "time" ) // 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 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) *pbClient { return &pbClient{ cfg: cfg, httpClient: &http.Client{Timeout: 15 * time.Second}, } } // ─── 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/admins/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", 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 } var result struct { Items []map[string]interface{} `json:"items"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, err } if len(result.Items) == 0 { return nil, nil } return result.Items[0], nil } // listAll returns all records (up to 500) from a collection matching filter. func (p *pbClient) listAll(ctx context.Context, collection, filter, sort string) ([]map[string]interface{}, error) { q := url.Values{} if filter != "" { q.Set("filter", filter) } if sort != "" { q.Set("sort", sort) } q.Set("perPage", "500") 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() var result struct { Items []map[string]interface{} `json:"items"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, err } return result.Items, 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 } resp.Body.Close() return nil } resp, err := p.do(ctx, http.MethodPost, fmt.Sprintf("/api/collections/%s/records", collection), data) if err != nil { return err } resp.Body.Close() 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 } 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 } // NewPocketBaseStore returns a connected PocketBaseStore. func NewPocketBaseStore(cfg PocketBaseConfig) *PocketBaseStore { return &PocketBaseStore{pb: newPBClient(cfg)} } // 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. collections := []map[string]interface{}{ { "name": "books", "type": "base", "schema": []map[string]interface{}{ {"name": "slug", "type": "text", "required": true, "options": map[string]interface{}{"min": 1}}, {"name": "title", "type": "text", "required": true}, {"name": "author", "type": "text"}, {"name": "cover", "type": "url"}, {"name": "status", "type": "text"}, {"name": "genres", "type": "json"}, {"name": "summary", "type": "text"}, {"name": "total_chapters", "type": "number"}, {"name": "source_url", "type": "url"}, {"name": "ranking", "type": "number"}, {"name": "meta_updated", "type": "date"}, }, }, { "name": "chapters_idx", "type": "base", "schema": []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", "schema": []map[string]interface{}{ {"name": "key", "type": "text", "required": true}, {"name": "data", "type": "json"}, {"name": "updated", "type": "date"}, }, }, { "name": "ranking_html", "type": "base", "schema": []map[string]interface{}{ {"name": "page", "type": "number", "required": true}, {"name": "html", "type": "text"}, {"name": "updated", "type": "date"}, }, }, { "name": "progress", "type": "base", "schema": []map[string]interface{}{ {"name": "session_id", "type": "text", "required": true}, {"name": "slug", "type": "text", "required": true}, {"name": "chapter", "type": "number"}, {"name": "updated", "type": "date"}, }, }, { "name": "audio_cache", "type": "base", "schema": []map[string]interface{}{ {"name": "cache_key", "type": "text", "required": true}, {"name": "filename", "type": "text"}, {"name": "updated", "type": "date"}, }, }, } for _, col := range collections { resp, err := s.pb.do(ctx, http.MethodPost, "/api/collections", col) if err != nil { return fmt.Errorf("pocketbase: ensure collection %v: %w", col["name"], err) } resp.Body.Close() // 400/422 = already exists or schema mismatch — ignore } 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, }) } 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, _ := s.ListChapterIdx(ctx, slug) return len(rows) } // ─── Ranking ────────────────────────────────────────────────────────────────── func (s *PocketBaseStore) SetRanking(ctx context.Context, dataJSON string) error { return s.pb.upsert(ctx, "ranking", `key="_ranking_"`, map[string]interface{}{ "key": "_ranking_", "data": dataJSON, "updated": time.Now().UTC().Format(time.RFC3339), }) } func (s *PocketBaseStore) GetRanking(ctx context.Context) (string, time.Time, error) { rec, err := s.pb.listOne(ctx, "ranking", `key="_ranking_"`) if err != nil || rec == nil { return "", time.Time{}, err } data, _ := rec["data"].(string) var updated time.Time if ts, ok := rec["updated"].(string); ok { updated, _ = time.Parse(time.RFC3339, ts) } return data, updated, nil } // ─── Ranking page HTML cache ────────────────────────────────────────────────── func (s *PocketBaseStore) SetRankingPageHTML(ctx context.Context, page int, html string) error { return s.pb.upsert(ctx, "ranking_html", fmt.Sprintf(`page=%d`, page), map[string]interface{}{ "page": page, "html": html, "updated": time.Now().UTC().Format(time.RFC3339), }) } func (s *PocketBaseStore) GetRankingPageHTML(ctx context.Context, page int) (string, time.Time, error) { rec, err := s.pb.listOne(ctx, "ranking_html", fmt.Sprintf(`page=%d`, page)) if err != nil || rec == nil { return "", time.Time{}, err } html, _ := rec["html"].(string) var updated time.Time if ts, ok := rec["updated"].(string); ok { updated, _ = time.Parse(time.RFC3339, ts) } return html, updated, 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 || rec == nil { return "", false, err } filename, _ := rec["filename"].(string) return filename, filename != "", nil } // ─── rankingFileInfo is a minimal os.FileInfo implementation ───────────────── type rankingFileInfo struct { modTime time.Time } func (r rankingFileInfo) Name() string { return "ranking" } func (r rankingFileInfo) Size() int64 { return 0 } func (r rankingFileInfo) Mode() os.FileMode { return 0o444 } func (r rankingFileInfo) ModTime() time.Time { return r.modTime } func (r rankingFileInfo) IsDir() bool { return false } func (r rankingFileInfo) Sys() interface{} { return nil } var _ os.FileInfo = rankingFileInfo{} // RankingModTime returns file-info-compatible data for the ranking record. func (s *PocketBaseStore) RankingModTime(ctx context.Context) (os.FileInfo, error) { _, updated, err := s.GetRanking(ctx) if err != nil { return nil, err } if updated.IsZero() { return nil, nil } return rankingFileInfo{modTime: updated}, nil } // RankingPageCacheModTime returns file-info for a cached ranking page. func (s *PocketBaseStore) RankingPageCacheModTime(ctx context.Context, page int) (os.FileInfo, error) { _, updated, err := s.GetRankingPageHTML(ctx, page) if err != nil { return nil, err } if updated.IsZero() { return nil, nil } return rankingFileInfo{modTime: updated}, nil } // ─── 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 }