fix: stop cross-rule contamination for same-titled automatic collections#2766
Conversation
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
|
/release-pr |
|
Released to |
|
@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. |
2511471 to
50e13ba
Compare
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.
|
/release-pr |
|
Released to |
|
@SHxKM Please test the latest changes.
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. |
|
Thanks @enoch85.
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. |
|
All tests pass here, and I've been over it a couple of times. Merging to |
* 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>
|
🎉 This PR is included in version 3.9.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
…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
* 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>
Summary
Fixes #2765. Two automatic rule groups with the same title currently end up linked to the same media server collection (because
checkAutomaticMediaServerLinkrelinks by name), and the rule executor's manual-import path then pulls the sibling rule's items into the wrong rule'scollection_mediaasmanual. The collection worker doesn't filter on membership type, so those items get handled at the wrong rule'sdeleteAfterDays. 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:
CollectionsService.getSiblingRuleOwnedMediaServerIds(collection)returns the set of media server IDs that are rule-owned by any other Maintainerr collection sharing this collection'smediaServerId.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.removeFromCollectionandcheckAutomaticMediaServerLinknow checkisMediaServerCollectionSharedbefore 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— cleanyarn build— clean