Being blacklisted is the only way a title becomes invisible on both Рекомендации (excluded there) and Просмотрено (excluded from its normal view too) at once - such a title can still be reached via modal navigation (clicking through another release's "recommends" list), so it needs its own way back in. When the currently displayed title is blacklisted and not already in the library, the modal now shows the same three actions as a normal card: "Добавить в Radarr/ Sonarr" (flips to IN_LIBRARY on success, same as everywhere else), "Добавить в рекомендации" (un-blacklists it), and "Уже смотрел". 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.