feat: add pb-init-v2.sh for v2 stack; wire into docker-compose-new.yml
All checks were successful
CI / Scraper / Lint (pull_request) Successful in 12s
CI / Scraper / Test (pull_request) Successful in 16s
CI / UI / Build (pull_request) Successful in 16s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Build (pull_request) Successful in 5m20s
iOS CI / Test (pull_request) Successful in 7m4s

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.
This commit is contained in:
Admin
2026-03-15 21:58:02 +05:00
parent 22b6ee824e
commit 4c9bb4adde
2 changed files with 258 additions and 1 deletions

257
scripts/pb-init-v2.sh Executable file
View File

@@ -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"