- Kotlin 77.1%
- Svelte 18.7%
- TypeScript 2.9%
- Shell 0.6%
- CSS 0.3%
- Other 0.4%
|
All checks were successful
Build and Publish / frontend (push) Successful in 9m37s
Build and Publish / mcp (push) Successful in 9m23s
Build and Publish / backend (push) Successful in 19m5s
Build and Publish / scan (push) Successful in 1m20s
Build and Publish / publish (push) Successful in 48s
The runtime image was scanning HIGH on CVE-2026-6732 (libxml2 2.13.9-r0) because `apk upgrade --no-cache` reused a cached layer from before Alpine published the 2.13.9-r1 fix. Adding `apk update` ahead of upgrade and `--available` makes the command re-read indexes and force-upgrade on each build, regardless of layer cache. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|---|---|---|
| .forgejo/workflows | ||
| docs | ||
| podradar-backend | ||
| podradar-frontend | ||
| podradar-mcp | ||
| scripts | ||
| .env | ||
| .env.example | ||
| .gitignore | ||
| BACKUP.md | ||
| CLAUDE.md | ||
| docker-compose.yml | ||
| PLAN.md | ||
| PLAN.md:Zone.Identifier | ||
| README.md | ||
| renovate.json | ||
| ROADMAP.md | ||
| TASKS.md | ||
| VERSION | ||
PodRadar
PodRadar is a self-hosted, multi-user podcast discovery engine. It replaces opodsync as your GPodder-compatible sync server while adding a Tinder-style swipe mechanic that learns your taste and surfaces new shows worth trying. Rate episodes, swipe on recommendations, and let the taste engine do the rest — all without leaving AntennaPod. The Svelte 5 frontend runs in the browser; AntennaPod talks to the same backend over the standard GPodder API.
Requirements
- Docker and Docker Compose
- (Optional) PodcastIndex API key + secret — improves search and metadata
- (Optional) OpenRouter API key — enables AI features (episode summaries, swipe recommendations)
Quick Start
git clone https://git.midasvo.nl/midas/podradar.git
cd podradar
cp .env.example .env
# Edit .env and fill in values (see table below)
docker compose up -d
Open http://localhost:3000 in your browser.
The first user to register is automatically granted admin access and approved. Subsequent registrations follow the PODRADAR_REGISTRATION_MODE setting.
Environment Variables
| Variable | Required | Description |
|---|---|---|
PODRADAR_DB_PASSWORD |
Required | Password for the PostgreSQL database |
OPENROUTER_API_KEY |
Optional | Enables AI features via OpenRouter (episode summaries, swipe recommendations). Leave blank to disable. |
PODCASTINDEX_API_KEY |
Optional | PodcastIndex API key for improved podcast search and metadata |
PODCASTINDEX_API_SECRET |
Optional | PodcastIndex API secret (required alongside the key) |
PODRADAR_REGISTRATION_MODE |
Optional | Controls who can register. Default: APPROVAL_REQUIRED. Options: OPEN, APPROVAL_REQUIRED, INVITE_ONLY, CLOSED |
PODRADAR_CORS_ALLOWED_ORIGINS |
Optional | Comma-separated list of origins allowed to call the API in the docker profile. Default: http://localhost:5173,http://localhost:3000. Set this to your production frontend origin (e.g. https://podradar.example.com). |
PODRADAR_COOKIE_SECURE |
Optional | Whether the session cookie has the Secure flag in the docker profile. Default: true (requires HTTPS). Set to false only when running over plain HTTP on a private network. |
PODRADAR_SWAGGER_ENABLED |
Optional | Enable the OpenAPI / Swagger UI endpoints (/v3/api-docs, /swagger-ui) in the docker profile. Default: false. Set to true if you want to expose the API docs to authenticated callers. |
PODRADAR_CSRF_ORIGIN_CHECK |
Optional | Enable the strict Origin / Referer check on state-changing /api/** requests in the docker profile. Default: true. Cross-origin POST/PUT/PATCH/DELETE whose Origin is not in PODRADAR_CORS_ALLOWED_ORIGINS are rejected with HTTP 403. GPodder (/api/2/...) and /actuator/... are exempt because their clients use HTTP Basic and do not send Origin. Set to false only for debugging. |
AntennaPod Setup
To use PodRadar as your sync server in AntennaPod:
- Open AntennaPod and go to Settings → Synchronization
- Select gpodder.net compatible
- Set the server to your PodRadar URL:
- Local:
http://localhost:8080 - Remote:
https://your-domain.com
- Local:
- Enter your PodRadar username and password
AntennaPod will sync subscriptions and episode playback positions to PodRadar automatically.
Architecture
| Layer | Technology |
|---|---|
| Backend | Kotlin / Spring Boot (Maven) |
| Frontend | Svelte 5 SPA (static assets) |
| Database | PostgreSQL |
| Reverse proxy | nginx (inside Docker) |
The backend exposes the GPodder Sync API for AntennaPod compatibility alongside a REST API consumed by the frontend. Liquibase manages database migrations. In production everything runs as Docker containers behind the nginx proxy.
Development
Backend
Runs against H2 in-memory database by default (no PostgreSQL needed).
cd podradar-backend
mvn spring-boot:run
The backend starts on http://localhost:8080.
Frontend
Proxies /api requests to the backend on localhost:8080.
cd podradar-frontend
npm install
npm run dev
The dev server starts on http://localhost:5173.
Tests
cd podradar-backend
mvn test
Tests run against H2 with PostgreSQL compatibility mode. No external services required.
Operations
Operating in production checklist
Before exposing PodRadar to the internet, walk through this list:
- HTTPS in front. Terminate TLS with Caddy / nginx / Traefik. The session cookie is
Secure+SameSite=Strict(B4); over plain HTTP browsers will refuse to send it. SetPODRADAR_COOKIE_SECURE=falseonly if you are deliberately running on a private HTTP network. - Lock down registration. After your own user is created, set
PODRADAR_REGISTRATION_MODE=CLOSEDand recycle the backend container. New signups will be refused at the API layer regardless of what the frontend exposes. - Firewall outbound traffic to private IP ranges. PodRadar's SSRF guard (B2) blocks most cases at the application layer, but defense-in-depth: add an iptables / nftables / Docker network rule that drops outbound traffic from the backend container to RFC1918 ranges (
10.0.0.0/8,172.16.0.0/12,192.168.0.0/16) and link-local (169.254.0.0/16). Allow only the public APIs you actually use. - Secure the env file.
chmod 600 .envand own it as the user that runsdocker compose. The file holds your DB password and any LLM/API credentials —0644leaks them to anyone who reads/etc/passwd. - Set up backups. See BACKUP.md. Without
pg_dumpsnapshots, a corrupted volume = total loss. - Monitor health. Schedule a curl health-check against
/actuator/healthfrom your monitoring system (UptimeKuma, Healthchecks.io, statping). A200with{"status":"UP"}means the app + database + Liquibase are all healthy. Optionally taildocker compose logs -f podradar-backendinto a log aggregator (Loki, Logtail) and alert onERRORlines. - Review the rate-limit + LLM cap.
PODRADAR_LLM_DAILY_TOKEN_CAP(default 1M) is sized for 1–2 users. Bump it before adding more or you'll see hard failures on Ask/summary calls.PODRADAR_RATELIMIT_LLM_PER_MINUTEdefaults to 10 (free-tier safe) — raise it on paid plans.
Image rollback
CI publishes both :latest and a semver-tagged image (:vX.Y.Z, read from the
repo-root VERSION file — see G8). If a deploy ships a regression, pin the
previous version explicitly and redeploy:
# Edit docker-compose.yml — change
# image: git.midasvo.nl/midas/podradar-backend:latest
# to
# image: git.midasvo.nl/midas/podradar-backend:v0.0.X
# (and the same for podradar-frontend)
docker compose pull
docker compose up -d
When the fix lands on main, edit the file back to :latest and bump
VERSION for the next release.
HTTPS / session cookies
Production deployments must terminate TLS. The session cookie is set with Secure and SameSite=Strict in the docker profile, which means it will not be transmitted over plain HTTP and won't be sent on cross-origin requests. If you front PodRadar with HTTPS via reverse proxy (Caddy, nginx, Traefik), this works out of the box.
If you genuinely need to run over HTTP for a private network, set PODRADAR_COOKIE_SECURE=false.
CSRF / Origin check
In the docker profile, every state-changing /api/** request (POST/PUT/PATCH/DELETE) must carry an Origin (or, as a fallback, a Referer) header that matches PODRADAR_CORS_ALLOWED_ORIGINS. Browsers attach Origin automatically on these methods, so legitimate same-origin SPA traffic is unaffected. Cross-site form-submission attacks are rejected with HTTP 403 even when SameSite=Strict is somehow bypassed. GPodder (/api/2/..., used by AntennaPod) and /actuator/... are exempt — their clients authenticate via HTTP Basic and do not send Origin. Disable with PODRADAR_CSRF_ORIGIN_CHECK=false only for diagnosis.
Health checks
The backend exposes Spring Boot Actuator endpoints:
GET /actuator/health— overall app + Liquibase + database health (returns{"status":"UP",...}orDOWN). Anonymous callers see only the top-level status; authenticated admins see component details.GET /actuator/health/liveness— used by the Docker healthcheck.GET /actuator/health/readiness— true once the app is ready to serve traffic.GET /actuator/info— app metadata (anonymous).GET /actuator/metrics/**— admin-only metrics.
Database migration recovery
Liquibase acquires a row in DATABASECHANGELOGLOCK while applying migrations. If a previous backend container was killed mid-migration, the lock can be left behind and a new backend will block on startup. The backend sets liquibase.changeLogLockWaitTimeInMinutes=5 as a JVM system property in PodradarApplication.main() (Spring Boot's LiquibaseProperties does not expose this key in YAML), so it fails fast after 5 minutes rather than hanging forever. Override with -Dliquibase.changeLogLockWaitTimeInMinutes=N if needed.
Symptom. docker compose up -d brings everything up, but podradar-backend never reaches healthy and docker compose ps shows it stuck or restarting.
Diagnosis. Look for the Liquibase wait message:
docker compose logs podradar-backend | grep -i liquibase
You will see lines like Waiting for changelog lock.... repeating, and eventually Could not acquire change log lock.
Recovery (last resort, manual). Only do this when you are certain no other Liquibase process is running against the database (e.g., no other backend container is starting). With the backend stopped, clear the lock row directly:
docker exec -it podradar-db psql -U podradar -d podradar -c "DELETE FROM DATABASECHANGELOGLOCK;"
docker compose restart podradar-backend
Clearing the lock while another Liquibase process is genuinely mid-migration can corrupt the migration state — when in doubt, wait the lock out instead.
Note on changeset 032 (split crawl error counters). When this migration runs, every existing podcast row inherits consecutive_parse_errors=0 and consecutive_network_errors=0 regardless of the legacy consecutive_errors value. This is intentional: feeds at the edge of being marked DEAD get a fresh retry budget on the first crawl after deploy. Genuinely dead feeds will re-hit the threshold within ~10 crawl cycles.