Four related UX fixes for a page that was starting to feel slow as the
library grew:
- Pagination controls now appear above the grid too, not just below.
- A skeleton grid renders instead of "Loading..." while a page's data is
in flight, instead of a blank screen.
- Pages are cached client-side and neighboring pages (page-1/page+1) are
quietly prefetched once the requested page has rendered - Назад/Вперёд
is then instant. Filter changes can't be prefetched the same way (too
many combinations, and the next one isn't predictable), so instead every
single-filter option's first page is warmed in the background, once,
right after the initial default load - the most likely first click is
then already cached too.
- Card actions (mark watched, blacklist, add to library) are optimistic:
the card disappears from the grid immediately instead of waiting for the
request, and is reinserted with a toast error if the request actually
fails. Same pattern applied to the detail modal's actions.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A plain sum over intersections let a candidate with many mediocre matches
outscore one with a single excellent match (5 sources rated 5 beat 1
source rated 10). relevance_score is now avg(source_rating * amplifier)
with a log-scaled breadth bonus on top (ScoringConfig.breadthBonus) -
untouched for single-intersection candidates, still rewarding further
confirmations without letting them dominate on count alone. Also blends a
small, deliberately modest share of external_rating directly into
relevance_score (ScoringConfig.externalRatingRelevanceBlend), so a very
well-reviewed title isn't beaten by a mediocre one purely for having fewer
intersections - external_rating remains a pure sort tiebreaker beyond that.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Until now every recommendation traced back to "similar to a source" via
TMDB recommendations/similar or Kinopoisk's similar-films list - anything
with zero graph edges to what you've watched (e.g. a brand-new release)
never surfaced at all. Two independent passes now feed the same
Intersection table:
- Franchise completion: every other entry in a movie source's TMDB
collection (John Wick 2 owning a source -> John Wick 1/3/4 as finds).
Collection membership is resolved once and cached (MediaEntity.
collectionId/collectionChecked), since it never changes.
- Person-affinity: a small taste profile built from the cast/directors of
your highest-rated movies (ScoringConfig.personAffinityRatingThreshold),
queried against TMDB Discover - catches things you'd like because of who
made them, not because of a prior "similar to X" edge. Movie-only, since
TMDB Discover's people filter doesn't exist for /discover/tv.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Growing libraries made the Home page slow to render and its response
payload large - filtering (type/source) now happens server-side and the
result is sliced into pages before serialization, instead of shipping
every scored candidate to the frontend on every load.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
Still 422 for a title confirmed present in Sonarr's own lookup with a
matching name and (2025) year - logs the raw candidate list so the
actual field values (title casing/whitespace, year, tvdbId) can be
compared against what titleMatches/yearMatches expect, instead of
guessing further.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The previous fix made year matching too strict (both sides required
non-null), which rejected a genuinely correct match: TheTVDB reports
year as 0 rather than omitting it for very recent shows without firm
air-date metadata yet ("Первый раз" (2025) - confirmed present and
correctly titled in Sonarr's own lookup, just with no usable year).
Year null/<=0 on either side is now treated as unknown and no longer
disqualifies a candidate - this is safe specifically because the title
match stays mandatory regardless, which was the actual missing
safeguard, not year strictness.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The title+year fallback added last commit had no title check at all -
it accepted the first search candidate whose year merely matched (or
ANY candidate at all when our year was null), silently adding a
completely unrelated show to Sonarr. Confirmed in production: "Первый
раз" (2025) resolved to "Around the World in 80 Weighs" (2025) - same
year, nothing else in common.
A candidate now must match both: title (against Sonarr's primary
title OR any alternateTitles, since a Russian-titled search rarely
hits TheTVDB's English primary title directly) AND year within 1,
with neither side allowed to be null/absent as a wildcard. No
confident match resolves to null (the existing 422 path) rather than
ever guessing - many Russian-titled shows will now correctly fail to
resolve instead of silently adding the wrong series.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Diagnostic logging showed lookupByTmdbId (GET /series/lookup?term=tmdb:{id})
returns zero results even for a well-known show on this Sonarr
instance - the "tmdb:" term prefix simply isn't recognized here, not a
missing TheTVDB cross-reference. Falls back to a plain keyword search
by title, picking the first candidate whose year is within 1 of ours
(title alone is unreliable since Sonarr's TheTVDB-backed provider may
not carry the same localized title we store).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The new lookup returned nothing for at least one real series, which
its 422 message correctly reported - but with no way to tell from the
logs whether Sonarr genuinely has no TheTVDB match or the term/response
shape assumption is wrong. Logs the raw first result (or lack of one)
and no longer lets a lookup-request exception propagate as an
unrelated 500.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
addToLibrary always passed null for a series' tvdbId (we only ever
capture tmdbId), and Sonarr's add endpoint hard-rejects a missing/
zero tvdbId with a 400 - so every single series add was failing.
Sonarr's own /series/lookup?term=tmdb:{id} resolves our tmdbId
through its TheTVDB-keyed metadata provider first; if it finds
nothing, the request fails fast with a clear message instead of
silently sending tvdbId=0 again.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The previous "orphaned" condition (blacklisted && not in library) was
too narrow - a title reached by drilling into another release's
recommends list, or via manual search, is very often not blacklisted
at all, just not something you've acted on yet, and still had no way
to act on it without leaving the modal. Now mirrors MediaCard's own
logic generally, by status/watched combination:
- IN_LIBRARY: badge only, same as everywhere else.
- not in library, already watched: "Добавить в Radarr/Sonarr" +
blacklist toggle, but no "Уже смотрел" (already true).
- not in library, not watched: all three actions.
The blacklist toggle is now two-way ("Не предлагать" / "Добавить в
рекомендации" depending on current state), matching normal cards
instead of only ever offering the un-hide direction.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The original design ("Rework source model: library membership drives
sources, watched is a weight only") meant marking something "Уже
смотрел" without owning it never seeded its own TMDB/Kinopoisk
discoveries at all - it only ever showed up as a candidate itself, or
boosted OTHER sources' weight if it happened to already be a source
via ownership. That contradicts the actual intent behind watched
tracking: it should build the recommendation graph, not just softly
exclude a title from Рекомендации. Sources are now IN_LIBRARY ∪
REMOVED ∪ every WatchSignal.watched=true title regardless of status.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
MediaEntity gets onDemandRecommendationsFetchedAt, gating a new
RecomputeService.fetchOnDemand(id) that runs the same TMDB+Kinopoisk
logic as a normal recompute for exactly one source (extracted the
TMDB per-source body out of the batch loop so both can share it),
persisting ordinary Intersection rows - so once found, a title's
recommendations behave identically everywhere (badges, scoring) with
no special-casing. Gated as a single flag regardless of outcome, so
re-opening the same detail modal never repeats the external calls.
New POST /api/media/{id}/fetch-recommends drives a button in the
detail modal that only appears when a release has no computed
recommendations yet. The modal's "this release recommends" list now
shows a small poster thumbnail per row and is clickable: clicking
re-renders the same modal for that title instead of closing it,
recursively applying the same empty-state/fetch-button logic.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Просмотрено gets the same filtered/total counter already on Рекомендации.
- "Показать скрытые" replaced its previous additive behavior (hidden titles
layered on top of the normal list) with an exclusive "Только скрытые"
toggle: off shows the normal list with blacklisted titles excluded, on
replaces it with just the blacklisted titles so they can be reviewed and
un-hidden without wading through everything else.
- The shared detail modal now shows what a release itself recommends (its
own Intersection rows) with a provider badge per pair, via a new
GET /api/media/{id}/recommends. Only meaningful for sources (Просмотрено
items); Рекомендации cards aren't sources today, so this comes back empty
for them until an on-demand per-card fetch (discussed, not yet built).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Three separate badges (TMDB, Kinopoisk, "found by both") for a title
confirmed by both providers looked cluttered - now it's a single badge
("TMDB + Кинопоиск") when there's more than one, or the lone provider's
badge otherwise.
The Recommendations source filter's "TMDB"/"Kinopoisk" buttons now mean
"found by this provider only", with a new third "Оба источника" button
for titles both providers agreed on - mutually exclusive and together
covering every discoverySources value, instead of the previous
inclusive filter that couldn't isolate single-provider matches.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Revised per feedback: Kinopoisk's "films by filter" endpoint
(/api/v2.2/films?imdbId=...) accepts a direct imdbId lookup and returns
imdbId on each result - no need to search by keyword and then spend
extra requests confirming candidates one by one. Resolution is now two
independent OR'd attempts (never one request ANDing both signals):
searchByImdbId first when the source has one, falling back to
searchByKeyword + the title/year heuristic only when there's no imdbId
or that search comes back empty. This is also cheaper (1-2 requests
per source instead of up to 4).
Per-source outcomes are logged at DEBUG (successes) and DEBUG (misses,
with a plain-English reason) rather than one INFO line per source -
meaningful per-request logging without burying the phase summary INFO
line under dozens/hundreds of lines every run.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The unmatched-search diagnostics showed the real problem with pure
title matching: Kinopoisk is a Russian-language catalog, so comparing
only against nameRu missed titles we store in English/original form
(e.g. "The Legend of Korra" vs "Легенда о Корре" - same year, same
film, zero string overlap). We already capture imdbId from Radarr/
Sonarr, and Kinopoisk's own film-details endpoint (/films/{id}) returns
imdbId too - so up to 3 year-plausible search candidates now get a
details fetch to confirm by that real external key first, falling back
to the (now also more lenient: checks nameRu OR nameEn, tolerates a
missing/zero year, allows substring containment) title heuristic only
when no imdbId match is found.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The rate-limit fix revealed a new mystery: all 126 candidates spent
exactly one request each and zero matched, with no errors at all -
meaning Kinopoisk's search returned 200 OK but either empty results or
results our title+year heuristic rejected for literally every title.
Log a few samples of "search succeeded but nothing matched" (our title
vs its first hit) to tell which case it actually is, without needing
the raw API key.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Diagnostic logging from the previous commit showed the real cause of
zero Kinopoisk intersections: HTTP 429 after only 14 unpaced requests,
not the 490/day budget being exhausted. 429 (a short per-second rate
limit) and 402 (Payment Required, the actual daily quota) were being
treated identically, aborting the whole phase on the very first burst.
Every Kinopoisk request is now paced (~1.1s apart), and a lone 429 gets
one backoff-and-retry before being treated like a real quota-exhausted
402 - so a brief rate-limit blip no longer kills the entire run.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
KinopoiskClient degraded every non-quota error to an empty result with
no trace at all, making it impossible to tell from the logs why zero
intersections were ever recorded (bad key permissions on a specific
endpoint vs. no title/year match vs. something else). Also logs a
one-line summary of each recompute's Kinopoisk phase (candidates,
resolved ids, requests spent, new intersections).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
processedSources was only incremented during the TMDB phase, so once
that finished the progress bar showed 100% while the Kinopoisk phase
(which can take a while due to per-request rate limiting) kept running
underneath, making the "Пересчитать" button look stuck. The Kinopoisk
candidate list is now computed upfront and folded into the total so
100% isn't reached until the whole recompute is actually done.
Also add an independent "Найдено через" filter (TMDB/Kinopoisk) on the
Recommendations page, AND'ed with the existing type filter, so it's
possible to check whether Kinopoisk found anything at all.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Matches the header logo's light chip so the navy strokes stay legible
in the browser tab too, instead of floating on transparent/dark tab UI.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Recreated the provided logo as SVG (navy concentric play-button rings
branching into orange distribution nodes) - used as the browser tab
favicon and in the header brand, the latter on a small light chip so
the navy strokes stay legible on the dark theme.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Kinopoisk's /similars endpoint is queried per source alongside TMDB's
recommendations/similar, resolved back to MediaEntity by title+year and
cached (kinopoiskId/kinopoiskCheckedAt) to respect its free-tier daily
quota (configurable per-integration requestLimitPerDay, throttled by
ScoringConfig.kinopoiskRefreshDays). A pair confirmed by both providers
upgrades its single Intersection row to via=BOTH instead of duplicating
it, and gets a relevance boost (ScoringConfig.multiSourceBoost) plus a
"found via" badge on the card.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- TmdbClient.searchMulti() hits /search/multi (language=ru-RU), per-item
media_type distinguishes movie/tv so each result gets its own MediaType
(with Animation-genre detection same as recompute).
- ScoringService.search(): local title-substring match (new
MediaEntityRepository.findByTitleContainingIgnoreCase) merged with new TMDB
hits, lazily persisted via the existing findOrCreateFromTmdb cache so every
result is a real MediaEntity and all existing card actions just work.
Extracted the DTO-mapping body of getWatched() into a shared toBasicCard()
used by both.
- New GET /api/search?q=... (SearchController) and pages/Search.jsx (debounced
input, reuses MediaCard). MediaCard now shows a plain "В библиотеке" badge
instead of Add/Blacklist buttons when a search hit is already owned.
- Просмотрено page: replaced the single ALL/IN/OUT radio with three
independent checkbox filter groups (type; Скачано/В библиотеке/Не в
библиотеке; Есть оценка/Нет оценки) that AND together, plus a "Сбросить
фильтры" button - sort and "Показать скрытые" are left untouched by reset.
- MediaService now actually populates MediaEntity.imdbId from Radarr/Sonarr's
own API responses (both already return it for free) - foundation for a
future IMDB/Kinopoisk pass, no behavior change yet.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Hibernate 6 auto-generates a DB CHECK constraint listing an enum's values the
first time an @Enumerated(STRING) column is created. ddl-auto=update never
refreshes that constraint when the Java enum gains a value later, so it goes
stale. This bit two columns already:
- media_entity_type_check still only allowed MOVIE/CARTOON/SERIES, so every
insert of a CARTOON_SERIES row (i.e. any Sonarr series with the Animation
genre) violated it and aborted the whole Sonarr sync loop silently
(caught by RecomputeService's per-source try/catch) - explains why newly
added animated shows, and everything after them in the sync, never made
it into media_entity.
- media_entity_status_check still only allowed the old WATCHED_REMOVED
value, so every attempt to set status=REMOVED (webhook-driven removals,
markRemovedIfMissing) has been silently failing since that rename - zero
REMOVED rows existed in the live database.
Manually dropped both stale constraints on the live DB (data-safe, purely
removes an incorrect restriction). Added explicit columnDefinition to every
@Enumerated column in the codebase so Hibernate stops synthesizing these
constraints at all - ddl-auto has no mechanism to keep them in sync with the
Java enum, so it must never create one to begin with.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
RecomputeService now tracks running/processed/total sources and the last
run's new-intersection count via atomics (recompute() is already
synchronized to one run at a time, so plain shared state is enough). New
GET /api/recompute/status exposes it. The header polls this every second
while a recompute is in flight (whether triggered by the button or an
already-running scheduled job picked up on page load) and shows a progress
bar + "processed / total" counter instead of just a static "Пересчёт..."
label.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Same issue as the earlier title/overview refresh: findOrCreateFromTmdb only
sets type on brand-new rows, so titles discovered before CARTOON_SERIES
existed (or whose animation genre wasn't recorded yet) stayed SERIES/MOVIE
forever even after the type-detection fix landed. Reclassify on every
recompute alongside title/overview, skipping anything the user has manually
overridden.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Previously every Sonarr-sourced title (and every found-via-TMDB series) was
typed plain SERIES with no way to flag it as animated, and any "is this a
series" check only tested `== MediaType.SERIES`. Add MediaType.CARTOON_SERIES
(mirrors CARTOON's Radarr-side "Animation" genre detection, but for Sonarr)
plus MediaType.isSeries() so every add-to-library/quality-profile check
treats CARTOON_SERIES the same as SERIES (Sonarr), never Radarr. Enum values
map to a varchar column, so no ddl-auto migration risk here.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Rating an unwatched candidate doesn't make sense - personal ratings now only
live on the "Просмотрено" page, for things actually in the library or
already watched.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Previously a title stayed NEVER_HAD until the next full recompute's library
sync, so it kept showing in Recommendations right after being added. Now
RadarrClient.addMovie/SonarrClient.addSeries return the created resource (its
new id), and MediaActionController stores that id and sets status=IN_LIBRARY
right on the 2xx response - RestClient throws on non-2xx, so this only
happens on genuine success. Drops the title out of Recommendations and into
"Просмотрено" instantly, no recompute needed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
findOrCreateFromTmdb only sets title/overview on brand-new rows, so titles
found before ru-RU localization was added stayed in English forever and
never picked up an overview even after a recompute. Now refresh both on
every recompute for non-IN_LIBRARY rows (library items keep their
Radarr/Sonarr-sourced title, already refreshed by the sync step this same
recompute run).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- MediaEntity gains overview + hasFile (informational only, doesn't affect
IN_LIBRARY status); Snooze gains a permanent `blacklisted` flag alongside
the existing temporary snoozedUntil; ScoringConfig gains
rewatchInsertInterval. All new NOT NULL columns use @ColumnDefault to avoid
repeating the watched_boost startup crash on the already-populated tables.
- TmdbClient now captures `overview` and requests language=ru-RU so found
titles get Russian title/overview when TMDB has a translation.
- ScoringService: getRecommendations() now excludes already-watched titles
from the main feed and instead splices one rewatch suggestion (a watched
title no longer in the library) in every rewatchInsertInterval items,
flagged for a distinguishing badge. Replaced getWatchAgain() with
getWatched(includeHidden) for the new "Просмотрено" page: everything
currently in the library (even unwatched - it can't be re-added anyway)
plus everything ever watched, with blacklisted titles surfaced only when
includeHidden is requested (for the "Вернуть" undo action).
- MediaActionController: new /watched (mark watched, no status change),
/blacklist and /unblacklist endpoints.
- Frontend: removed the separate "Посмотреть ещё раз" page/route in favor of
the rewatch-suggestion badge inline in Recommendations; added the
"Просмотрено" page (library/in-library filter, sort, hidden-blacklist
toggle); MediaCard/LibraryCard get a click-to-open MediaDetailModal showing
the overview text; hid the inherited-rating badge when it's 0; added an
intersection-count badge; added a type filter on Recommendations.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ddl-auto=update generated "ALTER TABLE scoring_config ADD COLUMN
watched_boost ... NOT NULL" with no default, which Postgres rejects on the
already-populated singleton config row ("column contains null values").
That aborted the migration, so every later query against scoring_config
failed with "column watched_boost does not exist" - including
/api/recommendations. @ColumnDefault backfills the existing row instead.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Previously "watched >=90%" was a hard gate for being a source (in addition
to library items), and Radarr sync only marked something IN_LIBRARY when
hasFile was true - both wrong per the corrected spec:
- anything ever added to Radarr/Sonarr is a source forever, regardless of
whether the file has finished downloading
- removal from the library flips it to REMOVED (renamed from
WATCHED_REMOVED) but it stays a source and keeps feeding intersections -
REMOVED only drives the "Watch Again" feed now
- being watched (and rewatch count) no longer gates source membership at
all; it's purely a scoring weight boost via the new watchedBoost config,
stacked with the existing rewatch amplifier
Also added MediaService.markRemovedIfMissing(), called from
RecomputeService's library sync, so a title that drops out of Radarr/Sonarr's
listing gets flagged REMOVED even if its delete webhook was missed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This server (Jellyfin 12.0.0) rejects X-Emby-Token, X-MediaBrowser-Token,
a raw Authorization value, and Authorization: Bearer <token> - all with
401 - even against a freshly generated API key confirmed valid via curl.
Its OpenAPI spec names Authorization as the apiKey header, but the only
value it actually accepts is the legacy 'MediaBrowser Token="<key>"'
scheme, confirmed working via curl against the live server.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
git.kayashov.keenetic.pro is the KeenDNS HTTPS proxy in front of Gitea's web
UI - its cert only covers one subdomain level and it doesn't route /v2/...
registry API traffic, so docker login got back the proxy's own 404 HTML page.
The real registry address is the NodePort 192.168.1.100:30008, already set as
insecure-registry in act-runner-docker-config (dind push) and
/etc/rancher/k3s/registries.yaml (containerd pull) - use that everywhere.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Workflow referenced GITEA_REGISTRY_USER/GITEA_REGISTRY_TOKEN/KUBECONFIG_DATA,
but the account's global secrets are named REGISTRY_USER/REGISTRY_TOKEN/
KUBE_CONFIG, so docker/login-action got empty credentials. Also drop the
base64 decode for KUBE_CONFIG since it's stored as plain kubeconfig YAML.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The Gitea Actions runner image has no system Maven (setup-node/setup-java
only install those tools, not Maven), so the pipeline failed at 'mvn:
command not found'. Switch the build step to ./mvnw and pin the wrapper's
line endings/executable bit via .gitattributes so it survives Windows
checkouts intact.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Spring Boot 3.3 backend + React frontend bundled into one jar, Postgres via
Hibernate ddl-auto, Radarr/Sonarr/Jellyfin/Plex/TMDB integrations with
runtime-editable settings, webhook-driven batch recompute, and k8s/Gitea CI-CD
for deployment into the arr namespace on k3s.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>