The real root cause of the earlier bad match: manually URLEncoder. encode()-ing the term into a raw string handed to .uri(String) gets re-encoded by RestClient's URI builder, turning a Cyrillic title into garbage (confirmed via logging: searching "Первый раз" returned "Around the World in 80 Days" and similar unrelated English titles - Sonarr never received the real term at all). Switched both lookup methods to the .uri(UriBuilder) + queryParam() form, which encodes correctly exactly once. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
recommendarr
A personal movie/series recommendation service. It looks at what's already in your Radarr/Sonarr library (plus anything you've watched to completion, even if later removed), asks TMDB for recommendations/similar titles for each of those, and ranks the results using your own personal ratings.
No authentication/login - this is a single-user, homelab-only MVP.
Stack
- Backend: Spring Boot 3.3 / Java 17 / Maven
- Frontend: React (Vite), built and embedded into the Spring Boot jar as static resources - one deployable artifact, one process, one port (8080).
- DB: PostgreSQL, schema managed by Hibernate
ddl-auto: update(no Flyway/Liquibase). - Deploy: Kubernetes (k3s), namespace
arr, exposed via NodePort30024. - CI/CD: Gitea Actions (
.gitea/workflows/build-deploy.yml). - Health: a plain
/healthendpoint (no Actuator/Micrometer) for liveness/readiness probes.
Data model (high level)
MediaEntity- a movie/cartoon/series, whether library-resident or only discovered via TMDB. Type (MOVIE/CARTOON/SERIES) is auto-detected on ingest (Sonarr -> SERIES, Radarr + "Animation" genre -> CARTOON, else MOVIE) and can be manually overridden afterwards. TMDB/IMDB ids live as plain columns on this entity rather than a separateExternalIdstable - there's exactly one id pair per item, so a join table would add nothing.PersonalRating- your 0-10 rating for aMediaEntity.WatchSignal- watched flag (>=90% progress), rewatch count, optional season number for per-season series tracking.Intersection- records that a title was found via a TMDB recommendations/similar call seeded from a given source title.Snooze- hides a title from the feeds untilsnoozedUntil(default: now + 3 months).ScoringConfig- a singleton row of configurable scoring weights (never hardcoded).IntegrationSetting- DB-stored Radarr/Sonarr/Jellyfin/Plex/TMDB connection settings, editable at runtime from the Settings UI.WebhookEvent- raw queued payloads from the unauthenticated webhook endpoints, processed in a daily batch rather than inline.
Scoring
For every source title, TMDB recommendations + similar results are
recorded as Intersection rows. For every found title:
relevance_score= sum of each intersecting source's personal rating (sources with no rating use the average of all rated sources), amplified per rewatch viaScoringConfig.rewatchAmplifier.inherited_rating= average personal rating of intersecting sources (tie-breaker 1).external_rating= cached TMDB vote average (tie-breaker 2).
Results are sorted relevance_score DESC, inherited_rating DESC, external_rating DESC; titles with zero intersections sort after everything
else, by external_rating DESC among themselves. All weights are
configurable via GET/POST /api/settings/scoring (backed by
ScoringConfig), not hardcoded.
Sorting/scoring happens on read (ScoringService) so a rating edit updates
the feed instantly without a full recompute.
Recompute triggers
POST /webhooks/{radarr,sonarr,jellyfin,plex}- unauthenticated, only queue aWebhookEventrow. No recompute happens inline.- Daily scheduled job (
03:00) processes queued webhook events and then runs a recompute. - Weekly scheduled job (
Sunday 03:30) forces a recompute if no webhook events arrived in the past 7 days, so new TMDB releases for existing sources still surface even with a quiet library. POST /api/recompute- manual trigger, wired to the "Recompute now" button in the UI header.
Cron schedules are configurable via recommendarr.scheduler.* in
application.yml.
Integrations & settings
Radarr, Sonarr, Jellyfin, Plex and TMDB base URL + API key are stored in
the database (integration_setting table) and edited at runtime from the
Settings page - never via application.yml or environment variables.
The only thing that stays in application.yml/env vars is the Postgres
connection itself (DB_HOST, DB_PORT, DB_NAME, DB_USER,
DB_PASSWORD).
Each integration form has a Test Connection button
(POST /api/settings/integrations/{type}/test) that verifies reachability
and API key validity (Radarr/Sonarr: GET /api/v3/system/status; Jellyfin:
GET /System/Info; Plex: GET /identity; TMDB: GET /authentication)
before you hit Save.
Local development
Requires: JDK 17, Maven, Node 20+ (or let frontend-maven-plugin download
its own), and a reachable PostgreSQL instance.
# one-time: create the database (see below), then:
# full build (frontend + backend, embeds frontend into the jar)
mvn clean package
# run it
DB_HOST=localhost DB_PORT=5432 DB_NAME=recommendarr \
DB_USER=recommendarr DB_PASSWORD=recommendarr \
java -jar target/recommendarr.jar
Open http://localhost:8080 and go to Settings to configure Radarr, Sonarr, Jellyfin, Plex and TMDB.
For frontend-only iteration with hot reload against a locally running jar:
cd frontend
npm install
npm run dev # http://localhost:5173, proxies /api and /webhooks to :8080
To skip the frontend build entirely during backend-only iteration:
mvn clean package -DskipFrontend=true
One-time database setup
ddl-auto: update only creates/updates tables inside an existing
database - it will not create the database itself. Before first deploy,
create it once against the existing Postgres instance, e.g.:
CREATE DATABASE recommendarr;
CREATE USER recommendarr WITH PASSWORD 'CHANGE_ME';
GRANT ALL PRIVILEGES ON DATABASE recommendarr TO recommendarr;
Run that via psql against the existing postgres-postgresql StatefulSet
in the media namespace, e.g.:
kubectl exec -it -n media postgres-postgresql-0 -- psql -U postgres
Kubernetes deploy (k3s)
Manifests live under k8s/:
00-namespace.yaml- namespacearr10-configmap.yaml- non-secret DB connection details (host/port/db) + JVM heap flags11-secret.yaml- DB credentials (DB_USER/DB_PASSWORD) - edit the placeholder password before applying, or manage it out-of-band and skip this file20-deployment.yaml- single-container Deployment, resources sized modestly (requests: 250m/256Mi,limits: 1/768Mi) for an AMD Athlon X4 740 / 16GB RAM host, with matching-Xmx512mJVM heap; liveness/readiness probes hit/health30-service.yaml- NodePort service,nodePort: 30024,targetPort: 8080
The Deployment connects cross-namespace to
postgres-postgresql.media.svc.cluster.local - no PVC is needed here since
all state lives in that Postgres instance.
kubectl apply -f k8s/00-namespace.yaml
kubectl apply -f k8s/11-secret.yaml # after editing the password
kubectl apply -f k8s/10-configmap.yaml
kubectl apply -f k8s/20-deployment.yaml
kubectl apply -f k8s/30-service.yaml
The app is then reachable at http://<any-k3s-node-ip>:30024.
CI/CD (Gitea Actions)
.gitea/workflows/build-deploy.yml runs on push to main:
- Checkout
- Set up Node + build the React frontend (via
mvn package, which invokesfrontend-maven-plugin) - Set up JDK 17 +
mvn package(producestarget/recommendarr.jarwith the frontend already embedded understatic/) - Docker build, tagged
:<commit-sha>and:latest - Push to the Gitea Container Registry (
secrets.GITEA_REGISTRY_USER/secrets.GITEA_REGISTRY_TOKEN) kubectl apply -f k8s/for the namespace/configmap/service, thenkubectl set imageto roll the Deployment to the freshly built image, using aKUBECONFIG_DATAsecret (base64-encoded kubeconfig) against the k3s cluster
Repo/org variables expected: vars.GITEA_REGISTRY, vars.GITEA_OWNER.
Secrets expected: GITEA_REGISTRY_USER, GITEA_REGISTRY_TOKEN,
KUBECONFIG_DATA. The Deployment also expects an
gitea-registry-credentials image pull secret in the arr namespace if
the registry is private - create it once with:
kubectl create secret docker-registry gitea-registry-credentials \
--docker-server=<gitea-registry-host> \
--docker-username=<user> --docker-password=<token> \
-n arr
Notable MVP judgment calls / corners cut
- TMDB/IMDB ids are columns on
MediaEntity, not a separateExternalIdsentity (one id pair per item; a join table added no value here). - Sort/score is computed on read, not persisted - simplest way to keep personal-rating edits reflected instantly without a full recompute.
- Sonarr add-to-library uses title-based lookup (no TVDB id resolution
step) - good enough for an MVP "click add" flow; a production version
would resolve the TVDB id via Sonarr's
/series/lookupfirst. - Webhook payload parsing (Radarr/Sonarr delete events, Jellyfin/Plex playback progress) covers the common webhook shapes but not every field variant each tool can emit; unrecognized payloads are safely ignored.
- No retry/backoff around the *arr/TMDB HTTP clients beyond default timeouts - a single failed source is skipped so it doesn't abort the whole recompute.
k8s/11-secret.yamlships with a placeholder password and is not auto-applied by CI (only the namespace/configmap/deployment/service are)- deploy it manually after editing, or manage it out-of-band.