Kayashov.SMandClaude Sonnet 5 5fb05d8bec
Build and Deploy / build-and-deploy (push) Successful in 4m37s
Fix CI: use plain docker build/push instead of buildx
buildx runs its own BuildKit daemon with a separate insecure-registry
config that act-runner-docker-config doesn't reach, so the push failed
with "server gave HTTP response to HTTPS client" even though the image
built fine and login succeeded. The classic docker engine in the dind
runner already trusts this registry, so use it directly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 18:56:35 +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
481 KiB
Languages
Java 73.6%
JavaScript 23.3%
CSS 2.5%
Dockerfile 0.4%
HTML 0.2%