chore: ban em/en dashes, convert existing source usages to hyphens (#3212)
Release 1 - Prepare PR / Guard release branch (push) Has been cancelled
Release 1 - Prepare PR / Create or update release PR (push) Has been cancelled
Release 1 - Prepare PR / guard_manual_run (push) Has been cancelled
Build Development / guard_manual_run (push) Has been cancelled
Build Development / Build linux/arm64 (push) Has been cancelled
Build Development / Build linux/amd64 (push) Has been cancelled
Build Development / Merge Docker digests and push (push) Has been cancelled

* chore: ban em/en dashes and convert existing usages to hyphens

Replaces every em dash (U+2014) and en dash (U+2013) with a plain ASCII hyphen
across TypeScript/TSX and CSS source (comments, log and UI strings, tests), and
adds the convention to AGENTS.md and project-notes.instructions.md so new code
keeps to a single ASCII dash. Purely mechanical in source; no behavior change.

Markdown prose was intentionally left out of scope.

* chore: extend em/en dash ban to docs, markdown, workflows, and tooling

Complete the sweep beyond source: convert em (U+2014) and en (U+2013)
dashes to plain hyphens across docs/, instruction files, .github
workflows, tooling scripts, CHANGELOG, and config. The two lines that
document the ban by showing the banned characters as examples
(AGENTS.md, project-notes.instructions.md) are intentionally preserved.
This commit is contained in:
enoch85
2026-07-03 23:57:16 +02:00
committed by GitHub
parent 2d12a0172f
commit b83ef448a5
184 changed files with 815 additions and 807 deletions
+2 -2
View File
@@ -1,3 +1,3 @@
At the start of every conversation, read [AGENTS.md](../../AGENTS.md) first it indexes all project docs: implementation guidance, conventions, architecture, and the release-review checklist.
At the start of every conversation, read [AGENTS.md](../../AGENTS.md) first - it indexes all project docs: implementation guidance, conventions, architecture, and the release-review checklist.
Before writing any code, read and follow the standing rules: [.github/instructions/implementation.instructions.md](../../.github/instructions/implementation.instructions.md) and [.github/instructions/project-notes.instructions.md](../../.github/instructions/project-notes.instructions.md). Other docs in `.github/instructions/` are task-specific read them when the task calls for it (AGENTS.md lists which).
Before writing any code, read and follow the standing rules: [.github/instructions/implementation.instructions.md](../../.github/instructions/implementation.instructions.md) and [.github/instructions/project-notes.instructions.md](../../.github/instructions/project-notes.instructions.md). Other docs in `.github/instructions/` are task-specific - read them when the task calls for it (AGENTS.md lists which).
+2 -2
View File
@@ -3,6 +3,6 @@ description: Project entrypoint and standing rules
alwaysApply: true
---
At the start of every conversation, read [AGENTS.md](../../AGENTS.md) first it indexes all project docs: implementation guidance, conventions, architecture, and the release-review checklist.
At the start of every conversation, read [AGENTS.md](../../AGENTS.md) first - it indexes all project docs: implementation guidance, conventions, architecture, and the release-review checklist.
Before writing any code, read and follow the standing rules: [.github/instructions/implementation.instructions.md](../../.github/instructions/implementation.instructions.md) and [.github/instructions/project-notes.instructions.md](../../.github/instructions/project-notes.instructions.md). Other docs in `.github/instructions/` are task-specific read them when the task calls for it (AGENTS.md lists which).
Before writing any code, read and follow the standing rules: [.github/instructions/implementation.instructions.md](../../.github/instructions/implementation.instructions.md) and [.github/instructions/project-notes.instructions.md](../../.github/instructions/project-notes.instructions.md). Other docs in `.github/instructions/` are task-specific - read them when the task calls for it (AGENTS.md lists which).
+1 -1
View File
@@ -7,7 +7,7 @@ FROM node:${NODE_VERSION}-bookworm
# - python3, make, g++: Required for building native node modules (like sqlite3)
# - curl, wget: Useful for downloading files
# - git: Already in base image but ensuring latest version
# Use https apt mirrors the host egress firewall blocks outbound port 80, so
# Use https apt mirrors - the host egress firewall blocks outbound port 80, so
# plain http://deb.debian.org would hang. (bookworm uses deb822 debian.sources.)
RUN for f in /etc/apt/sources.list /etc/apt/sources.list.d/debian.sources; do \
[ -f "$f" ] && sed -i 's|http://deb.debian.org|https://deb.debian.org|g; s|http://security.debian.org|https://security.debian.org|g' "$f"; \
+2 -2
View File
@@ -1,3 +1,3 @@
At the start of every conversation, read [AGENTS.md](../AGENTS.md) first it indexes all project docs: implementation guidance, conventions, architecture, and the release-review checklist.
At the start of every conversation, read [AGENTS.md](../AGENTS.md) first - it indexes all project docs: implementation guidance, conventions, architecture, and the release-review checklist.
Before writing any code, read and follow the standing rules: [.github/instructions/implementation.instructions.md](instructions/implementation.instructions.md) and [.github/instructions/project-notes.instructions.md](instructions/project-notes.instructions.md). Other docs in `.github/instructions/` are task-specific read them when the task calls for it (AGENTS.md lists which).
Before writing any code, read and follow the standing rules: [.github/instructions/implementation.instructions.md](instructions/implementation.instructions.md) and [.github/instructions/project-notes.instructions.md](instructions/project-notes.instructions.md). Other docs in `.github/instructions/` are task-specific - read them when the task calls for it (AGENTS.md lists which).
@@ -14,15 +14,15 @@ Read [ARCHITECTURE.md](../../ARCHITECTURE.md) for the system architecture overvi
## General
When implementing against any external API or SDK (Plex, Jellyfin, TypeORM, etc.), read the official API documentation to confirm behaviour. Do not guess or assume facts only, based on current documentation.
When implementing against any external API or SDK (Plex, Jellyfin, TypeORM, etc.), read the official API documentation to confirm behaviour. Do not guess or assume - facts only, based on current documentation.
### Workspace MCP servers
- Workspace MCP config lives in `.vscode/mcp.json` (VS Code), `.mcp.json` (Claude Code), and `.codex/config.toml` (Codex). Keep all three in sync.
- `github` MCP: read-only use for live GitHub context, never for writes.
- `playwright` MCP: use for browser-driven UI validation. **Screenshots must be saved as `filename: ".playwright-mcp/<name>.png"`** bare filenames land at the repo root (the `--output-dir` flag doesn't apply to explicit filenames).
- **Browser:** the MCP uses the **system Chromium baked into the devbox image** (`/usr/bin/chromium`, via `--executable-path` + `--no-sandbox` because the container runs as root). It does **not** download a browser per-session the per-session CDN download is blocked by the IPv4-only egress firewall (the CDN redirects to IPv6 hosts with no route). If `/usr/bin/chromium` is missing, the devbox image needs rebuilding (it's installed in `infra/Dockerfile.agents`).
- **These stdio MCP servers only load when your AI client (VS Code, Claude Code, or Codex) is launched from inside devbox at `/workspace`** `npx` (the server command) is only on PATH in the container, and the client must start at the repo root to pick up its mcp config. Launched on the bare host or from another directory, the `playwright`/`github` servers never register. Reload the editor / restart the client session after editing any mcp config.
- `github` MCP: read-only - use for live GitHub context, never for writes.
- `playwright` MCP: use for browser-driven UI validation. **Screenshots must be saved as `filename: ".playwright-mcp/<name>.png"`** - bare filenames land at the repo root (the `--output-dir` flag doesn't apply to explicit filenames).
- **Browser:** the MCP uses the **system Chromium baked into the devbox image** (`/usr/bin/chromium`, via `--executable-path` + `--no-sandbox` because the container runs as root). It does **not** download a browser per-session - the per-session CDN download is blocked by the IPv4-only egress firewall (the CDN redirects to IPv6 hosts with no route). If `/usr/bin/chromium` is missing, the devbox image needs rebuilding (it's installed in `infra/Dockerfile.agents`).
- **These stdio MCP servers only load when your AI client (VS Code, Claude Code, or Codex) is launched from inside devbox at `/workspace`** - `npx` (the server command) is only on PATH in the container, and the client must start at the repo root to pick up its mcp config. Launched on the bare host or from another directory, the `playwright`/`github` servers never register. Reload the editor / restart the client session after editing any mcp config.
### API documentation references
@@ -48,9 +48,9 @@ When implementing against any external API or SDK (Plex, Jellyfin, TypeORM, etc.
2. Follow repository copilot instructions and existing project conventions.
3. Keep separation of concerns clear and maintenance burden low.
4. Match existing codebase patterns and avoid regressions or unnecessary abstraction.
5. UI components: favor reusable, consistent components and solid React patterns. Promote shared helpers and modals from `apps/ui/src/components/Common/` where they exist for example use `SaveButton` and `TestingButton` instead of rolling custom save/test buttons.
6. Media server abstraction: keep `modules/api/media-server/` server-agnostic. The interface (`media-server.interface.ts`), factory, controller, and shared utilities must never import or reference Plex/Jellyfin types directly. Use `supportsFeature()` for conditional behaviour never branch on server type in the shared layer. All server-specific logic (constants, mappers, batch sizes, caching, SDK calls) belongs exclusively in `plex/` or `jellyfin/`. Mappers are type-conversion only no business logic. Any new method added to the abstracted layer must be implemented by all media servers partial support belongs behind `supportsFeature()`, not in the interface itself.
5. UI components: favor reusable, consistent components and solid React patterns. Promote shared helpers and modals from `apps/ui/src/components/Common/` where they exist - for example use `SaveButton` and `TestingButton` instead of rolling custom save/test buttons.
6. Media server abstraction: keep `modules/api/media-server/` server-agnostic. The interface (`media-server.interface.ts`), factory, controller, and shared utilities must never import or reference Plex/Jellyfin types directly. Use `supportsFeature()` for conditional behaviour - never branch on server type in the shared layer. All server-specific logic (constants, mappers, batch sizes, caching, SDK calls) belongs exclusively in `plex/` or `jellyfin/`. Mappers are type-conversion only - no business logic. Any new method added to the abstracted layer must be implemented by all media servers - partial support belongs behind `supportsFeature()`, not in the interface itself.
7. Contracts package: any new DTOs or request/response shapes should be deliberate and minimal.
8. Database/migrations: if persistence changes are needed, keep migrations safe, reversible, and edge-case aware. All migrations MUST be generated and run via TypeORM never manually crafted SQL. You MUST always follow [typeorm_instructions.txt](../../typeorm_instructions.txt) for migration commands and workflow. A migration is NEVER considered working until it has been tested run it end-to-end and verify the result before treating it as done.
8. Database/migrations: if persistence changes are needed, keep migrations safe, reversible, and edge-case aware. All migrations MUST be generated and run via TypeORM - never manually crafted SQL. You MUST always follow [typeorm_instructions.txt](../../typeorm_instructions.txt) for migration commands and workflow. A migration is NEVER considered working until it has been tested - run it end-to-end and verify the result before treating it as done.
9. Rules/metadata systems: make sure any cache invalidation approach stays consistent with existing getter/provider patterns.
10. Rule naming standards: preserve established rule `name` and `humanName` conventions for equivalent concepts across media servers. Do not rename user-facing rule labels to encode backend caveats; keep naming stable and document server-specific semantics in code comments and focused tests instead.
@@ -2,12 +2,12 @@
applyTo: "**"
---
# Maintainerr Project Notes & Handoff Knowledge
# Maintainerr - Project Notes & Handoff Knowledge
Hard-won, non-obvious knowledge about this codebase that isn't derivable from the
code, git history, or [ARCHITECTURE.md](../../ARCHITECTURE.md). Accumulated while
working on the rule engine, media-server integrations, the Tailwind v4 migration,
and the build/migration toolchain. Agent-neutral meant to be read by Claude,
and the build/migration toolchain. Agent-neutral - meant to be read by Claude,
GitHub Copilot, and Codex.
Read [ARCHITECTURE.md](../../ARCHITECTURE.md) for the system map and
@@ -19,10 +19,10 @@ covers the "why" and the traps those don't.
## Conventions (apply to all committed artifacts)
These are project conventions, established through review feedback. They apply to
code, comments, tests, fixtures, docs, commit messages, and PR bodies not to
code, comments, tests, fixtures, docs, commit messages, and PR bodies - not to
conversational chat.
- **Call the request service "Seerr" never "Overseerr" or "Jellyseerr".**
- **Call the request service "Seerr" - never "Overseerr" or "Jellyseerr".**
Maintainerr abstracts both behind a single "Seerr" layer
(`modules/api/seerr-api/`, `SeerrApiService`). Naming one leaks implementation
and implies favoritism. Even when verifying behavior against a specific
@@ -41,25 +41,32 @@ conversational chat.
rather than `.replace(/\/+$/, '')`. Rationale: explicit, no hidden engine cost,
no surprises on empty input or unicode.
- **Never use em or en dashes; always use a plain hyphen `-`.** This applies to
every committed artifact: code, comments, log and UI strings, tests, commit
messages, and docs. Do not paste `—` (U+2014) or `` (U+2013); type `-`
(U+002D). Disable any "smart dash" autocorrect in your editor. Rationale:
a single ASCII dash keeps logs, diffs, and greps consistent and copy-paste
safe. To find stragglers: `git ls-files | xargs grep -lP '[\x{2013}\x{2014}]'`.
- **Prefer the codebase's existing idiom over adding a dependency.** When a
feature request or issue names a specific library (e.g. `p-limit` for bounded
concurrency), check first whether the repo already solves it. It does for
bounded concurrency: chunk + `Promise.all` (see `addBatchToCollection` and the
metadata-refresh loop). Only add a dep when the existing idiom genuinely can't
do the job, and say why. Such library suggestions in issues are often
AI-sourced don't take them at face value.
AI-sourced - don't take them at face value.
- **Scope discipline.** Separate the actual blocker from cosmetic noise and fix
only what's needed. Before expanding scope (new deps, transformer swaps,
cross-cutting refactors), stop and confirm don't turn a warning fix into an
cross-cutting refactors), stop and confirm - don't turn a warning fix into an
architecture change. A known-benign warning can be left as a visible reminder
for a future dedicated overhaul rather than masked.
- **But finish the job within the area you're touching.** If you're already in a
subsystem and spot a _real_ correctness bug adjacent to your change, fix it and
add coverage "pre-existing" is not an excuse to leave a known bug. The
add coverage - "pre-existing" is not an excuse to leave a known bug. The
distinction from scope discipline: real-bug-in-scope → fix it; benign-noise →
leave it. For data changes, prefer **minimal behavioral change** preserve how
leave it. For data changes, prefer **minimal behavioral change** - preserve how
existing data evaluates today, making values explicit rather than flipping
behavior.
@@ -70,12 +77,12 @@ Kept for continuity; the next maintainer may adjust these.
- **PRs:** brief body (what changed and why, a few bullets). No "Test plan"
section. No Claude Code attribution footer or `Co-Authored-By: Claude`.
Validation is done manually + via CI.
- **Updating a PR branch:** rebase onto latest origin first (PR branches drift
- **Updating a PR branch:** rebase onto latest origin first (PR branches drift -
one was 20 commits behind by the time edits finished), fold into one clean
commit (`git reset --soft` then a single commit), run the **full** repo suite
(`yarn turbo test`, not just affected specs), then push. Verify
`git rev-list --left-right --count origin/<branch>...HEAD` is `0 N` (clean
fast-forward, no force-push). Keep diffs minimal don't rename existing
fast-forward, no force-push). Keep diffs minimal - don't rename existing
variables without cause.
---
@@ -84,7 +91,7 @@ Kept for continuity; the next maintainer may adjust these.
### Tailwind CSS v4 (CSS-first)
The UI is on **Tailwind v4, CSS-first** there is **no `tailwind.config.js`**.
The UI is on **Tailwind v4, CSS-first** - there is **no `tailwind.config.js`**.
- Built via the `@tailwindcss/vite` plugin (not PostCSS). No `postcss.config`,
no `autoprefixer`, no `@tailwindcss/aspect-ratio`.
@@ -101,25 +108,25 @@ The UI is on **Tailwind v4, CSS-first** — there is **no `tailwind.config.js`**
- `bg-opacity-*` / `text-opacity-*` don't exist → use the `/<n>` slash syntax
(`bg-zinc-900/80`). The old opacity classes render fully opaque, silently.
- `@tailwindcss/forms` is loaded with `strategy: base` on purpose its default
- `@tailwindcss/forms` is loaded with `strategy: base` on purpose - its default
strategy registers a `.form-input` utility that collides with (and, under v4
layer ordering, beats) the app's own `.form-input` component class. Don't
change the strategy.
- Checkbox focus ring = blue ring + **white offset ring**; `focus:ring-0` alone
leaves the white offset you also need `focus:ring-offset-0`.
leaves the white offset - you also need `focus:ring-offset-0`.
- `@tailwindcss/typography` prose colors must be set via `--tw-prose-*` vars in an
**unlayered** `.prose` rule (layered rules lose to the plugin's own `.prose`).
- v4 class names: `shadow-sm` (not `shadow`), `rounded-sm` (not `rounded`),
`outline-hidden` (not `outline-none`), `bg-linear-to-*` (not `bg-gradient-to-*`).
- **Checkbox standard:** there is one `.checkbox` class in globals.css
(`@layer components`). Every `<input type="checkbox">` uses
`className="checkbox"` and nothing else it bundles size/rounding/maintainerr
`className="checkbox"` and nothing else - it bundles size/rounding/maintainerr
fill/zinc border + the no-ring focus fix. Don't reintroduce per-checkbox inline
styling.
**v4.x features in use:**
- `color-scheme: dark` is set on `html` (base layer) native controls render
- `color-scheme: dark` is set on `html` (base layer) - native controls render
dark; don't re-add per-control dark hacks.
- The `ul.cards-vertical` grid breakpoint is a **`@container` query** (440px);
Overview/Content wraps the grid in `<div className="@container">`. It sizes to
@@ -129,7 +136,7 @@ The UI is on **Tailwind v4, CSS-first** — there is **no `tailwind.config.js`**
- `text-shadow-sm` on MediaCard poster-overlaid year/title/summary.
- **Default border color is `currentColor`** (v4 Preflight `border:0 solid`;
there is no compat shim). If you add a bordered element, give it an explicit
`border-*` color the app standard divider is `border-zinc-700`.
`border-*` color - the app standard divider is `border-zinc-700`.
### UI implementation conventions
@@ -141,13 +148,13 @@ The UI is on **Tailwind v4, CSS-first** — there is **no `tailwind.config.js`**
`SaveButton` and `TestingButton`, instead of recreating equivalent markup and
Tailwind classes inline. If something is missing, extend the shared primitive
rather than introducing a one-off version in a page component.
- **DRY** no one-off duplicated feedback/loading patterns. Use
- **DRY** - no one-off duplicated feedback/loading patterns. Use
`apps/ui/src/components/Settings/useSettingsFeedback.tsx` for inline page
feedback (not toasts) on normal settings saves. For joined field layouts and
prefix/suffix adornments, compose the `FieldJoin` / `SelectGroup` /
`*Adornment` components above rather than repeating their Tailwind classes
inline.
- **Avoid `useEffect` and `useCallback`** prefer derived values, event
- **Avoid `useEffect` and `useCallback`** - prefer derived values, event
handlers, and library-native reactive APIs (e.g. react-hook-form's `values`
option to sync a form to loaded data instead of `useEffect(reset)`). An effect
that resets/derives state from an unstable reference (a hook returning a fresh
@@ -172,7 +179,7 @@ Value-comparison operators (BEFORE, AFTER, EQUALS, NOT_EQUALS, …) stay
Why it matters: special-casing BEFORE-on-null for temporal properties
(`lastViewedAt`, `sw_lastWatched`) makes `NOT_EQUALS <date>` match every
never-watched item a semantic expansion, not a fix. The correct layer for
never-watched item - a semantic expansion, not a fix. The correct layer for
"never watched" is the **getter contract** (null = confirmed absent,
undefined = error), not the comparator.
Users get "never watched OR older than X days" by composing
@@ -183,7 +190,7 @@ what BEFORE/AFTER/EQUALS/NOT_EQUALS do for null/undefined inputs.
Each rule section combines with the previous via the operator on the section's
**first** rule. An unset operator is stored as `null`, so the comparator must
null-guard before coercing `+null === 0` is `true` in JS, which would
null-guard before coercing - `+null === 0` is `true` in JS, which would
otherwise read an unset operator as AND. Within-section default is OR;
section-boundary default is AND. YAML export/import must use a **null check**
(not a truthy check) for the operator, since AND is `0` and would be dropped.
@@ -192,9 +199,9 @@ section-boundary default is AND. YAML export/import must use a **null check**
- **Operand resolution runs in bounded-parallel batches.**
`rule.comparator.service.executeRule` resolves firstVal/secondVal for all items
in chunks via `Promise.all` (the codebase idiom not `p-limit`), then runs the
in chunks via `Promise.all` (the codebase idiom - not `p-limit`), then runs the
mutation loop. Knob: `RULE_EVALUATION_CONCURRENCY` in `rules.constants.ts`
(default **8**). Keep this as a **single global batching layer** do not nest
(default **8**). Keep this as a **single global batching layer** - do not nest
it inside getters, or you get N×N fan-out.
- **Don't raise the concurrency.** 8 is deliberate: higher values over-drive a
constrained all-in-one NAS's Tautulli history queries past the request timeout,
@@ -211,15 +218,15 @@ section-boundary default is AND. YAML export/import must use a **null check**
the full library can OOM large runs, and removal paths do not need fully
populated reasons.
- All rule caches are in-memory and `cacheManager.flushAll()` runs at every
rule-group run start (persistent metadata caches like tmdb/tvdb are exempt)
rule-group run start (persistent metadata caches like tmdb/tvdb are exempt) -
runs are cold-start by design; only SQLite persists.
### Streamystats watchlist rules Jellyfin only
### Streamystats watchlist rules - Jellyfin only
`Application.STREAMYSTATS` (enum id **8**, see `packages/contracts/src/rules/`)
is the Jellyfin analog of Tautulli-for-Plex. Two properties:
`isInWatchlist` (BOOL) and `watchlistedByUsers` (TEXT_LIST). There is
intentionally no `isPromoted` property promoted-only-private lists are
intentionally no `isPromoted` property - promoted-only-private lists are
unreachable with the auth Maintainerr has (see below).
Non-obvious facts:
@@ -236,7 +243,7 @@ Non-obvious facts:
resolves to a `system-api-key` pseudo-user → **only PUBLIC watchlists are
reachable**. Private/promoted-only lists are intentionally invisible.
- The membership snapshot is cached in the shared `streamystats` NodeCache (key
`watchlist-membership`), which is `flushAll()`'d between rule-group runs the
`watchlist-membership`), which is `flushAll()`'d between rule-group runs - the
correct lifecycle (no hand-rolled `Date.now()` memo). TTL/key constants live in
`modules/api/streamystats-api/streamystats-api.constants.ts`.
- The getter returns `undefined` (transient skip) when membership is null;
@@ -255,26 +262,26 @@ Non-obvious facts:
app behaves as if the media server is live and returning values. Each scenario
lists `rules` (sections + operators) and `values` (shifted per getter call, in
rule order). It is a **script that prints JSON** (`yarn workspace
@maintainerr/server test:e2e`), meant for cross-refactor comparison add
@maintainerr/server test:e2e`), meant for cross-refactor comparison - add
scenarios here to regression-test comparator / section-combine behavior end-to-end
through the HTTP path. Use this when you want "real results" without standing up a
mock HTTP media server. (For a fuller live setup, see the dev mocks below.)
### Testing YAML import/export and community-rule import
Two distinct import paths they do **not** share code, so test both:
Two distinct import paths - they do **not** share code, so test both:
- **YAML file export/import** → `POST /api/rules/yaml/encode` and `/yaml/decode`.
`decode` runs `getCustomValueFromIdentifier` (it does not).
- **Community rules** → fetched from `https://community.maintainerr.info`, served
by `GET /api/rules/community` as `JsonRules` (an **array**, already parsed not
by `GET /api/rules/community` as `JsonRules` (an **array**, already parsed - not
a string; `repr()` just makes it look Python-styled). Imported via the
cross-server converter `POST /api/rules/migrate` (`migrateImportedRuleDtos`).
This path never touches the YAML decoder.
No media server is required for either (both are parse + migrate, no live getter
calls); just `yarn dev`. `mediaType` is the **string** union (`"movie"`/`"show"`,
from `@maintainerr/contracts`), not a number encode/decode reject a mismatch.
from `@maintainerr/contracts`), not a number - encode/decode reject a mismatch.
Quick checks (Jellyfin server configured):
@@ -319,8 +326,8 @@ Quick checks (Jellyfin server configured):
404/empty result and **throws** on anything inconclusive; callers default to
"present" on throw (`let exists = true; try { … } catch { logger.debug }`) so
uncertainty never deletes. It's the single existence primitive on the shared
interface consumed by the collection handler, `removeStaleCollectionMedia`,
and the overlay processor so don't reintroduce a per-subsystem copy.
interface - consumed by the collection handler, `removeStaleCollectionMedia`,
and the overlay processor - so don't reintroduce a per-subsystem copy.
---
@@ -328,20 +335,20 @@ Quick checks (Jellyfin server configured):
When extracting transport shapes (Zod schemas, request/response DTOs) into
`@maintainerr/contracts`, **design fresh contract-owned DTOs** that describe the
actual API payload. Do **not** promote server-side interfaces verbatim contracts
actual API payload. Do **not** promote server-side interfaces verbatim - contracts
must stay transport-only.
- Server interfaces (e.g. `ICollection` in
`modules/collections/interfaces/collection.interface.ts`) carry server-only
concerns (entity refs, `media?: CollectionMedia[]`). Hoisting those into
contracts would force contracts to depend on server concerns wrong direction.
contracts would force contracts to depend on server concerns - wrong direction.
- Audit referenced types before moving a schema. If they pull in entity classes or
ORM-shaped fields, don't promote as-is; define a plain DTO/Zod schema in
contracts capturing only the payload, then adapt the server type to map into or
extend it.
- Types already transport-oriented (e.g. `CollectionMediaChange` in
`modules/collections/interfaces/collection-media.interface.ts`) are clean
promotion candidates. This is a multi-PR effort untangle ownership first, then
promotion candidates. This is a multi-PR effort - untangle ownership first, then
migrate schemas.
- **Build contracts before trusting downstream errors.** When shared types change
in `packages/contracts`, run the contracts build first. Server/UI diagnostics
@@ -355,8 +362,8 @@ must stay transport-only.
### Jest transform & circular imports
Server tests run through **`@swc/jest`** (not ts-jest). The codebase has
circular dependencies `forwardRef(() => X)` constructor injections plus
bidirectional TypeORM entity relations kept SWC-safe via TypeORM `Relation<>`
circular dependencies - `forwardRef(() => X)` constructor injections plus
bidirectional TypeORM entity relations - kept SWC-safe via TypeORM `Relation<>`
wrappers on relation props and type-only import aliases on `forwardRef`-injected
constructor params (DI tokens unchanged). **If you add a cross-module import that
closes a cycle, follow that pattern**, or SWC's strict CommonJS live bindings
@@ -372,29 +379,29 @@ standard Jest-under-Node entrypoints work without a PnP shim.
The server is on **TypeORM 1.x** (`better-sqlite3`). Two conventions to follow
when writing repository code:
- **`relations` and `select` use the object form**
- **`relations` and `select` use the object form** -
`relations: { ruleGroup: true }`, not the array form. `find`/`findOne` only
accept objects.
- **Never put a bare `null`/`undefined` in a `where`.** TypeORM 1.x throws on
them (the default `invalidWhereValuesBehavior` is `throw`, and we keep that
default rather than masking it). To match SQL `NULL`, use `IsNull()`
default rather than masking it). To match SQL `NULL`, use `IsNull()` -
e.g. `where: { ruleGroupId: IsNull() }`, `where: { sizeBytes: Not(IsNull()) }`.
For an optional value that may be absent, omit the key (conditional spread:
`...(x !== undefined ? { x } : {})`) or guard before querying don't pass it
`...(x !== undefined ? { x } : {})`) or guard before querying - don't pass it
through. `where: {}` (no keys) is fine for the settings-singleton lookups.
Note: a bare `null` does **not** mean "ignore the filter" older code that
Note: a bare `null` does **not** mean "ignore the filter" - older code that
relied on that was a latent bug (it matched everything); use `IsNull()`.
### Writing DATA migrations (backfills, no schema change)
- `migration:generate` **cannot** produce them it diffs entity metadata vs DB
- `migration:generate` **cannot** produce them - it diffs entity metadata vs DB
and reports "No changes in database schema were found." Scaffold with
`migration:create src/database/migrations/<Name>`.
- Write `up()` using TypeORM's **QueryBuilder**
(`queryRunner.manager.createQueryBuilder()…`), **not** raw
`queryRunner.query('SELECT/UPDATE…')` the implementation rules forbid manually
`queryRunner.query('SELECT/UPDATE…')` - the implementation rules forbid manually
crafted SQL.
- Migration **spec files must NOT live under `src/database/migrations/`** the
- Migration **spec files must NOT live under `src/database/migrations/`** - the
datasource glob (`src/database/migrations/**/*.ts`) makes the `migration:run`
CLI compile them with ts-node and fail on jest globals. Put them in a sibling
dir like `src/database/migration-tests/` (jest rootDir=src still finds them).
@@ -402,7 +409,7 @@ when writing repository code:
type:'better-sqlite3', database:':memory:', entities:[], synchronize:false })`,
create the table, run `migration.up(queryRunner)`, assert. QueryBuilder-on-table-
name needs no entity registration.
- `apps/server/.gitignore` ignores `/dist-test` (output of `test:e2e` tsc) don't
- `apps/server/.gitignore` ignores `/dist-test` (output of `test:e2e` tsc) - don't
`git add -A` blindly after `test:e2e`.
### TypeORM migration CLI workaround
@@ -443,19 +450,19 @@ schema were found."** `data/maintainerr.sqlite` is gitignored.
## Local dev: seeded DB + mock media servers
For end-to-end checks of media-server-dependent flows (rules, collections,
overview, storage) without a real Plex/Jellyfin and to drive the UI with
overview, storage) without a real Plex/Jellyfin - and to drive the UI with
Playwright against deterministic data. Full workflow is in
[AGENTS.md](../../AGENTS.md); the scripts live in `tools/dev/`:
- `tools/dev/fake-jellyfin.mjs` stateless mock Jellyfin (`:8096`). Answers the
- `tools/dev/fake-jellyfin.mjs` - stateless mock Jellyfin (`:8096`). Answers the
real `@jellyfin/sdk` paths the adapter calls: `GET /System/Info/Public`,
`GET /Users` (must include a `Policy.IsAdministrator` user), `GET
/Library/MediaFolders` (library ids must be `jellyfin-movies`/`jellyfin-shows`
to match the seed), and `GET /Items?…&ids=<id>` (the LIST form getMetadata
to match the seed), and `GET /Items?…&ids=<id>` (the LIST form - getMetadata
hydration uses this, not `/Items/{id}`). Item images 302-redirect to picsum.
- `tools/dev/fake-plex.mjs` stateless mock Plex (`:32400`); covers the Plex-only
- `tools/dev/fake-plex.mjs` - stateless mock Plex (`:32400`); covers the Plex-only
getter paths.
- `tools/dev/fake-radarr.mjs` mock Radarr v3 (`:7878`). The media-server mocks
- `tools/dev/fake-radarr.mjs` - mock Radarr v3 (`:7878`). The media-server mocks
don't cover \*arr, so the collection-handler → RadarrActionHandler flow
(DELETE / UNMONITOR / add-import-list-exclusion) needs this. Resolves any
`tmdbId` to a movie (movie id == tmdbId), and faithfully replicates Radarr's
@@ -464,19 +471,19 @@ Playwright against deterministic data. Full workflow is in
Movies" collection is UNMONITOR + listExclusions with `tmdbId`s set, so
`POST /api/collections/handle` exercises this path end-to-end. No fake Sonarr
exists yet, so the seed's show collection is DO_NOTHING.
- `tools/dev/seed-db.mjs` the only DB-touching script. Resets and seeds
- `tools/dev/seed-db.mjs` - the only DB-touching script. Resets and seeds
collections / rule groups (with rules covering ~all properties) / settings /
notifications / exclusions / overlays into `data/maintainerr.sqlite`. Target a
server with `MEDIA_SERVER=plex|jellyfin` (default jellyfin). Run with `yarn dev`
**stopped** (SQLite is single-writer), then restart.
**Key limitation:** a DB-only seed does **not** populate the collection-detail
media grid or Overview `CollectionsService.hydrateCollectionMediaWithMetadata`
media grid or Overview - `CollectionsService.hydrateCollectionMediaWithMetadata`
hydrates each row against the **live** media server and drops any id it can't
resolve. You need the matching mock running for grids to render and for rule
evaluation to run end-to-end via `POST /api/rules/test {rulegroupId, mediaId}`.
After editing server code, **restart `yarn dev`** a long-lived dev server serves
After editing server code, **restart `yarn dev`** - a long-lived dev server serves
stale getter logic. Watchlist / plex.tv user enrichment can't be mocked locally
(they hit plex.tv) and degrade gracefully.
@@ -2,11 +2,11 @@
# Task-specific: not auto-loaded on every interaction. Copilot's applyTo is a
# file-path glob and can't express "during a release review", so this triggers
# on release artifacts (changelogs, release workflows) as a proxy. For an
# explicit release audit, read this file directly AGENTS.md links it.
# explicit release audit, read this file directly - AGENTS.md links it.
applyTo: "CHANGELOG.md,apps/*/CHANGELOG.md,.github/workflows/release_*.yml"
---
## Release review how to audit a release candidate before tagging
## Release review - how to audit a release candidate before tagging
Read [ARCHITECTURE.md](../../ARCHITECTURE.md) for the system architecture overview before auditing cross-module or runtime changes.
@@ -21,9 +21,9 @@ changes.
This is the single most important step. Skipping it produces false
positives and wastes reviewer context on deliberate changes.
1. `git log <lastTag>..HEAD --oneline` get the full commit list.
1. `git log <lastTag>..HEAD --oneline` - get the full commit list.
2. `git log <lastTag>..HEAD --format="%H %s" | grep -iE "feat|fix|refactor|security|perf"`
isolate the substantive commits.
- isolate the substantive commits.
3. For every non-trivial PR number referenced in a commit subject
(`(#1234)`), run `gh pr view <n> --json title,body` and read the
description in full. Pay special attention to sections titled
@@ -53,7 +53,7 @@ git diff <lastTag>..HEAD --name-only | wc -l # churn size
```
Flag anything that looks like it needs a migration, a configuration
change, a client contract update, or release-note coverage those are
change, a client contract update, or release-note coverage - those are
common sources of upgrade-path surprises.
### 2. High-risk files to read in full
@@ -75,11 +75,11 @@ Always read these diffs end-to-end, even if small:
- Up and down paths both present and symmetric
- In TypeORM-**generated** schema migrations, raw `queryRunner.query(...)`
DDL is expected (SQLite table rebuilds) audit it, don't reject it. Verify
DDL is expected (SQLite table rebuilds) - audit it, don't reject it. Verify
each `INSERT INTO temporary_*` column list matches its `SELECT` column list.
Hand-written **data**/backfill migrations are different: those must use
`QueryBuilder`, never raw query strings (see project-notes.instructions.md)
- No manually written DDL must be TypeORM-generated
- No manually written DDL - must be TypeORM-generated
(see [typeorm_instructions.txt](../../typeorm_instructions.txt))
- Default values provided for every new `NOT NULL` column
- Indexes recreated after the table rebuild
@@ -89,7 +89,7 @@ Always read these diffs end-to-end, even if small:
#### Stateful domain logic / background execution
- Renames should be mechanical verify no semantic parameter was lost
- Renames should be mechanical - verify no semantic parameter was lost
- Reads of derived state should still go through the canonical helper or
accessor, not a stale raw field
- Any deletion or removal API should receive explicit scope when the
@@ -136,13 +136,13 @@ Always read these diffs end-to-end, even if small:
#### Security checklist (OWASP top 10)
- No new SQL built by string concatenation application/runtime queries go
- No new SQL built by string concatenation - application/runtime queries go
through TypeORM repositories or `QueryBuilder` with parameters. (The raw DDL
inside generated schema migrations is the documented exception above.)
- No `exec`/`spawn` of a shell with user input
- No new `fs` reads where the path is derived from request input
without `path.resolve` + allow-list check
- No secrets (tokens, api keys) in log messages run
- No secrets (tokens, api keys) in log messages - run
`git diff <lastTag>..HEAD | grep -iE "(api[_-]?key|token|secret|password)"`
and audit each hit
- New external HTTP calls should go through the shared client or wrapper
@@ -165,14 +165,14 @@ Run the relevant build, test, and typecheck suites for the changed
areas, plus at least one full-project validation command if the release
scope is broad.
A green build and test run is necessary but not sufficient tests only
A green build and test run is necessary but not sufficient - tests only
catch regressions that someone thought to write a test for.
### 5a. Exercise the affected flows end-to-end (seeded DB + Playwright)
Automated suites do not cover rendering, navigation, or the
media-server-dependent flows (rules, collections, overview, calendar,
storage). Always drive those in a real browser before signing off and
storage). Always drive those in a real browser before signing off - and
always against the **seeded dev DB + mock media server**, never a hand-set
or empty database, so every reviewer hits the same deterministic dataset.
@@ -184,7 +184,7 @@ or empty database, so every reviewer hits the same deterministic dataset.
`MEDIA_SERVER=plex node tools/dev/seed-db.mjs`.
- Re-run the seed after any DB-shape migration in the release so the
dataset matches the migrated schema.
3. Drive the UI with **Playwright** (the `playwright` MCP server) do not
3. Drive the UI with **Playwright** (the `playwright` MCP server) - do not
rely on eyeballing screenshots alone. At minimum, for the areas the diff
touches: load the page, perform the changed interaction, and assert on
the resulting DOM/network. Capture a screenshot of each flow you touched
@@ -192,9 +192,9 @@ or empty database, so every reviewer hits the same deterministic dataset.
4. For server-side rule/getter changes, confirm live output through the
seeded stack: `POST /api/rules/test {"mediaId","rulegroupId"}` or
`POST /api/rules/:id/execute`. After editing server code, **restart
`yarn dev`** a long-lived dev server serves stale getter logic.
`yarn dev`** - a long-lived dev server serves stale getter logic.
Note what you exercised (and what you could not e.g. plex.tv watchlist
Note what you exercised (and what you could not - e.g. plex.tv watchlist
enrichment can't be mocked locally) in the report. A flow you did not drive
is an untested flow; say so rather than implying coverage.
@@ -202,13 +202,13 @@ is an untested flow; say so rather than implying coverage.
Use severity levels, in this order:
- **CRITICAL** data loss, auth bypass, remote code execution, broken
- **CRITICAL** - data loss, auth bypass, remote code execution, broken
migration. Must fix before tagging.
- **HIGH** observable user-facing regression, silent failure mode,
- **HIGH** - observable user-facing regression, silent failure mode,
real security exposure. Should fix before tagging.
- **MEDIUM** performance regression, log quality, inconsistent
- **MEDIUM** - performance regression, log quality, inconsistent
behavior. Fix in a follow-up.
- **LOW** code hygiene, dead code, defense-in-depth. Nice-to-have.
- **LOW** - code hygiene, dead code, defense-in-depth. Nice-to-have.
Every finding must include:
@@ -216,7 +216,7 @@ Every finding must include:
- Exact line range of the problem
- What specifically breaks (one sentence)
- A concrete fix (code snippet or clear instruction)
- Why this is not already covered by the change's stated intent if you
- Why this is not already covered by the change's stated intent - if you
cannot answer this, the finding probably is not real
### 7. When in doubt
+4 -4
View File
@@ -179,7 +179,7 @@ jobs:
- **Always work from local clones, not github.com web views (they can be cached or truncated).
Clone `Maintainerr/Maintainerr` and `Maintainerr/Maintainerr_docs` before anything else.**
- **Read complete files, not excerpts or partial reads.** Coverage you would otherwise miss often
sits past the first screenful read each doc file and each commit diff end-to-end.
sits past the first screenful - read each doc file and each commit diff end-to-end.
- **Source of truth:** the upstream Maintainerr commits and file diffs listed above.
Use the prose summary as guidance, but always confirm against the local clone
(`git show <sha>`) before editing.
@@ -191,8 +191,8 @@ jobs:
- **Doc-only changes:** restrict edits to files under `docs/` and `static/openapi-spec/`.
Do not touch sidebars, config, or unrelated assets unless strictly required by a doc edit.
- **PR description structure:**
1. **What was added** one short bullet per doc edit, citing the upstream commit or PR.
2. **Already covered by prior PRs** list anything from this drift that earlier merged
1. **What was added** - one short bullet per doc edit, citing the upstream commit or PR.
2. **Already covered by prior PRs** - list anything from this drift that earlier merged
docs PRs already documented, so reviewers can confirm it's intentionally skipped.
- Keep the PR focused and tied to the actual code diff rather than the issue summary alone.
EOF
@@ -216,7 +216,7 @@ jobs:
echo "- Updated drift issue: [$DOCS_REPO#$issue_num](https://github.com/$DOCS_REPO/issues/$issue_num)" >> "$GITHUB_STEP_SUMMARY"
else
created=$(gh issue create --repo "$DOCS_REPO" \
--title "Docs drift updates needed for the next Maintainerr release" \
--title "Docs drift - updates needed for the next Maintainerr release" \
--body-file .drift-report.md)
echo "Created: $created"
echo "- Created drift issue: $created" >> "$GITHUB_STEP_SUMMARY"
@@ -2,7 +2,7 @@ name: Fider - Move enhancements
# Weekly sweep: mirrors open `label:enhancement` issues (excluding ones
# authored by a CODEOWNER) to the Fider feature board and closes the GitHub
# issue with a friendly comment linking to the new post. Idempotent re-runs
# issue with a friendly comment linking to the new post. Idempotent - re-runs
# skip anything already mirrored.
#
# Scheduled runs always apply. Manual dispatch defaults to dry-run for
@@ -12,7 +12,7 @@ name: Fider - Pre-existing scan
# independently of the high-confidence possibly-completed flag.
# - Stricter model rubric (must quote a phrase that directly delivers the
# specific ask, not just touch the same area).
# - Manual workflow_dispatch only no cron schedule. Run when you want a
# - Manual workflow_dispatch only - no cron schedule. Run when you want a
# periodic backlog sweep, not unattended.
on:
+24 -23
View File
@@ -22,15 +22,15 @@ For the broader system architecture map, see [ARCHITECTURE.md](ARCHITECTURE.md).
## Documentation map
**Standing rules read before writing any code (they apply to all work):**
**Standing rules - read before writing any code (they apply to all work):**
- [implementation.instructions.md](.github/instructions/implementation.instructions.md) implementation rules and API-doc references.
- [project-notes.instructions.md](.github/instructions/project-notes.instructions.md) non-obvious project knowledge, conventions, and gotchas (rule engine, Tailwind v4, migrations, naming) that isn't derivable from the code or git history.
- [implementation.instructions.md](.github/instructions/implementation.instructions.md) - implementation rules and API-doc references.
- [project-notes.instructions.md](.github/instructions/project-notes.instructions.md) - non-obvious project knowledge, conventions, and gotchas (rule engine, Tailwind v4, migrations, naming) that isn't derivable from the code or git history.
**Task-specific read only when the task calls for it (don't load them every session):**
**Task-specific - read only when the task calls for it (don't load them every session):**
- [release-review.instructions.md](.github/instructions/release-review.instructions.md) when auditing a release candidate before tagging.
- [ARCHITECTURE.md](ARCHITECTURE.md) before changing cross-module boundaries.
- [release-review.instructions.md](.github/instructions/release-review.instructions.md) - when auditing a release candidate before tagging.
- [ARCHITECTURE.md](ARCHITECTURE.md) - before changing cross-module boundaries.
When you add a doc, list it under the matching heading here. For how each agent
(Claude, Copilot, Cursor, Codex) loads these docs, see [README_AGENTS.md](README_AGENTS.md).
@@ -78,11 +78,11 @@ This is a **TypeScript monorepo** managed with **Turborepo** and **Yarn workspac
## Development Environment
The development environment runs inside **`devbox`** a rootless Docker container
The development environment runs inside **`devbox`** - a rootless Docker container
managed by `~/infra/compose.yml` on the host. This IS the devcontainer for this project.
- `devbox` mounts the repo at `/workspace` and has Node 26 + Yarn 4.11 pre-installed.
Work directly inside the container at `/workspace` open your editor/agent here,
Work directly inside the container at `/workspace` - open your editor/agent here,
run all `yarn` commands here. Node is only installed in the container, not the host.
- Git works normally from `/workspace`: `git commit` and `git push` directly. The
SSH key for GitHub is mounted into the container, so no host-side push relay is
@@ -102,22 +102,22 @@ in `~/dev-media.compose.yml`, reachable from inside `devbox` by hostname:
Credentials are in `~/dev-media-creds.env` (not committed). Media lives on `/mnt/dev-media`.
### Security model you are L3, the confined devbox
### Security model - you are L3, the confined devbox
Three trust levels, privilege descending: **`root`@host** (everything) **`maintainerr-dev`**
(the SSH host runs the dev media stack, holds its secrets, controls the devbox)
(the SSH host - runs the dev media stack, holds its secrets, controls the devbox)
**`devbox`** (you: dev/test only). You can reach the media/service stack by hostname
(`dev-plex`, `dev-radarr`, …) to test and seed against it, but you **cannot break out** to
the host and that boundary is enforced from above you, so you can't disable it:
the host - and that boundary is enforced from above you, so you can't disable it:
- **Read-only Docker** the socket-proxy allows `ps`/`logs`, never `start`/`stop`/`exec`/`create`.
- **Host egress firewall** outbound is default-deny to an allowlist (GitHub, npm, Anthropic,
- **Read-only Docker** - the socket-proxy allows `ps`/`logs`, never `start`/`stop`/`exec`/`create`.
- **Host egress firewall** - outbound is default-deny to an allowlist (GitHub, npm, Anthropic,
Plex/TMDB) plus the internal docker network; `NET_ADMIN` is stripped from the container.
- **Rootless + `cap_drop: ALL` + `no-new-privileges`** even container-root is an unprivileged
- **Rootless + `cap_drop: ALL` + `no-new-privileges`** - even container-root is an unprivileged
subuid, never the host user.
Don't fight these (e.g. trying to `exec` into another container, or reaching a non-allowlisted
host) they're intentional, not bugs. Operator-side detail lives in `~/infra/README.md`.
host) - they're intentional, not bugs. Operator-side detail lives in `~/infra/README.md`.
## Development Workflow
@@ -147,11 +147,11 @@ yarn check-types
```
> **`yarn test` / `yarn lint` fails with `command not found: <tool>` (exit 127)?**
> Almost always a stale `node_modules` out of sync with `yarn.lock` yarn can't
> Almost always a stale `node_modules` out of sync with `yarn.lock` - yarn can't
> resolve the binary (commonly `vitest`, which is the one test binary not hoisted
> to the root `node_modules/.bin`). Run `yarn install` to resync, then retry. It
> is **not** a PATH or workspace-config problem, so don't reach for explicit
> `./node_modules/.bin/...` paths fix the install instead.
> `./node_modules/.bin/...` paths - fix the install instead.
### CI Workflow Commands
@@ -215,6 +215,7 @@ yarn workspace @maintainerr/contracts build
- `BREAKING CHANGE` footer or `!` (e.g. `feat!:`) → major release
- **Import Organization**: Prefer absolute imports, group by type (external, internal, relative)
- **String Handling**: Avoid regex for simple prefix/suffix/substring checks or single-character trimming. Prefer string primitives such as `endsWith`, `startsWith`, `slice`, `substring`, or direct character inspection to reduce unnecessary regex risk; see [OWASP ReDoS guidance](https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS). Example: `fix: avoid regex backtracking in disk path normalization (#2526)`.
- **Punctuation**: Never use em or en dashes in any committed artifact (code, comments, log/UI strings, tests, commit messages, docs). Always type a plain ASCII hyphen `-` (U+002D), never `—` (U+2014) or `` (U+2013). See the convention in [project-notes.instructions.md](.github/instructions/project-notes.instructions.md).
### TypeScript Guidelines
@@ -359,19 +360,19 @@ These specifications provide comprehensive type definitions and endpoint documen
For end-to-end checks of media-server-dependent flows (rules, collections,
overview, calendar, storage) without a real Plex/Jellyfin, the `tools/dev/` folder
has scripts that **complement Playwright** Playwright drives the UI, these
has scripts that **complement Playwright** - Playwright drives the UI, these
provide the backend data:
- `tools/dev/fake-jellyfin.mjs` stateless mock Jellyfin (`:8096`).
- `tools/dev/fake-plex.mjs` stateless mock Plex (`:32400`); covers the Plex-only
- `tools/dev/fake-jellyfin.mjs` - stateless mock Jellyfin (`:8096`).
- `tools/dev/fake-plex.mjs` - stateless mock Plex (`:32400`); covers the Plex-only
getter paths (smart collections, watch history, accounts, ratings,
shows/seasons/episodes) that the Jellyfin mock can't.
- `tools/dev/fake-radarr.mjs` mock Radarr v3 (`:7878`); covers the
- `tools/dev/fake-radarr.mjs` - mock Radarr v3 (`:7878`); covers the
collection-handler → RadarrActionHandler flow (DELETE / UNMONITOR / "add import
list exclusion") that the media-server mocks can't. Resolves any `tmdbId` to a
movie, and replicates Radarr's exclusion semantics: `POST /exclusions/bulk`
de-dupes (idempotent), singular `POST /exclusions` 400s on a duplicate.
- `tools/dev/seed-db.mjs` the **only** DB-touching script. Seeds settings,
- `tools/dev/seed-db.mjs` - the **only** DB-touching script. Seeds settings,
collections, and rule groups **with rules** covering ~all rule properties, plus
notifications, cron, logs, exclusions, and overlays. The "Stale Movies"
collection is seeded as UNMONITOR + listExclusions with `tmdbId`s set, so it
@@ -383,7 +384,7 @@ run the seed, restart `yarn dev`. Inspect a getter's live output with
`POST /api/rules/test {"mediaId","rulegroupId"}`, run a rule with
`POST /api/rules/:id/execute`, or run collection handling with
`POST /api/collections/handle`. Note: after editing server code, **restart
`yarn dev`** a long-lived dev server can serve stale getter logic. Watchlist
`yarn dev`** - a long-lived dev server can serve stale getter logic. Watchlist
and plex.tv user enrichment can't be mocked locally (they hit plex.tv) and
degrade gracefully.
+3 -3
View File
@@ -157,11 +157,11 @@
## Global vs scoped exclusions
Exclusions are now either global (everywhere) or per-group not both. Setting a global exclusion replaces any per-group ones for that item. If you later remove the global exclusion, you'll need to re-add the per-group ones.
Exclusions are now either global (everywhere) or per-group - not both. Setting a global exclusion replaces any per-group ones for that item. If you later remove the global exclusion, you'll need to re-add the per-group ones.
## Per-group exclusions stay in their group
Per-group exclusions used to hide an item in every group. They now apply only to the group you set them in, so items you excluded in one group may start showing up in others. Existing exclusions aren't auto-converted to exclude something everywhere, use a global exclusion.
Per-group exclusions used to hide an item in every group. They now apply only to the group you set them in, so items you excluded in one group may start showing up in others. Existing exclusions aren't auto-converted - to exclude something everywhere, use a global exclusion.
## Rule section operators
@@ -553,7 +553,7 @@ If a multi-section rule wasn't matching as you expected, this is probably why. T
### Features
- add quality profile change action for Radarr and Sonarr ([#2360](https://github.com/maintainerr/Maintainerr/issues/2360)) ([fffdd05](https://github.com/maintainerr/Maintainerr/commit/fffdd0561f20b9d09f734aa436038ffb9eb56ae2))
- Plex connection resilience auto re-discovery + manual override ([#2661](https://github.com/maintainerr/Maintainerr/issues/2661)) ([befde07](https://github.com/maintainerr/Maintainerr/commit/befde07616b0e8738e987700c89c6564c55735a6))
- Plex connection resilience - auto re-discovery + manual override ([#2661](https://github.com/maintainerr/Maintainerr/issues/2661)) ([befde07](https://github.com/maintainerr/Maintainerr/commit/befde07616b0e8738e987700c89c6564c55735a6))
# [3.5.0](https://github.com/maintainerr/Maintainerr/compare/v3.4.1...v3.5.0) (2026-04-10)
+1 -1
View File
@@ -48,7 +48,7 @@ implementation.instructions.md ─→ ARCHITECTURE.md ✓
- **`AGENTS.md` is the single index.** Add any new doc to its "Documentation map".
- **Standing rules** (read before any code): `implementation.instructions.md` and
`project-notes.instructions.md` `applyTo: "**"` and named in every entrypoint.
`project-notes.instructions.md` - `applyTo: "**"` and named in every entrypoint.
- **Task-specific** (read on demand, not every session): `release-review.instructions.md`
(Copilot `applyTo` scoped to release artifacts) and `ARCHITECTURE.md`.
- **Each agent entrypoint is a thin router** to `AGENTS.md` + the two standing rules.
@@ -2,20 +2,20 @@ import * as fs from 'fs';
import * as path from 'path';
import { DataSource, MigrationInterface } from 'typeorm';
// Generic migration matrix we don't test each migration individually. These
// Generic migration matrix - we don't test each migration individually. These
// confirm TypeORM behaves and that migrations comply with typeorm_instructions.txt
// (generated from the entities, never hand-waived):
// 1. The whole chain applies in order on a fresh DB, each recorded once
// 1. The whole chain applies in order on a fresh DB, each recorded once -
// proving every migration is structurally valid SQL that TypeORM accepts.
// 2. The schema migration this PR adds reproduces its entity columns EXACTLY
// (type + NOT NULL + default). A hand-edited migration that drifts from the
// entity definition fails here the in-jest stand-in for `migration:generate`
// entity definition fails here - the in-jest stand-in for `migration:generate`
// reporting "No changes". (The repo-wide entity-vs-schema diff stays a manual
// release step: TypeORM's metadata builder can't run under @swc/jest, which
// reflects the codebase's `T | null` columns as `Object` and rejects the
// build. A new migration adds its columns to test 2.)
// 3. That migration's up() carries TypeORM's SQLite create-temporary-table
// rebuild the fingerprint of `migration:generate`. Matching columns (2)
// rebuild - the fingerprint of `migration:generate`. Matching columns (2)
// can be reproduced by a hand-written `ALTER TABLE ADD COLUMN`; the rebuild
// cannot, so its absence flags a hand-waived migration.
// 4. The newest migration's down() is symmetric.
@@ -116,7 +116,7 @@ describe('database migrations', () => {
const src = fs.readFileSync(path.join(MIGRATIONS_DIR, newest.file), 'utf8');
// SQLite can't ALTER most columns in place, so `migration:generate` always
// emits a full create-temporary-table / copy / drop / rename rebuild for the
// changed tables. A hand-written ALTER shortcut lacks it this is the
// changed tables. A hand-written ALTER shortcut lacks it - this is the
// cheapest signal the migration was generated rather than authored.
expect(src).toContain('CREATE TABLE "temporary_collection"');
expect(src).toContain('CREATE TABLE "temporary_settings"');
@@ -124,7 +124,7 @@ describe('database migrations', () => {
// We don't revert the whole chain: several pre-existing migrations have
// non-reversible down() paths (production only ever migrates up). We do confirm
// the newest migration's down() is symmetric the regression this catches when
// the newest migration's down() is symmetric - the regression this catches when
// a migration is added.
it('revert the newest migration cleanly (symmetric down)', async () => {
const ds = await makeDS(all.map((m) => m.cls)).initialize();
@@ -83,7 +83,7 @@ describe('upgrade from v1.7.1 to current', () => {
await insertRule(4, 1, '1'); // explicit operator -> untouched
await ds.destroy();
// --- Phase 3: upgrade register ALL migrations, run the pending set ---
// --- Phase 3: upgrade - register ALL migrations, run the pending set ---
ds = makeDS(all.map((m) => m.cls));
await ds.initialize();
const phase3 = await ds.runMigrations();
@@ -12,8 +12,8 @@ import { MigrationInterface, QueryRunner } from 'typeorm';
* To change behaviour as little as possible, this migration writes back the
* value each rule *already evaluates as today*, so no existing rule changes
* how it matches:
* - first rule of a later section -> "0" (AND) the section-combine default
* - any other (within-section) rule -> "1" (OR) the within-section default
* - first rule of a later section -> "0" (AND) - the section-combine default
* - any other (within-section) rule -> "1" (OR) - the within-section default
* - the first rule of a group keeps null; its operator is forced to null at
* runtime regardless, so it is left untouched.
* Making these explicit also stops the engine from re-inferring the value and
@@ -21,7 +21,7 @@ import { MigrationInterface, QueryRunner } from 'typeorm';
*
* Rules are ordered by id, which is how they are (re)written on every save and
* how the comparator iterates them, so the first id seen for a new section is
* that section's first rule the same boundary the comparator uses.
* that section's first rule - the same boundary the comparator uses.
*
* Operators are written as strings ("0"/"1") to match the values the UI
* persists; the comparator coerces with `+`, so string and numeric operators
+2 -2
View File
@@ -11,8 +11,8 @@ import { MaintainerrLogger } from './modules/logging/logs.service';
import { installStdioPipeGuards } from './modules/logging/winston/stdioPipeGuard';
import { isSharpAvailable, SHARP_UNAVAILABLE_MESSAGE } from './utils/sharp';
// Pre-bootstrap guard so the console.warn/console.error calls below and any
// other write before LogsModule loads cannot crash the process on a broken
// Pre-bootstrap guard so the console.warn/console.error calls below - and any
// other write before LogsModule loads - cannot crash the process on a broken
// stdio pipe. The logging module re-installs these (idempotent) for
// defence-in-depth.
installStdioPipeGuards();
@@ -31,7 +31,7 @@ describe('ServarrTagService', () => {
// By default every media-server id resolves to a tmdb/tvdb candidate; the
// *arr lookup (mocked per test) decides whether it matches an entity. The
// candidate id is irrelevant here the lookup mock ignores it.
// candidate id is irrelevant here - the lookup mock ignores it.
metadataService.resolveLookupCandidatesForService.mockImplementation(
async (_mediaServerId, service) => [
{ providerKey: service === 'radarr' ? 'tmdb' : 'tvdb', id: 100 },
@@ -39,7 +39,7 @@ describe('ServarrTagService', () => {
);
});
describe('Behavior A membership tagging', () => {
describe('Behavior A - membership tagging', () => {
it('tags newly added movies in Radarr with the collection title', async () => {
const radarr = mockRadarrApi(servarrService, logger);
jest
@@ -71,7 +71,7 @@ describe('ServarrTagService', () => {
);
});
it('uses the current (renamed) group name as the tag no stale old-label removal', async () => {
it('uses the current (renamed) group name as the tag - no stale old-label removal', async () => {
const radarr = mockRadarrApi(servarrService, logger);
jest
.spyOn(radarr, 'getMovieByTmdbId')
@@ -92,7 +92,7 @@ describe('ServarrTagService', () => {
);
// Only the current name is ensured/applied; the old (renamed-from) label is
// intentionally not chased here (documented edge case re-tagged on churn).
// intentionally not chased here (documented edge case - re-tagged on churn).
expect(radarr.ensureTag).toHaveBeenCalledTimes(1);
expect(radarr.ensureTag).toHaveBeenCalledWith('renamed-group');
expect(radarr.setMovieTags).toHaveBeenCalledWith([10], 5, 'add');
@@ -135,7 +135,7 @@ describe('ServarrTagService', () => {
[],
);
// Both titles normalize to 'my-group', so both resolve to the same tag id
// Both titles normalize to 'my-group', so both resolve to the same tag id -
// an untag from one then a re-add from the other converges on the same tag.
expect(ensureTag.mock.calls.every((c) => c[0] === 'my-group')).toBe(true);
expect(radarr.setMovieTags).toHaveBeenCalledWith([10], 5, 'add');
@@ -249,7 +249,7 @@ describe('ServarrTagService', () => {
expect(radarr.setMovieTags).not.toHaveBeenCalled();
});
it('skips untaggable types (season/episode) Sonarr has no per-season tag', async () => {
it('skips untaggable types (season/episode) - Sonarr has no per-season tag', async () => {
const sonarr = mockSonarrApi(servarrService, logger);
const collection = createCollection({
type: 'season',
@@ -380,7 +380,7 @@ describe('ServarrTagService', () => {
await service.syncMembershipTags(collection, added, []);
// Every distinct item is tagged exactly once nothing dropped or doubled.
// Every distinct item is tagged exactly once - nothing dropped or doubled.
const addCalls = setMovieTags.mock.calls.filter((c) => c[2] === 'add');
const taggedIds = addCalls.flatMap((c) => c[0]);
expect(taggedIds).toHaveLength(total);
@@ -395,7 +395,7 @@ describe('ServarrTagService', () => {
}, 30_000);
});
describe('Behavior B exclusion tagging', () => {
describe('Behavior B - exclusion tagging', () => {
const movieTarget = { mediaServerId: 'movie-1', type: 'movie' as const };
it('does nothing when exclusion tagging is disabled', async () => {
@@ -39,7 +39,7 @@ const RESOLVE_CONCURRENCY = 5;
const EDITOR_BATCH_SIZE = 100;
/**
* Applies/removes Radarr & Sonarr tags as a side effect of Maintainerr state
* Applies/removes Radarr & Sonarr tags as a side effect of Maintainerr state -
* NOT an action slot. Two triggers share this plumbing:
*
* - **Membership (Behavior A):** while a `tagInArr` collection holds an item, the
@@ -55,7 +55,7 @@ const EDITOR_BATCH_SIZE = 100;
* contract (undefined = transient → skip & retry; null = confirmed-not-tracked →
* nothing to do). A transient blip therefore never strips a tag.
*
* v1 is gated to movie (Radarr) and show (Sonarr) Sonarr has no per-season tag,
* v1 is gated to movie (Radarr) and show (Sonarr) - Sonarr has no per-season tag,
* so season/episode collections are skipped with a debug log.
*
* Known edge cases (v1 behaviour):
@@ -83,7 +83,7 @@ export class ServarrTagService {
}
/**
* Behavior A reconcile *arr tags for the items that just entered/left a
* Behavior A - reconcile *arr tags for the items that just entered/left a
* collection this run. `added`/`removed` are the executor's rule-scope deltas
* (manual / co-owned members are already excluded from `removed`), each
* carrying the item's cached provider ids. The tag label is the collection
@@ -199,7 +199,7 @@ export class ServarrTagService {
};
}
/** True if either *arr has exclusion tagging on lets callers skip the
/** True if either *arr has exclusion tagging on - lets callers skip the
* collection lookup entirely when both are off. */
public anyExclusionTaggingEnabled(): boolean {
return (
@@ -234,7 +234,7 @@ export class ServarrTagService {
}
/**
* Behavior B apply the protective exclusion tag to the excluded item's *arr
* Behavior B - apply the protective exclusion tag to the excluded item's *arr
* entity. No-ops unless exclusion tagging is enabled. The caller resolves the
* instance: a collection-scoped exclusion uses its rule group's collection, a
* global one the single configured instance (skipped when ambiguous). Adding on
@@ -251,10 +251,10 @@ export class ServarrTagService {
}
/**
* Behavior B remove the protective exclusion tag on un-exclude. This is
* Behavior B - remove the protective exclusion tag on un-exclude. This is
* conservative on purpose (Zipties' "second dnd source" pain): it only runs
* when the user opts in via `<service>_untag_on_unexclude`, and even then only
* ever touches the configured label never the user's other tags. With the
* ever touches the configured label - never the user's other tags. With the
* default (opt-in OFF) a manually-set "dnd" is never stripped by Maintainerr.
*/
public async removeExclusionTag(
@@ -318,7 +318,7 @@ export class ServarrTagService {
const arrId = await this.resolveArrId(client, service, target);
if (arrId == null) {
// undefined = transient (retried on the next exclude/un-exclude),
// null = the item isn't tracked in *arr nothing to tag either way.
// null = the item isn't tracked in *arr - nothing to tag either way.
return;
}
@@ -401,7 +401,7 @@ export class ServarrTagService {
* - null when the item is confirmed not tracked in *arr (no candidates, or
* *arr returned an empty match),
* - undefined when the lookup transiently failed (so callers skip & retry,
* never untagging on a blip) per the #3125 getter contract.
* never untagging on a blip) - per the #3125 getter contract.
* The item's cached tmdb/tvdb are passed as resolution fallbacks.
*/
private async resolveArrId(
@@ -244,7 +244,7 @@ describe('SonarrActionHandler', () => {
);
// A transient Sonarr lookup failure (getSeriesByTvdbId → undefined) must NOT
// be read as "not in Sonarr" and trigger a media-server delete fail closed
// be read as "not in Sonarr" and trigger a media-server delete - fail closed
// so the item stays in the collection and is retried next run. (#3125)
it('fails closed (no delete, no action) when the Sonarr lookup fails transiently for a DELETE action', async () => {
const collection = createCollection({
@@ -366,7 +366,7 @@ describe('SonarrActionHandler', () => {
// empty-show cleanup must resolve the series from the (uncached) Sonarr
// client on every run, never from a memo that could hold a pre-deletion
// snapshot. The rule-evaluation memo is intentionally never threaded into
// this path if a future refactor did so, the second run below would re-use
// this path - if a future refactor did so, the second run below would re-use
// the stale "still has files" series and wrongly skip the deletion.
it('resolves the series fresh from the client each run, never via a memo', async () => {
const collection = createCollection({
@@ -617,7 +617,7 @@ describe('SonarrActionHandler', () => {
// Regression for issues #2757 / #2891. Sonarr carries every TVDB season on
// the series, including ones the user never downloaded; those stay
// monitored forever. The no-Seerr fallback must not require every season to
// be unmonitored an ended show with zero episode files is deletable even
// be unmonitored - an ended show with zero episode files is deletable even
// when later (never-downloaded) seasons are still monitored.
it('should delete ended show with no episode files when Seerr is not configured even if later seasons remain monitored', async () => {
const collection = createCollection({
@@ -807,7 +807,7 @@ describe('SonarrActionHandler', () => {
});
// A season that is still monitored AND holds files means the user is not
// done with the show it must not be unmonitored.
// done with the show - it must not be unmonitored.
it('should not unmonitor show when a monitored season still has files', async () => {
const collection = createCollection({
arrAction: ServarrAction.UNMONITOR_SHOW_IF_EMPTY,
@@ -851,7 +851,7 @@ describe('SonarrActionHandler', () => {
});
// season.statistics is optional in Sonarr's response. A monitored season
// with no statistics has an unknown file count it must be treated as
// with no statistics has an unknown file count - it must be treated as
// still having content, never assumed empty.
it('should not unmonitor show when a monitored season has no statistics', async () => {
const collection = createCollection({
@@ -1757,7 +1757,7 @@ describe('SonarrActionHandler', () => {
await sonarrActionHandler.handleAction(collection, collectionMedia);
// Coverage could not be proven, so nothing is removed but the delete
// Coverage could not be proven, so nothing is removed - but the delete
// itself still runs (cleanup is best-effort).
expect(mockedSonarrApi.unmonitorSeasons).toHaveBeenCalled();
expect(downloadClient.removeDownloads).toHaveBeenCalledWith([]);
@@ -437,7 +437,7 @@ export class SonarrActionHandler {
/**
* The torrents a season/episode delete fully covers: those whose every backed
* episode is in the deleted set. A season/series pack also backs episodes that
* are kept, so it is excluded. Keyed on Sonarr's episodeId this is the only
* are kept, so it is excluded. Keyed on Sonarr's episodeId - this is the only
* safeguard for a lone pack, since removeDownloads' cross-seed guard protects
* torrents that share a content path, not one torrent backing several wanted
* episodes. Fails closed: returns [] whenever coverage cannot be proven.
@@ -632,7 +632,7 @@ export class SonarrActionHandler {
// episode files; `ended` confirms no further episodes are coming. We do
// NOT additionally require every season to be unmonitored: Sonarr carries
// every TVDB season on the series, including ones the user never
// downloaded, and those stay monitored forever which would block
// downloaded, and those stay monitored forever - which would block
// deletion of a genuinely empty, ended show indefinitely (issue #2757 /
// #2891: e.g. a show where the user only ever had seasons 1-4).
if (series.status !== 'ended') {
@@ -705,13 +705,13 @@ export class SonarrActionHandler {
// 'monitored': the show counts as empty once no season is still both
// monitored AND holding files. Seasons the user never downloaded stay
// monitored on the series object indefinitely (Sonarr carries every TVDB
// season) and have zero files they must not count as monitored content,
// season) and have zero files - they must not count as monitored content,
// or a genuinely finished show could never be unmonitored (#2757 / #2891).
//
// A monitored season is only treated as empty when Sonarr *explicitly*
// reports zero files. season.statistics is optional; if it's absent the
// file count is unknown, so the season is treated as still having content
// (conservative never unmonitor a show on an assumption).
// (conservative - never unmonitor a show on an assumption).
return series.seasons.every(
(season) =>
!season.monitored || season.statistics?.episodeFileCount === 0,
@@ -1,6 +1,6 @@
import { Controller } from '@nestjs/common';
// The download client has no direct HTTP surface of its own it is consumed
// The download client has no direct HTTP surface of its own - it is consumed
// internally by the action handlers and configured/tested via the settings
// controller.
@Controller('api/download-client')
@@ -12,7 +12,7 @@ const apiMock = {
};
// The service builds its client through the factory, which constructs a
// QbittorrentApi mock that so the factory returns our stub.
// QbittorrentApi - mock that so the factory returns our stub.
jest.mock('./helpers/qbittorrent.helper', () => ({
QbittorrentApi: jest.fn().mockImplementation(() => apiMock),
}));
@@ -12,10 +12,10 @@ import {
// blocks the caller. Bad credentials are NOT this case (they return HTTP 200
// "Fails." and are handled at login), so "Invalid API key" (the shared util's
// 401/403 message) is misleading. The reliable fix is whitelisting Maintainerr's
// IP it and qBittorrent commonly run on different (Docker) IPs so lead with
// IP - it and qBittorrent commonly run on different (Docker) IPs - so lead with
// that and only mention proxy/host validation as a secondary cause.
const DOWNLOAD_CLIENT_FORBIDDEN_MESSAGE =
'The download client accepted the login but returned 403 Forbidden a ' +
'The download client accepted the login but returned 403 Forbidden - a ' +
'qBittorrent Web UI security restriction, not a wrong username or password. ' +
'In qBittorrent → Options → Web UI → Security, add Maintainerrs IP or ' +
'subnet to “Bypass authentication for clients in whitelisted IP subnets” ' +
@@ -120,9 +120,9 @@ export class DownloadClientApiService {
/**
* Remove the completed downloads identified by the given download-client ids
* (Radarr/Sonarr `downloadId`s for a torrent client these are infohashes).
* (Radarr/Sonarr `downloadId`s - for a torrent client these are infohashes).
* No-op when no download client is configured. Best-effort: each download is
* handled independently and a failure never throws into the caller the
* handled independently and a failure never throws into the caller - the
* media has already been deleted from the *arr at this point, so this cleanup
* is a side effect.
*
@@ -159,7 +159,7 @@ export class DownloadClientApiService {
const torrent = await this.api.getTorrentByHash(hash);
if (!torrent) {
// Not in the download client (already removed, a different client, or
// a manual import that never had a download) nothing to clean up.
// a manual import that never had a download) - nothing to clean up.
continue;
}
@@ -21,9 +21,9 @@ export interface DownloadClientTorrent {
/**
* Whether the client's OWN seeding goal (its ratio / seed-time limit) is met,
* decided entirely by the client:
* - `true` goal reached, safe to remove
* - `false` a limit exists but isn't reached yet, keep seeding
* - `null` the client enforces no limit, so the caller applies its
* - `true` - goal reached, safe to remove
* - `false` - a limit exists but isn't reached yet, keep seeding
* - `null` - the client enforces no limit, so the caller applies its
* fallback ratio instead
*/
reachedSeedingGoal: boolean | null;
@@ -57,7 +57,7 @@ const toDownloadClientTorrent = (
};
/**
* Thin client for the qBittorrent WebUI API (v2, qBittorrent 4.1+) the
* Thin client for the qBittorrent WebUI API (v2, qBittorrent 4.1+) - the
* qBittorrent implementation of the backend-agnostic `DownloadClient` contract.
*
* qBittorrent uses cookie/session auth: `POST /api/v2/auth/login` issues a `SID`
@@ -86,7 +86,7 @@ export class QbittorrentApi
// qBittorrent's WebUI wants a `Referer` matching the host (its login is the
// only CSRF-exempt endpoint). Deliberately do NOT send `Origin`: qBittorrent
// treats a request whose Origin doesn't match its own as cross-site and
// rejects it with 403 on every endpoint except login which breaks
// rejects it with 403 on every endpoint except login - which breaks
// reverse-proxy / scheme-mismatch setups (the mature qbittorrent-api client
// sends Referer only, for the same reason). The SID cookie carries the auth.
super(`${url}/api/v2`, {}, logger, {
@@ -167,7 +167,7 @@ export class QbittorrentApi
// On a normal login qBittorrent issues an SID cookie to use on subsequent
// requests. When the WebUI bypasses authentication (e.g. "Bypass
// authentication for clients on localhost"/whitelisted subnets) it returns
// "Ok." with no cookie that is still a valid, authenticated session, so
// "Ok." with no cookie - that is still a valid, authenticated session, so
// capture the cookie when present but never require it.
const sid = this.extractSid(response.headers['set-cookie']);
if (sid) {
@@ -78,7 +78,7 @@ describe('ExternalApiService', () => {
return { service, cache };
};
it('does not cache Buffer responses second call hits the network again', async () => {
it('does not cache Buffer responses - second call hits the network again', async () => {
const { service } = createServiceWithCache();
const validObject = { data: 'ok' };
@@ -96,7 +96,7 @@ describe('ExternalApiService', () => {
expect(getFn).toHaveBeenCalledTimes(2);
});
it('does not cache null responses second call hits the network again', async () => {
it('does not cache null responses - second call hits the network again', async () => {
const { service } = createServiceWithCache();
const validObject = { items: [] };
@@ -113,7 +113,7 @@ describe('ExternalApiService', () => {
expect(getFn).toHaveBeenCalledTimes(2);
});
it('caches valid object responses second call does not hit the network', async () => {
it('caches valid object responses - second call does not hit the network', async () => {
const { service } = createServiceWithCache();
const validObject = { items: [1, 2, 3] };
+2 -2
View File
@@ -81,7 +81,7 @@ class CacheManager {
plexguid: new Cache('plexguid', 'Plex GUID', 'plexguid'),
// Holds the leaf watch-history map built by PlexApiService.prefetchWatchHistory.
// Persistent so the map survives flushAll() between rule groups in the same
// cron window; useClones is off because the value is a large Map
// cron window; useClones is off because the value is a large Map -
// getWatchHistory returns copies of the per-item arrays instead.
plexwatchhistory: new Cache(
'plexwatchhistory',
@@ -97,7 +97,7 @@ class CacheManager {
seerr: new Cache('seerr', 'Seerr API', 'seerr'),
// Holds the run-scoped request index built by SeerrApiService.getRequestsForMedia
// (one bulk /request sweep grouped by tmdbId). useClones is off because the
// value is a Map per-item reads copy the per-title array out. Unlike
// value is a Map - per-item reads copy the per-title array out. Unlike
// plexwatchhistory this is NOT persistent: request data changes between runs,
// so flushAll() at each rule-group start rebuilds it (freshness over reuse).
// Long TTL so a single long run can't expire it mid-sweep.
+2 -2
View File
@@ -2,8 +2,8 @@ import { type AxiosInstance } from 'axios';
import axiosRetry from 'axios-retry';
/**
* Apply Maintainerr's standard transient-failure retry policy 3 attempts
* with exponential backoff to an Axios instance. One home for the policy so
* Apply Maintainerr's standard transient-failure retry policy - 3 attempts
* with exponential backoff - to an Axios instance. One home for the policy so
* every outbound HTTP client (Plex, Emby, the Jellyfin SDK, external-api)
* retries identically.
*/
@@ -51,7 +51,7 @@ describe('PlexTvApi.validateToken', () => {
});
// plex.tv returns 422 {"error":"Invalid token"} for a bad token on this
// endpoint (verified live) not 401 so 422 must count as invalid too.
// endpoint (verified live) - not 401 - so 422 must count as invalid too.
it.each([422, 401, 403])(
'returns invalid when plex.tv answers %i',
async (status) => {
+1 -1
View File
@@ -185,7 +185,7 @@ export class PlexTvApi extends ExternalApiService {
public async getDevices(clientIdentifier: string): Promise<PlexDevice[]> {
try {
// v2 /api/v2/resources returns owned servers that legacy v1 /api/resources
// omits. It requires X-Plex-Client-Identifier use the same id the UI
// omits. It requires X-Plex-Client-Identifier - use the same id the UI
// authenticates with so plex.tv sees a consistent client.
const resources = await this.get<PlexV2Resource[]>(
'/api/v2/resources?includeHttps=1',
@@ -89,7 +89,7 @@ export class EmbyAdapterService implements IMediaServerService {
if (!url || !apiKey) {
this.logger.debug(
'Emby settings incomplete skipping initialize (url or api_key missing)',
'Emby settings incomplete - skipping initialize (url or api_key missing)',
);
this.initialized = false;
this.http = undefined;
@@ -482,7 +482,7 @@ export class EmbyAdapterService implements IMediaServerService {
/**
* User IDs of every user with `IsFavorite=true` on this item. Mirrors
* `JellyfinAdapterService.getItemFavoritedBy` (per-user fan-out Emby
* `JellyfinAdapterService.getItemFavoritedBy` (per-user fan-out - Emby
* has no central favorites endpoint).
*/
async getItemFavoritedBy(itemId: string): Promise<string[]> {
@@ -497,7 +497,7 @@ export class EmbyAdapterService implements IMediaServerService {
);
if (data.UserData?.IsFavorite) favoritedBy.push(user.id);
} catch {
// user may lack visibility on this item skip silently
// user may lack visibility on this item - skip silently
}
}
return favoritedBy;
@@ -541,7 +541,7 @@ export class EmbyAdapterService implements IMediaServerService {
* Users who watched at least one episode under `parentId` (season or show).
* Mirrors `JellyfinAdapterService.getDescendantEpisodeWatchers`. One
* /Items request per user, each scoped to that user with `IsPlayed=true`
* + `Limit=1` we only need to know whether any played episode exists.
* + `Limit=1` - we only need to know whether any played episode exists.
*/
async getDescendantEpisodeWatchers(parentId: string): Promise<string[]> {
if (!this.http) return [];
@@ -608,7 +608,7 @@ export class EmbyAdapterService implements IMediaServerService {
// documented path for Emby.
if (!this.embyUserId) {
this.logger.warn(
'Emby getRecentlyAdded requires a configured user ID none set',
'Emby getRecentlyAdded requires a configured user ID - none set',
);
return [];
}
@@ -728,7 +728,7 @@ export class EmbyAdapterService implements IMediaServerService {
async prefetchWatchHistory(): Promise<void> {
// Emby has no central watch-history endpoint (history is per-user), so
// there is nothing to bulk prefetch. Gated by
// supportsFeature(CENTRAL_WATCH_HISTORY) which is false for Emby callers
// supportsFeature(CENTRAL_WATCH_HISTORY) which is false for Emby - callers
// shouldn't reach here.
throw new Error(
'Bulk watch-history prefetch is not supported on Emby (per-user history)',
@@ -765,7 +765,7 @@ export class EmbyAdapterService implements IMediaServerService {
);
}
} catch {
// Some users may not have access to this item skip silently.
// Some users may not have access to this item - skip silently.
}
}
@@ -798,7 +798,7 @@ export class EmbyAdapterService implements IMediaServerService {
if (!item) continue;
// A collection can track an episode at any level, so protect the
// episode and its season and series. ParentId is intentionally
// omitted for Emby movies it is the library folder, not a
// omitted - for Emby movies it is the library folder, not a
// collectable ancestor. Movies only carry Id.
if (item.Id) playing.add(item.Id);
if (item.SeasonId) playing.add(item.SeasonId);
@@ -828,7 +828,7 @@ export class EmbyAdapterService implements IMediaServerService {
try {
// User-scoped read: Emby resolves the BoxSet query against a user's
// library view, so an unscoped read can miss or 404. Pass the user via the
// UserId query param on the literal /Items path functionally the same as
// UserId query param on the literal /Items path - functionally the same as
// /Users/{id}/Items, and the param idiom already used elsewhere here. (A
// user value interpolated into the request path is a CodeQL SSRF sink; a
// query param is not.)
@@ -938,7 +938,7 @@ export class EmbyAdapterService implements IMediaServerService {
// Create with one item: Emby's create-collection endpoint throws HTTP 500
// ("Sequence contains no elements" in CollectionManager) when creating an
// empty collection under a library folder, so it needs at least one item
// (#3075 the regression from #3001's empty-create). The rest are added
// (#3075 - the regression from #3001's empty-create). The rest are added
// afterwards via addBatchToCollection; re-adding this item there is an
// idempotent no-op (collection membership is a set).
const { data } = await this.http.post<EmbyBaseItemDto>(
@@ -1167,7 +1167,7 @@ export class EmbyAdapterService implements IMediaServerService {
// Emby exposes DisplayOrder = PremiereDate | SortName on a BoxSet (via
// ItemUpdateService) but no item-move/reorder endpoint, so an explicit
// ordered list of item IDs can't be expressed. Gated by
// supportsFeature(COLLECTION_SORT) which is false for Emby callers
// supportsFeature(COLLECTION_SORT) which is false for Emby - callers
// shouldn't reach here.
throw new Error(
'Collection sort is not supported on Emby (no item-move API)',
@@ -1220,7 +1220,7 @@ export class EmbyAdapterService implements IMediaServerService {
error,
'Connection failed',
);
// A 500 here is raised inside Emby's own image handler most often the
// A 500 here is raised inside Emby's own image handler - most often the
// library's "Save artwork into media folders" setting (per library, not
// global) makes Emby write the poster next to the media file, and that
// path is read-only (e.g. a movie library mounted read-only while the TV
@@ -1233,7 +1233,7 @@ export class EmbyAdapterService implements IMediaServerService {
typeof error.response.data === 'string'
? error.response.data
: JSON.stringify(error.response.data);
if (body) detail = ` ${body.slice(0, 500)}`;
if (body) detail = ` - ${body.slice(0, 500)}`;
}
throw new Error(
`Failed to upload Emby collection image: ${message}${detail}`,
@@ -6,7 +6,7 @@ import type { EmbyBaseItemDto, EmbyUserDto } from './emby.types';
* Maintainerr's MediaItem contract. Emby's API and Jellyfin's API share the
* same .NET-derived BaseItemDto field shape (Jellyfin forked Emby in 2018),
* so the synthetic fixtures below mirror the ones in jellyfin.mapper.spec.ts
* they assert how the mapper transforms a known input, not what Emby
* - they assert how the mapper transforms a known input, not what Emby
* returns over the wire.
*/
describe('EmbyMapper', () => {
@@ -236,7 +236,7 @@ export class EmbyMapper {
updatedAt: extras.DateLastSaved
? new Date(extras.DateLastSaved)
: undefined,
// Emby has no native smart collections only manual BoxSets and the
// Emby has no native smart collections - only manual BoxSets and the
// TheMovieDb-driven "Automatic Creation of Collections" (movie franchise
// grouping, not filter rules). Always false, matching the Jellyfin mapper.
smart: false,
@@ -772,7 +772,7 @@ describe('JellyfinAdapterService', () => {
it('rejects blank Jellyfin item ids before calling the API', async () => {
await expect(service.refreshItemMetadata(' ')).rejects.toThrow(
'refreshItemMetadata called with empty itemId aborting metadata refresh request',
'refreshItemMetadata called with empty itemId - aborting metadata refresh request',
);
expect(jellyfinApiMocks.refreshItem).not.toHaveBeenCalled();
@@ -1516,7 +1516,7 @@ describe('JellyfinAdapterService', () => {
isLocked: true,
}),
);
// Collections are created empty item ids must never be sent on create
// Collections are created empty - item ids must never be sent on create
// (they go in the query string → HTTP 414 at scale); items are added via
// addBatchToCollection.
expect(collectionApiMocks.createCollection).not.toHaveBeenCalledWith(
@@ -408,7 +408,7 @@ export class JellyfinAdapterService implements IMediaServerService {
* Pick a single random media item (movie or series, configurable) from the
* given section ids. When no section ids are provided, picks across all
* supported libraries. Uses Jellyfin's native `ItemSortBy.Random` so the
* server does the randomisation no client-side sampling needed.
* server does the randomisation - no client-side sampling needed.
*/
async findRandomItem(
sectionIds: string[] | undefined,
@@ -419,7 +419,7 @@ export class JellyfinAdapterService implements IMediaServerService {
try {
const userId = await this.getUserId();
// The overlay editor UI passes a single section key; for "all sections"
// the caller omits the param. Anything else is unsupported the
// the caller omits the param. Anything else is unsupported - the
// recursive getItems call spans the selected parent or the whole server.
const parentId = sectionIds?.[0];
const response = await getItemsApi(this.api).getItems({
@@ -456,7 +456,7 @@ export class JellyfinAdapterService implements IMediaServerService {
/**
* Download the raw bytes of a specific image for an item. The `imageType`
* is the caller's choice `Primary` for movie/show posters, `Thumb` for
* is the caller's choice - `Primary` for movie/show posters, `Thumb` for
* episode title-card stills. Forces JPEG so callers can rely on a known
* Content-Type (the overlay editor's /poster proxy hard-codes image/jpeg;
* the render pipeline also emits JPEG). Returns null when the item has no
@@ -488,7 +488,7 @@ export class JellyfinAdapterService implements IMediaServerService {
/**
* Replace the given image type on an item. Sends the image as a
* base64-encoded string body the Jellyfin server (at least through the
* base64-encoded string body - the Jellyfin server (at least through the
* versions this project targets) rejects raw binary payloads on this
* endpoint with a 500, despite the OpenAPI description hinting at
* `image/*` binary. Base64 is the empirically-verified working path; see
@@ -654,11 +654,11 @@ export class JellyfinAdapterService implements IMediaServerService {
error instanceof AxiosError ? error.response?.status : undefined;
if (status === 404) {
this.logger.debug(
'Jellyfin /System/Info/Storage not available server is older than 10.11',
'Jellyfin /System/Info/Storage not available - server is older than 10.11',
);
} else if (status === 401 || status === 403) {
this.logger.debug(
'Jellyfin /System/Info/Storage denied the configured user is not an administrator',
'Jellyfin /System/Info/Storage denied - the configured user is not an administrator',
);
} else {
this.logger.debug(error);
@@ -887,7 +887,7 @@ export class JellyfinAdapterService implements IMediaServerService {
/**
* Confirm a Jellyfin item is still present. Distinguishes "definitely
* gone" (200 with empty Items, or 404) from "could not check" the
* gone" (200 with empty Items, or 404) from "could not check" - the
* latter throws so revert callers don't drop their state on a blip.
* An uninitialised adapter is treated as inconclusive (throws) for the
* same reason: callers must never delete the only restore-from-overlay
@@ -1045,7 +1045,7 @@ export class JellyfinAdapterService implements IMediaServerService {
async prefetchWatchHistory(): Promise<void> {
// Jellyfin has no central watch-history endpoint (history is per-user), so
// there is nothing to bulk prefetch. Gated by
// supportsFeature(CENTRAL_WATCH_HISTORY) which is false for Jellyfin
// supportsFeature(CENTRAL_WATCH_HISTORY) which is false for Jellyfin -
// callers shouldn't reach here.
throw new Error(
'Bulk watch-history prefetch is not supported on Jellyfin (per-user history)',
@@ -1116,7 +1116,7 @@ export class JellyfinAdapterService implements IMediaServerService {
if (!item) continue;
// A collection can track an episode at any level, so protect the
// episode and its season and series. ParentId is intentionally
// omitted for Jellyfin movies it is the library folder, not a
// omitted - for Jellyfin movies it is the library folder, not a
// collectable ancestor. Movies only carry Id.
if (item.Id) playing.add(item.Id);
if (item.SeasonId) playing.add(item.SeasonId);
@@ -1136,7 +1136,7 @@ export class JellyfinAdapterService implements IMediaServerService {
* Jellyfin's Series Played flag is an all-or-nothing aggregate, so the
* show-level watch history degenerates to sw_allEpisodesSeenBy (#2559).
* One getItems call per user (batched via mapUsersBatched, shared with
* getAllUserItemData) O(users), not O(users × episodes).
* getAllUserItemData) - O(users), not O(users × episodes).
*/
async getDescendantEpisodeWatchers(parentId: string): Promise<string[]> {
if (!this.api) return [];
@@ -1160,7 +1160,7 @@ export class JellyfinAdapterService implements IMediaServerService {
// Ignore unaired placeholders (mirrors #2624).
excludeLocationTypes: [LocationType.Virtual],
enableUserData: true,
// Minimize payload we only need UserData per episode.
// Minimize payload - we only need UserData per episode.
fields: [],
})
).data.Items ?? [],
@@ -1312,7 +1312,7 @@ export class JellyfinAdapterService implements IMediaServerService {
try {
// Use getItems with enableUserData instead of the dedicated
// getItemUserData endpoint the latter does not reliably return
// getItemUserData endpoint - the latter does not reliably return
// per-user data when authenticating with an API key on all
// Jellyfin versions.
const response = await getItemsApi(this.api).getItems({
@@ -1489,7 +1489,7 @@ export class JellyfinAdapterService implements IMediaServerService {
if (await this.getCollection(collectionId).then(Boolean)) {
this.logger.error(`Failed to delete collection ${collectionId}`);
this.logger.debug(error);
// Throw before the cache invalidation below the collection still
// Throw before the cache invalidation below - the collection still
// exists, so cached entries are still valid.
throw error;
}
@@ -2040,13 +2040,13 @@ export class JellyfinAdapterService implements IMediaServerService {
async deleteFromDisk(itemId: string): Promise<void> {
if (!this.api) {
throw new Error(
'Jellyfin API not initialized cannot delete item from disk',
'Jellyfin API not initialized - cannot delete item from disk',
);
}
if (!itemId || itemId.trim() === '') {
throw new Error(
'deleteFromDisk called with empty itemId aborting to prevent unintended deletion',
'deleteFromDisk called with empty itemId - aborting to prevent unintended deletion',
);
}
@@ -2081,13 +2081,13 @@ export class JellyfinAdapterService implements IMediaServerService {
async refreshItemMetadata(itemId: string): Promise<void> {
if (!this.api) {
throw new Error(
'Jellyfin API not initialized cannot refresh item metadata',
'Jellyfin API not initialized - cannot refresh item metadata',
);
}
if (isBlankMediaServerId(itemId)) {
throw new Error(
'refreshItemMetadata called with empty itemId aborting metadata refresh request',
'refreshItemMetadata called with empty itemId - aborting metadata refresh request',
);
}
@@ -18,7 +18,7 @@ export const JELLYFIN_BATCH_SIZE = {
} as const;
/**
* Default query options for every library-scoped getItems() call i.e.
* Default query options for every library-scoped getItems() call - i.e.
* any call whose parentId is a library (or no parentId for a global recursive
* search) and that expects to surface real media items.
*
@@ -132,7 +132,7 @@ describe('media-server-id.utils', () => {
// #2853: malformed strings used to slip through because the filter only
// rejected the all-zero Guid and Plex-shaped numeric IDs. Anything else
// truncated UUIDs, non-hex garbage, fully-dashed but wrong-length must
// - truncated UUIDs, non-hex garbage, fully-dashed but wrong-length - must
// now be rejected before Maintainerr sends it to Jellyfin's refresh queue.
it.each([
'abc',
@@ -86,8 +86,8 @@ export function shouldRefreshMetadataItemId(
}
// Jellyfin Ids are always either 32-char unbroken hex or a 36-char dashed
// UUID. Anything else truncated strings, garbage from migrations, the
// all-zero "empty Guid" is rejected before the refresh request is issued
// UUID. Anything else - truncated strings, garbage from migrations, the
// all-zero "empty Guid" - is rejected before the refresh request is issued
// so Jellyfin doesn't end up parsing the route as `Guid.Empty` and spamming
// `ProviderManager.StartProcessingRefreshQueue` with "Guid can't be empty"
// errors (#2853). The previous filter only rejected the all-zero Guid and
@@ -283,7 +283,7 @@ export class MediaServerFactory {
return adapter;
}
// Connection is dead force re-initialization
// Connection is dead - force re-initialization
this.logger.debug(
'Media server unreachable during pre-job check, attempting re-initialization',
);
@@ -95,7 +95,7 @@ export interface IMediaServerService {
/**
* Compute per-library size on disk by enumerating items. Potentially slow
* meant to be called on demand. Returns a map of library id → bytes.
* - meant to be called on demand. Returns a map of library id → bytes.
* Libraries missing from the map could not be sized.
*/
computeLibraryStorageSizes(): Promise<Map<string, number>>;
@@ -166,7 +166,7 @@ export interface IMediaServerService {
* HTTP requests.
*
* Gated by MediaServerFeature.CENTRAL_WATCH_HISTORY (a centrally queryable
* history endpoint). Throws if not supported callers must check
* history endpoint). Throws if not supported - callers must check
* supportsFeature() first; when unsupported, evaluation uses per-item queries.
*/
prefetchWatchHistory(abortSignal?: AbortSignal): Promise<void>;
@@ -206,7 +206,7 @@ export interface IMediaServerService {
* an active streaming session. The collection worker uses this to defer
* handling of in-use media to the next run (deletion is the case that
* matters; the occasional non-destructive action is deferred too rather
* than scoped a deliberate simplification).
* than scoped - a deliberate simplification).
*
* For hierarchical media the set includes every level a collection might
* track: a playing episode contributes its own id plus its season and show
@@ -214,7 +214,7 @@ export interface IMediaServerService {
* protected.
*
* Best-effort: returns an empty set when nothing is playing and, after the
* HTTP client's own retries, when the lookup could not be completed so a
* HTTP client's own retries, when the lookup could not be completed - so a
* session outage degrades to the pre-existing behaviour (handle as usual)
* rather than blocking the run. The worker reads this once at the start of a
* run, so media that starts playing mid-run isn't protected until the next
@@ -332,7 +332,7 @@ export interface IMediaServerService {
* Set the primary poster image on a collection on the media server.
*
* Maintainerr is one writer among several (Kometa, Posterizarr, manual
* uploads). This is a single write last writer wins. Unlike per-item
* uploads). This is a single write - last writer wins. Unlike per-item
* overlays (which re-apply on cron because they carry day-counter state),
* collection posters carry no per-cycle state, so callers should write
* only when the source bytes change (user upload, collection re-create);
@@ -387,7 +387,7 @@ export interface IMediaServerService {
/**
* Ask the media server to re-fetch metadata for a specific item from its
* own configured agents. This is a best-effort, fire-and-forget operation
* on the server side the call returns quickly while the server works async.
* on the server side - the call returns quickly while the server works async.
*/
refreshItemMetadata(itemId: string): Promise<void>;
}
@@ -120,7 +120,7 @@ describe('PlexAdapterService', () => {
it('should reject blank item ids before calling PlexApiService', async () => {
await expect(service.refreshItemMetadata(' ')).rejects.toThrow(
'refreshItemMetadata called with empty itemId aborting metadata refresh request',
'refreshItemMetadata called with empty itemId - aborting metadata refresh request',
);
expect(plexApi.refreshMediaMetadata).not.toHaveBeenCalled();
@@ -777,7 +777,7 @@ export class PlexAdapterService implements IMediaServerService {
async deleteFromDisk(itemId: string): Promise<void> {
if (!itemId || itemId.trim() === '') {
throw new Error(
'deleteFromDisk called with empty itemId aborting to prevent unintended deletion',
'deleteFromDisk called with empty itemId - aborting to prevent unintended deletion',
);
}
@@ -815,7 +815,7 @@ export class PlexAdapterService implements IMediaServerService {
async refreshItemMetadata(itemId: string): Promise<void> {
if (isBlankMediaServerId(itemId)) {
throw new Error(
'refreshItemMetadata called with empty itemId aborting metadata refresh request',
'refreshItemMetadata called with empty itemId - aborting metadata refresh request',
);
}
@@ -9,7 +9,7 @@ export const PLEX_PAGE_SIZE = {
export const PLEX_REQUEST_TIMEOUT_MS = 30_000;
// Key in the 'plexwatchhistory' cache for the leaf watch-history map built by
// prefetchWatchHistory() leaf items (movies + episodes) keyed by own
// prefetchWatchHistory() - leaf items (movies + episodes) keyed by own
// ratingKey. TTL and flush behaviour live on the cache definition in
// lib/cache.ts.
export const WATCH_HISTORY_BULK_CACHE_KEY = 'watch-history-bulk';
@@ -801,7 +801,7 @@ describe('PlexApiService.prefetchWatchHistory', () => {
const cacheManager = (await import('../lib/cache')).default;
// A full-looking page with no totalSize: queryAll may have truncated, so the
// map must NOT be cached callers fall back to the per-item query instead.
// map must NOT be cached - callers fall back to the per-item query instead.
const queryAll = jest.fn().mockResolvedValue({
MediaContainer: {
Metadata: [
@@ -1076,7 +1076,7 @@ describe('PlexApiService.getWatchHistory bulk map', () => {
it('falls through to per-item query for untyped callers on a leaf-map miss', async () => {
// Untyped callers may pass show or season ratingKeys, which are never in
// the leaf map a miss must not be reported as confirmed-empty history.
// the leaf map - a miss must not be reported as confirmed-empty history.
const cacheManager = (await import('../lib/cache')).default;
cacheManager
.getCache('plexwatchhistory')
@@ -187,7 +187,7 @@ export class PlexApiService {
this.plexClient = undefined;
this.plexCommunityClient = undefined;
this.plexTvClient = undefined;
// Drop the watch-history snapshot too on a server/token switch it would
// Drop the watch-history snapshot too - on a server/token switch it would
// otherwise serve the previous server's history for up to its TTL.
this.watchHistoryPrefetch = undefined;
cacheManager.getCache('plexguid').data.flushAll();
@@ -237,7 +237,7 @@ export class PlexApiService {
if (settingsPlex.manualMode) {
this.plexClient = undefined;
this.logger.warn(
'Plex connection failed (manual mode active skipping re-discovery)',
'Plex connection failed (manual mode active - skipping re-discovery)',
);
return;
}
@@ -270,7 +270,7 @@ export class PlexApiService {
if (!storedMachineId) {
this.logger.debug(
'No stored machine ID cannot identify server for re-discovery',
'No stored machine ID - cannot identify server for re-discovery',
);
return false;
}
@@ -306,7 +306,7 @@ export class PlexApiService {
const ok = await testClient.getStatus();
if (!ok) continue;
// Found a working connection promote it
// Found a working connection - promote it
this.plexClient = new PlexApi({
hostname: conn.address,
port: conn.port,
@@ -708,11 +708,11 @@ export class PlexApiService {
* Show/season history is intentionally NOT rolled up here: the bulk endpoint
* keys each row by leaf ratingKey, and grouping to show/season would depend on
* grandparentKey/parentKey, which are undocumented on this endpoint and absent
* over some Plex connections a missing key would silently read as "never
* over some Plex connections - a missing key would silently read as "never
* watched". Show/season queries therefore fall through to the per-item
* metadataItemID query in getWatchHistory, which Plex rolls up server-side.
*
* On failure the error is logged and swallowed getWatchHistory falls back
* On failure the error is logged and swallowed - getWatchHistory falls back
* to per-item queries automatically when the map is absent.
*/
public prefetchWatchHistory(abortSignal?: AbortSignal): Promise<void> {
@@ -763,7 +763,7 @@ export class PlexApiService {
if (typeof totalSize !== 'number' || records.length < totalSize) {
this.logger.warn(
`Watch history prefetch returned an unverifiable result ` +
`(received ${records.length}, totalSize ${totalSize ?? 'absent'}) ` +
`(received ${records.length}, totalSize ${totalSize ?? 'absent'}) - ` +
`falling back to per-item queries.`,
);
return;
@@ -784,7 +784,7 @@ export class PlexApiService {
.data.set(WATCH_HISTORY_BULK_CACHE_KEY, leafMap);
this.logger.log(
`Watch history prefetch complete: ${records.length} records ` +
`Watch history prefetch complete: ${records.length} records - ` +
`${leafMap.size} leaf items.`,
);
} catch (error) {
@@ -793,13 +793,13 @@ export class PlexApiService {
}
this.logger.warn(
`Watch history prefetch failed falling back to per-item queries. Error: ${error}`,
`Watch history prefetch failed - falling back to per-item queries. Error: ${error}`,
);
}
}
// The 'plexwatchhistory' cache stores by reference (useClones: false), so
// always hand callers a copy plex-getter sorts these arrays in place.
// always hand callers a copy - plex-getter sorts these arrays in place.
private getBulkWatchHistory(
mapKey: string,
itemId: string,
@@ -842,7 +842,7 @@ export class PlexApiService {
break;
default: {
// Untyped callers may pass any kind of ratingKey, so only a non-empty
// leaf hit is trusted a miss falls through to the per-item query.
// leaf hit is trusted - a miss falls through to the per-item query.
const records = this.getBulkWatchHistory(
WATCH_HISTORY_BULK_CACHE_KEY,
itemId,
@@ -872,7 +872,7 @@ export class PlexApiService {
* Returns the items in every active play session. Plex's
* `/status/sessions` returns only the `MediaContainer` (no `Metadata`) when
* nothing is playing, so an empty array is the normal "idle" result. Never
* cached sessions are live state. Best-effort: the plexClient retries
* cached - sessions are live state. Best-effort: the plexClient retries
* transient failures (axios-retry, exponential backoff), and a persistent
* failure returns [] so a session outage degrades to normal handling rather
* than blocking the run.
@@ -1077,7 +1077,7 @@ export class PlexApiService {
try {
// Plex move is per-item. Omitting `after` puts the item at the front;
// otherwise it lands immediately after `afterId`. Reordering a full
// collection is therefore O(n) sequential PUTs acceptable for the
// collection is therefore O(n) sequential PUTs - acceptable for the
// collection sizes Maintainerr manages.
const afterQuery = afterId ? `?after=${afterId}` : '';
await this.plexClient.putQuery({
@@ -1224,7 +1224,7 @@ export class PlexApiService {
message: string;
} {
// lib/plexApi wraps Axios failures in a plain Error with the original
// attached as `cause` unwrap it, or the status and response body
// attached as `cause` - unwrap it, or the status and response body
// (Plex's actual rejection reason) never reach the logs.
const cause = error instanceof Error ? error.cause : undefined;
const axiosError = axios.isAxiosError(error)
@@ -1937,7 +1937,7 @@ export class PlexApiService {
* Confirm a Plex item is still present.
*
* `getItemType` swallows every error as `null`, which conflates "gone"
* with "I couldn't ask right now" fine for type lookup, dangerous for
* with "I couldn't ask right now" - fine for type lookup, dangerous for
* cleanup decisions that delete the only restore-from-overlay backup.
* This variant returns `false` only when Plex explicitly reports 404
* and rethrows on auth / network / 5xx so callers preserve state.
@@ -2080,7 +2080,7 @@ export class PlexApiService {
const episode = withThumb[Math.floor(Math.random() * withThumb.length)];
const displayTitle = episode.grandparentTitle
? `${episode.grandparentTitle} ${episode.title ?? episode.ratingKey}`
? `${episode.grandparentTitle} - ${episode.title ?? episode.ratingKey}`
: (episode.title ?? String(episode.ratingKey));
return { plexId: String(episode.ratingKey), title: displayTitle };
@@ -332,7 +332,7 @@ describe('SeerrApiService', () => {
await expect(service.getRequestsForMedia(100)).resolves.toHaveLength(2);
await expect(service.getRequestsForMedia(200)).resolves.toHaveLength(1);
await expect(service.getRequestsForMedia(999)).resolves.toEqual([]);
// One sweep total later lookups are served from the cached index.
// One sweep total - later lookups are served from the cached index.
expect(getWithoutCache).toHaveBeenCalledTimes(1);
// Returned values are deep copies: neither reshaping the array nor
@@ -332,7 +332,7 @@ export class SeerrApiService {
// The HTTP helper swallows request failures and returns undefined; a
// genuine empty result still carries pageInfo. A missing pageInfo means
// the sweep failed surface that (transient), don't read it as empty.
// the sweep failed - surface that (transient), don't read it as empty.
if (!resp?.pageInfo) {
return undefined;
}
@@ -367,8 +367,8 @@ export class SeerrApiService {
* Returns a deep copy of the title's request list (the cache holds the Map by
* reference with useClones off), so callers may read or mutate it freely
* without corrupting the shared index. `[]` means the sweep succeeded and the
* title has no request (definitive). `undefined` means the sweep failed
* Seerr is unreachable so the getter returns `undefined` (transient) and the
* title has no request (definitive). `undefined` means the sweep failed -
* Seerr is unreachable - so the getter returns `undefined` (transient) and the
* comparator protects the item rather than treating it as "not requested".
*/
public async getRequestsForMedia(
@@ -415,7 +415,7 @@ export class SeerrApiService {
// requestDate reads requests[0].createdAt and the legacy per-item
// getMovie/getShow path returned mediaInfo.requests oldest-first. The bulk
// /request sweep is newest-first, so sort ascending by createdAt (tie-break
// on id) requestDate, addUser and the season ordering then match the
// on id) - requestDate, addUser and the season ordering then match the
// pre-#3152 behaviour regardless of how Seerr happened to page the sweep.
requests.sort(
(a, b) =>
@@ -424,7 +424,7 @@ export class SeerrApiService {
);
// Group by media.tmdbId: Seerr keys every media row by tmdbId (non-null,
// indexed tvdbId/imdbId are optional extras), and the metadata service
// indexed - tvdbId/imdbId are optional extras), and the metadata service
// resolves each library item to that tmdbId via all its providers (with
// tvdb/imdb -> tmdb bridging), so tmdbId is the canonical join key (and
// matches the per-item getMovie/getShow path this replaces). media.requests
@@ -254,7 +254,7 @@ export abstract class ServarrApi<QueueItemAppendT> extends ExternalApiService {
/**
* Resolve a tag id for `label`, creating the tag if it doesn't exist yet.
* Matching is case-insensitive *arr stores labels verbatim but treats them
* Matching is case-insensitive - *arr stores labels verbatim but treats them
* case-insensitively, so we never create a duplicate that differs only in case.
*
* Race-tolerant: if the create fails (another caller created the same label in
@@ -277,7 +277,7 @@ export abstract class ServarrApi<QueueItemAppendT> extends ExternalApiService {
return created.id;
}
// Create failed (already exists from a concurrent caller, or errored)
// Create failed (already exists from a concurrent caller, or errored) -
// re-read and return the id if the label is now present.
return match(await this.getTags());
};
@@ -90,7 +90,7 @@ describe('RadarrApi', () => {
});
// A 400 from a different validation rule (e.g. an invalid year) is a real
// failure it must not be silently reported as "already excluded".
// failure - it must not be silently reported as "already excluded".
it('returns false on a non-duplicate validation 400', async () => {
postSpy.mockRejectedValue({
isAxiosError: true,
@@ -48,7 +48,7 @@ export class RadarrApi extends ServarrApi<{ movieId: number }> {
};
// Intentionally uncached: this drives rule evaluation and resolves the
// movie that actions then mutate both need Radarr's current truth, not a
// movie that actions then mutate - both need Radarr's current truth, not a
// snapshot that can be up to DEFAULT_TTL stale.
// Returns `null` when Radarr confirms the movie isn't tracked (empty
// response) and `undefined` when the lookup itself failed (transport, auth,
@@ -99,7 +99,7 @@ export class RadarrApi extends ServarrApi<{ movieId: number }> {
/**
* Add or remove a single tag on a batch of movies via the movie editor.
* `applyTags: 'add' | 'remove'` only never 'replace', which would wipe every
* `applyTags: 'add' | 'remove'` only - never 'replace', which would wipe every
* other tag the user has on those movies. Best-effort: returns false on failure
* (callers treat tagging as non-fatal). No-ops on an empty id list.
*/
@@ -217,7 +217,7 @@ export class RadarrApi extends ServarrApi<{ movieId: number }> {
* "already added" 400 as success rather than failing the whole collection
* action (its unmonitor/delete has already run) on every re-run. The validator
* also enforces non-empty tmdbId/title and a non-negative year, so a 400 from
* one of those is a real failure surface it instead of silently marking the
* one of those is a real failure - surface it instead of silently marking the
* movie excluded when it isn't.
*
* Goes through the shared post() client (rethrowing so we can read the status)
@@ -259,7 +259,7 @@ export class RadarrApi extends ServarrApi<{ movieId: number }> {
/**
* True only for the exclusion validator's uniqueness failure. Radarr returns a
* 400 with an array of `{ propertyName, errorMessage }`; the uniqueness rule is
* the one that means "already excluded goal met". Any other validation
* the one that means "already excluded - goal met". Any other validation
* failure (empty title, negative year, …) must stay a failure.
*/
private isAlreadyExcludedError(body: unknown): boolean {
@@ -204,7 +204,7 @@ describe('SonarrApi', () => {
// contract: `undefined` = the lookup itself failed (fail closed), `null` =
// Sonarr confirmed the series isn't tracked. getWithoutCache swallows HTTP
// errors to `undefined` without throwing, so the failure must be detected
// from that value the catch path never sees it. (#3125)
// from that value - the catch path never sees it. (#3125)
describe('getSeriesByTvdbId null/undefined contract (#3125)', () => {
it('returns undefined when the lookup fails transiently (getWithoutCache → undefined)', async () => {
jest
@@ -107,7 +107,7 @@ export class SonarrApi extends ServarrApi<{
// Intentionally uncached: this endpoint is read straight after mutations
// (unmonitor + file deletes) by the empty-show cleanup, and also drives
// rule evaluation both need Sonarr's current truth, not a snapshot that
// rule evaluation - both need Sonarr's current truth, not a snapshot that
// can be up to DEFAULT_TTL stale (see issue #2757 / #2891).
// Returns `null` when Sonarr confirms the series isn't tracked (empty
// response) and `undefined` when the lookup itself failed (transport, auth,
@@ -153,7 +153,7 @@ export class SonarrApi extends ServarrApi<{
/**
* Add or remove a single tag on a batch of series via the series editor.
* `applyTags: 'add' | 'remove'` only never 'replace', which would wipe every
* `applyTags: 'add' | 'remove'` only - never 'replace', which would wipe every
* other tag the user has on those series. Best-effort: returns false on failure
* (callers treat tagging as non-fatal). No-ops on an empty id list. Sonarr tags
* are series-level; there is no per-season tag.
@@ -66,7 +66,7 @@ export interface Tag {
}
/**
* A Radarr/Sonarr history record. `downloadId` is the download-client item id
* A Radarr/Sonarr history record. `downloadId` is the download-client item id -
* for a torrent client (e.g. qBittorrent) this is the torrent infohash. Only
* the fields we consume are typed.
*
@@ -294,7 +294,7 @@ describe('StreamystatsApiService', () => {
});
expect(result.status).toBe('OK');
// The helper is constructed with url only no apiKey leaks to a
// The helper is constructed with url only - no apiKey leaks to a
// user-supplied URL via /api/settings/test/streamystats.
const callArgs = StreamystatsApiMock.mock.calls.at(-1)?.[0];
expect(callArgs?.url).toBe('http://streamystats');
@@ -143,8 +143,8 @@ export class StreamystatsApiService {
* so callers can skip rather than treat absence as "not watchlisted".
*
* The watchlist endpoints authenticate via Jellyfin's MediaBrowser token
* scheme unlike the item-details endpoint, the `Bearer` header is rejected
* so each call overrides the Authorization header accordingly.
* scheme - unlike the item-details endpoint, the `Bearer` header is rejected
* - so each call overrides the Authorization header accordingly.
*/
public async getWatchlistMembership(): Promise<StreamystatsWatchlistMembership | null> {
if (!this.api || !this.settings.jellyfin_api_key) {
@@ -536,7 +536,7 @@ describe('CollectionHandler', () => {
await collectionHandler.handleMedia(collection, collectionMedia);
// The post-handle save must carry the cleared link forward saving the
// The post-handle save must carry the cleared link forward - saving the
// stale snapshot would resurrect the dead BoxSet id and force the next rule
// run to rediscover it via a 404.
expect(collectionsService.saveCollection).toHaveBeenCalledWith(
@@ -18,7 +18,7 @@ import { RecentlyHandledMediaService } from './recently-handled-media.service';
* - `handled`: the configured action ran and the item was processed.
* - `failed`: the action could not be completed; the item stays for retry.
* - `removed-missing`: the item no longer existed on the media server and was
* pruned from the collection(s) a cleanup, not a failure or a real handle.
* pruned from the collection(s) - a cleanup, not a failure or a real handle.
*/
export type HandleMediaResult = 'handled' | 'failed' | 'removed-missing';
@@ -114,8 +114,8 @@ export class CollectionHandler {
// The action didn't run. Before treating this as a retryable failure,
// check whether the item still exists: if it's already gone from the
// media server there is nothing left to act on, and leaving it in the
// collection means re-processing it and re-resolving its dead BoxSet
// link on every run (#3023). A failed existence check is treated as
// collection means re-processing it - and re-resolving its dead BoxSet
// link - on every run (#3023). A failed existence check is treated as
// "still present" so a transient blip never drops a live item.
let exists = true;
try {
@@ -134,7 +134,7 @@ export class CollectionHandler {
// The removal-by-id is a no-op on the media server for a gone item (Plex
// skips 404, Jellyfin/Emby return 2xx), so these drop the stale DB rows.
// A genuinely transient removal failure keeps the row, which the next run
// retries no permanent stale state, so no special-casing needed here.
// retries - no permanent stale state, so no special-casing needed here.
await this.collectionService.removeFromCollection(collection.id, [
{
mediaServerId: media.mediaServerId,
@@ -185,7 +185,7 @@ export class CollectionHandler {
}
case 'episode': {
// Seerr tracks requests per season, not per episode, so there is
// no per-episode request to remove deleting the season request
// no per-episode request to remove - deleting the season request
// would drop the request for every other (still-present) episode
// in that season. Skip the force-removal and let Seerr's
// availability sync reconcile, as it does when Force Seerr is off.
@@ -270,8 +270,8 @@ export class CollectionHandler {
* dead BoxSet link doesn't linger and get re-resolved on every rule run
* (#3023). Each pruned sibling is marked handled: the rule executor checks
* that guard per collection, so without it the sibling's next pass could
* re-add the item it may still resolve on the media server, and conditions
* like `isWatched` stay true firing a spurious `Media Added` notification
* re-add the item - it may still resolve on the media server, and conditions
* like `isWatched` stay true - firing a spurious `Media Added` notification
* and recreating the link this cleanup just removed.
*/
private async pruneSiblingCollections(
@@ -19,14 +19,14 @@ export class InvalidCollectionPosterError extends Error {}
* Storage model:
* - One JPEG per Maintainerr collection at
* `data/collection-posters/{collectionDbId}.jpg`. File presence is the
* flag there is no DB column. Same on-disk pattern as the overlay
* flag - there is no DB column. Same on-disk pattern as the overlay
* originals backup ([overlay-processor.service.ts](../overlays/overlay-processor.service.ts)).
* - Db id is stable across media-server collection re-creates, so a stored
* poster survives Plex/Jellyfin collection deletion + recreation.
*
* Coexistence:
* - Maintainerr is one writer among several (Kometa, Posterizarr, manual
* uploads). This is a single write last writer wins. Unlike per-item
* uploads). This is a single write - last writer wins. Unlike per-item
* overlays (which re-apply on cron because they carry day-counter state),
* collection posters carry no per-cycle state, so callers should write
* only when the source bytes change (user upload, collection re-create);
@@ -91,7 +91,7 @@ export class CollectionPosterService {
.toBuffer();
} catch (error) {
this.logger.warn(
`Rejected collection ${collectionDbId} poster upload not a valid image`,
`Rejected collection ${collectionDbId} poster upload - not a valid image`,
);
this.logger.debug(error);
throw new InvalidCollectionPosterError(
@@ -109,7 +109,7 @@ export class CollectionPosterService {
}
/**
* Remove the stored poster file. Does not touch the media-server side
* Remove the stored poster file. Does not touch the media-server side -
* the user must clear/refresh the poster in Plex/Jellyfin themselves if
* they want the original artwork back.
*/
@@ -122,7 +122,7 @@ export class CollectionPosterService {
/**
* Issue a generic metadata-refresh request to the media server for the
* given collection. This does not force image replacement it just nudges
* given collection. This does not force image replacement - it just nudges
* Plex/Jellyfin to re-evaluate their own artwork sources, which may or may
* not change the visible poster depending on configured agents and server
* caching. Best-effort: returns `requested: false` when no mediaServerId is
@@ -140,7 +140,7 @@ export class CollectionPosterService {
mediaServer = await this.mediaServerFactory.getService();
} catch (error) {
this.logger.warn(
'Cannot refresh collection metadata no media server configured',
'Cannot refresh collection metadata - no media server configured',
);
this.logger.debug(error);
return { requested: false };
@@ -177,7 +177,7 @@ export class CollectionPosterService {
mediaServer = await this.mediaServerFactory.getService();
} catch (error) {
this.logger.warn(
'Cannot push collection poster no media server configured',
'Cannot push collection poster - no media server configured',
);
this.logger.debug(error);
return { attempted: false, pushed: false };
@@ -256,7 +256,7 @@ describe('CollectionWorkerService', () => {
await collectionWorkerService.execute();
// The item was already gone nothing on disk changed, so no sync and
// The item was already gone - nothing on disk changed, so no sync and
// neither the handled nor the failed notification fires.
expect(seerrApi.api.post).not.toHaveBeenCalled();
expect(eventEmitter.emit).not.toHaveBeenCalledWith(
@@ -95,7 +95,7 @@ export class CollectionWorkerService extends TaskBase {
}
// Currently-playing media is deferred to the next run so we don't act on
// it chiefly delete it out from under an active viewer. Best-effort:
// it - chiefly delete it - out from under an active viewer. Best-effort:
// fetched once at the start of the run (media that starts playing mid-run
// isn't protected until next time), and an empty set (nothing playing or
// a failed lookup) simply means "handle as usual".
@@ -246,7 +246,7 @@ export class CollectionWorkerService extends TaskBase {
// The item was already gone from the media server and has been
// pruned from the collection(s). It wasn't a failure and nothing
// on disk was altered, so it stays out of both notification lists
// and doesn't trigger availability sync the handler already
// and doesn't trigger availability sync - the handler already
// logged the cleanup.
removedMissingMedia++;
} else {
@@ -2081,7 +2081,7 @@ describe('CollectionsService', () => {
// `deleteSoonest` is equivalent to ordering by `collection_media.addDate`
// because `deleteAfterDays` is constant across a collection. SQL does the
// pagination so we don't have to hydrate every row in the collection
// before slicing critical for collections with hundreds of items where
// before slicing - critical for collections with hundreds of items where
// hydrating all rows would block the UI for minutes.
const collection = createCollection({
id: 9,
@@ -2707,7 +2707,7 @@ describe('CollectionsService', () => {
childCount: rows.length,
} as any);
// Hand back metadata where every item has the same library addedAt
// Hand back metadata where every item has the same library addedAt -
// if the comparator falls through to MediaItem.addedAt (the bug) the
// ordering becomes whatever Map iteration gives us; the assertion
// below would fail.
@@ -2811,7 +2811,7 @@ describe('CollectionsService', () => {
);
expect(found?.id).toBe('box-1');
// Own library searched first, then the other one never re-searching it.
// Own library searched first, then the other one - never re-searching it.
expect(mediaServer.getCollections).toHaveBeenCalledWith('shows');
expect(mediaServer.getCollections).toHaveBeenCalledWith('movies');
expect(mediaServer.getCollections).toHaveBeenCalledTimes(2);
@@ -235,7 +235,7 @@ export class CollectionsService {
* automatic collection sharing this collection's media server collection.
*
* Throws on repository failure. Callers must treat a thrown error as
* "ownership unknown" silently defaulting to an empty set would
* "ownership unknown" - silently defaulting to an empty set would
* re-introduce the cross-rule contamination this method exists to prevent
* (sibling-owned children would be imported as `manual` into the wrong
* rule's collection_media).
@@ -321,7 +321,7 @@ export class CollectionsService {
);
this.logger.debug(error);
// An exception is not evidence the server rejected the adds, so
// report nothing attempted callers must not heal on this.
// report nothing attempted - callers must not heal on this.
return { attempted: 0, rejected: 0 };
}
}
@@ -329,7 +329,7 @@ export class CollectionsService {
/**
* Collections healed once already (per process). A second total
* rejection without an accepted add in between means recreation didn't
* fix the cause stop churning delete/recreate and leave the loud log.
* fix the cause - stop churning delete/recreate and leave the loud log.
*/
private readonly healedCollectionIds = new Set<number>();
@@ -359,7 +359,7 @@ export class CollectionsService {
if (this.healedCollectionIds.has(collection.id)) {
this.logger.error(
`Media server collection for "${collection.title}" still rejects every add after being recreated leaving it in place. Check the Plex response body logged above for the rejection reason.`,
`Media server collection for "${collection.title}" still rejects every add after being recreated - leaving it in place. Check the Plex response body logged above for the rejection reason.`,
);
return false;
}
@@ -374,7 +374,7 @@ export class CollectionsService {
}
this.logger.warn(
`Media server collection ${collection.mediaServerId} for "${collection.title}" is empty and rejected every add deleting it so it can be recreated`,
`Media server collection ${collection.mediaServerId} for "${collection.title}" is empty and rejected every add - deleting it so it can be recreated`,
);
await mediaServer.deleteCollection(serverColl.id);
this.healedCollectionIds.add(collection.id);
@@ -694,7 +694,7 @@ export class CollectionsService {
);
} catch (verificationError) {
this.logger.warn(
`Failed to verify collection "${collection.title}" after getCollectionChildren error keeping link`,
`Failed to verify collection "${collection.title}" after getCollectionChildren error - keeping link`,
);
this.logger.debug(error);
this.logger.debug(verificationError);
@@ -703,13 +703,13 @@ export class CollectionsService {
if (!stillExists) {
this.logger.warn(
`Collection "${collection.title}" references a media server collection that no longer exists clearing stale link`,
`Collection "${collection.title}" references a media server collection that no longer exists - clearing stale link`,
);
collection.mediaServerId = null;
await this.saveCollection(collection);
} else {
this.logger.warn(
`getCollectionChildren failed for "${collection.title}" but collection still exists on server keeping link`,
`getCollectionChildren failed for "${collection.title}" but collection still exists on server - keeping link`,
);
this.logger.debug(error);
}
@@ -906,7 +906,7 @@ export class CollectionsService {
}
try {
// Plex rejects move/prefs on smart collections skip defensively even
// Plex rejects move/prefs on smart collections - skip defensively even
// though Maintainerr-managed collections are non-smart.
const serverCollection = await mediaServer.getCollection(
collection.mediaServerId,
@@ -1018,7 +1018,7 @@ export class CollectionsService {
if (!sort || sort === 'deleteSoonest') {
// SQL-paginate by `collection_media.addDate`. `deleteSoonest` is
// equivalent to `addDate` ordering because `deleteAfterDays` is
// constant for every item in a collection so the only sort key
// constant for every item in a collection - so the only sort key
// that actually matters lives on the `collection_media` row and the
// database can paginate it directly without hydrating MediaItem
// metadata for every row in the collection. This keeps page loads
@@ -1029,7 +1029,7 @@ export class CollectionsService {
// so the polished alphabetical-within-day order is what users see
// when browsing the actual Plex/Jellyfin collection. The Maintainerr
// UI page may show same-day items in a slightly different order
// (by `addDate, id` instead of by title) acceptable because the
// (by `addDate, id` instead of by title) - acceptable because the
// primary sort key is correct and Maintainerr's DB remains the
// source of truth driving the next push.
const direction =
@@ -1052,7 +1052,7 @@ export class CollectionsService {
}
// Explicit sort on a MediaItem-side key (airDate / rating / watchCount /
// title) the sort value isn't on `collection_media`, so we have to
// title) - the sort value isn't on `collection_media`, so we have to
// hydrate the whole collection before paginating. Acceptable because
// these sorts are rarely used compared to `deleteSoonest` and the
// default load.
@@ -1122,7 +1122,7 @@ export class CollectionsService {
// Only remove a row when the media server *confirms* the item is gone.
// `itemExists` returns false solely on a 404/empty result and throws on
// an inconclusive check (network / 5xx / auth), unlike `getMetadata`
// which returns undefined for both absent and failed reads so a
// which returns undefined for both absent and failed reads - so a
// transient blip can no longer delete a still-present item's row.
let exists = true;
try {
@@ -1918,7 +1918,7 @@ export class CollectionsService {
.filter((childId): childId is string => Boolean(childId)),
);
this.logger.debug(
`[checkAutomaticMediaServerLink] Shared collection ${serverColl.id} has ${serverChildIds.size} children checking for local rule-owned drift`,
`[checkAutomaticMediaServerLink] Shared collection ${serverColl.id} has ${serverChildIds.size} children - checking for local rule-owned drift`,
);
const resyncResult =
await this.resyncRuleOwnedItemsToMediaServerCollection(
@@ -1934,7 +1934,7 @@ export class CollectionsService {
}
// An empty shared collection that rejected every resynced item
// can't be repaired in place fall back to delete-and-recreate.
// can't be repaired in place - fall back to delete-and-recreate.
// The sibling rule group loses nothing: the collection has no
// children, and its link re-establishes via the title relink.
if (
@@ -1977,8 +1977,8 @@ export class CollectionsService {
collection.mediaServerId !== null &&
originalMediaServerId !== null
) {
// Jellyfin/Emby: a BoxSet can drain its items re-imported with new
// ids, or a one-time add that didn't fully land and sit on the server
// Jellyfin/Emby: a BoxSet can drain - its items re-imported with new
// ids, or a one-time add that didn't fully land - and sit on the server
// under-populated while the DB still lists its rule-owned items. Re-add
// the missing ones: empty BoxSets accept adds, so this repopulates in
// place, the BoxSet id (with its overlays/poster) stays stable, and
@@ -2007,7 +2007,7 @@ export class CollectionsService {
// A missing server collection has two very different causes: the
// library is fine and simply has no matching items yet (auto-create
// will kick in), or the library the collection targets no longer
// exists on the server (removed, or recreated with a new id) in
// exists on the server (removed, or recreated with a new id) - in
// which case nothing will ever be created and the user must re-point
// the collection. Only claim the library is gone when we positively
// fetched the library list and it's absent; treat an empty/failed
@@ -2020,10 +2020,10 @@ export class CollectionsService {
this.logger.debug(
libraryMissing
? `[checkAutomaticMediaServerLink] Library ${collection.libraryId} for "${collection.title}" no longer exists on the media server clearing link. Re-point the collection at an existing library.`
? `[checkAutomaticMediaServerLink] Library ${collection.libraryId} for "${collection.title}" no longer exists on the media server - clearing link. Re-point the collection at an existing library.`
: originalMediaServerId
? `[checkAutomaticMediaServerLink] Media server collection for "${collection.title}" no longer exists clearing link. It will be recreated automatically when items match the rule.`
: `[checkAutomaticMediaServerLink] No media server collection for "${collection.title}" collection is empty and will be created automatically when items match the rule.`,
? `[checkAutomaticMediaServerLink] Media server collection for "${collection.title}" no longer exists - clearing link. It will be recreated automatically when items match the rule.`
: `[checkAutomaticMediaServerLink] No media server collection for "${collection.title}" - collection is empty and will be created automatically when items match the rule.`,
);
collection.mediaServerId = null;
collection = await this.saveCollection(collection);
@@ -2322,7 +2322,7 @@ export class CollectionsService {
manualMembershipSource,
);
// Only notify for items whose membership was persisted both
// Only notify for items whose membership was persisted - both
// server-rejected items and locally-rolled-back items never
// entered the collection and will be retried (and re-notified)
// on a later run.
@@ -2347,7 +2347,7 @@ export class CollectionsService {
}
// Every add rejected by the server: if the collection is also
// empty it is unpopulatable in place heal by delete so the
// empty it is unpopulatable in place - heal by delete so the
// next pass recreates it fresh. Keyed to server rejections only;
// local persistence failures must never delete the collection.
if (serverRejectedIds.size >= newMedia.length) {
@@ -2426,7 +2426,7 @@ export class CollectionsService {
*
* Called after a delete-style action frees the underlying file. The item
* still resolves on the media server at this point, so removing it from the
* sibling BoxSets now while we still have a valid id to remove keeps the
* sibling BoxSets now - while we still have a valid id to remove - keeps the
* media server from holding unresolved linked-item paths once the library
* drops the item on its next scan. Those dead links are what Jellyfin
* re-resolves on every rule run, producing the "Unable to find linked item
@@ -2435,7 +2435,7 @@ export class CollectionsService {
* the sibling memberships.
*
* Returns the ids of the sibling collections it pruned, so the caller can
* mark the item recently-handled for each of them otherwise the rule
* mark the item recently-handled for each of them - otherwise the rule
* executor's next pass re-adds it (the id still resolves and conditions like
* `isWatched` stay true), recreating the membership this just removed.
*/
@@ -3263,7 +3263,7 @@ export class CollectionsService {
try {
await this.collectionRepo.delete(collection.id);
// Drop any stored poster bytes; the media-server side is left alone
// Drop any stored poster bytes; the media-server side is left alone -
// Plex/Jellyfin will recompute a thumb from member items as usual.
try {
this.collectionPosterService.removeStoredPoster(collection.id);
@@ -3335,7 +3335,7 @@ export class CollectionsService {
// movie<->show crossover is intentional and required: a BoxSet is one
// server-global container that can hold both, and this fallback exists
// precisely to let a show rule find a BoxSet currently populated with
// movies only. Do not narrow this to the collection's own type that
// movies only. Do not narrow this to the collection's own type - that
// would reintroduce the bug. Matching reuses matchCollectionInLibrary so
// the primary and fallback share one comparison; the name match only
// bootstraps the first link, after which the stored mediaServerId is used.
@@ -135,7 +135,7 @@ export class Collection {
// When true, Maintainerr keeps a Radarr/Sonarr tag (label = this collection's
// title / rule group name) on the *arr entity for as long as the item is a
// member of this collection applied on entry, removed on exit.
// member of this collection - applied on entry, removed on exit.
@Column({ nullable: false, default: false })
tagInArr: boolean;
@@ -11,7 +11,7 @@ import { OnEvent } from '@nestjs/event-emitter';
* item from the collection. The rule executor then re-evaluates the same
* conditions seconds later. Conditions like "watched at all" or
* "lastViewedAt before N days" stay true after the action, so the item is
* re-added and a `Media Added` notification fires confusing users who
* re-added and a `Media Added` notification fires - confusing users who
* just received a `Media Removed` event for the same title.
*
* Lifecycle:
@@ -26,7 +26,7 @@ import { OnEvent } from '@nestjs/event-emitter';
*
* State is in-memory and per-collection. A process restart wipes it, so
* one re-add/notification can slip through after a restart until the
* handler runs again acceptable. Because the rule executor consumes
* handler runs again - acceptable. Because the rule executor consumes
* each collection's marks on its next pass, the structure stays bounded
* by what the handler produces between two consecutive rule passes.
*/
@@ -88,7 +88,7 @@ describe('compareMediaItemsBySort tiebreakers', () => {
});
it('does not apply a title tiebreaker to status sorts (manual/excluded)', () => {
// Status sorts intentionally only partition incoming order must be
// Status sorts intentionally only partition - incoming order must be
// preserved within each partition for stable filter UX.
const items: MediaItem[] = [
item({ title: 'C', maintainerrIsManual: true }),
@@ -120,7 +120,7 @@ describe('compareMediaItemsBySort show-aware title ordering', () => {
it('groups episodes from the same show together when sorting by title', () => {
// Episode titles are deliberately interleaved across shows so that an
// episode-title-only sort would yield Aurora, Bravo, Comet, Delta
// episode-title-only sort would yield Aurora, Bravo, Comet, Delta -
// which interleaves Show Alpha and Show Beta episodes. The show-aware
// comparator must instead group all of Show Alpha first.
const items: MediaItem[] = [
@@ -424,7 +424,7 @@ describe('compareMediaItemsBySort missing values', () => {
it('treats viewCount === 0 as a real value, only undefined trails to the end', () => {
// Distinguishes "watched zero times" (a real data point) from
// "watch count not reported" pre-fix both collapsed to 0.
// "watch count not reported" - pre-fix both collapsed to 0.
const items: MediaItem[] = [
item({ title: 'Unknown', viewCount: undefined }),
item({ title: 'Watched', viewCount: 3 }),
@@ -426,7 +426,7 @@ describe('MetadataService', () => {
if (id === 'movie-1') {
return movieItem;
}
// The id-less container must never be fetched for a movie.
// The id-less container - must never be fetched for a movie.
return createMediaItem({
id: 'container-1',
type: 'movie',
@@ -740,7 +740,7 @@ describe('MetadataService', () => {
});
// Re-scan mixup: a newer library item wrongly tagged with an older entry's
// id. Titles are literally identical only the year distinguishes them.
// id. Titles are literally identical - only the year distinguishes them.
// This is the case a title-first policy would silently accept.
it('rejects a rescan id mixup where titles match exactly but years differ', async () => {
const libraryItem = createMediaItem({
@@ -1071,7 +1071,7 @@ describe('MetadataService', () => {
title: 'Sample Series',
type: 'tv',
externalIds: { type: 'tv', tvdb: 1 },
// No `ended` from TVDB say its status was 'Unknown'.
// No `ended` from TVDB - say its status was 'Unknown'.
ended: undefined,
firstAirDate: '2017-04-25',
seasonCount: 4,
@@ -92,7 +92,7 @@ export class MetadataService {
/**
* Validates policy provider keys against all registered providers (not just
* available ones). An unavailable provider key like 'tvdb' is still valid
* available ones). An unavailable provider key like 'tvdb' is still valid -
* it just means the provider isn't configured right now. The "unsupported"
* warning only fires for completely unknown keys (e.g. a typo).
*/
@@ -371,7 +371,7 @@ export class MetadataService {
// Only episodes/seasons resolve their ids from a parent (up to the show).
// A movie's (or show's) own provider ids live on the item itself, and on
// Emby/Jellyfin a movie's parentId points at an id-less library/container
// folder walking up there discards the real ids and the lookup fails
// folder - walking up there discards the real ids and the lookup fails
// (#3065). Switch on item.type, never on parentId presence.
if (item.type !== 'season' && item.type !== 'episode') {
return item;
@@ -516,7 +516,7 @@ export class MetadataService {
* Returns metadata details for the given IDs.
*
* Default behavior (no options): walks providers in preference order and
* returns the first non-undefined record the fast path that existing
* returns the first non-undefined record - the fast path that existing
* callers (poster/backdrop lookups, ID resolution) rely on.
*
* `{ merge: true }`: walks every available provider and fills any optional
@@ -634,7 +634,7 @@ export class MetadataService {
continue;
}
// Same-provider id is a redirect authoritative, no lookup needed.
// Same-provider id is a redirect - authoritative, no lookup needed.
const isRedirect = provider === sourceProvider;
// Don't corroborate against an unconfigured provider: keep the id silently
@@ -940,7 +940,7 @@ export class MetadataService {
* configured providers in preference order. Each provider is asked for
* details about whatever direct ID it can extract from the item, and
* the first provider whose release year matches the media server's year
* "vouches" for the ID set we return its details and use its external
* "vouches" for the ID set - we return its details and use its external
* IDs to fill in the other provider slots.
*
* This is the ID-primary / year-sanity model:
@@ -951,7 +951,7 @@ export class MetadataService {
* form, edition suffixes) is normal and comparing them caused the
* regressions in #2636 / #2638.
* - Cross-provider fallback gives the library a second opinion when
* the preferred provider disagrees with the media server on year
* the preferred provider disagrees with the media server on year -
* the scenario the metadata settings description already promises
* users when they configure TVDB alongside TMDB.
*
@@ -996,13 +996,13 @@ export class MetadataService {
// in untagged libraries and stays at debug.
if (providerDetails.year === undefined) {
this.logger.warn(
`Accepted direct provider IDs for "${item.title}" via ${provider.name} without a year check ${provider.name} returned no release year for this entry.`,
`Accepted direct provider IDs for "${item.title}" via ${provider.name} without a year check - ${provider.name} returned no release year for this entry.`,
);
return providerDetails;
}
if (itemYear === undefined) {
this.logger.debug(
`Accepted direct provider IDs for "${item.title}" via ${provider.name} without a year check media server item has no year.`,
`Accepted direct provider IDs for "${item.title}" via ${provider.name} without a year check - media server item has no year.`,
);
return providerDetails;
}
@@ -1054,7 +1054,7 @@ export class MetadataService {
}
// Two providers agreeing on a year the media server disputes ⇒ the media
// server is the outlier. Accept rather than reject we can't write its year
// server is the outlier. Accept rather than reject - we can't write its year
// back anyway, so rejecting would only block the rule.
const agreement = this.findProviderYearAgreement(disagreements);
if (agreement) {
@@ -28,11 +28,11 @@ describe('TvdbMetadataProvider', () => {
year: '2017',
defaultSeasonType: 1,
seasons: [
// Specials in the default ordering should be filtered out.
// Specials in the default ordering - should be filtered out.
{ number: 0, type: { id: 1 } },
{ number: 1, type: { id: 1 } },
{ number: 2, type: { id: 1 } },
// Alternative orderings should be filtered out by defaultSeasonType.
// Alternative orderings - should be filtered out by defaultSeasonType.
{ number: 1, type: { id: 2 } },
{ number: 2, type: { id: 2 } },
{ number: 3, type: { id: 3 } },
@@ -64,7 +64,7 @@ describe('TvdbMetadataProvider', () => {
const details = await provider.getDetails(322399, 'tv');
// Only number > 0 in the default ordering (type.id === 1) 2 seasons.
// Only number > 0 in the default ordering (type.id === 1) - 2 seasons.
expect(details?.seasonCount).toBe(2);
});
@@ -14,9 +14,9 @@ export interface WebhookUrlValidation {
* notification agent into the way the server leaves the network. On success the
* normalised URL string is returned for posting.
*
* Shared by every agent that posts to an operator-supplied URL the
* Shared by every agent that posts to an operator-supplied URL - the
* `webhookUrl` agents (webhook, slack, lunasea, discord) and ntfy's server
* `url` so the guard stays in one place.
* `url` - so the guard stays in one place.
*/
export function validateWebhookUrl(
url: string | undefined,
@@ -863,7 +863,7 @@ export class NotificationService implements OnModuleInit {
dayAmount?: number,
): Promise<string> {
// Collection name and day count are plain string substitutions that don't
// need the media server resolve them up front so an unavailable media
// need the media server - resolve them up front so an unavailable media
// server (which only affects the media-title lookups below) can't leave
// their placeholders raw. Strip the collection clause entirely when there's
// no collection context (e.g. an infrastructure-level failure) so we never
@@ -942,7 +942,7 @@ export class NotificationService implements OnModuleInit {
private getTitle(item: MediaItem): string {
// Branch on the server-agnostic item type, not on parentId/grandparentId
// presence. Plex leaves a movie's parent empty, but Emby/Jellyfin set
// parentId to the containing library folder so keying off parentId
// parentId to the containing library folder - so keying off parentId
// misclassified Emby movies as seasons and rendered them as
// "undefined - season undefined".
switch (item.type) {
@@ -985,7 +985,7 @@ export class NotificationService implements OnModuleInit {
private ruleQueueStatusChanged(event: RuleHandlerQueueStatusUpdatedEventDto) {
const nowActive = !!event.data?.processingQueue;
if (nowActive === this.batchActive) {
// Mid-batch progress update nothing to do.
// Mid-batch progress update - nothing to do.
return;
}
// Reset on every transition (in either direction). Clearing on the
@@ -991,9 +991,9 @@ describe('OverlayProcessorService', () => {
await service.revertCollection(42);
// Backup file must not be deleted on failure we still need it for retry.
// Backup file must not be deleted on failure - we still need it for retry.
expect(deleteSpy).not.toHaveBeenCalled();
// State must not be cleared on failure next run reattempts the revert.
// State must not be cleared on failure - next run reattempts the revert.
expect(stateService.removeState).not.toHaveBeenCalled();
// No reverted event should be emitted because nothing was actually reverted.
expect(eventEmitter.emit).not.toHaveBeenCalled();
@@ -1038,10 +1038,10 @@ describe('OverlayProcessorService', () => {
await service.revertCollection(42);
expect(mediaServer.itemExists).toHaveBeenCalledWith('media-1');
// Skip the upload Plex would close the connection mid-stream (EPIPE)
// Skip the upload - Plex would close the connection mid-stream (EPIPE)
// for a deleted item.
expect(provider.uploadImage).not.toHaveBeenCalled();
// Stale state and the backup are no longer useful clear them so we
// Stale state and the backup are no longer useful - clear them so we
// don't retry forever and pin a deleted item's bitmap on disk.
expect(deleteSpy).toHaveBeenCalledWith('media-1');
expect(stateService.removeState).toHaveBeenCalledWith(42, 'media-1');
@@ -159,7 +159,7 @@ export class OverlayProcessorService {
// A failed existence check (network blip, 5xx, auth, or a media-server
// switch in progress) leaves `exists` optimistically true so the upload
// still runs and any failure follows the existing retry path we never
// still runs and any failure follows the existing retry path - we never
// drop a backup on uncertainty. `getService()` is resolved inside the try
// so its transient throws are caught here too.
let exists = true;
@@ -334,7 +334,7 @@ export class OverlayProcessorService {
if (shouldApply) {
this.logger.log(
`Applying template overlay to item ${itemId} ${daysLeft} day(s) left`,
`Applying template overlay to item ${itemId} - ${daysLeft} day(s) left`,
);
const success = await this.applyTemplateOverlay(
itemId,
@@ -603,7 +603,7 @@ export class OverlayProcessorService {
await this.saveOriginalPoster(itemId, posterBuf);
}
// Build render context raw data; per-element formatting is done by the render service
// Build render context - raw data; per-element formatting is done by the render service
const daysLeft = this.getDaysLeft(deleteDate);
const context: TemplateRenderContext = {
deleteDate,
@@ -573,7 +573,7 @@ export class OverlayRenderService {
layerBuf = await this.applyOpacity(layerBuf, el.opacity);
}
// Clamp layer to poster bounds sharp.composite() throws
// Clamp layer to poster bounds - sharp.composite() throws
// when a composite layer extends beyond the base image.
const layerMeta = await sharp(layerBuf).metadata();
let lw = layerMeta.width ?? sw;
@@ -16,7 +16,7 @@ export class OverlayTaskService extends TaskBase {
super(taskService, logger);
this.logger.setContext(OverlayTaskService.name);
this.name = 'Overlay Handler';
// Default to a rarely-firing cron will be set in onBootstrapHook
// Default to a rarely-firing cron - will be set in onBootstrapHook
this.cronSchedule = '0 0 0 1 1 *'; // Once a year, Jan 1st
}
@@ -50,7 +50,7 @@ export class EmbyOverlayProvider implements IOverlayProvider {
const ep = await this.emby.findRandomEpisode(sectionKeys);
if (!ep?.Id) return null;
const name = ep.Name ?? '';
const title = ep.SeriesName ? `${ep.SeriesName} ${name}` : name;
const title = ep.SeriesName ? `${ep.SeriesName} - ${name}` : name;
return { itemId: ep.Id, title };
}
@@ -81,7 +81,7 @@ describe('JellyfinOverlayProvider', () => {
await expect(provider.getRandomEpisode(['lib-1'])).resolves.toEqual({
itemId: 'jf-ep',
title: 'Series Name Episode One',
title: 'Series Name - Episode One',
});
});
@@ -16,7 +16,7 @@ import { IOverlayProvider } from './overlay-provider.interface';
* Reads/writes only the `Primary` image: movies and shows have their poster
* there, and episodes have their still there (Jellyfin's `Thumb` is mostly
* unpopulated for episodes and shows a 16:9 series banner for
* continue-watching fallback neither is what an overlay should target).
* continue-watching fallback - neither is what an overlay should target).
*/
@Injectable()
export class JellyfinOverlayProvider implements IOverlayProvider {
@@ -54,7 +54,7 @@ export class JellyfinOverlayProvider implements IOverlayProvider {
const ep = await this.jf.findRandomEpisode(sectionKeys);
if (!ep?.Id) return null;
const name = ep.Name ?? '';
const title = ep.SeriesName ? `${ep.SeriesName} ${name}` : name;
const title = ep.SeriesName ? `${ep.SeriesName} - ${name}` : name;
return { itemId: ep.Id, title };
}
@@ -11,7 +11,7 @@ import { PlexOverlayProvider } from './plex-overlay.provider';
*
* Delegates server-type resolution to MediaServerFactory so the inferred-type
* fallback (Plex credentials present but media_server_type unset infer Plex)
* stays a single source of truth. Returns null when no server is configured
* stays a single source of truth. Returns null when no server is configured -
* callers (processor, controller) log and skip.
*
* Runtime server switches are guarded at MediaServerFactory.getService(); the
@@ -6,7 +6,7 @@ import {
/**
* Server-agnostic contract for overlay-specific media-server interactions.
*
* Intentionally narrower than IMediaServerService overlays are a feature,
* Intentionally narrower than IMediaServerService - overlays are a feature,
* not a core media-server responsibility, so the I/O and editor helpers the
* overlay module needs live here. The overlay processor, controller, and
* editor UI depend on this interface only; each supported media server
@@ -22,7 +22,7 @@ export interface IOverlayProvider {
/**
* Library sections suitable for the overlay editor's section picker.
* Returns only movie and show libraries music / photos etc. never carry
* Returns only movie and show libraries - music / photos etc. never carry
* overlay-worthy artwork in this feature.
*/
getSections(): Promise<OverlayLibrarySection[]>;
@@ -42,8 +42,8 @@ export interface IOverlayProvider {
/**
* Download the artwork for `itemId`. Both Plex and Jellyfin expose the
* correct image on the item itself poster for movies/shows, still for
* episodes so providers don't need a kind hint. Returns null when no
* correct image on the item itself - poster for movies/shows, still for
* episodes - so providers don't need a kind hint. Returns null when no
* artwork exists for the item.
*/
downloadImage(itemId: string): Promise<Buffer | null>;
@@ -13,7 +13,7 @@ import { PlexOverlayProvider } from './plex-overlay.provider';
* MediaServerModule exports MediaServerFactory (used by the overlay factory
* to resolve the configured server type) and JellyfinAdapterService (used by
* JellyfinOverlayProvider for overlay-specific Jellyfin methods).
* PlexApiModule exports PlexApiService (used by PlexOverlayProvider the
* PlexApiModule exports PlexApiService (used by PlexOverlayProvider - the
* existing Plex overlay helpers live there unchanged).
*/
@Module({
@@ -9,7 +9,7 @@ import { IOverlayProvider } from './overlay-provider.interface';
/**
* Plex implementation of IOverlayProvider.
*
* Pure delegation over existing PlexApiService helpers every Plex-specific
* Pure delegation over existing PlexApiService helpers - every Plex-specific
* concept (`thumb` URL, `upload://posters/` URI scheme, X-Plex-Token,
* type=4 episode filter, content-addressed dedup, eventual-consistency
* retry loop) stays inside PlexApiService. This class adds no Plex logic.
@@ -99,7 +99,7 @@ export class RuleConstanstService {
* Translate a (null) rule value into a human-readable explanation of why
* it was missing. Surfaces in the Test Media YAML output so users stop
* seeing bare "null" values and can tell the field has no data for this
* item. Derived dynamically from the property's existing metadata no
* item. Derived dynamically from the property's existing metadata - no
* static table to maintain. Rules comparisons still fail closed; this is
* purely diagnostic.
*/
@@ -184,7 +184,7 @@ export class RuleConstanstService {
let value: string;
// The encoder writes the RuleType humanName (e.g. TEXT_LIST -> "text list"),
// so normalise spaces to underscores before matching otherwise "TEXT LIST"
// so normalise spaces to underscores before matching - otherwise "TEXT LIST"
// misses the 'TEXT_LIST' case, leaving ruleType undefined and throwing on the
// return's .toString(), which fails the whole YAML import.
switch (identifier.type.toUpperCase().split(' ').join('_')) {
@@ -1300,7 +1300,7 @@ export class RuleConstants {
type: RuleType.NUMBER,
showType: ['episode'],
},
// Rating properties sourced from Jellyfin's CommunityRating and CriticRating.
// Rating properties - sourced from Jellyfin's CommunityRating and CriticRating.
// CommunityRating is provider-dependent, commonly TMDb and sometimes IMDb.
// CriticRating is typically the Rotten Tomatoes Tomatometer via OMDb.
// IDs match Plex so rules migrate without property ID remapping.
@@ -1415,7 +1415,7 @@ export class RuleConstants {
// configured and Jellyfin is the active server (see RulesService).
//
// A Streamystats "watchlist" is a user-created curated list, and only
// PUBLIC lists are reachable with Maintainerr's Jellyfin API key see
// PUBLIC lists are reachable with Maintainerr's Jellyfin API key - see
// the StreamystatsWatchlistMembership contract for why. These properties
// act as a "users curated this" protection signal.
id: Application.STREAMYSTATS,
@@ -1439,7 +1439,7 @@ export class RuleConstants {
// Parent-inclusive variants: a Streamystats list holds the show item ID,
// not its seasons/episodes, so the item-only props above never match a
// watchlisted show when evaluated below show level. These roll the
// parent show (and season) in. Show-only and season/episode-only a
// parent show (and season) in. Show-only and season/episode-only - a
// show is the top level (no parent) and a movie has no parent show.
{
id: 2,
@@ -25,7 +25,7 @@ import {
* Implements property getters for Emby media server.
* Mirrors PlexGetterService functionality for Emby. Emby and Jellyfin share
* the same .NET BoxSet backend, so the quirks below are inherited from that
* lineage and largely match the Jellyfin getter they're not Emby-specific.
* lineage and largely match the Jellyfin getter - they're not Emby-specific.
*
* Key differences from Plex:
* - Watch history requires iterating over all users (no central endpoint)
@@ -290,7 +290,7 @@ export class EmbyGetterService {
}
// At season/show level this returns the UNION of users that watched
// any descendant episode not the intersection. A user who watched
// any descendant episode - not the intersection. A user who watched
// 3/6 episodes is included. This is the documented behaviour and is
// covered by the #2559 regression test in
// jellyfin-getter.service.spec.ts. Use `sw_allEpisodesSeenBy` when
@@ -421,7 +421,7 @@ export class EmbyGetterService {
return communityRating?.value ?? null;
}
// Smart collection properties Emby has no native smart collections
// Smart collection properties - Emby has no native smart collections
// (TheMovieDb-driven "Automatic Creation of Collections" is metadata
// grouping, not filter rules; the third-party Smart Playlists plugin
// is out of scope here). Fall back to normal collection count/names,
@@ -604,7 +604,7 @@ export class EmbyGetterService {
* show or season, or null when nothing has been watched. Emby does not
* expose a watched timestamp on the parent item, so the only way to derive
* a "last watched" signal for shows/seasons is to walk the children and
* take the max. This is an aggregate it is not the view date of the
* take the max. This is an aggregate - it is not the view date of the
* highest-numbered episode, the way the Plex/Tautulli `sw_lastWatched`
* getters compute it. Used by the `lastViewedAt` rule only.
*/
@@ -20,12 +20,12 @@ import { TautulliGetterService } from './tautulli-getter.service';
// Purpose: prove that EVERY rule property (all ~166 across all applications)
// has a getter path that resolves to a RuleValueType without throwing, and
// that the result is independent of the TypeORM version. Run on both 0.3.x and
// 1.0.x with the same code and diff the emitted JSON (GETTER_MATRIX_OUT) it
// 1.0.x with the same code and diff the emitted JSON (GETTER_MATRIX_OUT) - it
// must be byte-identical, since getters derive values from (mocked) API
// responses in memory, not from the database.
//
// Deterministic by construction: dependencies are auto-mocked (return
// undefined) and the input media item is a fixed literal no faker, no clock.
// undefined) and the input media item is a fixed literal - no faker, no clock.
const libItem: MediaItem = {
id: 'fixed-item-1',
@@ -122,7 +122,7 @@ const apps: Array<{
];
// Deterministic, version-independent summary of a getter result (no raw values
// like random ids/dates we record shape, which is what must stay stable).
// like random ids/dates - we record shape, which is what must stay stable).
const describeResult = (v: unknown): string => {
if (v === undefined) return 'undefined';
if (v === null) return 'null';
@@ -44,7 +44,7 @@ export class ValueGetterService {
switch (val1) {
// Route Plex/Jellyfin/Emby Application IDs to the configured media
// server's getter. This handles community rules that reference the
// "wrong" server type e.g. a rule authored with Application.JELLYFIN
// "wrong" server type - e.g. a rule authored with Application.JELLYFIN
// can still evaluate against a configured Emby server.
case Application.PLEX:
case Application.JELLYFIN:
@@ -291,7 +291,7 @@ export class JellyfinGetterService {
}
// At season/show level this returns the UNION of users that watched
// any descendant episode not the intersection. A user who watched
// any descendant episode - not the intersection. A user who watched
// 3/6 episodes is included. This is the documented behaviour and is
// covered by the #2559 regression test in
// jellyfin-getter.service.spec.ts. Use `sw_allEpisodesSeenBy` when
@@ -603,7 +603,7 @@ export class JellyfinGetterService {
* show or season, or null when nothing has been watched. Jellyfin does not
* expose a watched timestamp on the parent item, so the only way to derive
* a "last watched" signal for shows/seasons is to walk the children and
* take the max. This is an aggregate it is not the view date of the
* take the max. This is an aggregate - it is not the view date of the
* highest-numbered episode, the way the Plex/Tautulli `sw_lastWatched`
* getters compute it. Used by the `lastViewedAt` rule only.
*/
@@ -937,7 +937,7 @@ describe('PlexGetterService', () => {
// Pre-refactor behaviour (#1630): dedupe runs on the RAW tag, so the
// exact-equal 'Space Saga' from season, show and the smart collection
// collapse to one but the episode's ' Space Saga ' (whitespace variant)
// collapse to one - but the episode's ' Space Saga ' (whitespace variant)
// survives the raw dedupe and only trims afterwards, leaving two entries.
expect(result).toEqual(['Space Saga', 'Space Saga', 'Season Set']);
});
@@ -1140,7 +1140,7 @@ describe('PlexGetterService', () => {
return [makeWatchEntry({ viewedAt: 1_700_000_000, accountID: 1 })];
}
if (rk === 'sibling-a') {
// A non-admin user watched this sibling only visible via history.
// A non-admin user watched this sibling - only visible via history.
return [makeWatchEntry({ viewedAt: 1_710_000_000, accountID: 2 })];
}
return [];
@@ -306,7 +306,7 @@ export class PlexGetterService {
season.ratingKey,
);
for (const episode of episodes) {
// Errors propagate to the outer catch silently treating a
// Errors propagate to the outer catch - silently treating a
// failed lookup as "no viewers" would drop genuine viewers from
// `allViewers` and mark the show as unwatched-by-everyone.
const viewers = await this.plexApi.getWatchHistory(
@@ -342,7 +342,7 @@ export class PlexGetterService {
return [];
}
// At season/show level this returns the UNION of users that watched
// any descendant episode not the intersection. Plex's per-show
// any descendant episode - not the intersection. Plex's per-show
// watch history aggregates child views, so any account that watched
// at least one episode appears here. Use `sw_allEpisodesSeenBy` when
// you need "watched every episode" semantics instead.
@@ -820,8 +820,8 @@ export class PlexGetterService {
// collection with this item, so one recently-watched sibling keeps
// the whole set out of the delete pool.
//
// We use getWatchHistory (/status/sessions/history/all) not the
// per-child lastViewedAt field because library metadata is scoped
// We use getWatchHistory (/status/sessions/history/all) - not the
// per-child lastViewedAt field - because library metadata is scoped
// to the calling account (admin-only), while the history endpoint
// returns every user's entries when called with an admin token.
// Same pattern as the existing lastViewedAt rule (prop id 7).
@@ -102,7 +102,7 @@ export class RadarrGetterService {
if (movieResponse === undefined) {
// The Radarr lookup itself failed (or every candidate's lookup
// returned undefined) could be a transient outage. Fail closed
// returned undefined) - could be a transient outage. Fail closed
// rather than returning null (definitive absence), which would drop
// the item from the collection on a transient blip. (#3125)
return undefined;
@@ -570,7 +570,7 @@ describe('SeerrGetterService', () => {
const { service, seerrApi } = createService();
// SeerrApiService.buildRequestIndex normalises each title's list to
// oldest-first, so requestDate is the first (earliest) request matching
// oldest-first, so requestDate is the first (earliest) request - matching
// the pre-#3152 getMovie ordering, not the newest re-request (#3152).
seerrApi.getRequestsForMedia.mockResolvedValue([
movieRequest({ id: 1, createdAt: '2026-04-01' }),
@@ -720,7 +720,7 @@ describe('SeerrGetterService', () => {
});
});
describe('releaseDate (property id=2) per-item fallback', () => {
describe('releaseDate (property id=2) - per-item fallback', () => {
it('should resolve movie releaseDate via getMovie, not the request index', async () => {
const { service, seerrApi } = createService();
@@ -70,7 +70,7 @@ export class SeerrGetterService {
// releaseDate (movie releaseDate / tv firstAirDate / season|episode
// airDate) is not carried by the /request list endpoint, so it keeps the
// per-item getMovie/getShow/getSeason fallback. Accepted limitation
// per-item getMovie/getShow/getSeason fallback. Accepted limitation -
// releaseDate rules were not part of #3152.
if (prop?.name === 'releaseDate') {
return await this.getReleaseDate(
@@ -83,7 +83,7 @@ export class SeerrGetterService {
// Every other Seerr property derives from the request set. Read the
// run-scoped request index (one bulk /request sweep, deduped + cached)
// instead of a per-item getMovie/getShow the per-item path rate-limited
// instead of a per-item getMovie/getShow - the per-item path rate-limited
// under whole-library runs and silently degraded matches to near-zero
// (#3152).
const requestsForMedia = await this.seerrApi.getRequestsForMedia(tmdbId);
@@ -97,7 +97,7 @@ export class SeerrGetterService {
// Reconstruct the per-title view the property logic expects. When the
// title has no request the synthetic mediaInfo carries an empty request
// list, so the switch yields the definitive "not requested" values
// (0 / [] / null) the core #3152 fix (these items previously
// (0 / [] / null) - the core #3152 fix (these items previously
// rate-limited to null and were skipped).
const mediaResponse = this.toMediaResponse(requestsForMedia);
const tvMediaResponse =
@@ -238,7 +238,7 @@ export class SeerrGetterService {
/**
* Resolves the Seerr release/air date via the per-item getMovie/getShow/
* getSeason endpoints. The bulk /request index carries request data, not the
* TMDB release/air dates, so this property keeps the per-item fallback
* TMDB release/air dates, so this property keeps the per-item fallback -
* releaseDate-seeded rules were not part of #3152. A communication failure
* returns `undefined` (transient skip, mirrors #3125); an untracked title
* (no mediaInfo) returns `null`, preserving the prior behavior.
@@ -250,7 +250,7 @@ describe('SonarrGetterService', () => {
// concurrently, all sharing ONE memoized `showResponse.seasons` array via the
// run-scoped ArrLookupCache. The latest-aired-season scan must not mutate that
// shared array, or evaluating one season corrupts the answer for the others.
// (Test Media passes no cache, so it never hit this hence the run/test split.)
// (Test Media passes no cache, so it never hit this - hence the run/test split.)
describe('shared ArrLookupCache across show seasons (#3153)', () => {
it.each([
{ type: 'season', title: 'SEASONS' },
@@ -1068,7 +1068,7 @@ describe('SonarrGetterService', () => {
const response = await callGet(7);
// Returns undefined (comparator skips) must NOT serve metadata's
// Returns undefined (comparator skips) - must NOT serve metadata's
// 'ended: true' while Sonarr is unreachable, since that would change
// collection membership during an outage.
expect(response).toBeUndefined();
@@ -116,7 +116,7 @@ export class SonarrGetterService {
// The series lookup is keyed on the resolved tvdbId and is identical for
// every episode/season of a show. The API call stays uncached (the
// cleanup needs post-deletion truth #2757/#2891), but during rule
// cleanup needs post-deletion truth - #2757/#2891), but during rule
// evaluation we dedupe it through the run-scoped memo, which is gone
// before any deletion runs. Evict on a failed (undefined) lookup so a
// transient error doesn't mark the whole series unresolved for the run.
@@ -141,7 +141,7 @@ export class SonarrGetterService {
if (showResponse === undefined) {
// The Sonarr lookup itself failed (or every candidate's lookup
// returned undefined) could be a transient outage. Fail closed
// returned undefined) - could be a transient outage. Fail closed
// rather than substituting metadata-provider values, which would
// silently change rule evaluation while Sonarr is down.
return undefined;
@@ -549,7 +549,7 @@ export class SonarrGetterService {
// similar: `status` (Sonarr lowercase enum vs provider free-form strings),
// `originalLanguage` (full name vs ISO 639-1 vs ISO 639-2/B), and `rating`
// (different scales / aggregations). Sonarr-only state (monitored, tags,
// filePath, diskSize, …) is also absent providers can't supply it.
// filePath, diskSize, …) is also absent - providers can't supply it.
private static readonly METADATA_FALLBACK_SUPPORTED = new Set([
'ended',
'firstAirDate',
@@ -20,7 +20,7 @@ const membershipOf = (
describe('StreamystatsGetterService', () => {
const createService = (
users: { id: string; name: string }[] = [],
// Items resolvable by getMetadata the `_including_parent` props look up
// Items resolvable by getMetadata - the `_including_parent` props look up
// the item's parent chain through the media server's metadata path.
items: ReturnType<typeof createMediaItem>[] = [],
) => {
@@ -115,7 +115,7 @@ describe('StreamystatsGetterService', () => {
it('returns undefined (transient skip) when the user lookup fails closed', async () => {
// getUsers() returns [] on failure; with owners present that is a lookup
// failure, not "nobody owns it" must skip, never an empty list.
// failure, not "nobody owns it" - must skip, never an empty list.
const { service, streamystatsApi } = createService([]);
const libItem = createMediaItem({ type: 'movie', id: 'item-1' });
streamystatsApi.getWatchlistMembership.mockResolvedValue(
@@ -193,7 +193,7 @@ describe('StreamystatsGetterService', () => {
});
it('skips (undefined) when the item metadata cannot be fetched', async () => {
// getMetadata returns undefined (item not registered) the parent chain
// getMetadata returns undefined (item not registered) - the parent chain
// is unknown, so skip rather than fall back to an item-only check.
const season = createMediaItem({
type: 'season',
@@ -225,7 +225,7 @@ describe('StreamystatsGetterService', () => {
);
expect(await service.get(IS_IN_WATCHLIST_PROP_ID, season)).toBe(false);
// The base prop is item-only no parent resolution needed.
// The base prop is item-only - no parent resolution needed.
expect(getMetadata).not.toHaveBeenCalled();
});
});
@@ -16,7 +16,7 @@ import { definedUniqueValues } from '../helpers/rule-property.helper';
* contributes its own rule Application. Properties surface membership of
* Streamystats watchlists (a "users curated this" protection signal).
*
* Only PUBLIC watchlists are visible to Maintainerr see
* Only PUBLIC watchlists are visible to Maintainerr - see
* StreamystatsWatchlistMembership for why. Usernames are resolved through the
* server-agnostic media-server abstraction (Jellyfin is the only configured
* server when this getter runs, since the Application is gated to it).
@@ -52,7 +52,7 @@ export class StreamystatsGetterService {
}
const itemIds = await this.resolveWatchlistItemIds(prop.name, libItem);
// `undefined` when the parent chain couldn't be resolved skip rather
// `undefined` when the parent chain couldn't be resolved - skip rather
// than fall back to an item-only check, which would defeat the parent
// variant and could let a protected item match a destructive rule.
if (!itemIds) {
@@ -95,13 +95,13 @@ export class StreamystatsGetterService {
/**
* The Jellyfin item IDs to check for watchlist membership. The base props
* check the item alone. The `_including_parent` variants additionally roll
* in the parent show (and season) for a season/episode a Streamystats list
* in the parent show (and season) for a season/episode - a Streamystats list
* holds the show item ID, not its seasons, so a season would otherwise never
* inherit its watchlisted show.
*
* Parents are resolved through the media server's `getMetadata` (the same
* canonical path the other getters use) so the parent IDs come from the
* mapper's hierarchy resolution `SeriesId` for seasons, not the
* mapper's hierarchy resolution - `SeriesId` for seasons, not the
* library-folder `parentId`. Gated on the server-agnostic `type` so a movie
* is never rolled up. Returns `undefined` when metadata can't be fetched, so
* the caller skips rather than falling back to an item-only check.
@@ -137,7 +137,7 @@ export class StreamystatsGetterService {
const users = await mediaServer.getUsers();
// getUsers() is fail-closed (returns [] on error). A public watchlist is
// always owned by a user, so an empty user list while we have owners to
// resolve means the lookup failed surface that as undefined (transient
// resolve means the lookup failed - surface that as undefined (transient
// skip) rather than an empty list, which would otherwise flip negative
// list comparisons and could let protected items match destructive rules.
if (users.length === 0) {
@@ -52,7 +52,7 @@ export class TautulliGetterService {
switch (prop.name) {
// At season/show level `sw_watchers` returns the UNION of users that
// watched any descendant episode not the intersection. Tautulli's
// watched any descendant episode - not the intersection. Tautulli's
// history aggregates child views via grandparent_rating_key /
// parent_rating_key, so any account that watched at least one
// episode appears here. Use `sw_allEpisodesSeenBy` when you need
@@ -5,7 +5,7 @@
* Those two lookups are deliberately uncached at the API layer: the empty-show
* cleanup re-reads them straight after a deletion, and a persisted entry would
* serve pre-deletion state (#2757 / #2891). #2897 de-cached them for that
* reason but that also de-cached the rule-evaluation path, where the same
* reason - but that also de-cached the rule-evaluation path, where the same
* series/movie is resolved once per item. On an episode-level rule that means
* the same series is looked up thousands of times per run.
*
@@ -15,7 +15,7 @@
* and the cleanup still reads Sonarr/Radarr fresh.
*
* It is intentionally NOT used by the Tautulli / Seerr / Plex / Jellyfin / Emby
* getters those already cache at the API layer, so routing them through here
* getters - those already cache at the API layer, so routing them through here
* would just be redundant double-caching.
*/
export class ArrLookupCache {
@@ -26,7 +26,7 @@ export class ArrLookupCache {
* caller (items are evaluated in parallel batches, so the same series/movie
* is commonly requested many times at once). If `evictOnFailure` reports the
* resolved value as a failure, the entry is dropped so a transient error
* doesn't poison the rest of the run later items retry instead.
* doesn't poison the rest of the run - later items retry instead.
*/
memoize<T>(
key: string,
@@ -3,7 +3,7 @@ import { Exclusion } from '../entities/exclusion.entities';
/**
* Pre-computed sets used to decide whether a media item is covered by any
* exclusion. Cascade is driven by `exclusion.type` and `exclusion.mediaServerId`
* the `parent` field on Exclusion records the entry point of the original
* - the `parent` field on Exclusion records the entry point of the original
* exclusion request (used by bulk find/delete) and must not be used as a
* cascade key for typed rows, or a single-episode exclusion would skip every
* other episode of the same show (issue #2858).

Some files were not shown because too many files have changed in this diff Show More