From 4c9bb4adde9309ebb971a442fce156e033546c38 Mon Sep 17 00:00:00 2001 From: Admin Date: Sun, 15 Mar 2026 21:58:02 +0500 Subject: [PATCH] feat: add pb-init-v2.sh for v2 stack; wire into docker-compose-new.yml Minimal PocketBase bootstrap for the v2 stack (backend + runner + ui-v2). Creates only the 6 collections actually used by v2: books, chapters_idx, ranking, progress, scraping_tasks, audio_jobs Drops v1-only collections (app_users, user_settings, audio_cache, book_comments, comment_votes, user_library, user_sessions, user_subscriptions) and unused fields (date_label, user_id/audio_time on progress). heartbeat_at is included in create_collection from the start and also covered by ensure_field for existing instances. docker-compose-new.yml pb-init service now mounts pb-init-v2.sh. --- docker-compose-new.yml | 2 +- scripts/pb-init-v2.sh | 257 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 258 insertions(+), 1 deletion(-) create mode 100755 scripts/pb-init-v2.sh diff --git a/docker-compose-new.yml b/docker-compose-new.yml index c9cc343..8ae4a0a 100644 --- a/docker-compose-new.yml +++ b/docker-compose-new.yml @@ -65,7 +65,7 @@ services: POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}" POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD:-changeme123}" volumes: - - ./scripts/pb-init.sh:/pb-init.sh:ro + - ./scripts/pb-init-v2.sh:/pb-init.sh:ro entrypoint: ["sh", "/pb-init.sh"] # ─── Backend API ────────────────────────────────────────────────────────────── diff --git a/scripts/pb-init-v2.sh b/scripts/pb-init-v2.sh new file mode 100755 index 0000000..69678c6 --- /dev/null +++ b/scripts/pb-init-v2.sh @@ -0,0 +1,257 @@ +#!/bin/sh +# pb-init-v2.sh — idempotent PocketBase collection bootstrap for the v2 stack +# +# Creates all collections required by libnovel v2 (backend + runner + ui-v2). +# Safe to re-run: POST returns 400/422 when a collection already exists; both +# are treated as success. The ensure_field helper adds fields to existing +# instances without touching fields that are already present. +# +# Collections created: +# books — book metadata +# chapters_idx — per-chapter index (title, number) +# ranking — novelfire ranking snapshots +# progress — per-session reading progress +# scraping_tasks — scrape job queue (runner ↔ backend) +# audio_jobs — TTS job queue (runner ↔ backend) +# +# Required env vars (with defaults matching docker-compose-new.yml): +# POCKETBASE_URL http://pocketbase:8090 +# POCKETBASE_ADMIN_EMAIL admin@libnovel.local +# POCKETBASE_ADMIN_PASSWORD changeme123 + +set -e + +PB_URL="${POCKETBASE_URL:-http://pocketbase:8090}" +PB_EMAIL="${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}" +PB_PASSWORD="${POCKETBASE_ADMIN_PASSWORD:-changeme123}" + +log() { echo "[pb-init-v2] $*"; } + +# ─── 0. Ensure curl and python3 are available ──────────────────────────────── +if ! command -v curl > /dev/null 2>&1; then + apk add --no-cache curl > /dev/null 2>&1 +fi +if ! command -v python3 > /dev/null 2>&1; then + apk add --no-cache python3 > /dev/null 2>&1 +fi + +# ─── 1. Wait for PocketBase to be ready ────────────────────────────────────── +log "waiting for PocketBase at $PB_URL ..." +until curl -sf "$PB_URL/api/health" > /dev/null 2>&1; do + sleep 2 +done +log "PocketBase is up" + +# ─── 2. Ensure the superuser exists ────────────────────────────────────────── +# +# On a fresh install PocketBase v0.23+ exposes a one-time install token in the +# /_/ redirect Location header. Use it to create the superuser if needed; on +# subsequent runs the token is gone and we fall through to normal auth. + +log "ensuring superuser $PB_EMAIL exists ..." + +LOCATION=$(curl -sf -o /dev/null -w "%{redirect_url}" "$PB_URL/_/" 2>/dev/null || true) +if echo "$LOCATION" | grep -q "pbinstal/"; then + INSTALL_TOKEN=$(echo "$LOCATION" | sed 's|.*pbinstal/||' | tr -d ' \r\n') + log "install token found — creating superuser via install endpoint" + curl -sf -X POST "$PB_URL/api/collections/_superusers/records" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $INSTALL_TOKEN" \ + -d "{\"email\":\"$PB_EMAIL\",\"password\":\"$PB_PASSWORD\",\"passwordConfirm\":\"$PB_PASSWORD\"}" \ + > /dev/null 2>&1 || true + log "superuser create attempted (may already exist)" +fi + +# ─── 3. Authenticate and obtain a superuser token ──────────────────────────── +log "authenticating as $PB_EMAIL ..." +AUTH_RESPONSE=$(curl -sf -X POST "$PB_URL/api/collections/_superusers/auth-with-password" \ + -H "Content-Type: application/json" \ + -d "{\"identity\":\"$PB_EMAIL\",\"password\":\"$PB_PASSWORD\"}") + +TOKEN=$(echo "$AUTH_RESPONSE" | sed 's/.*"token":"\([^"]*\)".*/\1/') +if [ -z "$TOKEN" ] || [ "$TOKEN" = "$AUTH_RESPONSE" ]; then + log "ERROR: failed to obtain auth token. Response: $AUTH_RESPONSE" + exit 1 +fi +log "auth token obtained" + +# ─── 4. Helpers ────────────────────────────────────────────────────────────── + +# create_collection NAME JSON_BODY +# POSTs to /api/collections. 400/422 = already exists → treated as success. +create_collection() { + NAME="$1" + BODY="$2" + STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + -X POST "$PB_URL/api/collections" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d "$BODY") + case "$STATUS" in + 200|201) log "created collection: $NAME" ;; + 400|422) log "collection already exists (skipped): $NAME" ;; + *) log "WARNING: unexpected status $STATUS for collection: $NAME" ;; + esac +} + +# ensure_field COLLECTION FIELD_NAME FIELD_TYPE +# +# Uses python3 to parse the collection schema, then PATCHes the full fields +# array with the new field appended — only if it is not already present. +# python3 is required to correctly extract the top-level collection id from +# the JSON response (sed-based extraction is unreliable on multi-field schemas +# because the greedy pattern picks up a field id instead of the collection id). +ensure_field() { + COLL="$1" + FIELD_NAME="$2" + FIELD_TYPE="$3" + + SCHEMA=$(curl -sf \ + -H "Authorization: Bearer $TOKEN" \ + "$PB_URL/api/collections/$COLL" 2>/dev/null) + + PARSED=$(echo "$SCHEMA" | python3 -c " +import sys, json +try: + d = json.load(sys.stdin) + fields = d.get('fields', []) + exists = any(f.get('name') == '$FIELD_NAME' for f in fields) + print('exists=' + str(exists)) + print('id=' + d.get('id', '')) + if not exists: + fields.append({'name': '$FIELD_NAME', 'type': '$FIELD_TYPE'}) + print('fields=' + json.dumps(fields)) +except Exception as e: + print('error=' + str(e)) +" 2>/dev/null) + + if echo "$PARSED" | grep -q "^exists=True"; then + log "field $COLL.$FIELD_NAME already exists — skipping" + return + fi + + COLLECTION_ID=$(echo "$PARSED" | grep "^id=" | sed 's/^id=//') + if [ -z "$COLLECTION_ID" ]; then + log "WARNING: could not get id for collection $COLL — skipping ensure_field" + return + fi + + NEW_FIELDS=$(echo "$PARSED" | grep "^fields=" | sed 's/^fields=//') + STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + -X PATCH "$PB_URL/api/collections/$COLLECTION_ID" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d "{\"fields\":${NEW_FIELDS}}") + case "$STATUS" in + 200|201) log "patched $COLL — added field: $FIELD_NAME ($FIELD_TYPE)" ;; + *) log "WARNING: patch returned $STATUS when adding $FIELD_NAME to $COLL" ;; + esac +} + +# ─── 5. Collections ─────────────────────────────────────────────────────────── + +# books — one record per scraped novel +create_collection "books" '{ + "name": "books", + "type": "base", + "fields": [ + {"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"} + ] +}' + +# chapters_idx — lightweight chapter list (no content; content lives in MinIO) +create_collection "chapters_idx" '{ + "name": "chapters_idx", + "type": "base", + "fields": [ + {"name": "slug", "type": "text", "required": true}, + {"name": "number", "type": "number", "required": true}, + {"name": "title", "type": "text"} + ] +}' + +# ranking — periodic novelfire ranking snapshots +create_collection "ranking" '{ + "name": "ranking", + "type": "base", + "fields": [ + {"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"} + ] +}' + +# progress — per-session reading progress (no user accounts required) +create_collection "progress" '{ + "name": "progress", + "type": "base", + "fields": [ + {"name": "session_id", "type": "text", "required": true}, + {"name": "slug", "type": "text", "required": true}, + {"name": "chapter", "type": "number"} + ] +}' + +# scraping_tasks — scrape job queue consumed by the runner +create_collection "scraping_tasks" '{ + "name": "scraping_tasks", + "type": "base", + "fields": [ + {"name": "kind", "type": "text"}, + {"name": "target_url", "type": "text"}, + {"name": "from_chapter", "type": "number"}, + {"name": "to_chapter", "type": "number"}, + {"name": "worker_id", "type": "text"}, + {"name": "status", "type": "text", "required": true}, + {"name": "books_found", "type": "number"}, + {"name": "chapters_scraped", "type": "number"}, + {"name": "chapters_skipped", "type": "number"}, + {"name": "errors", "type": "number"}, + {"name": "error_message", "type": "text"}, + {"name": "started", "type": "date"}, + {"name": "finished", "type": "date"}, + {"name": "heartbeat_at", "type": "date"} + ] +}' + +# audio_jobs — TTS generation queue consumed by the runner +create_collection "audio_jobs" '{ + "name": "audio_jobs", + "type": "base", + "fields": [ + {"name": "cache_key", "type": "text", "required": true}, + {"name": "slug", "type": "text", "required": true}, + {"name": "chapter", "type": "number", "required": true}, + {"name": "voice", "type": "text"}, + {"name": "worker_id", "type": "text"}, + {"name": "status", "type": "text", "required": true}, + {"name": "error_message", "type": "text"}, + {"name": "started", "type": "date"}, + {"name": "finished", "type": "date"}, + {"name": "heartbeat_at", "type": "date"} + ] +}' + +# ─── 6. Schema migrations (idempotent — safe to re-run on existing instances) ─ +# +# heartbeat_at was added after the initial v2 deploy. ensure_field is a no-op +# if the field already exists (e.g. fresh installs that ran this script from +# the start already have it from the create_collection call above). +ensure_field "scraping_tasks" "heartbeat_at" "date" +ensure_field "audio_jobs" "heartbeat_at" "date" + +log "all collections ready"