Skip to content

fix(collections): self-heal empty Plex collections that reject every add#3094

Merged
enoch85 merged 3 commits into
developmentfrom
fix/self-heal-rejected-empty-collections
Jun 13, 2026
Merged

fix(collections): self-heal empty Plex collections that reject every add#3094
enoch85 merged 3 commits into
developmentfrom
fix/self-heal-rejected-empty-collections

Conversation

@enoch85

@enoch85 enoch85 commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

Fixes the loop reported on Discord (and the tail of #2412 / #1446-style reports): a stale/corrupt Plex collection record rejects every item add with 400 while reads still succeed. Since #3001 collections are created empty, so such a record sticks forever — #2766 made shared collections never-deleted, every run retried the same adds, and the collection stayed empty/invisible in Plex while 'added' notifications kept firing. The reporter confirmed that manually deleting the Plex collection fixed it; this automates that recovery.

  • Restore the delete-and-recreate heal (f0dcea7) for this case: when every add is rejected and a live read confirms the automatic collection is still empty, delete it and clear the link so the next pass recreates it fresh. Plex-only, matching the existing empty-collection cleanup gate. Shared collections heal too — an empty collection holds nothing a sibling rule group could lose, and the sibling relinks by title.
  • Churn guard: a collection heals once per process; a second total rejection without an accepted add in between logs an error instead of looping delete/recreate.
  • Emit CollectionMedia_Added only for items whose collection membership was actually persisted: server-rejected adds and adds rolled back after a local persistence failure no longer notify as successes. Local failures stay out of the heal's rejection count, so a database hiccup can never delete a healthy collection.
  • Unwrap the lib/plexApi error wrapper in buildCollectionMutationFailure so Plex's 400 response body (the rejection reason) reaches the logs.
  • Drop a duplicated reconcileSharedManualCollectionState call.

When a Plex collection record goes stale/corrupt it rejects all item adds
with 400 while reads still succeed. Since #3001 collections are created
empty, so such a record sticks forever: shared collections were never
deleted (#2766), every run retried the same adds, and the empty collection
stayed invisible in Plex while 'added' notifications kept firing.

- Restore the delete-and-recreate heal (f0dcea7) for this case: when a
  media server add attempt is rejected for every item and a live read
  confirms the automatic collection is still empty, delete it and clear
  the link so the next pass recreates it fresh. Plex-only, matching the
  existing empty-collection cleanup gate. Shared collections heal too:
  an empty collection holds nothing a sibling rule group could lose.
- Guard against churn: a collection heals once per process; a second
  total rejection without an accepted add in between logs an error
  instead of looping delete/recreate every run.
- Emit CollectionMedia_Added only for items the media server accepted,
  so failed adds no longer notify as successes.
- Unwrap the lib/plexApi error wrapper in buildCollectionMutationFailure
  so Plex's 400 response body (the rejection reason) reaches the logs.
- Drop a duplicated reconcileSharedManualCollectionState call.
@enoch85 enoch85 added the plex label Jun 12, 2026
@enoch85 enoch85 merged commit 1ec9bb4 into development Jun 13, 2026
14 checks passed
@enoch85 enoch85 deleted the fix/self-heal-rejected-empty-collections branch June 13, 2026 00:01
maintainerr-automation Bot added a commit that referenced this pull request Jun 13, 2026
* build(deps-dev): bump semantic-release from 25.0.3 to 25.0.5 (#3078)

Bumps [semantic-release](https://github.com/semantic-release/semantic-release) from 25.0.3 to 25.0.5.
- [Release notes](https://github.com/semantic-release/semantic-release/releases)
- [Commits](semantic-release/semantic-release@v25.0.3...v25.0.5)

---
updated-dependencies:
- dependency-name: semantic-release
  dependency-version: 25.0.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* build(deps-dev): bump @tailwindcss/typography from 0.5.19 to 0.5.20 (#3079)

Bumps [@tailwindcss/typography](https://github.com/tailwindlabs/tailwindcss-typography) from 0.5.19 to 0.5.20.
- [Release notes](https://github.com/tailwindlabs/tailwindcss-typography/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss-typography/blob/main/CHANGELOG.md)
- [Commits](tailwindlabs/tailwindcss-typography@v0.5.19...v0.5.20)

---
updated-dependencies:
- dependency-name: "@tailwindcss/typography"
  dependency-version: 0.5.20
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* build(deps): bump react-konva from 19.2.4 to 19.2.5 (#3080)

Bumps [react-konva](https://github.com/konvajs/react-konva) from 19.2.4 to 19.2.5.
- [Release notes](https://github.com/konvajs/react-konva/releases)
- [Commits](https://github.com/konvajs/react-konva/commits)

---
updated-dependencies:
- dependency-name: react-konva
  dependency-version: 19.2.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* build(deps-dev): bump @swc/core from 1.15.40 to 1.15.41 (#3081)

Bumps [@swc/core](https://github.com/swc-project/swc/tree/HEAD/packages/core) from 1.15.40 to 1.15.41.
- [Release notes](https://github.com/swc-project/swc/releases)
- [Changelog](https://github.com/swc-project/swc/blob/main/CHANGELOG.md)
- [Commits](https://github.com/swc-project/swc/commits/v1.15.41/packages/core)

---
updated-dependencies:
- dependency-name: "@swc/core"
  dependency-version: 1.15.41
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* build(deps): bump nodemailer from 8.0.10 to 8.0.11 (#3086)

Bumps [nodemailer](https://github.com/nodemailer/nodemailer) from 8.0.10 to 8.0.11.
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](nodemailer/nodemailer@v8.0.10...v8.0.11)

---
updated-dependencies:
- dependency-name: nodemailer
  dependency-version: 8.0.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* build(deps): bump rolldown from 1.1.0 to 1.1.1 (#3087)

Bumps [rolldown](https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown) from 1.1.0 to 1.1.1.
- [Release notes](https://github.com/rolldown/rolldown/releases)
- [Changelog](https://github.com/rolldown/rolldown/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rolldown/rolldown/commits/v1.1.1/packages/rolldown)

---
updated-dependencies:
- dependency-name: rolldown
  dependency-version: 1.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* build(deps-dev): bump prettier from 3.8.3 to 3.8.4 (#3088)

Bumps [prettier](https://github.com/prettier/prettier) from 3.8.3 to 3.8.4.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](prettier/prettier@3.8.3...3.8.4)

---
updated-dependencies:
- dependency-name: prettier
  dependency-version: 3.8.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* build(deps-dev): bump @types/node from 22.19.20 to 22.19.21 (#3089)

Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 22.19.20 to 22.19.21.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 22.19.21
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* build(deps-dev): bump @typescript-eslint/parser from 8.60.1 to 8.61.0 (#3090)

Bumps [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) from 8.60.1 to 8.61.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.61.0/packages/parser)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.61.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* build(deps-dev): bump turbo from 2.9.16 to 2.9.18 (#3092)

Bumps [turbo](https://github.com/vercel/turborepo) from 2.9.16 to 2.9.18.
- [Release notes](https://github.com/vercel/turborepo/releases)
- [Changelog](https://github.com/vercel/turborepo/blob/main/RELEASE.md)
- [Commits](vercel/turborepo@v2.9.16...v2.9.18)

---
updated-dependencies:
- dependency-name: turbo
  dependency-version: 2.9.18
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* build(deps): bump sharp from 0.34.5 to 0.35.1 (#3093)

Bumps [sharp](https://github.com/lovell/sharp) from 0.34.5 to 0.35.1.
- [Release notes](https://github.com/lovell/sharp/releases)
- [Commits](lovell/sharp@v0.34.5...v0.35.1)

---
updated-dependencies:
- dependency-name: sharp
  dependency-version: 0.35.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* build(deps-dev): bump @typescript-eslint/eslint-plugin (#3091)

Bumps [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) from 8.60.1 to 8.61.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.61.0/packages/eslint-plugin)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-version: 8.61.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* fix(collections): self-heal empty Plex collections that reject every add (#3094)

* fix(collections): self-heal empty Plex collections that reject every add

When a Plex collection record goes stale/corrupt it rejects all item adds
with 400 while reads still succeed. Since #3001 collections are created
empty, so such a record sticks forever: shared collections were never
deleted (#2766), every run retried the same adds, and the empty collection
stayed invisible in Plex while 'added' notifications kept firing.

- Restore the delete-and-recreate heal (f0dcea7) for this case: when a
  media server add attempt is rejected for every item and a live read
  confirms the automatic collection is still empty, delete it and clear
  the link so the next pass recreates it fresh. Plex-only, matching the
  existing empty-collection cleanup gate. Shared collections heal too:
  an empty collection holds nothing a sibling rule group could lose.
- Guard against churn: a collection heals once per process; a second
  total rejection without an accepted add in between logs an error
  instead of looping delete/recreate every run.
- Emit CollectionMedia_Added only for items the media server accepted,
  so failed adds no longer notify as successes.
- Unwrap the lib/plexApi error wrapper in buildCollectionMutationFailure
  so Plex's 400 response body (the rejection reason) reaches the logs.
- Drop a duplicated reconcileSharedManualCollectionState call.

* fix(collections): only notify added media whose local membership was persisted

* test(collections): pin stale-link clearing that sibling heal recovery relies on

* fix(radarr): treat already-excluded 400 as success when adding import-list exclusions (#3084) (#3096)

Since Radarr v5.26.2, RestController.OnActionExecuting unpacks and validates
IEnumerable bodies, so POST /exclusions/bulk now runs the uniqueness validator
and returns HTTP 400 ("This exclusion has already been added") on a re-add —
before the request reaches Radarr's server-side de-dup. That failed the whole
UNMONITOR/UNMONITOR_DELETE_ALL action on every re-run, so items never left the
collection (the singular endpoint always validated, so neither avoids it).

- Adding the exclusion is best-effort; an already-excluded 400 means the goal
  is already met, so treat it as success instead of failing the action whose
  unmonitor/delete already ran. Other failures still fail.
- Keep the bulk endpoint (server de-dup where reachable, single-movie array).
- Route through the shared post() client via a new opt-in { rethrow } so the
  caller can read the HTTP status, keeping the request on the one HTTP client
  the rest of servarr uses.
- Make the fake-radarr dev mock 400 on a duplicate for both endpoints, matching
  Radarr v5.26.2+.

* fix(emby): create collections with an initial item to avoid HTTP 500 (#3075) (#3097)

Emby's create-collection endpoint throws HTTP 500 ("Sequence contains no
elements" in CollectionManager) when asked to create an empty collection under
a library folder. Since #3001 (v3.13) switched all servers to create-empty
then batch-add (to avoid a 414 from seeding every id), Emby rejected every
create, so no rule action could complete and nothing was deleted.

- Add optional initialItemId to CreateCollectionParams; the Emby adapter sends
  it as Ids on create so the collection is created with one member. Plex and
  Jellyfin create empty and ignore it.
- One id keeps the create request well under the URL length limit that the
  all-ids create hit (#3001); the rest are added via addBatchToCollection
  (re-adding the seed there is an idempotent no-op).
- fake-emby mock now 500s on an empty create, matching the real server.

* fix(collections): skip remote create for empty collections (Emby) (#3075) (#3098)

createCollectionWithChildren is reachable via POST /api/collections with no
media (the body's media is optional), and it always created the remote
collection. With no items that means an empty remote create — pointless on
every server and a hard HTTP 500 on Emby, the same failure #3075 fixed for the
item-bearing paths, just through a narrower route.

- Create the DB row only when there are no items; the remote collection is
  created lazily on the first add, which seeds it with an item. Server-agnostic
  (no per-server branching in the shared layer).
- Tests: assert the singular initialItemId seed is passed on create (the prior
  assertions guarded the removed plural initialItemIds, so they passed
  vacuously), refresh the stale "create empty" comments, and add an empty-media
  case asserting no remote create.

* fix(radarr): only swallow the "already excluded" exclusion 400 (#3084) (#3099)

The exclusion add treated every HTTP 400 from /exclusions/bulk as "already
excluded". Radarr's validator also enforces non-empty tmdbId/title and a
non-negative year, so a non-duplicate validation 400 was misreported as
success — leaving the movie unmonitored/deleted but not actually import-excluded.

Inspect the 400 body and only treat the uniqueness failure ("This exclusion has
already been added") as success; any other validation 400 stays a failure and
is surfaced.

* fix(metadata): resolve movie/show ids from the item, not its parent (#3065) (#3100)

getHierarchyResolutionItem walked up to item.grandparentId ?? item.parentId
for any item with a parent. That's right for episodes/seasons (resolve up to
the show) but wrong for movies: on Emby/Jellyfin a movie's parentId points at
an id-less library/container folder, so the movie's own provider ids were
discarded → 0 lookup candidates → every Radarr movie action failed with
"Couldn't resolve any supported external IDs". The matching path was unaffected,
so collections filled but no action could run.

Switch on item.type (never parentId presence), matching resolveShowIdsForImage:
only episodes/seasons resolve from a parent; movies and shows use their own ids.
Plex is unchanged (its top-level movies already had no parent).

* test(overlays): raise timeout for sharp-based render tests to avoid CI flake (#3101)

The OverlayRenderService render tests do real sharp image processing and
TrueType font loading; the native cold-start can exceed Jest's 5s default on a
slow/cold CI runner (the whole suite is ~1.5s locally), intermittently failing
with "Exceeded timeout of 5000 ms". Raise the suite timeout to 15s.

* fix(rules): sw_lastWatched returns null for never-watched shows on Plex (#3083) (#3102)

The Plex sw_lastWatched getter ("Newest episode view date") guarded the date
branch with a truthy check on watchHistory. getWatchHistory returns [] for a
confirmed-empty history (it throws on a real outage), and [] is truthy — so for
a never-watched show it read viewedAt off watchHistory[0] (undefined) and threw
TypeError. The outer catch surfaced that as undefined, which the comparator
treats as a transient error, so the executor preserved the item — never-watched
shows got stuck in collections across runs.

Guard on length so an empty history takes the existing null branch ("never
watched", confirmed absent), per the getter contract (null = absent, undefined =
error). Emby/Jellyfin already length-guard this. Adds Plex coverage for empty →
null, populated → newest date, and outage → undefined.

* docs: update README feature wording

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: maintainerr-automation[bot] <261505141+maintainerr-automation[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: enoch85 <mailto@danielhansson.nu>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant