Skip to content

fix: stop cross-rule contamination for same-titled automatic collections#2766

Merged
enoch85 merged 4 commits into
developmentfrom
fix/auto-collection-cross-rule-contamination
Apr 25, 2026
Merged

fix: stop cross-rule contamination for same-titled automatic collections#2766
enoch85 merged 4 commits into
developmentfrom
fix/auto-collection-cross-rule-contamination

Conversation

@enoch85

@enoch85 enoch85 commented Apr 25, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes #2765. Two automatic rule groups with the same title currently end up linked to the same media server collection (because checkAutomaticMediaServerLink relinks by name), and the rule executor's manual-import path then pulls the sibling rule's items into the wrong rule's collection_media as manual. The collection worker doesn't filter on membership type, so those items get handled at the wrong rule's deleteAfterDays. There's also a related landmine where whichever rule's local rows clear first deletes the shared media server collection out from under the sibling rule.

This PR keeps the existing same-title sharing behavior intact (it's still useful for relinking after a Plex restart), but makes it safe:

  • New CollectionsService.getSiblingRuleOwnedMediaServerIds(collection) returns the set of media server IDs that are rule-owned by any other Maintainerr collection sharing this collection's mediaServerId.
  • The rule executor's manual-import loop (rule-executor.service.ts) skips children that fall in that set, so a sibling rule's items never get imported as manual into the wrong rule.
  • removeFromCollection and checkAutomaticMediaServerLink now check isMediaServerCollectionShared before deleting an empty media server collection. When shared, the local link is cleared but the media server collection is preserved for the sibling rule.

This mirrors the provenance approach used by #2616 for manual collections, applied to the automatic-collection same-title case it didn't cover.

Validation

  • yarn workspace @maintainerr/server test — 1021/1021 passing (4 new)
  • yarn workspace @maintainerr/server lint — clean
  • yarn build — clean

Two automatic rule groups with the same title end up linked to the same
media server collection because checkAutomaticMediaServerLink relinks by
name. The rule executor's manual-import path then imported the sibling
rule's items as manual into the wrong rule, subjecting them to the
wrong deleteAfterDays. Whichever rule emptied first would also delete
the shared media server collection in removeFromCollection /
checkAutomaticMediaServerLink, taking the sibling rule's items with it.

Skip the manual import for items already rule-owned by a sibling
collection sharing the same mediaServerId, and guard the two empty-
collection deletes against shared links by unlinking locally instead.

Refs #2765
@enoch85

enoch85 commented Apr 25, 2026

Copy link
Copy Markdown
Collaborator Author

/release-pr

@github-actions

Copy link
Copy Markdown
Contributor

Released to maintainerr/maintainerr:pr-2766 🚀

@enoch85 enoch85 marked this pull request as ready for review April 25, 2026 10:11
@SHxKM

SHxKM commented Apr 25, 2026

Copy link
Copy Markdown

@enoch85 Thanks for the super fast response to #2765 🙏 this looks awesome.

One reservation I have is: does that mean shared collection will NEVER be deleted? My concern with this is that we'll end up with cruft / users will see empty collections. I think it's OK for a collection handler to delete the collection as long as it checks (with a lock) that it has just deleted the last item(s), then the handler for the next rule (and assuming it needs to) would just recreate it, no?

I'm unfamiliar with the codebase so I may be offering suboptimal suggestions here.

@enoch85 enoch85 force-pushed the development branch 3 times, most recently from 2511471 to 50e13ba Compare April 25, 2026 15:53
Adds three corrections on top of the same-title automatic collection
contamination fix:

- Partial drift: checkAutomaticMediaServerLink now diffs server children
  against local rule-owned items for shared automatic collections (not
  just empty ones) and pushes any missing back via addBatchToCollection.
  Fixes the "Plex shows 1 of 5 items, Maintainerr DB shows 5" case where
  the rule executor's local-DB-only delta can't recover server drift.
- Sibling lookup error path: getSiblingRuleOwnedMediaServerIds no longer
  swallows DB errors into an empty Set. The rule executor catches the
  throw and skips manual child import, since a silent empty would let
  sibling-owned children be imported as manual into the wrong rule.
- Cross-type sharing: isMediaServerCollectionShared and
  getSiblingRuleOwnedMediaServerIds now filter siblings by
  manualCollection so a manual collection sharing a mediaServerId can no
  longer be treated as a sibling automatic rule group.
@enoch85

enoch85 commented Apr 25, 2026

Copy link
Copy Markdown
Collaborator Author

/release-pr

@github-actions

Copy link
Copy Markdown
Contributor

Released to maintainerr/maintainerr:pr-2766 🚀

@enoch85

enoch85 commented Apr 25, 2026

Copy link
Copy Markdown
Collaborator Author

@SHxKM Please test the latest changes.

One reservation I have is: does that mean shared collection will NEVER be deleted? My concern with this is that we'll end up with cruft / users will see empty collections. I think it's OK for a collection handler to delete the collection as long as it checks (with a lock) that it has just deleted the last item(s), then the handler for the next rule (and assuming it needs to) would just recreate it, no?

I'm unfamiliar with the codebase so I may be offering suboptimal suggestions here.

Yeah, fair concern - but it's not "never deleted", just "not deleted while a sibling still points at it". Once the last sibling rule group is gone (deleted/renamed/moved library), the empty collection gets cleaned up on the next run via the normal path. Plex collections are also recreated by name, so the recreate side is already free.

Your lock-based idea is cleaner in theory, but Maintainerr doesn't have a cross-rule lock today and adding one felt heavy for what's basically "two rule groups with the same title in the same library" — pretty rare. The sibling-count check is the passive version of the same idea: no other rule holds the link → safe to delete.

If empty-collection cruft ever becomes a real problem, a periodic sweep would be the easy follow-up.

@SHxKM

SHxKM commented Apr 25, 2026

Copy link
Copy Markdown

Thanks @enoch85.

The sibling-count check is the passive version of the same idea: no other rule holds the link → safe to delete.

But wouldn't there always be a sibling when two rules point to the same collections? Or is this referring to actual items in the Maintainerr collection.

@enoch85

enoch85 commented Apr 25, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks @enoch85.

The sibling-count check is the passive version of the same idea: no other rule holds the link → safe to delete.

But wouldn't there always be a sibling when two rules point to the same collections? Or is this referring to actual items in the Maintainerr collection.

@SHxKM "Sibling" here means another Maintainerr collection row pointing at the same Plex mediaServerId - not items inside the collection. So yes, while both rule groups exist and both link to the shared Plex collection, there's always a sibling and we won't delete it. The sibling goes away when you delete one of the rule groups, rename one so it no longer collides, or unlink one from Plex. At that point the remaining rule is no longer "shared" and the normal delete-if-empty path kicks back in.

@enoch85

enoch85 commented Apr 25, 2026

Copy link
Copy Markdown
Collaborator Author

All tests pass here, and I've been over it a couple of times.

Merging to development. 🚀

@enoch85 enoch85 merged commit 98b96e8 into development Apr 25, 2026
13 checks passed
@enoch85 enoch85 deleted the fix/auto-collection-cross-rule-contamination branch April 25, 2026 21:43
maintainerr-automation Bot added a commit that referenced this pull request Apr 26, 2026
* ci(release): restore version header and add New Contributors section

Two gaps since we swapped @semantic-release/release-notes-generator for
the custom AI-backed tool:

- No top-level "# [X.Y.Z](compare-url) (YYYY-MM-DD)" header. Now
  prepended in all emit paths (model, fallback, empty).

- No "New Contributors" section. Now fetched from GitHub's
  POST /releases/generate-notes endpoint and appended if present.

* ci(fider): add automated triage, invitation, re-eval, and stale workflows

Five workflows under a unified Fider - * naming scheme that operate the
features.maintainerr.info Fider instance from CI. Tags + comments only —
no auto-closes from the triage path. Maintainers always have the final
call on possibly-* posts; the stale sweep is the one place the bot will
auto-decline (after a 30-day warning + grace window).

## Workflows

  fider-triage.yml              Fider - Triage              cron daily 06:00 UTC
  fider-re-evaluate.yml         Fider - Re-evaluate         cron monthly + manual
  fider-pre-existing-scan.yml   Fider - Pre-existing scan   manual only
  fider-stale.yml               Fider - Stale sweep         cron weekly Sunday
  fider-invite-codeowners.yml   Fider - Invite CODEOWNERS   cron weekly Sunday

## Tags applied by the bot

  triage-checked         every processed post (idempotency gate)
  possibly-completed     a recent merged PR clearly delivers the request
  possibly-duplicate     this post duplicates an earlier open post
  possibly-pre-existing  feature already shipped before the post was filed
                         (lower confidence than possibly-completed)
  stale                  open >1.5 years with <3 votes; warned for review

## Triage pipeline (per post)

1. Skip if already triage-checked, unless FORCE_REEVAL=true
2. Extract keywords from title + first 400 chars of description, drop
   stopwords, keep top 4
3. Search merged PRs one keyword at a time via gh pr list, union and
   dedupe (AND'ing keywords misses real matches)
4. Filter candidates: feat/fix/feature prefix only, mergedAt > createdAt
5. Ask the model (openai/gpt-4.1-mini) with a strict JSON rubric that
   requires a verbatim quote from a candidate PR; verify the quote
   actually appears in what we sent (defence against hallucination)
6. If status=completed + confidence=high: tag possibly-completed and
   upsert a comment citing the evidence URL and verbatim quote (PUT
   to update on re-eval if a better PR is now cited)
7. (CHECK_PRE_EXISTING=true) Re-run the search with mergedAt < createdAt
   and a stricter rubric to surface "user didn't know feature exists"
8. Compare against other open posts (Jaccard >= 0.4 on keywords) for
   duplicate detection; the cited original_number must be in the
   candidate list AND the quote must appear in its title/description

## Stale sweep (two-phase)

Phase 1: post older than 1.5 years with <3 votes and no exempt tag →
         apply 'stale' tag, post a "still relevant?" warn comment
         carrying the same cc:@maintainers prefix as triage comments
Phase 2: 30 days later, if no engagement (any comment newer than the
         warn) → PUT /api/v1/posts/{n}/status to declined with
         explanation
Exempt tags: never-stale, planned, started, bug, enhancement,
possibly-completed

## Invite-CODEOWNERS sweep

Weekly: parse .github/CODEOWNERS, look up each user's public GitHub
email, skip those already Collaborator/Administrator on Fider, send
remaining recipients a single POST /api/v1/invitations/send. Users
with private email visibility are logged and skipped (no automatic
fallback — invite manually if needed).

## Safety guards (triage)

- Per-comment-type hidden HTML markers prevent double-commenting on
  re-runs (completed / duplicate / pre-existing each independent);
  upsertBotComment edits in place if the body changed
- 5-second throttle between model calls (free-tier 15 req/min cap)
- Per-call retry on 429/5xx: 60s/120s/240s backoff, honour Retry-After
  but cap at 5 minutes — anything beyond (e.g. 23-hour daily quota
  reset) becomes a hard failure so the run aborts cleanly instead of
  sleeping inside CI
- Abort run after 3 consecutive model failures (~15s on quota-exhausted
  days, ~21min worst case during real outages) — remaining posts get
  picked up on the next scheduled run
- Rate-limit headers logged via an explicit allowlist; trace IDs and
  infra identifiers are deliberately NOT logged
- CODEOWNERS guard on workflow_dispatch
- models: read permission only; no actions write, repo write, PR write

## Maintainer @mentions on every bot comment

Resolves a "cc:" prefix once per run from GET /api/v1/users (works for
Collaborator OR Administrator per docs.fider.io/api/users) or falls
back to FIDER_MENTION_USERS_FALLBACK env var. The bot's own user is
auto-excluded via FIDER_BOT_USERNAME (no /me endpoint exists in
Fider's public API). If neither source yields anyone, the comment
posts without the cc line.

## Shared helpers

tools/fider-shared.mjs provides createFider, postHasTag,
buildMentionToken, and buildMentionPrefix to all four entry-point
scripts so the API client, role filtering, and cc-prefix logic stay
in one place.

## Bot identity

  GitHub user:  maintainerr-fider-bot (Read collaborator on the repo)
  Fider role:   Collaborator (with one-time Administrator promotion
                to create the five private tags)
  Secret:       FIDER_API_KEY (personal access token)

* fix(fider-stale): create the 'stale' tag on first run + share ensureTags

The first live stale-sweep run failed with 404 on tag application
because the 'stale' tag didn't exist on Fider yet — the script never
called the equivalent of fider-triage's ensureTags(). Both scripts
now use a single shared helper:

- New export ensureTags({ fider, log, dryRun, host, tags }) in
  tools/fider-shared.mjs that idempotently creates a list of tags
  and converts the 403-on-Collaborator failure into the same
  promote-then-demote instructional error message both scripts
  used to inline.
- fider-stale.mjs now calls it for the 'stale' tag (color e74c3c).
- fider-triage.mjs's local ensureTags shrinks from 36 lines to a
  10-line invocation passing its four tag definitions.

Net: -23 lines despite adding tag creation to fider-stale.

* feat(fider): replace cc:@user comment prefix with Discord notifications

The cc: prefix was cosmetic — Fider doesn't send notifications on
@mentions, so maintainers never saw the bot's output until they opened
the post directly. Two visible bugs in the live runs (bot included
itself as @maintainerr-fider-bot, ydkmlt84 listed twice because of
two Fider accounts under the same display name) were also confusing.

Replace it with a Discord webhook integration.

## What changed

- New notifyDiscord helper in fider-shared.mjs. Posts a coloured embed
  to a webhook URL on each meaningful bot action: possibly-completed,
  possibly-pre-existing, possibly-duplicate, stale-warned, and
  stale-declined. Embed contains the post title (clickable), the cited
  PR / quote / vote count, and a colour matching the Fider tag.
- Optional DISCORD_PING_ROLE_ID — when set, the message also includes
  <@&ROLE_ID> in the content field so a Discord role gets pinged
  (embeds alone never trigger Discord notifications). allowed_mentions
  is scoped to that single role so it can never accidentally @everyone.
- All buildMentionPrefix / withMentionPrefix / FIDER_BOT_USERNAME /
  FIDER_MENTION_USERS_FALLBACK code removed from triage and stale
  scripts. Comments now post without a cc line.
- buildMentionPrefix and buildMentionToken removed from
  tools/fider-shared.mjs (they were the only consumers).

## Workflow plumbing

All four runtime workflows (fider-triage, fider-re-evaluate,
fider-pre-existing-scan, fider-stale) pass two new env vars:
  DISCORD_FIDER_BOT_WEBHOOK  ← repo secret
  DISCORD_PING_ROLE_ID       ← repo secret (treated as sensitive too)
Both default to empty: webhook empty = no notifications,
role ID empty = embed-only with no @-ping. The bot's Fider work always
happens; Discord is best-effort and never throws on failure.

## Setup required by maintainers

1. Create a Discord webhook in the target channel
   (Server Settings → Integrations → Webhooks → New Webhook),
   copy the URL, store as repo secret DISCORD_FIDER_BOT_WEBHOOK.
2. (Optional) Create or pick a "maintainers" role in the Discord
   server, copy its numeric snowflake ID (Developer Mode →
   right-click role → Copy ID), store as repo secret
   DISCORD_PING_ROLE_ID. Members of that role get pinged on every
   bot action.

* feat: add ntfy support (#2769)

* fix: stop cross-rule contamination for same-titled automatic collections (#2766)

* fix: stop cross-rule contamination for same-titled automatic collections

Two automatic rule groups with the same title end up linked to the same
media server collection because checkAutomaticMediaServerLink relinks by
name. The rule executor's manual-import path then imported the sibling
rule's items as manual into the wrong rule, subjecting them to the
wrong deleteAfterDays. Whichever rule emptied first would also delete
the shared media server collection in removeFromCollection /
checkAutomaticMediaServerLink, taking the sibling rule's items with it.

Skip the manual import for items already rule-owned by a sibling
collection sharing the same mediaServerId, and guard the two empty-
collection deletes against shared links by unlinking locally instead.

Refs #2765

* fix: resync rule-owned items + close sibling-lookup gaps

Adds three corrections on top of the same-title automatic collection
contamination fix:

- Partial drift: checkAutomaticMediaServerLink now diffs server children
  against local rule-owned items for shared automatic collections (not
  just empty ones) and pushes any missing back via addBatchToCollection.
  Fixes the "Plex shows 1 of 5 items, Maintainerr DB shows 5" case where
  the rule executor's local-DB-only delta can't recover server drift.
- Sibling lookup error path: getSiblingRuleOwnedMediaServerIds no longer
  swallows DB errors into an empty Set. The rule executor catches the
  throw and skips manual child import, since a silent empty would let
  sibling-owned children be imported as manual into the wrong rule.
- Cross-type sharing: isMediaServerCollectionShared and
  getSiblingRuleOwnedMediaServerIds now filter siblings by
  manualCollection so a manual collection sharing a mediaServerId can no
  longer be treated as a sibling automatic rule group.

* ci(docs-drift): flag user-facing fix commits and default to latest release

- Adds a "Behavioral fixes worth reviewing" section that scans fix:
  commits and surfaces the ones touching user-visible surfaces (UI,
  settings, notifications, collections, rule executor, controllers,
  README) so behavior-changing fixes don't slip past the drift report.
  Each entry lists the touched files for cheap triage.
- workflow_dispatch base_ref is now a small choice dropdown (latest /
  origin/main / origin/development) defaulting to 'latest', which the
  resolve step expands to the most recent GitHub release tag at run
  time. No per-version maintenance burden.

* fix(notifications): dedupe sibling-rule media events within a batch

When two automatic rule groups share a media server collection (same
title in the same library), each emits its own CollectionMedia_Added /
_Removed for the same item, so the user sees N identical notifications
for what is one user-visible change. Dedupe these within a single
rule-executor batch (one processQueue() pass), keyed on
(event, collectionName, mediaServerId).

Notifications across separate batches (e.g. scheduled runs at different
times) are never collapsed — those are genuinely distinct events.
Manual / user-triggered notifications are unaffected because dedupe
lives in the @onevent handlers, not in handleNotification itself.

Reuses the existing RuleHandlerQueue_StatusUpdated event for batch
lifecycle so no new contracts surface is added. Adds a defensive
emitStatusUpdate() to processQueue's outer finally so listeners can
observe the processingQueue=true→false transition reliably (previously
no event fired on drain). Verified safe under stop/abort: the same
finally path runs and clears dedupe state.

* chore: add workspace MCP configuration

---------

Co-authored-by: maintainerr-automation[bot] <261505141+maintainerr-automation[bot]@users.noreply.github.com>
Co-authored-by: enoch85 <mailto@danielhansson.nu>
Co-authored-by: Gitro <108683123+GitGitro@users.noreply.github.com>
@enoch85 enoch85 added this to the 3.9.0 milestone Apr 28, 2026 — with GitHub Codespaces
@maintainerr-automation

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 3.9.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

enoch85 added a commit that referenced this pull request Jun 13, 2026
…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
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.

Rules targeting same Plex collection are populating both Maintainerr collections

2 participants