Kayashov.SMandClaude Sonnet 5 6355820847
Build and Deploy / build-and-deploy (push) Successful in 2m4s
Fix double-encoded Cyrillic query in Sonarr lookup requests
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>
2026-09-18 01:49:16 +04:00
2026-09-15 21:57:06 +04:00

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 NodePort 30024.
  • CI/CD: Gitea Actions (.gitea/workflows/build-deploy.yml).
  • Health: a plain /health endpoint (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 separate ExternalIds table - there's exactly one id pair per item, so a join table would add nothing.
  • PersonalRating - your 0-10 rating for a MediaEntity.
  • 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 until snoozedUntil (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 via ScoringConfig.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 a WebhookEvent row. 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 - namespace arr
  • 10-configmap.yaml - non-secret DB connection details (host/port/db) + JVM heap flags
  • 11-secret.yaml - DB credentials (DB_USER/DB_PASSWORD) - edit the placeholder password before applying, or manage it out-of-band and skip this file
  • 20-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 -Xmx512m JVM heap; liveness/readiness probes hit /health
  • 30-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:

  1. Checkout
  2. Set up Node + build the React frontend (via mvn package, which invokes frontend-maven-plugin)
  3. Set up JDK 17 + mvn package (produces target/recommendarr.jar with the frontend already embedded under static/)
  4. Docker build, tagged :<commit-sha> and :latest
  5. Push to the Gitea Container Registry (secrets.GITEA_REGISTRY_USER / secrets.GITEA_REGISTRY_TOKEN)
  6. kubectl apply -f k8s/ for the namespace/configmap/service, then kubectl set image to roll the Deployment to the freshly built image, using a KUBECONFIG_DATA secret (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 separate ExternalIds entity (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/lookup first.
  • 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.yaml ships 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.
S
Description
сервис для вычисления рекомендаций к просмотру фильмов, мультфильмов, сериалов
Readme
422 KiB
Languages
Java 75%
JavaScript 21.9%
CSS 2.5%
Dockerfile 0.4%
HTML 0.2%