diff --git a/.claude/rules/implementation.md b/.claude/rules/implementation.md index 0ef11a88a..76a5157d7 100644 --- a/.claude/rules/implementation.md +++ b/.claude/rules/implementation.md @@ -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). diff --git a/.cursor/rules/project.mdc b/.cursor/rules/project.mdc index 5af885cb8..9133ecf60 100644 --- a/.cursor/rules/project.mdc +++ b/.cursor/rules/project.mdc @@ -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). diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index f0069299a..4e71590aa 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -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"; \ diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 3cc9fce5f..4f325a47e 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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). diff --git a/.github/instructions/implementation.instructions.md b/.github/instructions/implementation.instructions.md index e70c6ac90..d66c22c0f 100644 --- a/.github/instructions/implementation.instructions.md +++ b/.github/instructions/implementation.instructions.md @@ -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/.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/.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. diff --git a/.github/instructions/project-notes.instructions.md b/.github/instructions/project-notes.instructions.md index c55538f95..0d8c9c572 100644 --- a/.github/instructions/project-notes.instructions.md +++ b/.github/instructions/project-notes.instructions.md @@ -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/...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 `/` 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 `` 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 `
`. 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 ` 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/`. - 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=` (the LIST form — getMetadata + to match the seed), and `GET /Items?…&ids=` (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. diff --git a/.github/instructions/release-review.instructions.md b/.github/instructions/release-review.instructions.md index 6d5fd4a93..811f9229b 100644 --- a/.github/instructions/release-review.instructions.md +++ b/.github/instructions/release-review.instructions.md @@ -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 ..HEAD --oneline` — get the full commit list. +1. `git log ..HEAD --oneline` - get the full commit list. 2. `git log ..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 --json title,body` and read the description in full. Pay special attention to sections titled @@ -53,7 +53,7 @@ git diff ..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 ..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 diff --git a/.github/workflows/docs_drift.yml b/.github/workflows/docs_drift.yml index 7fcc9fff4..39519eb51 100644 --- a/.github/workflows/docs_drift.yml +++ b/.github/workflows/docs_drift.yml @@ -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 `) 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" diff --git a/.github/workflows/fider-move-enhancements.yml b/.github/workflows/fider-move-enhancements.yml index f4408a0e6..84a9f59bd 100644 --- a/.github/workflows/fider-move-enhancements.yml +++ b/.github/workflows/fider-move-enhancements.yml @@ -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 diff --git a/.github/workflows/fider-pre-existing-scan.yml b/.github/workflows/fider-pre-existing-scan.yml index f07cb4473..d7df6603d 100644 --- a/.github/workflows/fider-pre-existing-scan.yml +++ b/.github/workflows/fider-pre-existing-scan.yml @@ -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: diff --git a/AGENTS.md b/AGENTS.md index 62c66c2e2..120e752c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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: ` (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. diff --git a/CHANGELOG.md b/CHANGELOG.md index f2635c4ea..fe3567d36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/README_AGENTS.md b/README_AGENTS.md index eb3afa850..f7dc4b2d1 100644 --- a/README_AGENTS.md +++ b/README_AGENTS.md @@ -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. diff --git a/apps/server/src/database/migration-tests/migrations.spec.ts b/apps/server/src/database/migration-tests/migrations.spec.ts index 6fa55594e..7bf4dc455 100644 --- a/apps/server/src/database/migration-tests/migrations.spec.ts +++ b/apps/server/src/database/migration-tests/migrations.spec.ts @@ -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(); diff --git a/apps/server/src/database/migration-tests/upgrade-from-1x.spec.ts b/apps/server/src/database/migration-tests/upgrade-from-1x.spec.ts index 58b7b6397..45b146741 100644 --- a/apps/server/src/database/migration-tests/upgrade-from-1x.spec.ts +++ b/apps/server/src/database/migration-tests/upgrade-from-1x.spec.ts @@ -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(); diff --git a/apps/server/src/database/migrations/1779622081794-NormalizeRuleSectionOperators.ts b/apps/server/src/database/migrations/1779622081794-NormalizeRuleSectionOperators.ts index c6a1ce34f..95a931c8c 100644 --- a/apps/server/src/database/migrations/1779622081794-NormalizeRuleSectionOperators.ts +++ b/apps/server/src/database/migrations/1779622081794-NormalizeRuleSectionOperators.ts @@ -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 diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 5d1dc0086..f3483128f 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -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(); diff --git a/apps/server/src/modules/actions/servarr-tag.service.spec.ts b/apps/server/src/modules/actions/servarr-tag.service.spec.ts index 297beaaf8..3d2d77db4 100644 --- a/apps/server/src/modules/actions/servarr-tag.service.spec.ts +++ b/apps/server/src/modules/actions/servarr-tag.service.spec.ts @@ -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 () => { diff --git a/apps/server/src/modules/actions/servarr-tag.service.ts b/apps/server/src/modules/actions/servarr-tag.service.ts index 6a7196178..e1925e25c 100644 --- a/apps/server/src/modules/actions/servarr-tag.service.ts +++ b/apps/server/src/modules/actions/servarr-tag.service.ts @@ -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 `_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( diff --git a/apps/server/src/modules/actions/sonarr-action-handler.spec.ts b/apps/server/src/modules/actions/sonarr-action-handler.spec.ts index 99d5dc35d..fbd134ba5 100644 --- a/apps/server/src/modules/actions/sonarr-action-handler.spec.ts +++ b/apps/server/src/modules/actions/sonarr-action-handler.spec.ts @@ -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([]); diff --git a/apps/server/src/modules/actions/sonarr-action-handler.ts b/apps/server/src/modules/actions/sonarr-action-handler.ts index c814d1322..a0efc9b1f 100644 --- a/apps/server/src/modules/actions/sonarr-action-handler.ts +++ b/apps/server/src/modules/actions/sonarr-action-handler.ts @@ -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, diff --git a/apps/server/src/modules/api/download-client-api/download-client-api.controller.ts b/apps/server/src/modules/api/download-client-api/download-client-api.controller.ts index 1ed3a4ef2..deea079af 100644 --- a/apps/server/src/modules/api/download-client-api/download-client-api.controller.ts +++ b/apps/server/src/modules/api/download-client-api/download-client-api.controller.ts @@ -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') diff --git a/apps/server/src/modules/api/download-client-api/download-client-api.service.spec.ts b/apps/server/src/modules/api/download-client-api/download-client-api.service.spec.ts index 3798d8c1d..c8cbc7624 100644 --- a/apps/server/src/modules/api/download-client-api/download-client-api.service.spec.ts +++ b/apps/server/src/modules/api/download-client-api/download-client-api.service.spec.ts @@ -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), })); diff --git a/apps/server/src/modules/api/download-client-api/download-client-api.service.ts b/apps/server/src/modules/api/download-client-api/download-client-api.service.ts index e3c30f23f..ec0a2ba82 100644 --- a/apps/server/src/modules/api/download-client-api/download-client-api.service.ts +++ b/apps/server/src/modules/api/download-client-api/download-client-api.service.ts @@ -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 Maintainerr’s 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; } diff --git a/apps/server/src/modules/api/download-client-api/download-client.interface.ts b/apps/server/src/modules/api/download-client-api/download-client.interface.ts index 77f20f399..539426c6b 100644 --- a/apps/server/src/modules/api/download-client-api/download-client.interface.ts +++ b/apps/server/src/modules/api/download-client-api/download-client.interface.ts @@ -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; diff --git a/apps/server/src/modules/api/download-client-api/helpers/qbittorrent.helper.ts b/apps/server/src/modules/api/download-client-api/helpers/qbittorrent.helper.ts index 9064d72a8..a1b4e9352 100644 --- a/apps/server/src/modules/api/download-client-api/helpers/qbittorrent.helper.ts +++ b/apps/server/src/modules/api/download-client-api/helpers/qbittorrent.helper.ts @@ -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) { diff --git a/apps/server/src/modules/api/external-api/external-api.service.spec.ts b/apps/server/src/modules/api/external-api/external-api.service.spec.ts index 496e96a99..e8048678e 100644 --- a/apps/server/src/modules/api/external-api/external-api.service.spec.ts +++ b/apps/server/src/modules/api/external-api/external-api.service.spec.ts @@ -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] }; diff --git a/apps/server/src/modules/api/lib/cache.ts b/apps/server/src/modules/api/lib/cache.ts index cb8c55500..8e6bc9c9f 100644 --- a/apps/server/src/modules/api/lib/cache.ts +++ b/apps/server/src/modules/api/lib/cache.ts @@ -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. diff --git a/apps/server/src/modules/api/lib/httpRetry.ts b/apps/server/src/modules/api/lib/httpRetry.ts index e397bd8fc..c1f74b586 100644 --- a/apps/server/src/modules/api/lib/httpRetry.ts +++ b/apps/server/src/modules/api/lib/httpRetry.ts @@ -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. */ diff --git a/apps/server/src/modules/api/lib/plextvApi.spec.ts b/apps/server/src/modules/api/lib/plextvApi.spec.ts index 7260ce68b..7ac3ef008 100644 --- a/apps/server/src/modules/api/lib/plextvApi.spec.ts +++ b/apps/server/src/modules/api/lib/plextvApi.spec.ts @@ -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) => { diff --git a/apps/server/src/modules/api/lib/plextvApi.ts b/apps/server/src/modules/api/lib/plextvApi.ts index 05f044d6c..48262cf11 100644 --- a/apps/server/src/modules/api/lib/plextvApi.ts +++ b/apps/server/src/modules/api/lib/plextvApi.ts @@ -185,7 +185,7 @@ export class PlexTvApi extends ExternalApiService { public async getDevices(clientIdentifier: string): Promise { 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( '/api/v2/resources?includeHttps=1', diff --git a/apps/server/src/modules/api/media-server/emby/emby-adapter.service.ts b/apps/server/src/modules/api/media-server/emby/emby-adapter.service.ts index 6ecb20c2c..7098d7320 100644 --- a/apps/server/src/modules/api/media-server/emby/emby-adapter.service.ts +++ b/apps/server/src/modules/api/media-server/emby/emby-adapter.service.ts @@ -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 { @@ -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 { 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 { // 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( @@ -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}`, diff --git a/apps/server/src/modules/api/media-server/emby/emby.mapper.spec.ts b/apps/server/src/modules/api/media-server/emby/emby.mapper.spec.ts index 3a2ca8174..a02c5a523 100644 --- a/apps/server/src/modules/api/media-server/emby/emby.mapper.spec.ts +++ b/apps/server/src/modules/api/media-server/emby/emby.mapper.spec.ts @@ -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', () => { diff --git a/apps/server/src/modules/api/media-server/emby/emby.mapper.ts b/apps/server/src/modules/api/media-server/emby/emby.mapper.ts index 05e0f5127..96c8710ea 100644 --- a/apps/server/src/modules/api/media-server/emby/emby.mapper.ts +++ b/apps/server/src/modules/api/media-server/emby/emby.mapper.ts @@ -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, diff --git a/apps/server/src/modules/api/media-server/jellyfin/jellyfin-adapter.service.spec.ts b/apps/server/src/modules/api/media-server/jellyfin/jellyfin-adapter.service.spec.ts index fa273d68a..ff9ba61c3 100644 --- a/apps/server/src/modules/api/media-server/jellyfin/jellyfin-adapter.service.spec.ts +++ b/apps/server/src/modules/api/media-server/jellyfin/jellyfin-adapter.service.spec.ts @@ -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( diff --git a/apps/server/src/modules/api/media-server/jellyfin/jellyfin-adapter.service.ts b/apps/server/src/modules/api/media-server/jellyfin/jellyfin-adapter.service.ts index 0bf61e101..20d3e03c4 100644 --- a/apps/server/src/modules/api/media-server/jellyfin/jellyfin-adapter.service.ts +++ b/apps/server/src/modules/api/media-server/jellyfin/jellyfin-adapter.service.ts @@ -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 { // 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 { 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 { 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 { 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', ); } diff --git a/apps/server/src/modules/api/media-server/jellyfin/jellyfin.constants.ts b/apps/server/src/modules/api/media-server/jellyfin/jellyfin.constants.ts index 220cb582a..084cda673 100644 --- a/apps/server/src/modules/api/media-server/jellyfin/jellyfin.constants.ts +++ b/apps/server/src/modules/api/media-server/jellyfin/jellyfin.constants.ts @@ -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. * diff --git a/apps/server/src/modules/api/media-server/media-server-id.utils.spec.ts b/apps/server/src/modules/api/media-server/media-server-id.utils.spec.ts index 5c8eb5509..9f16b5add 100644 --- a/apps/server/src/modules/api/media-server/media-server-id.utils.spec.ts +++ b/apps/server/src/modules/api/media-server/media-server-id.utils.spec.ts @@ -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', diff --git a/apps/server/src/modules/api/media-server/media-server-id.utils.ts b/apps/server/src/modules/api/media-server/media-server-id.utils.ts index e4e7be80b..956165437 100644 --- a/apps/server/src/modules/api/media-server/media-server-id.utils.ts +++ b/apps/server/src/modules/api/media-server/media-server-id.utils.ts @@ -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 diff --git a/apps/server/src/modules/api/media-server/media-server.factory.ts b/apps/server/src/modules/api/media-server/media-server.factory.ts index 7297249fe..7a614d010 100644 --- a/apps/server/src/modules/api/media-server/media-server.factory.ts +++ b/apps/server/src/modules/api/media-server/media-server.factory.ts @@ -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', ); diff --git a/apps/server/src/modules/api/media-server/media-server.interface.ts b/apps/server/src/modules/api/media-server/media-server.interface.ts index 7455ee5b3..211150eb9 100644 --- a/apps/server/src/modules/api/media-server/media-server.interface.ts +++ b/apps/server/src/modules/api/media-server/media-server.interface.ts @@ -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>; @@ -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; @@ -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; } diff --git a/apps/server/src/modules/api/media-server/plex/plex-adapter.service.spec.ts b/apps/server/src/modules/api/media-server/plex/plex-adapter.service.spec.ts index 0afdb99fe..c2020d7f5 100644 --- a/apps/server/src/modules/api/media-server/plex/plex-adapter.service.spec.ts +++ b/apps/server/src/modules/api/media-server/plex/plex-adapter.service.spec.ts @@ -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(); diff --git a/apps/server/src/modules/api/media-server/plex/plex-adapter.service.ts b/apps/server/src/modules/api/media-server/plex/plex-adapter.service.ts index 85a7f2f39..19fc94c7b 100644 --- a/apps/server/src/modules/api/media-server/plex/plex-adapter.service.ts +++ b/apps/server/src/modules/api/media-server/plex/plex-adapter.service.ts @@ -777,7 +777,7 @@ export class PlexAdapterService implements IMediaServerService { async deleteFromDisk(itemId: string): Promise { 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 { if (isBlankMediaServerId(itemId)) { throw new Error( - 'refreshItemMetadata called with empty itemId — aborting metadata refresh request', + 'refreshItemMetadata called with empty itemId - aborting metadata refresh request', ); } diff --git a/apps/server/src/modules/api/plex-api/plex-api.constants.ts b/apps/server/src/modules/api/plex-api/plex-api.constants.ts index 1c48d7810..62c0acb5e 100644 --- a/apps/server/src/modules/api/plex-api/plex-api.constants.ts +++ b/apps/server/src/modules/api/plex-api/plex-api.constants.ts @@ -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'; diff --git a/apps/server/src/modules/api/plex-api/plex-api.service.spec.ts b/apps/server/src/modules/api/plex-api/plex-api.service.spec.ts index 0f28de399..5e7b167d0 100644 --- a/apps/server/src/modules/api/plex-api/plex-api.service.spec.ts +++ b/apps/server/src/modules/api/plex-api/plex-api.service.spec.ts @@ -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') diff --git a/apps/server/src/modules/api/plex-api/plex-api.service.ts b/apps/server/src/modules/api/plex-api/plex-api.service.ts index dd84cc646..dcc13c017 100644 --- a/apps/server/src/modules/api/plex-api/plex-api.service.ts +++ b/apps/server/src/modules/api/plex-api/plex-api.service.ts @@ -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 { @@ -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 }; diff --git a/apps/server/src/modules/api/seerr-api/seerr-api.service.spec.ts b/apps/server/src/modules/api/seerr-api/seerr-api.service.spec.ts index 4de9bca84..30759cbe1 100644 --- a/apps/server/src/modules/api/seerr-api/seerr-api.service.spec.ts +++ b/apps/server/src/modules/api/seerr-api/seerr-api.service.spec.ts @@ -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 diff --git a/apps/server/src/modules/api/seerr-api/seerr-api.service.ts b/apps/server/src/modules/api/seerr-api/seerr-api.service.ts index af9d5ff57..fe02abcc0 100644 --- a/apps/server/src/modules/api/seerr-api/seerr-api.service.ts +++ b/apps/server/src/modules/api/seerr-api/seerr-api.service.ts @@ -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 diff --git a/apps/server/src/modules/api/servarr-api/common/servarr-api.service.ts b/apps/server/src/modules/api/servarr-api/common/servarr-api.service.ts index 08f5d65b2..715bceba6 100644 --- a/apps/server/src/modules/api/servarr-api/common/servarr-api.service.ts +++ b/apps/server/src/modules/api/servarr-api/common/servarr-api.service.ts @@ -254,7 +254,7 @@ export abstract class ServarrApi 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 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()); }; diff --git a/apps/server/src/modules/api/servarr-api/helpers/radarr.helper.spec.ts b/apps/server/src/modules/api/servarr-api/helpers/radarr.helper.spec.ts index 9e1cad8d5..b4bf4c6b7 100644 --- a/apps/server/src/modules/api/servarr-api/helpers/radarr.helper.spec.ts +++ b/apps/server/src/modules/api/servarr-api/helpers/radarr.helper.spec.ts @@ -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, diff --git a/apps/server/src/modules/api/servarr-api/helpers/radarr.helper.ts b/apps/server/src/modules/api/servarr-api/helpers/radarr.helper.ts index fe9b38ced..478cef0eb 100644 --- a/apps/server/src/modules/api/servarr-api/helpers/radarr.helper.ts +++ b/apps/server/src/modules/api/servarr-api/helpers/radarr.helper.ts @@ -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 { diff --git a/apps/server/src/modules/api/servarr-api/helpers/sonarr.helper.spec.ts b/apps/server/src/modules/api/servarr-api/helpers/sonarr.helper.spec.ts index 4d651ada5..3ef4caece 100644 --- a/apps/server/src/modules/api/servarr-api/helpers/sonarr.helper.spec.ts +++ b/apps/server/src/modules/api/servarr-api/helpers/sonarr.helper.spec.ts @@ -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 diff --git a/apps/server/src/modules/api/servarr-api/helpers/sonarr.helper.ts b/apps/server/src/modules/api/servarr-api/helpers/sonarr.helper.ts index 528c48bac..bcb9317bd 100644 --- a/apps/server/src/modules/api/servarr-api/helpers/sonarr.helper.ts +++ b/apps/server/src/modules/api/servarr-api/helpers/sonarr.helper.ts @@ -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. diff --git a/apps/server/src/modules/api/servarr-api/interfaces/servarr.interface.ts b/apps/server/src/modules/api/servarr-api/interfaces/servarr.interface.ts index a12006614..6caf9302f 100644 --- a/apps/server/src/modules/api/servarr-api/interfaces/servarr.interface.ts +++ b/apps/server/src/modules/api/servarr-api/interfaces/servarr.interface.ts @@ -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. * diff --git a/apps/server/src/modules/api/streamystats-api/streamystats-api.service.spec.ts b/apps/server/src/modules/api/streamystats-api/streamystats-api.service.spec.ts index 87b2f5b77..81279b7af 100644 --- a/apps/server/src/modules/api/streamystats-api/streamystats-api.service.spec.ts +++ b/apps/server/src/modules/api/streamystats-api/streamystats-api.service.spec.ts @@ -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'); diff --git a/apps/server/src/modules/api/streamystats-api/streamystats-api.service.ts b/apps/server/src/modules/api/streamystats-api/streamystats-api.service.ts index 2d0169966..8b6090213 100644 --- a/apps/server/src/modules/api/streamystats-api/streamystats-api.service.ts +++ b/apps/server/src/modules/api/streamystats-api/streamystats-api.service.ts @@ -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 { if (!this.api || !this.settings.jellyfin_api_key) { diff --git a/apps/server/src/modules/collections/collection-handler.spec.ts b/apps/server/src/modules/collections/collection-handler.spec.ts index cde3f948b..3c0535f74 100644 --- a/apps/server/src/modules/collections/collection-handler.spec.ts +++ b/apps/server/src/modules/collections/collection-handler.spec.ts @@ -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( diff --git a/apps/server/src/modules/collections/collection-handler.ts b/apps/server/src/modules/collections/collection-handler.ts index adc160a16..6d14c9b79 100644 --- a/apps/server/src/modules/collections/collection-handler.ts +++ b/apps/server/src/modules/collections/collection-handler.ts @@ -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( diff --git a/apps/server/src/modules/collections/collection-poster.service.ts b/apps/server/src/modules/collections/collection-poster.service.ts index 394d7d312..3f6fd0e4f 100644 --- a/apps/server/src/modules/collections/collection-poster.service.ts +++ b/apps/server/src/modules/collections/collection-poster.service.ts @@ -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 }; diff --git a/apps/server/src/modules/collections/collection-worker.server.spec.ts b/apps/server/src/modules/collections/collection-worker.server.spec.ts index 10ccd7e44..b864ad918 100644 --- a/apps/server/src/modules/collections/collection-worker.server.spec.ts +++ b/apps/server/src/modules/collections/collection-worker.server.spec.ts @@ -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( diff --git a/apps/server/src/modules/collections/collection-worker.service.ts b/apps/server/src/modules/collections/collection-worker.service.ts index d549cc068..fd10b288b 100644 --- a/apps/server/src/modules/collections/collection-worker.service.ts +++ b/apps/server/src/modules/collections/collection-worker.service.ts @@ -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 { diff --git a/apps/server/src/modules/collections/collections.service.spec.ts b/apps/server/src/modules/collections/collections.service.spec.ts index eff8a6f65..e9c40b4a5 100644 --- a/apps/server/src/modules/collections/collections.service.spec.ts +++ b/apps/server/src/modules/collections/collections.service.spec.ts @@ -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); diff --git a/apps/server/src/modules/collections/collections.service.ts b/apps/server/src/modules/collections/collections.service.ts index 294074ab9..402dc81a6 100644 --- a/apps/server/src/modules/collections/collections.service.ts +++ b/apps/server/src/modules/collections/collections.service.ts @@ -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(); @@ -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. diff --git a/apps/server/src/modules/collections/entities/collection.entities.ts b/apps/server/src/modules/collections/entities/collection.entities.ts index 0db6a3d82..6f19b859f 100644 --- a/apps/server/src/modules/collections/entities/collection.entities.ts +++ b/apps/server/src/modules/collections/entities/collection.entities.ts @@ -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; diff --git a/apps/server/src/modules/collections/recently-handled-media.service.ts b/apps/server/src/modules/collections/recently-handled-media.service.ts index 2fb19cac5..be6bd2e43 100644 --- a/apps/server/src/modules/collections/recently-handled-media.service.ts +++ b/apps/server/src/modules/collections/recently-handled-media.service.ts @@ -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. */ diff --git a/apps/server/src/modules/collections/sort-utils.spec.ts b/apps/server/src/modules/collections/sort-utils.spec.ts index 96c0c68ca..0e1bf0f17 100644 --- a/apps/server/src/modules/collections/sort-utils.spec.ts +++ b/apps/server/src/modules/collections/sort-utils.spec.ts @@ -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 }), diff --git a/apps/server/src/modules/metadata/metadata.service.spec.ts b/apps/server/src/modules/metadata/metadata.service.spec.ts index 0002bd109..00271b5e1 100644 --- a/apps/server/src/modules/metadata/metadata.service.spec.ts +++ b/apps/server/src/modules/metadata/metadata.service.spec.ts @@ -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, diff --git a/apps/server/src/modules/metadata/metadata.service.ts b/apps/server/src/modules/metadata/metadata.service.ts index 678966e92..d713e62fd 100644 --- a/apps/server/src/modules/metadata/metadata.service.ts +++ b/apps/server/src/modules/metadata/metadata.service.ts @@ -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) { diff --git a/apps/server/src/modules/metadata/providers/tvdb-metadata.provider.spec.ts b/apps/server/src/modules/metadata/providers/tvdb-metadata.provider.spec.ts index 690872862..99786829f 100644 --- a/apps/server/src/modules/metadata/providers/tvdb-metadata.provider.spec.ts +++ b/apps/server/src/modules/metadata/providers/tvdb-metadata.provider.spec.ts @@ -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); }); diff --git a/apps/server/src/modules/notifications/agents/webhookUrl.ts b/apps/server/src/modules/notifications/agents/webhookUrl.ts index 20c192db3..125d9f422 100644 --- a/apps/server/src/modules/notifications/agents/webhookUrl.ts +++ b/apps/server/src/modules/notifications/agents/webhookUrl.ts @@ -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, diff --git a/apps/server/src/modules/notifications/notifications.service.ts b/apps/server/src/modules/notifications/notifications.service.ts index 7c3c9afd8..3456e8ac2 100644 --- a/apps/server/src/modules/notifications/notifications.service.ts +++ b/apps/server/src/modules/notifications/notifications.service.ts @@ -863,7 +863,7 @@ export class NotificationService implements OnModuleInit { dayAmount?: number, ): Promise { // 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 diff --git a/apps/server/src/modules/overlays/overlay-processor.service.spec.ts b/apps/server/src/modules/overlays/overlay-processor.service.spec.ts index e73545697..946c7e6d9 100644 --- a/apps/server/src/modules/overlays/overlay-processor.service.spec.ts +++ b/apps/server/src/modules/overlays/overlay-processor.service.spec.ts @@ -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'); diff --git a/apps/server/src/modules/overlays/overlay-processor.service.ts b/apps/server/src/modules/overlays/overlay-processor.service.ts index 9063cbc98..1f31cb036 100644 --- a/apps/server/src/modules/overlays/overlay-processor.service.ts +++ b/apps/server/src/modules/overlays/overlay-processor.service.ts @@ -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, diff --git a/apps/server/src/modules/overlays/overlay-render.service.ts b/apps/server/src/modules/overlays/overlay-render.service.ts index 02cc25f71..55dad831a 100644 --- a/apps/server/src/modules/overlays/overlay-render.service.ts +++ b/apps/server/src/modules/overlays/overlay-render.service.ts @@ -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; diff --git a/apps/server/src/modules/overlays/overlay-task.service.ts b/apps/server/src/modules/overlays/overlay-task.service.ts index a3a091e5e..38c4ade0b 100644 --- a/apps/server/src/modules/overlays/overlay-task.service.ts +++ b/apps/server/src/modules/overlays/overlay-task.service.ts @@ -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 } diff --git a/apps/server/src/modules/overlays/providers/emby-overlay.provider.ts b/apps/server/src/modules/overlays/providers/emby-overlay.provider.ts index be50c15d1..6508eb97a 100644 --- a/apps/server/src/modules/overlays/providers/emby-overlay.provider.ts +++ b/apps/server/src/modules/overlays/providers/emby-overlay.provider.ts @@ -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 }; } diff --git a/apps/server/src/modules/overlays/providers/jellyfin-overlay.provider.spec.ts b/apps/server/src/modules/overlays/providers/jellyfin-overlay.provider.spec.ts index 6a0b9bb33..45fe44471 100644 --- a/apps/server/src/modules/overlays/providers/jellyfin-overlay.provider.spec.ts +++ b/apps/server/src/modules/overlays/providers/jellyfin-overlay.provider.spec.ts @@ -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', }); }); diff --git a/apps/server/src/modules/overlays/providers/jellyfin-overlay.provider.ts b/apps/server/src/modules/overlays/providers/jellyfin-overlay.provider.ts index b9ee0ab9e..d47fabf8d 100644 --- a/apps/server/src/modules/overlays/providers/jellyfin-overlay.provider.ts +++ b/apps/server/src/modules/overlays/providers/jellyfin-overlay.provider.ts @@ -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 }; } diff --git a/apps/server/src/modules/overlays/providers/overlay-provider.factory.ts b/apps/server/src/modules/overlays/providers/overlay-provider.factory.ts index 0dbd24e22..fc10f4476 100644 --- a/apps/server/src/modules/overlays/providers/overlay-provider.factory.ts +++ b/apps/server/src/modules/overlays/providers/overlay-provider.factory.ts @@ -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 diff --git a/apps/server/src/modules/overlays/providers/overlay-provider.interface.ts b/apps/server/src/modules/overlays/providers/overlay-provider.interface.ts index a93a5eda0..c5e666eef 100644 --- a/apps/server/src/modules/overlays/providers/overlay-provider.interface.ts +++ b/apps/server/src/modules/overlays/providers/overlay-provider.interface.ts @@ -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; @@ -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; diff --git a/apps/server/src/modules/overlays/providers/overlay-provider.module.ts b/apps/server/src/modules/overlays/providers/overlay-provider.module.ts index 62d692864..31157e5bd 100644 --- a/apps/server/src/modules/overlays/providers/overlay-provider.module.ts +++ b/apps/server/src/modules/overlays/providers/overlay-provider.module.ts @@ -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({ diff --git a/apps/server/src/modules/overlays/providers/plex-overlay.provider.ts b/apps/server/src/modules/overlays/providers/plex-overlay.provider.ts index 477539e15..cdb61cea2 100644 --- a/apps/server/src/modules/overlays/providers/plex-overlay.provider.ts +++ b/apps/server/src/modules/overlays/providers/plex-overlay.provider.ts @@ -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. diff --git a/apps/server/src/modules/rules/constants/constants.service.ts b/apps/server/src/modules/rules/constants/constants.service.ts index 7d8aae69d..93a8146f5 100644 --- a/apps/server/src/modules/rules/constants/constants.service.ts +++ b/apps/server/src/modules/rules/constants/constants.service.ts @@ -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('_')) { diff --git a/apps/server/src/modules/rules/constants/rules.constants.ts b/apps/server/src/modules/rules/constants/rules.constants.ts index 759f2a8a1..598b7ffe2 100644 --- a/apps/server/src/modules/rules/constants/rules.constants.ts +++ b/apps/server/src/modules/rules/constants/rules.constants.ts @@ -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, diff --git a/apps/server/src/modules/rules/getter/emby-getter.service.ts b/apps/server/src/modules/rules/getter/emby-getter.service.ts index 42a9e3c25..a21dd0600 100644 --- a/apps/server/src/modules/rules/getter/emby-getter.service.ts +++ b/apps/server/src/modules/rules/getter/emby-getter.service.ts @@ -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. */ diff --git a/apps/server/src/modules/rules/getter/getter-property-coverage.spec.ts b/apps/server/src/modules/rules/getter/getter-property-coverage.spec.ts index a6274263e..962e125db 100644 --- a/apps/server/src/modules/rules/getter/getter-property-coverage.spec.ts +++ b/apps/server/src/modules/rules/getter/getter-property-coverage.spec.ts @@ -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'; diff --git a/apps/server/src/modules/rules/getter/getter.service.ts b/apps/server/src/modules/rules/getter/getter.service.ts index 2742c0e69..acb7c24c3 100644 --- a/apps/server/src/modules/rules/getter/getter.service.ts +++ b/apps/server/src/modules/rules/getter/getter.service.ts @@ -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: diff --git a/apps/server/src/modules/rules/getter/jellyfin-getter.service.ts b/apps/server/src/modules/rules/getter/jellyfin-getter.service.ts index 0e0a0d2e2..b7c4602a5 100644 --- a/apps/server/src/modules/rules/getter/jellyfin-getter.service.ts +++ b/apps/server/src/modules/rules/getter/jellyfin-getter.service.ts @@ -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. */ diff --git a/apps/server/src/modules/rules/getter/plex-getter.service.spec.ts b/apps/server/src/modules/rules/getter/plex-getter.service.spec.ts index 97a2e0328..da20e0ed3 100644 --- a/apps/server/src/modules/rules/getter/plex-getter.service.spec.ts +++ b/apps/server/src/modules/rules/getter/plex-getter.service.spec.ts @@ -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 []; diff --git a/apps/server/src/modules/rules/getter/plex-getter.service.ts b/apps/server/src/modules/rules/getter/plex-getter.service.ts index 959500c69..8d1226be2 100644 --- a/apps/server/src/modules/rules/getter/plex-getter.service.ts +++ b/apps/server/src/modules/rules/getter/plex-getter.service.ts @@ -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). diff --git a/apps/server/src/modules/rules/getter/radarr-getter.service.ts b/apps/server/src/modules/rules/getter/radarr-getter.service.ts index 7ef072783..ab2a60bcd 100644 --- a/apps/server/src/modules/rules/getter/radarr-getter.service.ts +++ b/apps/server/src/modules/rules/getter/radarr-getter.service.ts @@ -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; diff --git a/apps/server/src/modules/rules/getter/seerr-getter.service.spec.ts b/apps/server/src/modules/rules/getter/seerr-getter.service.spec.ts index 03fd3d73a..7a6109d38 100644 --- a/apps/server/src/modules/rules/getter/seerr-getter.service.spec.ts +++ b/apps/server/src/modules/rules/getter/seerr-getter.service.spec.ts @@ -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(); diff --git a/apps/server/src/modules/rules/getter/seerr-getter.service.ts b/apps/server/src/modules/rules/getter/seerr-getter.service.ts index 23806a025..6705cf803 100644 --- a/apps/server/src/modules/rules/getter/seerr-getter.service.ts +++ b/apps/server/src/modules/rules/getter/seerr-getter.service.ts @@ -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. diff --git a/apps/server/src/modules/rules/getter/sonarr-getter.service.spec.ts b/apps/server/src/modules/rules/getter/sonarr-getter.service.spec.ts index 767374528..aa7704098 100644 --- a/apps/server/src/modules/rules/getter/sonarr-getter.service.spec.ts +++ b/apps/server/src/modules/rules/getter/sonarr-getter.service.spec.ts @@ -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(); diff --git a/apps/server/src/modules/rules/getter/sonarr-getter.service.ts b/apps/server/src/modules/rules/getter/sonarr-getter.service.ts index 74d50c3e1..357c12573 100644 --- a/apps/server/src/modules/rules/getter/sonarr-getter.service.ts +++ b/apps/server/src/modules/rules/getter/sonarr-getter.service.ts @@ -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', diff --git a/apps/server/src/modules/rules/getter/streamystats-getter.service.spec.ts b/apps/server/src/modules/rules/getter/streamystats-getter.service.spec.ts index 9b0567236..f87519ffb 100644 --- a/apps/server/src/modules/rules/getter/streamystats-getter.service.spec.ts +++ b/apps/server/src/modules/rules/getter/streamystats-getter.service.spec.ts @@ -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[] = [], ) => { @@ -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(); }); }); diff --git a/apps/server/src/modules/rules/getter/streamystats-getter.service.ts b/apps/server/src/modules/rules/getter/streamystats-getter.service.ts index 06a398d7c..36354a343 100644 --- a/apps/server/src/modules/rules/getter/streamystats-getter.service.ts +++ b/apps/server/src/modules/rules/getter/streamystats-getter.service.ts @@ -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) { diff --git a/apps/server/src/modules/rules/getter/tautulli-getter.service.ts b/apps/server/src/modules/rules/getter/tautulli-getter.service.ts index 790a1113c..aab48bbb2 100644 --- a/apps/server/src/modules/rules/getter/tautulli-getter.service.ts +++ b/apps/server/src/modules/rules/getter/tautulli-getter.service.ts @@ -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 diff --git a/apps/server/src/modules/rules/helpers/arr-lookup-cache.ts b/apps/server/src/modules/rules/helpers/arr-lookup-cache.ts index 6f17e5297..08879fa06 100644 --- a/apps/server/src/modules/rules/helpers/arr-lookup-cache.ts +++ b/apps/server/src/modules/rules/helpers/arr-lookup-cache.ts @@ -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( key: string, diff --git a/apps/server/src/modules/rules/helpers/exclusion-cascade.helper.ts b/apps/server/src/modules/rules/helpers/exclusion-cascade.helper.ts index cc2c66ad9..2d4856b2f 100644 --- a/apps/server/src/modules/rules/helpers/exclusion-cascade.helper.ts +++ b/apps/server/src/modules/rules/helpers/exclusion-cascade.helper.ts @@ -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). diff --git a/apps/server/src/modules/rules/helpers/rule-property.helper.ts b/apps/server/src/modules/rules/helpers/rule-property.helper.ts index 910cbb5b9..730cf6f13 100644 --- a/apps/server/src/modules/rules/helpers/rule-property.helper.ts +++ b/apps/server/src/modules/rules/helpers/rule-property.helper.ts @@ -16,8 +16,8 @@ export function uniqueTrimmedRulePropertyNames( names: readonly string[], ): string[] { // Behaviour-preserving extraction of the Plex smart-collection name logic - // from #1630: de-duplicate on the RAW value first — collapsing a collection - // that appears at several parent levels or as a smart collection — and trim + // from #1630: de-duplicate on the RAW value first - collapsing a collection + // that appears at several parent levels or as a smart collection - and trim // only afterwards. Because dedupe runs before trimming, names that differ // only in surrounding whitespace (or case) remain separate list entries. // That distinction is user-visible: these feed COUNT_* comparators on the diff --git a/apps/server/src/modules/rules/helpers/rule.comparator.service.ts b/apps/server/src/modules/rules/helpers/rule.comparator.service.ts index 0816424f6..24da4f21a 100644 --- a/apps/server/src/modules/rules/helpers/rule.comparator.service.ts +++ b/apps/server/src/modules/rules/helpers/rule.comparator.service.ts @@ -130,9 +130,9 @@ export class RuleComparatorService { // condition of the new section and is persisted as a string // ("0"/"1"), or null when unset. +null === 0 is true in JS, so the // bare `+operator === 0` check coerced an unset operator to AND. - // Guard against null first, then coerce — mirroring the + // Guard against null first, then coerce - mirroring the // within-section idiom (`operator != null && +operator === ...`) - // used elsewhere in this service — so an unset operator falls + // used elsewhere in this service - so an unset operator falls // through to OR while an explicit AND ("0") is still honoured. // Persisted rules are normalised to an explicit operator by // migration, so null should not reach here in practice. @@ -226,7 +226,7 @@ export class RuleComparatorService { // trigger an external lookup. Plex leaf watch history is prefetched for the // rule batch, but show/season rollups and other integrations can still make // per-item calls. These getters are pure reads and touch no comparator - // state, so we resolve them up front in bounded parallel batches — the same + // state, so we resolve them up front in bounded parallel batches - the same // chunk + Promise.all idiom used for Plex collection writes. Batching lives // only here, so the total in-flight lookups never exceed // RULE_EVALUATION_CONCURRENCY. The mutation pass below then stays strictly @@ -405,7 +405,7 @@ export class RuleComparatorService { // Key conversion off the first-value slot's *declared* type, not the // runtime value. This preserves the previous behavior for every action // (before/after/in_last/in_next plus equals/not_equals) while still - // converting custom_days when firstVal is null — see issue #2582. + // converting custom_days when firstVal is null - see issue #2582. const firstValProperty = this.ruleConstanstService .getRuleConstants() .applications.find((app) => app.id === rule.firstVal[0]) diff --git a/apps/server/src/modules/rules/helpers/yaml.service.spec.ts b/apps/server/src/modules/rules/helpers/yaml.service.spec.ts index 722c6f55d..169c08ab5 100644 --- a/apps/server/src/modules/rules/helpers/yaml.service.spec.ts +++ b/apps/server/src/modules/rules/helpers/yaml.service.spec.ts @@ -19,7 +19,7 @@ describe('RuleYamlService', () => { }); // A two-section rule whose section-1 boundary uses an explicit AND operator. - // YAML imports persist operators numerically, so AND is the number 0 — which + // YAML imports persist operators numerically, so AND is the number 0 - which // is falsy and used to be dropped on export. const twoSectionRules = (sectionOperator: RuleDto['operator']): RuleDto[] => [ { diff --git a/apps/server/src/modules/rules/rules.service.deleteRuleGroup.spec.ts b/apps/server/src/modules/rules/rules.service.deleteRuleGroup.spec.ts index 223308126..1e7fb1908 100644 --- a/apps/server/src/modules/rules/rules.service.deleteRuleGroup.spec.ts +++ b/apps/server/src/modules/rules/rules.service.deleteRuleGroup.spec.ts @@ -189,7 +189,7 @@ describe('RulesService.deleteRuleGroup', () => { }); }); - describe('Behavior A — membership tag cleanup on delete', () => { + describe('Behavior A - membership tag cleanup on delete', () => { it("strips members' *arr membership tags before deleting a tagging-enabled group", async () => { const group = { id: 42, collectionId: 100 }; const collection = { diff --git a/apps/server/src/modules/rules/rules.service.exclusion-scoping.integration.spec.ts b/apps/server/src/modules/rules/rules.service.exclusion-scoping.integration.spec.ts index c49f02474..8fad59402 100644 --- a/apps/server/src/modules/rules/rules.service.exclusion-scoping.integration.spec.ts +++ b/apps/server/src/modules/rules/rules.service.exclusion-scoping.integration.spec.ts @@ -12,13 +12,13 @@ import { RulesService } from './rules.service'; // Deterministic, real-DB integration test for cross-group exclusion scoping. // // This composes the two real pieces the rule executor uses: -// 1. RulesService.getExclusions(rulegroupId) — against a real SQLite repo +// 1. RulesService.getExclusions(rulegroupId) - against a real SQLite repo // 2. the executor's gate: `if (!isMediaItemExcluded(cascade, item)) add(item)` // (rule-executor.service.ts handleCollection) // // It proves end-to-end that an item excluded *in another group* IS added to a // group that didn't exclude it, while global exclusions still apply everywhere. -// No cron/scheduler/media-server — fully reproducible (unlike a live dev run). +// No cron/scheduler/media-server - fully reproducible (unlike a live dev run). // EntitySchema mirror of the Exclusion entity (avoids decorator/metadata setup). const ExclusionSchema = new EntitySchema({ @@ -38,7 +38,7 @@ const GROUP_B = 169; // a different group const MOVIE_X = 'movie-x'; // scoped-excluded in A only const MOVIE_G = 'movie-global'; // globally excluded -describe('Exclusion scoping (real DB) — excluded-in-A item is added in B', () => { +describe('Exclusion scoping (real DB) - excluded-in-A item is added in B', () => { let ds: DataSource; let repo: Repository; let service: RulesService; @@ -91,7 +91,7 @@ describe('Exclusion scoping (real DB) — excluded-in-A item is added in B', () return !isMediaItemExcluded(cascade, { id: itemId }); }; - it('group B (no own exclusion for MOVIE_X) ADDS it — the A-scoped exclusion does not leak', async () => { + it('group B (no own exclusion for MOVIE_X) ADDS it - the A-scoped exclusion does not leak', async () => { expect(await isAddedTo(GROUP_B, MOVIE_X)).toBe(true); }); diff --git a/apps/server/src/modules/rules/rules.service.exclusions.spec.ts b/apps/server/src/modules/rules/rules.service.exclusions.spec.ts index 6ef7ed48d..eba995d71 100644 --- a/apps/server/src/modules/rules/rules.service.exclusions.spec.ts +++ b/apps/server/src/modules/rules/rules.service.exclusions.spec.ts @@ -8,7 +8,7 @@ import { RulesService } from './rules.service'; // Regression coverage for global-exclusion handling (ruleGroupId IS NULL). // TypeORM 1.x throws on a bare `null` in a `where` clause, so these paths must // use IsNull() and must not feed a null id into a lookup. -describe('RulesService exclusions — global (null ruleGroupId) handling', () => { +describe('RulesService exclusions - global (null ruleGroupId) handling', () => { const logger = createMockLogger(); const createService = (overrides?: { @@ -95,7 +95,7 @@ describe('RulesService exclusions — global (null ruleGroupId) handling', () => expect(exclusionRepo.find).toHaveBeenNthCalledWith(1, { where: { ruleGroupId: 5 }, }); - // second call: the global exclusions — must use IsNull(), never `null` + // second call: the global exclusions - must use IsNull(), never `null` const globalCallWhere = exclusionRepo.find.mock.calls[1][0].where; expect(isNullOperator(globalCallWhere.ruleGroupId)).toBe(true); }); @@ -408,7 +408,7 @@ describe('RulesService exclusions — global (null ruleGroupId) handling', () => type: 'movie', }), delete: jest.fn().mockResolvedValue(undefined), - // another rule group still excludes this item — last-exclusion-wins + // another rule group still excludes this item - last-exclusion-wins count: jest.fn().mockResolvedValue(1), }; const ruleGroupRepository = { diff --git a/apps/server/src/modules/rules/rules.service.ts b/apps/server/src/modules/rules/rules.service.ts index 07d8b8713..e80c6678e 100644 --- a/apps/server/src/modules/rules/rules.service.ts +++ b/apps/server/src/modules/rules/rules.service.ts @@ -282,7 +282,7 @@ export class RulesService { if (group) { if (group.collectionId) { - // Behavior A: deleting a tagging group makes every member "leave" — strip + // Behavior A: deleting a tagging group makes every member "leave" - strip // their *arr membership tags first (best-effort), while the collection // media rows still exist (deleteCollection removes them next). try { @@ -500,7 +500,7 @@ export class RulesService { // Behavior A: if a tagging-enabled collection is about to have its members // wiped below (crucial-setting change) and tagInArr is being turned off in // the same save, capture the members first so the toggle reconcile can - // still untag them (otherwise the rows — and our only record of them — are + // still untag them (otherwise the rows - and our only record of them - are // gone before the reconcile runs). let preDeleteMembers: CollectionMedia[] | undefined; @@ -537,7 +537,7 @@ export class RulesService { if (dbCollection.mediaServerId) { const mediaServer = await this.getMediaServer(); try { - // Use the OLD library ID — we're cleaning up items that belonged + // Use the OLD library ID - we're cleaning up items that belonged // to the previous library, not the one the rule is moving to. await mediaServer.cleanupCollectionForLibrary( dbCollection.mediaServerId, @@ -595,7 +595,7 @@ export class RulesService { sonarrQualityProfileId: params.sonarrQualityProfileId ?? null, tagInArr: params.tagInArr ?? false, // If the collection block is left out of an update, keep the saved - // values instead of sending undefined — otherwise we'd unlink a manual + // values instead of sending undefined - otherwise we'd unlink a manual // collection or switch off Plex visibility. visibleOnRecommended: params.collection?.visibleOnRecommended ?? @@ -651,7 +651,7 @@ export class RulesService { } // Behavior A: one-time *arr membership-tag reconcile on a tagInArr toggle - // — enabling tags current members, disabling untags them (ongoing changes + // - enabling tags current members, disabling untags them (ongoing changes // are handled by the executor's per-run deltas). Best-effort; awaited so // the backfill completes before the save returns. if ( @@ -724,7 +724,7 @@ export class RulesService { // disabling untags them using the previous collection (still tagInArr=true, with // its old title) so the correct label is removed even if renamed in the same save. // `preDeleteMembers` covers the disable case where a crucial-setting change in the - // same save already wiped the rows — pass the snapshot taken before the wipe. + // same save already wiped the rows - pass the snapshot taken before the wipe. private async reconcileMembershipTagsOnToggle( previous: Collection | undefined, saved: Collection, @@ -792,7 +792,7 @@ export class RulesService { // collection; a global one resolves the single configured instance. Removal is // conservative and must run AFTER the rows are deleted: it leaves the tag in // place if any exclusion for the item survives (another rule group or a global - // one), so a still-excluded item keeps its protection — last-exclusion-wins. + // one), so a still-excluded item keeps its protection - last-exclusion-wins. private async syncExclusionTag( mode: 'add' | 'remove', item: { mediaServerId: string; type: MediaItemType | undefined }, @@ -1113,7 +1113,7 @@ export class RulesService { } // Behavior B (https://features.maintainerr.info/posts/81): opt-in removal of - // the protective *arr tag on un-exclude — this is the POST /rules/exclusion + // the protective *arr tag on un-exclude - this is the POST /rules/exclusion // remove path used by the media modal. Untag the top-level item once, after // its rows are deleted so the shared-tag guard is accurate. if (this.servarrTagService.anyExclusionUntaggingEnabled()) { diff --git a/apps/server/src/modules/rules/tasks/exclusion-corrector.service.ts b/apps/server/src/modules/rules/tasks/exclusion-corrector.service.ts index a7e2f64c1..fab3b91b1 100644 --- a/apps/server/src/modules/rules/tasks/exclusion-corrector.service.ts +++ b/apps/server/src/modules/rules/tasks/exclusion-corrector.service.ts @@ -138,7 +138,7 @@ export class ExclusionTypeCorrectorService implements OnModuleInit { } this.logger.log( - `Backfilling type for ${exclusionsWithoutType.length} exclusion(s) from media server metadata — this may take a moment on first run`, + `Backfilling type for ${exclusionsWithoutType.length} exclusion(s) from media server metadata - this may take a moment on first run`, ); const mediaServer = await this.mediaServerFactory.getService(); diff --git a/apps/server/src/modules/rules/tasks/rule-executor-job-manager.service.ts b/apps/server/src/modules/rules/tasks/rule-executor-job-manager.service.ts index b5bf4f5f1..3223f537b 100644 --- a/apps/server/src/modules/rules/tasks/rule-executor-job-manager.service.ts +++ b/apps/server/src/modules/rules/tasks/rule-executor-job-manager.service.ts @@ -76,7 +76,7 @@ export class RuleExecutorJobManagerService implements OnApplicationShutdown { } public async stopProcessing() { - // Drop any queued work – we only care about the in-flight execution + // Drop any queued work - we only care about the in-flight execution this.queue.length = 0; this.abortController?.abort(); @@ -233,7 +233,7 @@ export class RuleExecutorJobManagerService implements OnApplicationShutdown { ); // Everything between acquire and release lives inside this try so - // release() always runs — including if `emitStatusUpdate` synchronously + // release() always runs - including if `emitStatusUpdate` synchronously // throws (e.g. an event listener throws). Without this, a thrown // listener would leak the lock entry forever and every future // `tryAcquire` would return null until restart (#2799). diff --git a/apps/server/src/modules/rules/tasks/rule-executor.service.spec.ts b/apps/server/src/modules/rules/tasks/rule-executor.service.spec.ts index 8ff714b4f..ca54c484f 100644 --- a/apps/server/src/modules/rules/tasks/rule-executor.service.spec.ts +++ b/apps/server/src/modules/rules/tasks/rule-executor.service.spec.ts @@ -319,7 +319,7 @@ describe('RuleExecutorService', () => { expect(collectionService.addToCollection).not.toHaveBeenCalled(); expect(collectionService.removeFromCollection).not.toHaveBeenCalled(); expect(logger.debug).toHaveBeenCalledWith( - "Skipping media server sync for 'Test Collection' — no media server collection exists because no items currently match the rule.", + "Skipping media server sync for 'Test Collection' - no media server collection exists because no items currently match the rule.", ); }); @@ -1331,7 +1331,7 @@ describe('RuleExecutorService', () => { collectionService.getCollectionMedia.mockResolvedValue([] as any); rulesService.getExclusions.mockResolvedValue([ // parent=showId mirrors what setExclusion writes when the AddModal is - // opened from a show overview — the regression hinges on this shape. + // opened from a show overview - the regression hinges on this shape. { mediaServerId: 'ep-1', parent: 'show-1', type: 'episode' }, ] as any); @@ -1731,7 +1731,7 @@ describe('RuleExecutorService', () => { .mockResolvedValue(undefined); // Abort during the pre-evaluation cache reset, i.e. after the top-level - // abort check but before the prefetch — so only the pre-prefetch check + // abort check but before the prefetch - so only the pre-prefetch check // can stop the sweep. const abortController = new AbortController(); rulesService.resetCacheIfGroupUsesRuleThatRequiresIt.mockImplementation( diff --git a/apps/server/src/modules/rules/tasks/rule-executor.service.ts b/apps/server/src/modules/rules/tasks/rule-executor.service.ts index a0c80bb09..64818dcca 100644 --- a/apps/server/src/modules/rules/tasks/rule-executor.service.ts +++ b/apps/server/src/modules/rules/tasks/rule-executor.service.ts @@ -236,7 +236,7 @@ export class RuleExecutorService { // collection; the post-handle state would then look "already linked" and // make the sync below import that collection's existing contents as // manual. Capturing the pre-run state lets the run that *establishes* the - // link skip that import — the membership-provenance guard (#2663) + // link skip that import - the membership-provenance guard (#2663) // otherwise reads the link state too late (after handleCollection has // already linked it) and never skips. // @@ -246,7 +246,7 @@ export class RuleExecutorService { // pre-existing, rule-unselected items get tracked as manual then. We can't // distinguish "was on the BoxSet at adoption time" from "the user added it // to the BoxSet afterwards" without persisted provenance, so the guard - // only removes the noisy first-run flood — a long-lived adopted collection + // only removes the noisy first-run flood - a long-lived adopted collection // still absorbs its foreign items as manual on a later run. const collectionLinkedBeforeRun = ruleGroup.collection?.id ? Boolean( @@ -271,7 +271,7 @@ export class RuleExecutorService { // evaluation are served from an in-memory map instead of individual // HTTP requests. Reused across rule groups within a scheduler batch; // rebuilt here when the reset above flushed it. Gated on a centrally - // queryable history endpoint — Jellyfin/Emby (per-user history) keep + // queryable history endpoint - Jellyfin/Emby (per-user history) keep // their per-item path. Abort-checked first so a cancellation that // lands just before evaluation doesn't kick off a long history sweep. abortSignal.throwIfAborted(); @@ -436,7 +436,7 @@ export class RuleExecutorService { } else if (children && children.length > 0) { // When two automatic rule groups share a title they end up linked // to the same media server collection. Items rule-owned by a - // sibling collection must not be imported here as manual — that + // sibling collection must not be imported here as manual - that // would subject them to this rule's deleteAfterDays. If we cannot // determine sibling ownership (DB error), refuse to import: a // silent fallback to "no siblings" would re-introduce the @@ -625,7 +625,7 @@ export class RuleExecutorService { if (!linkedCollection.mediaServerId) { this.logger.debug( - `Skipping media server sync for '${linkedCollection.title}' — no media server collection exists because no items currently match the rule.`, + `Skipping media server sync for '${linkedCollection.title}' - no media server collection exists because no items currently match the rule.`, ); return {}; } @@ -690,7 +690,7 @@ export class RuleExecutorService { // Filter exclusions out of results. Cascade is keyed off the show/season // exclusion's own mediaServerId (via type), so a single-episode exclusion - // only skips that episode — not its siblings (issue #2858). + // only skips that episode - not its siblings (issue #2858). const desiredMediaServerIds = new Set(); for (const item of this.resultData ?? []) { @@ -756,7 +756,7 @@ export class RuleExecutorService { // Conditions like "watched" / "lastViewedAt before N days" stay true // after the handler action, so without this guard the user gets a // `Media Removed` event immediately followed by `Media Added` for - // the same title — confusing and noisy via email/Discord. + // the same title - confusing and noisy via email/Discord. // // The marks are consumed here: this pass blocks the immediate echo, // and any subsequent pass treats the items normally. If the rule diff --git a/apps/server/src/modules/rules/tests/rule.comparator.service.executeRulesWithData.spec.ts b/apps/server/src/modules/rules/tests/rule.comparator.service.executeRulesWithData.spec.ts index 0c6689890..364504b05 100644 --- a/apps/server/src/modules/rules/tests/rule.comparator.service.executeRulesWithData.spec.ts +++ b/apps/server/src/modules/rules/tests/rule.comparator.service.executeRulesWithData.spec.ts @@ -439,7 +439,7 @@ describe('RuleComparatorService.executeRulesWithData', () => { }); // Unary EXISTS/NOT_EXISTS must distinguish the getter's `null` (definitive - // absence — e.g. lastViewedAt for a never-watched item) from `undefined` + // absence - e.g. lastViewedAt for a never-watched item) from `undefined` // (outer-catch transport failure in plex/seerr-getter). Without the // tightened shouldCompare, `!hasExistsValue(undefined) === true` would // spuriously add items on every transient API blip (#1446). @@ -490,7 +490,7 @@ describe('RuleComparatorService.executeRulesWithData', () => { it('completes the run and logs a skip (not a crash) when a unary rule value is unavailable', async () => { // getCustomValueIdentifier dereferences customValue.ruleTypeId. A unary // rule (EXISTS/NOT_EXISTS) carries no customVal, so logging the skipped - // comparison must not reach this helper — otherwise the run threw + // comparison must not reach this helper - otherwise the run threw // "Cannot read properties of undefined (reading 'ruleTypeId')" and aborted. ruleConstanstService.getCustomValueIdentifier.mockImplementation( (customValue: { ruleTypeId: number; value: string }) => ({ @@ -537,7 +537,7 @@ describe('RuleComparatorService.executeRulesWithData', () => { describe('OR sections', () => { // section 0: viewCount EQUALS 0 (operator: null = OR boundary) // section 1: viewCount BIGGER 0 (operator: null = OR boundary) - // These two conditions are mutually exclusive, which proves OR semantics — + // These two conditions are mutually exclusive, which proves OR semantics - // if AND were used, no item could satisfy both simultaneously. // // Field [Application.PLEX, 5] = viewCount @@ -624,7 +624,7 @@ describe('RuleComparatorService.executeRulesWithData', () => { // section 0: viewCount EQUALS 0 // section 1: viewCount SMALLER 1 // An item with viewCount = 0 matches both sections. OR semantics must - // union the sections and dedupe, so the item appears exactly once — a + // union the sections and dedupe, so the item appears exactly once - a // regression here (e.g. pushing per matching section) would yield two. const overlappingRules = [ createStoredRule( diff --git a/apps/server/src/modules/settings/dto's/setting.dto.ts b/apps/server/src/modules/settings/dto's/setting.dto.ts index 4c1a59107..3026c45be 100644 --- a/apps/server/src/modules/settings/dto's/setting.dto.ts +++ b/apps/server/src/modules/settings/dto's/setting.dto.ts @@ -74,7 +74,7 @@ export class SettingDto { rules_handler_job_cron: string; - // *arr exclusion tagging (https://features.maintainerr.info/posts/81) — Radarr and Sonarr configured independently + // *arr exclusion tagging (https://features.maintainerr.info/posts/81) - Radarr and Sonarr configured independently radarr_tag_exclusions: boolean; radarr_exclusion_tag: string; diff --git a/apps/server/src/modules/settings/media-server-switch.service.ts b/apps/server/src/modules/settings/media-server-switch.service.ts index 3f61ab52b..e2d8a3164 100644 --- a/apps/server/src/modules/settings/media-server-switch.service.ts +++ b/apps/server/src/modules/settings/media-server-switch.service.ts @@ -122,7 +122,7 @@ export class MediaServerSwitchService { } // Initialize the now-active media server adapter once the switch lock is - // released — getService() refuses while a switch is in progress, so this + // released - getService() refuses while a switch is in progress, so this // can't run inside executeSwitchInternal. When the target's credentials // carried over (e.g. switching back to a still-configured server), this // brings the adapter up immediately so its connection test reflects reality diff --git a/apps/server/src/modules/settings/rule-migration.service.spec.ts b/apps/server/src/modules/settings/rule-migration.service.spec.ts index 3afaab4c6..486fbd7d7 100644 --- a/apps/server/src/modules/settings/rule-migration.service.spec.ts +++ b/apps/server/src/modules/settings/rule-migration.service.spec.ts @@ -523,7 +523,7 @@ describe('RuleMigrationService', () => { expect(result.skippedRules).toBe(1); const sectionOne = result.rules.filter((r) => r.section === 1); expect(sectionOne).toHaveLength(1); - // Inherits the dropped boundary's AND — does NOT keep its own OR. + // Inherits the dropped boundary's AND - does NOT keep its own OR. expect(sectionOne[0].operator).toBe(RuleOperators.AND); // Group's first rule still null. expect(result.rules[0].operator).toBeNull(); diff --git a/apps/server/src/modules/settings/rule-migration.service.ts b/apps/server/src/modules/settings/rule-migration.service.ts index 4b9b0e934..eedc6c1a7 100644 --- a/apps/server/src/modules/settings/rule-migration.service.ts +++ b/apps/server/src/modules/settings/rule-migration.service.ts @@ -18,12 +18,12 @@ import { reassertSectionBoundaryOperators } from '../rules/helpers/section-opera import { RuleGroup } from '../rules/entities/rule-group.entities'; import { Rules } from '../rules/entities/rules.entities'; -/** Singleton instance – avoids re-creating the constant data on every call. */ +/** Singleton instance - avoids re-creating the constant data on every call. */ const RULE_CONSTANTS = new RuleConstants(); /** * Single source of truth mapping each supported media server to its rule - * `Application` id. The `Record` type is exhaustive — adding + * `Application` id. The `Record` type is exhaustive - adding * a new media server to `MediaServerType` is a compile error until it is mapped * here, which keeps the migrator in step with the supported-server list. */ @@ -81,7 +81,7 @@ interface PropertyCompatibility { * property. The ID will be rewritten during migration. * - **incompatible** if none of the above applies. * - * This is fully data-driven from `rules.constants.ts` — no hardcoded lists. + * This is fully data-driven from `rules.constants.ts` - no hardcoded lists. */ function computePropertyCompatibility( sourceApp: Application, @@ -382,7 +382,7 @@ export class RuleMigrationService { } } - // Cache compatibility per source app – avoids recomputing for every rule. + // Cache compatibility per source app - avoids recomputing for every rule. const compatCache = new Map(); const getCompat = (sourceApp: Application): PropertyCompatibility => { if (!compatCache.has(sourceApp)) { @@ -452,7 +452,7 @@ export class RuleMigrationService { // rule so a dropped boundary can't silently flip the section AND<->OR. reassertSectionBoundaryOperators(result, sectionCombineOp); - // Backfill any remaining unset within-section operator to OR — the default + // Backfill any remaining unset within-section operator to OR - the default // the comparator and the NormalizeRuleSectionOperators migration both apply. // reassert only sets section-boundary operators, so a pre-explicit-operator // community rule can still carry a null within-section operator here, which @@ -500,7 +500,7 @@ export class RuleMigrationService { /** * Analyze an in-memory rule DTO to determine if it can be migrated. - * Only the `incompatible` set is checked — remapped properties are compatible + * Only the `incompatible` set is checked - remapped properties are compatible * by definition (the two sets are mutually exclusive). */ private analyzeRuleDto( diff --git a/apps/server/src/modules/settings/settings-operations.service.ts b/apps/server/src/modules/settings/settings-operations.service.ts index c23670aa2..fb37dee3c 100644 --- a/apps/server/src/modules/settings/settings-operations.service.ts +++ b/apps/server/src/modules/settings/settings-operations.service.ts @@ -64,7 +64,7 @@ export class SettingsOperationsService { } // ========================================================================== - // Read API — delegated to the passive settings store + // Read API - delegated to the passive settings store // ========================================================================== public init() { @@ -143,7 +143,7 @@ export class SettingsOperationsService { } // ========================================================================== - // Coordination — test / save / reinit flows + // Coordination - test / save / reinit flows // ========================================================================== public async addRadarrSetting( @@ -1314,7 +1314,7 @@ export class SettingsOperationsService { } const unreachableMessage = - "Couldn't reach plex.tv to verify your credentials — retrying. Your saved token is still in use."; + "Couldn't reach plex.tv to verify your credentials - retrying. Your saved token is still in use."; try { switch (await this.plexApi.validateAuthToken()) { diff --git a/apps/server/src/modules/storage-metrics/storage-metrics.controller.ts b/apps/server/src/modules/storage-metrics/storage-metrics.controller.ts index 8cdb10a40..4a63d6f45 100644 --- a/apps/server/src/modules/storage-metrics/storage-metrics.controller.ts +++ b/apps/server/src/modules/storage-metrics/storage-metrics.controller.ts @@ -27,7 +27,7 @@ export class StorageMetricsController { @Get('library-sizes') @ApiOperation({ summary: - 'Accurate per-library size computed by iterating media items. Potentially slow — call on demand.', + 'Accurate per-library size computed by iterating media items. Potentially slow - call on demand.', }) @ApiResponse({ status: 200, diff --git a/apps/server/src/modules/storage-metrics/storage-metrics.service.ts b/apps/server/src/modules/storage-metrics/storage-metrics.service.ts index 5e75a122e..c86e41fe2 100644 --- a/apps/server/src/modules/storage-metrics/storage-metrics.service.ts +++ b/apps/server/src/modules/storage-metrics/storage-metrics.service.ts @@ -285,8 +285,8 @@ export class StorageMetricsService { // Coalesce mounts that survived the per-host dedupe but clearly point at // the same shared volume from different hosts (e.g. Radarr/Sonarr running // in separate LXC containers against the same NAS). A byte-exact match on - // totalSpace plus freeSpace — or a matching volume label plus byte-exact - // totalSpace — is implausible across truly unrelated filesystems, since + // totalSpace plus freeSpace - or a matching volume label plus byte-exact + // totalSpace - is implausible across truly unrelated filesystems, since // any block-level write would diverge them. const merged = this.mergeSharedMountsAcrossHosts(seen); @@ -690,7 +690,7 @@ export class StorageMetricsService { // Group by mediaServerId so the same item across multiple collections // is counted once. Per-item sizes are identical across rows for the - // same id, so MAX is equivalent to "any" — it just deduplicates. + // same id, so MAX is equivalent to "any" - it just deduplicates. // Partitioning by collection.type assigns each unique item to its // collection's type bucket; collections are typed homogeneously, so // overlap between movie and show buckets is not a real-world concern. diff --git a/apps/server/src/modules/tasks/execution-lock.service.ts b/apps/server/src/modules/tasks/execution-lock.service.ts index 7b1b689e6..b582f0f9b 100644 --- a/apps/server/src/modules/tasks/execution-lock.service.ts +++ b/apps/server/src/modules/tasks/execution-lock.service.ts @@ -45,7 +45,7 @@ export class ExecutionLockService { // Store `current` directly so the release callback below can recognise // its own entry by reference and delete it on release. Storing the // chained promise (prior.then(() => current)) instead would leak the - // entry forever — `tryAcquire` checks `locks.has(key)` and would never + // entry forever - `tryAcquire` checks `locks.has(key)` and would never // return non-null again, which is the root cause of #2799. this.locks.set(key, current); diff --git a/apps/server/src/utils/secretMasking.ts b/apps/server/src/utils/secretMasking.ts index a767ebb2b..8aacbf3e3 100644 --- a/apps/server/src/utils/secretMasking.ts +++ b/apps/server/src/utils/secretMasking.ts @@ -205,7 +205,7 @@ const maskUrlAuthority = (authority: string): string => { } } - // Only treat as host:port when there is exactly one colon — multiple colons + // Only treat as host:port when there is exactly one colon - multiple colons // means a bare IPv6 literal (e.g. 2001:db8::1), which must not be split. const colonIdx = authority.lastIndexOf(':'); if ( diff --git a/apps/server/src/utils/sharp.ts b/apps/server/src/utils/sharp.ts index 9a078cd51..feb52c1ff 100644 --- a/apps/server/src/utils/sharp.ts +++ b/apps/server/src/utils/sharp.ts @@ -3,8 +3,8 @@ * * sharp ships prebuilt native binaries. Since libvips 8.18 (sharp 0.35) the * prebuilt Linux x64 binary requires a CPU with the x86-64-v2 microarchitecture - * (SSE4.2, POPCNT, …). On older CPUs — or minimal VM CPU models such as QEMU - * `kvm64`, which do not expose those features to the guest — sharp throws + * (SSE4.2, POPCNT, …). On older CPUs - or minimal VM CPU models such as QEMU + * `kvm64`, which do not expose those features to the guest - sharp throws * "Unsupported CPU" while loading its addon at `require()` time. * * Both consumers (overlay rendering and collection posters) import sharp at diff --git a/apps/server/test/rules-test-matrix.e2e.ts b/apps/server/test/rules-test-matrix.e2e.ts index af7ff1967..aa557ef2e 100644 --- a/apps/server/test/rules-test-matrix.e2e.ts +++ b/apps/server/test/rules-test-matrix.e2e.ts @@ -192,7 +192,7 @@ async function executeScenario(baseUrl: string, scenario: Scenario) { // Generated coverage of every RuleType x RulePossibility. The identical // generator runs on the baseline and current builds, so any per-cell diff -// signals real comparator drift — the operand values only need to reach the +// signals real comparator drift - the operand values only need to reach the // code path, not be domain-realistic. Binary ops get a value + missing-first // case; unary ops (EXISTS / NOT_EXISTS) get a present + absent case. function buildGeneratedMatrix(): Scenario[] { diff --git a/apps/ui/src/api/settings.ts b/apps/ui/src/api/settings.ts index 49ea0b228..a4c6102e9 100644 --- a/apps/ui/src/api/settings.ts +++ b/apps/ui/src/api/settings.ts @@ -69,7 +69,7 @@ export interface ISettings { collection_handler_job_cron: string rules_handler_job_cron: string metadata_provider_preference?: MetadataProviderPreference - // *arr exclusion tagging (https://features.maintainerr.info/posts/81) — Radarr and Sonarr configured independently + // *arr exclusion tagging (https://features.maintainerr.info/posts/81) - Radarr and Sonarr configured independently radarr_tag_exclusions?: boolean radarr_exclusion_tag?: string radarr_untag_on_unexclude?: boolean diff --git a/apps/ui/src/components/AddModal/index.spec.tsx b/apps/ui/src/components/AddModal/index.spec.tsx index a4241a52f..3de579e3d 100644 --- a/apps/ui/src/components/AddModal/index.spec.tsx +++ b/apps/ui/src/components/AddModal/index.spec.tsx @@ -30,7 +30,7 @@ vi.mock('../../utils/ApiHandler', () => ({ PostApiHandler: vi.fn(), })) -describe('AddModal — global exclusion warning', () => { +describe('AddModal - global exclusion warning', () => { const getApiHandlerMock = vi.mocked(GetApiHandler) const postApiHandlerMock = vi.mocked(PostApiHandler) @@ -82,14 +82,14 @@ describe('AddModal — global exclusion warning', () => { }) afterEach(() => cleanup()) - it('Add + all collections, item has scoped exclusions: warns with item — rule-group links, then Proceed submits a global exclusion', async () => { + it('Add + all collections, item has scoped exclusions: warns with item - rule-group links, then Proceed submits a global exclusion', async () => { stubApi(scopedStatus) renderExclude() fireEvent.click(await screen.findByRole('button', { name: 'Submit' })) await screen.findByText('Confirmation Required') - // each scoped exclusion is listed as "" + // each scoped exclusion is listed as " - " expect(screen.getByRole('button', { name: 'Archive Queue' })).toBeTruthy() expect(screen.getByRole('button', { name: 'Stale Movies' })).toBeTruthy() expect(screen.getAllByText(/Mock Charlie/).length).toBeGreaterThan(0) diff --git a/apps/ui/src/components/AddModal/index.tsx b/apps/ui/src/components/AddModal/index.tsx index cdd356417..91525b07f 100644 --- a/apps/ui/src/components/AddModal/index.tsx +++ b/apps/ui/src/components/AddModal/index.tsx @@ -130,7 +130,7 @@ const AddModal = (props: IAddModal) => { } // Only ADDING a global exclusion clears the item's rule-group exclusions. - // If it has any, warn and list each as "item — rule group", reusing the + // If it has any, warn and list each as "item - rule group", reusing the // backdrop's status data (no-cache fetch) so labels/links match and stay // fresh. (selectedAction 0 = Add, 1 = Remove.) if ( @@ -165,7 +165,7 @@ const AddModal = (props: IAddModal) => { return } } catch { - // Warning data unavailable — proceed without it. + // Warning data unavailable - proceed without it. } } @@ -346,7 +346,7 @@ const AddModal = (props: IAddModal) => {
    {affectedExclusions.map((e) => (
  • - {e.title} —{' '} + {e.title} -{' '}
- {/* Server — only shown when authenticated */} + {/* Server - only shown when authenticated */} {isAuthenticated && (
)} - {/* Advanced Settings — hidden collapsible section */} + {/* Advanced Settings - hidden collapsible section */} {isAuthenticated && (
{!accurateTotalSpace ? (

- Total size not reported by this instance — only free space is + Total size not reported by this instance - only free space is accurate.

) : null} diff --git a/apps/ui/src/components/StorageMetrics/index.spec.tsx b/apps/ui/src/components/StorageMetrics/index.spec.tsx index 8614c7ca2..2d3a3e0df 100644 --- a/apps/ui/src/components/StorageMetrics/index.spec.tsx +++ b/apps/ui/src/components/StorageMetrics/index.spec.tsx @@ -197,7 +197,7 @@ describe('StorageMetrics', () => { expect( screen.getByText( - '2 of 3 reclaimable collections sized — duplicates not yet deduplicated, refreshes after next collection run', + '2 of 3 reclaimable collections sized - duplicates not yet deduplicated, refreshes after next collection run', ), ).toBeTruthy() expect( diff --git a/apps/ui/src/components/StorageMetrics/index.tsx b/apps/ui/src/components/StorageMetrics/index.tsx index 115d0c0ac..c5860d406 100644 --- a/apps/ui/src/components/StorageMetrics/index.tsx +++ b/apps/ui/src/components/StorageMetrics/index.tsx @@ -149,7 +149,7 @@ const StorageMetrics: React.FC = () => { const mountLabel = (count: number) => `${count} mount${count === 1 ? '' : 's'}` const noTotalSubtitle = hasAnyFree - ? 'Free space only — Sonarr/Radarr do not report total size for NFS/CIFS mounts' + ? 'Free space only - Sonarr/Radarr do not report total size for NFS/CIFS mounts' : 'No instance reports total capacity' return ( @@ -168,7 +168,7 @@ const StorageMetrics: React.FC = () => {
{ /> { value={formatBytes(metrics.collectionSummary.activeSizeBytes)} subtitle={ metrics.collectionSummary.reclaimableUsingFallback - ? `${metrics.collectionSummary.reclaimableSizedCount} of ${metrics.collectionSummary.reclaimableCount} reclaimable collections sized — duplicates not yet deduplicated, refreshes after next collection run` - : `${metrics.collectionSummary.reclaimableSizedCount} of ${metrics.collectionSummary.reclaimableCount} reclaimable collections sized — duplicates counted once` + ? `${metrics.collectionSummary.reclaimableSizedCount} of ${metrics.collectionSummary.reclaimableCount} reclaimable collections sized - duplicates not yet deduplicated, refreshes after next collection run` + : `${metrics.collectionSummary.reclaimableSizedCount} of ${metrics.collectionSummary.reclaimableCount} reclaimable collections sized - duplicates counted once` } icon={} /> diff --git a/apps/ui/src/hooks/useLockBodyScroll.spec.tsx b/apps/ui/src/hooks/useLockBodyScroll.spec.tsx index f03eaf366..8a6ef594e 100644 --- a/apps/ui/src/hooks/useLockBodyScroll.spec.tsx +++ b/apps/ui/src/hooks/useLockBodyScroll.spec.tsx @@ -37,7 +37,7 @@ describe('useLockBodyScroll', () => { const { unmount: unmountA } = renderHook(() => useLockBodyScroll(true)) const { unmount: unmountB } = renderHook(() => useLockBodyScroll(true)) - // Both are locked – overflow is hidden. + // Both are locked - overflow is hidden. expect(document.body.style.overflow).toBe('hidden') // Releasing the first lock should NOT restore scrolling yet. @@ -72,7 +72,7 @@ describe('useLockBodyScroll', () => { expect(document.body.style.overflow).toBe('hidden') - // Parent (mounted first) releases before child — the scenario from #2748. + // Parent (mounted first) releases before child - the scenario from #2748. // The old snapshot-restore implementation would leave overflow='hidden' // here because the child had captured 'hidden' as its "original" value. unmountParent() diff --git a/apps/ui/src/hooks/useLockBodyScroll.ts b/apps/ui/src/hooks/useLockBodyScroll.ts index d113ac21f..58bd5d407 100644 --- a/apps/ui/src/hooks/useLockBodyScroll.ts +++ b/apps/ui/src/hooks/useLockBodyScroll.ts @@ -30,7 +30,7 @@ const release = (): void => { /** * Resets the module-level counter and clears the body overflow style. - * Intended for test teardown only — keeps counter leaks from propagating + * Intended for test teardown only - keeps counter leaks from propagating * across cases if a test throws before its hooks are tracked by RTL. */ export const __resetLockBodyScrollForTests = (): void => { diff --git a/apps/ui/src/pages/OverlayTemplateEditorPage.tsx b/apps/ui/src/pages/OverlayTemplateEditorPage.tsx index 43eef365f..76f05646e 100644 --- a/apps/ui/src/pages/OverlayTemplateEditorPage.tsx +++ b/apps/ui/src/pages/OverlayTemplateEditorPage.tsx @@ -48,7 +48,7 @@ const defaults = (mode: OverlayTemplateMode) => // Outer component remounts the inner editor whenever `id` changes. This keeps // useState initial values fresh on transitions (e.g. preset → /new) so we -// don't need a reset effect — which the lint rule +// don't need a reset effect - which the lint rule // `react-hooks/set-state-in-effect` correctly flags as cascading-render bait. const OverlayTemplateEditorPage = () => { const { id } = useParams<{ id: string }>() @@ -102,7 +102,7 @@ const OverlayTemplateEditor = ({ routeId }: { routeId: string }) => { // Load existing template. The wrapper remounts this component when `id` // changes (see OverlayTemplateEditorPage above), so the new-template - // branch needs no manual state reset — useState initial values handle it. + // branch needs no manual state reset - useState initial values handle it. useEffect(() => { if (isNew) return const templateId = Number(id) @@ -208,7 +208,7 @@ const OverlayTemplateEditor = ({ routeId }: { routeId: string }) => { const handleUploadImage = useCallback( async (file: File) => { - // The upload itself is the success boundary — once the server has + // The upload itself is the success boundary - once the server has // accepted and stored the file, refreshing the list is a separate, // non-fatal concern. A transient list-fetch failure must not roll // back the success message or prevent selecting the new asset. @@ -258,7 +258,7 @@ const OverlayTemplateEditor = ({ routeId }: { routeId: string }) => { } }, []) - // Fetch a random poster only when section or mode actually changes — not + // Fetch a random poster only when section or mode actually changes - not // on every render that would create a new loadRandomPoster identity. useEffect(() => { if (!selectedSection) return @@ -297,7 +297,7 @@ const OverlayTemplateEditor = ({ routeId }: { routeId: string }) => { return () => window.removeEventListener('keydown', handler) }, [undo, redo, selectedId, setElements]) - // Saving a preset must not mutate it — the server rejects it and the + // Saving a preset must not mutate it - the server rejects it and the // shared presets are seeded once, so any "edit" of a preset is really // a fork. We open the copy modal pre-filled with a sensible default, // then create a fresh user-owned template on confirm. @@ -324,7 +324,7 @@ const OverlayTemplateEditor = ({ routeId }: { routeId: string }) => { isDefault: false, } satisfies OverlayTemplateCreate) if (created) { - // No success alert here — the navigation to the new template's URL + // No success alert here - the navigation to the new template's URL // (with its name visible in the title field) is the confirmation. // Showing an inline alert just before unmount would flash visibly // for one frame and then disappear, which reads as a flicker. @@ -420,7 +420,7 @@ const OverlayTemplateEditor = ({ routeId }: { routeId: string }) => {

{isPreset - ? 'You’re editing a preset. Saving will create your own copy — the original preset is left unchanged.' + ? 'You’re editing a preset. Saving will create your own copy - the original preset is left unchanged.' : 'Design overlay elements on the canvas. Enter a valid template name in the Template Name field before saving your changes.'}

@@ -509,7 +509,7 @@ const OverlayTemplateEditor = ({ routeId }: { routeId: string }) => { controlsClassName="sm:w-auto" /> - {/* Main editor area — desktop: 3 columns, mobile: stacked. + {/* Main editor area - desktop: 3 columns, mobile: stacked. Uses h-[60vh] with a hard min so it stays stable regardless of header/tab/control-row height changes above it. */}
@@ -519,7 +519,7 @@ const OverlayTemplateEditor = ({ routeId }: { routeId: string }) => {
) : ( <> - {/* Left: Toolbox — desktop sidebar */} + {/* Left: Toolbox - desktop sidebar */}
{ />
- {/* Right: Properties + Layers — desktop sidebar */} + {/* Right: Properties + Layers - desktop sidebar */}
{ } >

- Presets can’t be modified. Enter a name for your copy — it + Presets can’t be modified. Enter a name for your copy - it will start from the current canvas and become editable.

— making it lazy +// Settings is kept eager because it wraps an - making it lazy // would cause two sequential fetches (wrapper then child) on every settings navigation. import Overlays from './components/Overlays' import Settings from './components/Settings' @@ -123,7 +123,7 @@ const overlayTemplateEditorRoute = createLazyRoute( ) /** - * Preloadable route definition — single source of truth for both + * Preloadable route definition - single source of truth for both * the React Router config and the prefetch system. Routes that use * createLazyRoute carry both `lazy` (for the router) and `preload` * (for hover-prefetching) from the same object, so they can't drift. @@ -400,7 +400,7 @@ const collectPreloaders = ( const rest = remaining.slice(routeSegments.length) if (rest.length === 0) { - // Exact match — also preload the index child if present + // Exact match - also preload the index child if present const indexChild = route.children?.find( (child): child is AppRoute => child.index === true, ) diff --git a/apps/ui/src/utils/PlexAuth.ts b/apps/ui/src/utils/PlexAuth.ts index e79c8c908..f7ef50833 100644 --- a/apps/ui/src/utils/PlexAuth.ts +++ b/apps/ui/src/utils/PlexAuth.ts @@ -1,7 +1,7 @@ import axios from 'axios' import Bowser from 'bowser' -const PIN_POLL_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes — matches Plex PIN expiry +const PIN_POLL_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes - matches Plex PIN expiry interface PlexHeaders extends Record { Accept: string diff --git a/apps/ui/src/utils/formatBytes.ts b/apps/ui/src/utils/formatBytes.ts index 95943e355..e4a840772 100644 --- a/apps/ui/src/utils/formatBytes.ts +++ b/apps/ui/src/utils/formatBytes.ts @@ -47,6 +47,6 @@ export const getPercentValue = ( export const formatPercent = (used: number, total: number): string => { const percent = getPercentValue(used, total) - if (percent === null) return '—' + if (percent === null) return '-' return `${percent.toFixed(1)}%` } diff --git a/apps/ui/styles/globals.css b/apps/ui/styles/globals.css index b6a1462a0..41d28059b 100644 --- a/apps/ui/styles/globals.css +++ b/apps/ui/styles/globals.css @@ -534,7 +534,7 @@ /* The single source of truth for checkboxes. Add `className="checkbox"` to any - `` and it is fully and identically styled — nothing else + `` and it is fully and identically styled - nothing else is needed. `focus:ring-0` + `focus:ring-offset-0` together remove the @tailwindcss/forms focus ring (its blue ring AND white offset ring); a subtle border change is the focus affordance instead. @@ -548,7 +548,7 @@ field element (raw or rendered by Forms/Input, Forms/Select, etc.), so changing it here restyles all inputs, selects, and textareas at once. The components only add element-specific deltas (select chevron, adornments, - error states) on top — they must not re-declare this base. + error states) on top - they must not re-declare this base. */ input[type='text'], input[type='password'], @@ -653,7 +653,7 @@ } /* Focus: maintainerr border, no ring (react-select paints a blue box-shadow - ring by default) — mirrors the field's focus:border-maintainerr-600 + ring-0. + ring by default) - mirrors the field's focus:border-maintainerr-600 + ring-0. The :hover variant keeps the focus colour while hovering. */ .react-select-container .react-select__control--is-focused, .react-select-container .react-select__control--is-focused:hover { diff --git a/docs/emby-followups.md b/docs/emby-followups.md index dcb4fc78a..eecf843ff 100644 --- a/docs/emby-followups.md +++ b/docs/emby-followups.md @@ -1,14 +1,14 @@ -# Emby Support — Follow-up Fixes +# Emby Support - Follow-up Fixes Consolidated punch list for `emby-support` branch / PR #2911. Sources for each item: -- **Tester** — production failure reported by Nomsplease in HOPS Discord -- **Review** — GitHub Copilot agent PR review -- **RE** — reverse engineering against a live Emby 4.9.3.0 install (decompiled `Emby.Api.dll` + the server's own `/openapi` spec) +- **Tester** - production failure reported by Nomsplease in HOPS Discord +- **Review** - GitHub Copilot agent PR review +- **RE** - reverse engineering against a live Emby 4.9.3.0 install (decompiled `Emby.Api.dll` + the server's own `/openapi` spec) References that anchor every claim in this document: -- `/openapi` and `/swagger` on a running Emby server return a 2.5 MB OpenAPI 2.0 spec titled *"Emby Server REST API 4.9.3.0"* — 422 endpoints. +- `/openapi` and `/swagger` on a running Emby server return a 2.5 MB OpenAPI 2.0 spec titled *"Emby Server REST API 4.9.3.0"* - 422 endpoints. - `Emby.Api.dll` decompiled with `ilspycmd` (10,087 lines, 343 `[Route]` attributes) is the source-of-truth handler code. - `Emby.Server.Implementations.dll` decompiled (72,570 lines) contains the auth header parser at lines 53494-53557. @@ -23,9 +23,9 @@ References that anchor every claim in this document: --- -## HIGH — must fix before merge +## HIGH - must fix before merge -### H1. Auto-create flow produces 500 against real Emby — confirmed by tester +### H1. Auto-create flow produces 500 against real Emby - confirmed by tester **Source**: Tester **Files**: [emby-adapter.service.ts:765-803](../apps/server/src/modules/api/media-server/emby/emby-adapter.service.ts#L765-L803), the upstream call site in `CollectionsService` (Maintainerr's shared `addToCollectionInternal`). @@ -33,7 +33,7 @@ References that anchor every claim in this document: **Symptom (from the tester log)**: ``` INFO Adding 76 media items to 'Delete Watched TV Shows by Season' -DEBUG [checkAutomaticMediaServerLink] No media server collection — will be created automatically when items match +DEBUG [checkAutomaticMediaServerLink] No media server collection - will be created automatically when items match ERROR Failed to create Jellyfin collection Request failed with status code 500 (ERR_BAD_RESPONSE) ``` @@ -42,11 +42,11 @@ The tester is on the pre-PR Jellyfin-against-Emby workaround, but the failure mo **Fix options** (must verify against the live local Emby before picking): -A. **Pass initial items at create time when known** — change `createCollection` to forward the first batch of `Ids` as a query param on `POST /Collections`. Add a regression test that asserts the request shape via a mocked HTTP layer, plus a smoke test that runs against the local Emby Server to confirm the response is 200 and the items land. +A. **Pass initial items at create time when known** - change `createCollection` to forward the first batch of `Ids` as a query param on `POST /Collections`. Add a regression test that asserts the request shape via a mocked HTTP layer, plus a smoke test that runs against the local Emby Server to confirm the response is 200 and the items land. -B. **Capability-aware fallback** — keep the abstraction (create empty, then add) and have the Emby adapter internally buffer the first add call so that, when called immediately after a create, it issues a single combined create-with-items. Less invasive at the call site, more state in the adapter. +B. **Capability-aware fallback** - keep the abstraction (create empty, then add) and have the Emby adapter internally buffer the first add call so that, when called immediately after a create, it issues a single combined create-with-items. Less invasive at the call site, more state in the adapter. -Recommendation: A. The Maintainerr abstraction does not promise "empty create" semantics — it promises "this collection ends up with these items". The Emby adapter is allowed to issue whichever wire calls accomplish that. +Recommendation: A. The Maintainerr abstraction does not promise "empty create" semantics - it promises "this collection ends up with these items". The Emby adapter is allowed to issue whichever wire calls accomplish that. **Verification**: 1. Reproduce the 500 against the local Emby in this Codespace by hand-curling `POST /Collections?Name=Foo&ParentId=` with no `Ids`. Capture the exact response body. @@ -91,7 +91,7 @@ for (const season of seasons) { --- -### H3. `cleanupCollectionForLibrary` filter is always empty on Emby — collection switching leaks items +### H3. `cleanupCollectionForLibrary` filter is always empty on Emby - collection switching leaks items **Source**: Review **Files**: [emby-adapter.service.ts:820-839](../apps/server/src/modules/api/media-server/emby/emby-adapter.service.ts#L820-L839), data shape set by [emby.mapper.ts:150-152](../apps/server/src/modules/api/media-server/emby/emby.mapper.ts#L150-L152). @@ -104,13 +104,13 @@ const fromLibrary = children.filter((c) => c.library?.id === libraryId); The mapper builds each `MediaItem.library.id` from the item's `ParentId`. For items returned by `/Items?ParentId=`, `ParentId` is **the collection itself**, not the source library. The filter is therefore always empty, so library-scoped cleanup never removes anything. Switching a rule group's library leaves stale items behind in shared collections. -**Fix**: mirror the Jellyfin pattern at [jellyfin-adapter.service.ts:720-739, 1640-1665](../apps/server/src/modules/api/media-server/jellyfin/jellyfin-adapter.service.ts#L1640-L1665) — query Emby for each item's actual ancestors (the equivalent on Emby is `GET /Items/{id}/Ancestors` or `GET /Users/{userId}/Items/{id}` with the `Path`/`ParentId` chain) and check membership against `libraryId`. Encapsulate in a private `itemIsInLibrary(itemId, libraryId)` helper to match Jellyfin. +**Fix**: mirror the Jellyfin pattern at [jellyfin-adapter.service.ts:720-739, 1640-1665](../apps/server/src/modules/api/media-server/jellyfin/jellyfin-adapter.service.ts#L1640-L1665) - query Emby for each item's actual ancestors (the equivalent on Emby is `GET /Items/{id}/Ancestors` or `GET /Users/{userId}/Items/{id}` with the `Path`/`ParentId` chain) and check membership against `libraryId`. Encapsulate in a private `itemIsInLibrary(itemId, libraryId)` helper to match Jellyfin. **Verification**: spec the helper directly with mocked ancestor responses, plus an integration test that exercises the rule-group switch path end-to-end against the local Emby. --- -### H4. `getWatchHistory` collapses transient failures into "never watched" — can drive wrong removals +### H4. `getWatchHistory` collapses transient failures into "never watched" - can drive wrong removals **Source**: Review **Files**: [emby-adapter.service.ts:670-700](../apps/server/src/modules/api/media-server/emby/emby-adapter.service.ts#L670-L700). @@ -130,9 +130,9 @@ The reference Jellyfin implementation explicitly documents why this is wrong, at > Errors must propagate so callers can distinguish a real outage from a confirmed empty history. Returning `[]` here would misclassify failures as "never watched", which leaks into NOT_EXISTS checks and missing-value diagnostics in the rules layer. -A rule that removes "watched media older than 30 days" would, during an Emby outage, evaluate every item as never watched and *delete nothing*. A rule that removes "never-watched media older than 30 days" would, during the same outage, evaluate every item as never watched and *delete everything*. The second is the dangerous case — and it's exactly the kind of rule community libraries use. +A rule that removes "watched media older than 30 days" would, during an Emby outage, evaluate every item as never watched and *delete nothing*. A rule that removes "never-watched media older than 30 days" would, during the same outage, evaluate every item as never watched and *delete everything*. The second is the dangerous case - and it's exactly the kind of rule community libraries use. -**Fix**: rethrow on the outer failure. Keep the inner per-user `try { ... } catch { /* skip */ }` — that pattern is correct because individual users may legitimately lack access to an item. +**Fix**: rethrow on the outer failure. Keep the inner per-user `try { ... } catch { /* skip */ }` - that pattern is correct because individual users may legitimately lack access to an item. ```ts async getWatchHistory(itemId: string): Promise { @@ -150,7 +150,7 @@ async getWatchHistory(itemId: string): Promise { --- -### H5. Overlay `itemExists` can permanently delete the original poster backup — RESOLVED +### H5. Overlay `itemExists` can permanently delete the original poster backup - RESOLVED **Source**: Review @@ -158,7 +158,7 @@ async getWatchHistory(itemId: string): Promise { --- -## MEDIUM — should fix before merge +## MEDIUM - should fix before merge ### M1. `sortTitle` is silently dropped on create and update @@ -194,7 +194,7 @@ const updated = { --- -## LOW — nice to fix before merge +## LOW - nice to fix before merge ### L1. Stale auth header comment claims things that aren't true @@ -207,7 +207,7 @@ const updated = { Both claims are false, verified against the decompiled parser at `Emby.Server.Implementations:53494-53557`: - The header name we actually send is `X-Emby-Authorization` (also `Authorization` is accepted). `X-MediaBrowser-Authorization` is not the name we use anywhere. -- There is no `Version` validation in Emby's parser. A live curl with `Version="999.999.999"` returns the same 401 as `Version="1.0.0"` — the parser stores the version on session info but never compares it to a required value. `grep -r '"1\.0\.0"'` across the entire decompiled `Emby.Api.dll` and `Emby.Server.Implementations.dll` returns zero matches. +- There is no `Version` validation in Emby's parser. A live curl with `Version="999.999.999"` returns the same 401 as `Version="1.0.0"` - the parser stores the version on session info but never compares it to a required value. `grep -r '"1\.0\.0"'` across the entire decompiled `Emby.Api.dll` and `Emby.Server.Implementations.dll` returns zero matches. **Fix**: replace the comment with a one-liner that accurately describes what we send: @@ -221,7 +221,7 @@ No code change. --- -### L2. `GET /Users` is a hidden endpoint — switch to `/Users/Query` +### L2. `GET /Users` is a hidden endpoint - switch to `/Users/Query` **Source**: RE **File**: [emby-adapter.service.ts:186](../apps/server/src/modules/api/media-server/emby/emby-adapter.service.ts#L186) @@ -287,26 +287,26 @@ The script prints the generated API key but never assigns it to `$APIKEY`, then --- -## OPTIONAL — improvements to consider (not bugs) +## OPTIONAL - improvements to consider (not bugs) ### O1. Set `IsLocked: true` on `createCollection` -The Jellyfin adapter sets `isLocked: true` on collection creation with the comment *"enables composite image generation from collection items"*. Without it, Emby may not auto-generate the composite cover image when items are added. Worth a quick A/B against the local Emby — if the composite cover behaviour matches Jellyfin, set the flag. Independent of H1, but the changes can land together. +The Jellyfin adapter sets `isLocked: true` on collection creation with the comment *"enables composite image generation from collection items"*. Without it, Emby may not auto-generate the composite cover image when items are added. Worth a quick A/B against the local Emby - if the composite cover behaviour matches Jellyfin, set the flag. Independent of H1, but the changes can land together. ### O2. Wire `POST /Playlists/{Id}/Items/{ItemId}/Move/{NewIndex}` for playlist reorder -Confirmed implementable against a live Emby — but deliberately deferred because the call site doesn't exist in Maintainerr. +Confirmed implementable against a live Emby - but deliberately deferred because the call site doesn't exist in Maintainerr. **What the source dig confirmed**: - The route is real (`Emby.Api:1841`) and the handler at `Emby.Api:1909-1919` delegates straight to `IPlaylistManager.MoveItem(playlist, request.ItemId, request.NewIndex)`. -- The DTO declares `ItemId` as `long`, but the official Emby web client at `dashboard-ui/modules/emby-apiclient/apiclient.js` passes `items[i].PlaylistItemId` — a `string` field on `BaseItemDto` (`Emby.Api:6488`) returned by `GET /Playlists/{Id}/Items`. ServiceStack handles the string-to-long conversion at the path-binding layer. No client-side internal-ID mapping needed. +- The DTO declares `ItemId` as `long`, but the official Emby web client at `dashboard-ui/modules/emby-apiclient/apiclient.js` passes `items[i].PlaylistItemId` - a `string` field on `BaseItemDto` (`Emby.Api:6488`) returned by `GET /Playlists/{Id}/Items`. ServiceStack handles the string-to-long conversion at the path-binding layer. No client-side internal-ID mapping needed. **Why we still defer**: - `IMediaServerService` only declares `reorderCollectionItems` ([media-server.interface.ts:280](../apps/server/src/modules/api/media-server/media-server.interface.ts#L280)). - The sole call site for that method is the Plex `COLLECTION_SORT` path at [collections.service.ts:849](../apps/server/src/modules/collections/collections.service.ts#L849). - No `reorderPlaylistItems` method exists, no Maintainerr rule action generates one, and no UI flow asks for it. -Shipping the Emby impl would mean adding an interface method, three adapter implementations (Plex/Jellyfin/Emby), and a feature flag — all unreachable from any user flow. File as a follow-up issue if a real use case appears. +Shipping the Emby impl would mean adding an interface method, three adapter implementations (Plex/Jellyfin/Emby), and a feature flag - all unreachable from any user flow. File as a follow-up issue if a real use case appears. Collection reorder remains genuinely unavailable on Emby (no equivalent `/Collections/{Id}/Items/{ItemId}/Move` route in either the swagger spec or the decompiled handler set). The current `COLLECTION_SORT = false` capability is correct. @@ -316,7 +316,7 @@ The PR doc states up front that a wide Emby HTTP surface is scaffolded but unver --- -## Dismissed — not a finding +## Dismissed - not a finding ### D1. `release_pr.yml` removed the `environment: release-builds` gate @@ -332,12 +332,12 @@ No action on this PR. If the gate should be re-added, that is a separate convers ## Suggested execution order -1. **H1, H2, H3, H4** — wire bugs that produce wrong data. Land each as its own commit with a focused regression test. -2. **H5** — overlay safety. Land with its own spec. -3. **M1** — sortTitle plumbing. -4. **L1, L2, L3, L4, L5, L6** — batch as a single "review nits" commit (small, low-risk). -5. **O1** — verify against local Emby, land in the same commit if it works as expected. -6. **O2, O3** — defer to follow-up issues. +1. **H1, H2, H3, H4** - wire bugs that produce wrong data. Land each as its own commit with a focused regression test. +2. **H5** - overlay safety. Land with its own spec. +3. **M1** - sortTitle plumbing. +4. **L1, L2, L3, L4, L5, L6** - batch as a single "review nits" commit (small, low-risk). +5. **O1** - verify against local Emby, land in the same commit if it works as expected. +6. **O2, O3** - defer to follow-up issues. After step 1-3 land, re-test the full Emby flow end-to-end against the local Codespace Emby AND request that Nomsplease re-runs his rule against the next pr-2911 image build. diff --git a/docs/emby-support.md b/docs/emby-support.md index 0bf170dcb..5ccf04f03 100644 --- a/docs/emby-support.md +++ b/docs/emby-support.md @@ -1,4 +1,4 @@ -# Emby Support — Technical Documentation +# Emby Support - Technical Documentation This document covers the addition of Emby as a third supported media server in Maintainerr alongside Plex and Jellyfin. It records what was built, why @@ -127,7 +127,7 @@ The following pre-existing files were extended with Emby cases: ## Design decisions -### 1. Separate adapter, separate getter — no shared base +### 1. Separate adapter, separate getter - no shared base Emby and Jellyfin share a common ancestor (Jellyfin forked Emby in 2018) and the Emby getter ended up as essentially a 1:1 port of the Jellyfin getter. @@ -136,11 +136,11 @@ The temptation to extract a shared `JellyfinLikeGetterBase` was explicitly - The fork is seven years old and the APIs will continue to diverge. - A shared base either gets polluted with subclass branches when the first - divergence appears, or gets forked back anyway — and the "refactor back" + divergence appears, or gets forked back anyway - and the "refactor back" cost is higher than the duplicated-fix cost. - Keeping three distinct servers (Plex, Jellyfin, Emby) as three distinct code paths matches the existing project convention and the architecture - guardrail "Use `supportsFeature()` for conditional behaviour — never + guardrail "Use `supportsFeature()` for conditional behaviour - never branch on server type in the shared layer". The cost is real: every bug fix in `JellyfinGetterService` needs a parallel @@ -150,7 +150,7 @@ fix in `EmbyGetterService`. That's the accepted trade. The first draft constructed `axios.create({baseURL: userUrl})` directly inside `EmbyAdapterService`. That worked but didn't match the established -layering — `PlexApiService` wraps the `plex-api` npm package, and +layering - `PlexApiService` wraps the `plex-api` npm package, and `TautulliApi` extends `ExternalApiService`. The HTTP-client construction was extracted into a small `EmbyApi` helper class at `apps/server/src/modules/api/emby-api/emby-api.helper.ts`, and @@ -235,7 +235,7 @@ Cache invalidation in `rules.service.ts:requiresCacheReset` flushes - Rule creation against an Emby library (`firstVal: [7, 0]`, "Date added") - Rule executor run end-to-end (no errors, walked the full adapter path) - Media-server switch: Emby ↔ Jellyfin in both directions, rule migration `[7, X]` ↔ `[6, X]`, DB state verified -- Full UI walkthrough — 0 console errors with Emby configured +- Full UI walkthrough - 0 console errors with Emby configured - 5 screenshots captured via Playwright MCP and embedded in the PR description ### Scaffolded but unverified @@ -250,14 +250,14 @@ Each unverified path is marked `TODO(emby-server-test):` in the code (10 sites). | `getWatchHistory` per-user iteration, `getWatchState`, `getItemSeenBy` | Coded, unverified at scale | | `computeLibraryStorageSizes` | Coded, may report misleading totals if the `Size` field aggregates differently than expected | | `getAllIdsForContextAction` (show ↔ episode traversal) | Coded, unverified | -| `EmbyGetterService` — all 50+ property cases | Ported 1:1 from the verified `JellyfinGetterService`; structurally correct but the underlying adapter HTTP calls are unverified | +| `EmbyGetterService` - all 50+ property cases | Ported 1:1 from the verified `JellyfinGetterService`; structurally correct but the underlying adapter HTTP calls are unverified | | `EmbyOverlayProvider` | `isAvailable`, `getSections`, image upload have implementations; `getRandomItem`, `getRandomEpisode`, `downloadImage` return `null` with a TODO. Item-existence checks go through the shared `IMediaServerService.itemExists` (backed by `EmbyAdapterService`), not the provider | | `supportsFeature()` matrix | Conservative defaults matching Jellyfin: COLLECTION_VISIBILITY, WATCHLIST, CENTRAL_WATCH_HISTORY, COLLECTION_SORT all off. The COLLECTION_SORT note is updated to reflect that Emby has no item-move endpoint (only DisplayOrder = PremiereDate \| SortName), so Maintainerr's "push an ordered list of IDs" contract is structurally unsatisfiable | ### Tests landed in this PR These specs cover the in-process Maintainerr logic that branches on -`MediaServerType.EMBY` — none of them call out to a live Emby server, so they +`MediaServerType.EMBY` - none of them call out to a live Emby server, so they verify _Maintainerr's_ behaviour when configured for Emby rather than what Emby itself returns: @@ -268,7 +268,7 @@ Emby itself returns: | `rule-migration.service.spec.ts` (extended) | Emby ↔ Plex migrations, Emby ↔ Jellyfin no-op remaps (shared `props[]` reference), incompatible-property skip + delete, EMBY source detection for community imports, `getApplicationId(EMBY)` resolution | | `rules.service.cacheReset.spec.ts` (new) | `resetCacheIfGroupUsesRuleThatRequiresIt` flushes `getCache('emby')` when configured for Emby (with peer assertions for Plex and Jellyfin so the dispatch table is fully covered) | | `emby.mapper.spec.ts` (new) | The pure `EmbyBaseItemDto → MediaItem/MediaLibrary/MediaCollection/MediaPlaylist/MediaUser/MediaServerStatus/WatchRecord` transforms, including parent/grandparent semantics, RunTimeTicks→ms conversion, AspectRatio parsing, Tags→labels mapping, and the "Emby has no smart collections" invariant | -| `Settings.spec.tsx` (extended) | Emby render path — when `media_server_type === EMBY` the desktop tab list contains "Emby" and not "Plex"/"Jellyfin", and the link points at `/settings/emby` | +| `Settings.spec.tsx` (extended) | Emby render path - when `media_server_type === EMBY` the desktop tab list contains "Emby" and not "Plex"/"Jellyfin", and the link points at `/settings/emby` | --- @@ -276,7 +276,7 @@ Emby itself returns: The original plan included Emby Connect (the emby.media cloud-account flow) as an MVP feature, citing an `embyconnect.ts` reference implementation in -Seerr. **That file does not exist** — verified via the GitHub API against +Seerr. **That file does not exist** - verified via the GitHub API against [Seerr](https://github.com/seerr-team/seerr/tree/develop/server/api). The repo has no dedicated Emby Connect module, and `jellyfin.ts` contains any references to `api.emby.media`, `/service/`, or @@ -298,7 +298,7 @@ against a Premiere-enabled, Connect-linked server. `apps/server/src/database/migrations/1779021820174-AddEmbySupport.ts` was generated via the documented TypeORM workflow (`yarn migration:generate` -against a database with all prior migrations applied — see +against a database with all prior migrations applied - see `typeorm_instructions.txt`). It adds four nullable `varchar` columns to the `settings` table: @@ -319,7 +319,7 @@ when the user configures Emby. `apps/ui/public/icons_logos/emby.png` is a 377×377 crop of the leftmost icon portion of the [Wikimedia Commons Emby-logo.png](https://commons.wikimedia.org/wiki/File:Emby-logo.png) -(original 1238×377). Licensed under **CC BY-SA 4.0** — the same license +(original 1238×377). Licensed under **CC BY-SA 4.0** - the same license as the existing `jellyfin.svg`. Attribution and license terms are recorded in `apps/ui/public/icons_logos/emby.png.LICENSE.txt`. @@ -333,12 +333,12 @@ getting squashed. CodeQL's `js/server-side-request-forgery` query flags the `axios.create({baseURL: userUrl})` call in `emby-api.helper.ts`. These -alerts will be **dismissed in the GitHub Security UI as "Won't fix — by +alerts will be **dismissed in the GitHub Security UI as "Won't fix - by design"**, not patched, for the following reason: Maintainerr is a self-hosted application whose entire purpose is to talk to the user's own media server. The user supplying the URL is the same -person running the Maintainerr instance — there is no untrusted +person running the Maintainerr instance - there is no untrusted operator/attacker separation. The existing Plex and Jellyfin adapters have the exact same data flow (user URL → HTTP request host); CodeQL doesn't flag them only because the URL crosses an external SDK boundary @@ -347,7 +347,7 @@ analysis doesn't trace into. The Emby helper is in-repo so CodeQL sees the full chain. Hostname blocklists (rejecting `192.168.*` / `10.*` / `fc*` / `fe80:*`, -etc.) were **explicitly considered and rejected** — they would break the +etc.) were **explicitly considered and rejected** - they would break the documented LAN use case for what is a non-issue here. The maintainer's own words: _"this is intended to be run locally so fixes like this seems super messy and hacky"_. @@ -407,7 +407,7 @@ These are deferred deliberately and tracked in code via `TODO(emby-server-test): `emby-getter.service.spec.ts`, or `emby-overlay.provider.spec.ts`. Writing synthetic fixtures against unverified endpoints would just codify assumptions about Emby's actual HTTP response shapes. The - in-process branching that does _not_ depend on Emby HTTP is covered — + in-process branching that does _not_ depend on Emby HTTP is covered - see [Tests landed in this PR](#tests-landed-in-this-pr). Add the server-dependent specs once the live endpoints have been confirmed and the actual response shapes are known. @@ -436,16 +436,16 @@ These are deferred deliberately and tracked in code via `TODO(emby-server-test): Surfaced and fixed inside this PR: -1. **`testSetup()` missing EMBY case** (settings.service.ts) — caused 403 +1. **`testSetup()` missing EMBY case** (settings.service.ts) - caused 403 on `/api/media-server/*` after a successful Emby save. Fixed by adding an EMBY branch alongside Plex and Jellyfin. -2. **`testMediaServerConnection()` missing EMBY case** — similar oversight, +2. **`testMediaServerConnection()` missing EMBY case** - similar oversight, meant the connection re-verification helper would always return false for Emby. -3. **Prettier failures** on the initial commit — 9 files; resolved with +3. **Prettier failures** on the initial commit - 9 files; resolved with `yarn format` plus replacement of `/\/+$/` regex with string ops (also eliminates the CodeQL ReDoS alert). -4. **Scope-creep refactors caught in self-review** — +4. **Scope-creep refactors caught in self-review** - `settings.service.ts:autoDetect` had been rewritten as an array-loop when a simple `else if` would do; `factory.ts:resolveServerType` had been rewritten with a count-based check. Both reverted to the minimal @@ -453,7 +453,7 @@ Surfaced and fixed inside this PR: Surfaced by an external tester (Nomsplease on the beta-testers Discord channel): -5. **`sw_*` show-level rule properties returned `null`** — rules like +5. **`sw_*` show-level rule properties returned `null`** - rules like `Emby - Amount of watched episodes equals 0` evaluated to null because `EmbyGetterService` only implemented 16 simple properties and fell through to a `default:` TODO for everything else. Fixed by: @@ -466,14 +466,14 @@ Surfaced by an external tester (Nomsplease on the beta-testers Discord channel): Caught while reviewing my own comments: -6. **False claim that Emby Premiere supports smart collections** — verified +6. **False claim that Emby Premiere supports smart collections** - verified against [Emby's Collections docs](https://emby.media/support/articles/Collections.html) and a [closed-as-duplicate 2018 feature request](https://emby.media/community/index.php?/topic/58063-emby-server-option-to-auto-add-to-collections-based-on-pathattribute-matching-rules/). Emby has manual BoxSets and TheMovieDb-driven "Automatic Creation of Collections" (franchise grouping, not filter rules). No native smart collections. Comments in `emby.mapper.ts` and `emby-getter.service.ts` corrected. -7. **False claim that Emby retained pre-fork boxset Move endpoints** — +7. **False claim that Emby retained pre-fork boxset Move endpoints** - verified against an [Emby forum thread](https://emby.media/community/topic/124081-set-display-order-of-a-collection-with-api/) where Luke (Emby CEO) confirmed the API exposes `DisplayOrder = PremiereDate | SortName` only; no item-move/reorder endpoint exists. @@ -550,5 +550,5 @@ narrow; the scaffolded surface is wide. When a tester reports a bug: 4. Fix the call site, run `yarn test`, push The Jellyfin adapter and getter are the closest reference for "what -correct looks like" given the shared backend lineage — but never assume +correct looks like" given the shared backend lineage - but never assume parity. Verify, then code. diff --git a/docs/overlay-feature.md b/docs/overlay-feature.md index 32325438d..81873f68a 100644 --- a/docs/overlay-feature.md +++ b/docs/overlay-feature.md @@ -1,4 +1,4 @@ -# Overlay Feature — Technical Documentation +# Overlay Feature - Technical Documentation This document describes the overlay functionality added to Maintainerr, covering architecture, data flow, API surface, rendering pipeline, and integration points. @@ -25,20 +25,20 @@ This document describes the overlay functionality added to Maintainerr, covering ## Overview -The overlay feature automatically applies visual overlays to media-server posters and title cards for items in Maintainerr collections. Overlays are defined by **templates** — reusable, visually-editable compositions of text, variable, shape, and image elements. Works on both Plex and Jellyfin via the `IOverlayProvider` abstraction. It supports: - -- **Template-based design** — a visual Konva.js canvas editor for composing overlay elements -- **Four element types** — static text, variable text (date/countdown), shapes (rectangle/ellipse), and images -- **Poster overlays** — applied to movie/show poster art (1000×1500 canvas, 2:3 aspect ratio) -- **Title card overlays** — applied to episode title cards (1920×1080 canvas, 16:9 aspect ratio) -- **Per-element formatting** — each variable element carries its own date format, locale, and countdown text templates -- **Built-in presets** — 4 preset templates seeded on first run (Classic Pill, Countdown Bar, Corner Badge, Title Card Pill) -- **Template resolution** — per-collection template override → default template for mode → skip -- **Live preview** — server-side rendering of templates onto actual media-server artwork -- **Preview background** — the visual editor can display a real item poster or title card as the canvas background -- **Cron scheduling** — periodic updates to refresh day-countdown labels -- **Original poster backup** — saved to disk so overlays can be cleanly reverted -- **Import/Export** — templates can be shared as JSON files between instances +The overlay feature automatically applies visual overlays to media-server posters and title cards for items in Maintainerr collections. Overlays are defined by **templates** - reusable, visually-editable compositions of text, variable, shape, and image elements. Works on both Plex and Jellyfin via the `IOverlayProvider` abstraction. It supports: + +- **Template-based design** - a visual Konva.js canvas editor for composing overlay elements +- **Four element types** - static text, variable text (date/countdown), shapes (rectangle/ellipse), and images +- **Poster overlays** - applied to movie/show poster art (1000×1500 canvas, 2:3 aspect ratio) +- **Title card overlays** - applied to episode title cards (1920×1080 canvas, 16:9 aspect ratio) +- **Per-element formatting** - each variable element carries its own date format, locale, and countdown text templates +- **Built-in presets** - 4 preset templates seeded on first run (Classic Pill, Countdown Bar, Corner Badge, Title Card Pill) +- **Template resolution** - per-collection template override → default template for mode → skip +- **Live preview** - server-side rendering of templates onto actual media-server artwork +- **Preview background** - the visual editor can display a real item poster or title card as the canvas background +- **Cron scheduling** - periodic updates to refresh day-countdown labels +- **Original poster backup** - saved to disk so overlays can be cleanly reverted +- **Import/Export** - templates can be shared as JSON files between instances --- @@ -101,7 +101,7 @@ Elements are the building blocks of templates. Each element has a discriminated | `height` | `number` | Height in canvas coordinates (min 1) | | `rotation` | `number` | Rotation in degrees (-360 to 360) | | `layerOrder` | `number` | Z-index (0 = bottom) | -| `opacity` | `number` | Element opacity (0–1) | +| `opacity` | `number` | Element opacity (0-1) | | `visible` | `boolean` | Whether element is rendered | #### Text Element (`type: 'text'`) @@ -110,11 +110,11 @@ Static text with font styling and optional background. | Field | Type | Default | Description | | ------------------- | -------------- | ---------- | -------------------------------------- | -| `text` | `string` | — | Display text | -| `fontFamily` | `string` | — | Font family name | -| `fontPath` | `string` | — | Font file path (bare name or absolute) | -| `fontSize` | `number` | — | Font size in canvas units | -| `fontColor` | `string` | — | Text color (hex, supports `#RRGGBBAA`) | +| `text` | `string` | - | Display text | +| `fontFamily` | `string` | - | Font family name | +| `fontPath` | `string` | - | Font file path (bare name or absolute) | +| `fontSize` | `number` | - | Font size in canvas units | +| `fontColor` | `string` | - | Text color (hex, supports `#RRGGBBAA`) | | `fontWeight` | `enum` | `"bold"` | `normal` or `bold` | | `textAlign` | `enum` | `"left"` | `left`, `center`, or `right` | | `verticalAlign` | `enum` | `"middle"` | `top`, `middle`, or `bottom` | @@ -130,7 +130,7 @@ Dynamic text with date/countdown substitution. Extends all text element fields p | Field | Type | Default | Description | | ----------------- | ------------------- | --------------- | ------------------------------------------- | -| `segments` | `VariableSegment[]` | — | Array of literal text and variable segments | +| `segments` | `VariableSegment[]` | - | Array of literal text and variable segments | | `dateFormat` | `string` | `"MMM d"` | `date-fns` format pattern for `{date}` | | `language` | `string` | `"en-US"` | BCP 47 locale for formatting | | `enableDaySuffix` | `boolean` | `false` | Append English ordinal (st/nd/rd/th) | @@ -138,7 +138,7 @@ Dynamic text with date/countdown substitution. Extends all text element fields p | `textDay` | `string` | `"in 1 day"` | Text when 1 day remains | | `textDays` | `string` | `"in {0} days"` | Template for multiple days (`{0}` = count) | -**Variable Segments** — concatenated at render time: +**Variable Segments** - concatenated at render time: - `{ type: 'text', value: '...' }` → literal string - `{ type: 'variable', field: 'date' }` → formatted deletion date @@ -150,7 +150,7 @@ Dynamic text with date/countdown substitution. Extends all text element fields p | Field | Type | Default | Description | | -------------- | -------------- | ------------- | ------------------------------- | | `shapeType` | `enum` | `"rectangle"` | `rectangle` or `ellipse` | -| `fillColor` | `string` | — | Fill color | +| `fillColor` | `string` | - | Fill color | | `strokeColor` | `string\|null` | `null` | Stroke color | | `strokeWidth` | `number` | `0` | Stroke width | | `cornerRadius` | `number` | `0` | Corner radius (rectangles only) | @@ -167,7 +167,7 @@ Dynamic text with date/countdown substitution. Extends all text element fields p | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | `OverlayTemplate` | Full template with `id`, `name`, `description`, `mode`, `canvasWidth`, `canvasHeight`, `elements[]`, `isDefault`, `isPreset`, `createdAt`, `updatedAt` | | `OverlayTemplateCreate` | Omits `id`, `isPreset`, `createdAt`, `updatedAt` | -| `OverlayTemplateUpdate` | Partial — `name?`, `description?`, `elements?` | +| `OverlayTemplateUpdate` | Partial - `name?`, `description?`, `elements?` | | `OverlayTemplateExport` | Sharing format with `name`, `description`, `mode`, `canvasWidth`, `canvasHeight`, `elements[]` | | `OverlayTemplateMode` | `'poster' \| 'titlecard'` | @@ -201,12 +201,12 @@ Dynamic text with date/countdown substitution. Extends all text element fields p | ----------------------- | -------------------- | ------- | -------------------------------------------- | | `enabled` | `boolean` | `false` | Master switch for overlay processing | | `cronSchedule` | `string\|null` | `null` | Cron expression for scheduled runs | -| `posterOverlayText` | `OverlayTextConfig` | — | Legacy poster text config | -| `posterOverlayStyle` | `OverlayStyleConfig` | — | Legacy poster style config | -| `posterFrame` | `FrameConfig` | — | Legacy poster frame config | -| `titleCardOverlayText` | `OverlayTextConfig` | — | Legacy title card text config | -| `titleCardOverlayStyle` | `OverlayStyleConfig` | — | Legacy title card style config | -| `titleCardFrame` | `FrameConfig` | — | Legacy title card frame config | +| `posterOverlayText` | `OverlayTextConfig` | - | Legacy poster text config | +| `posterOverlayStyle` | `OverlayStyleConfig` | - | Legacy poster style config | +| `posterFrame` | `FrameConfig` | - | Legacy poster frame config | +| `titleCardOverlayText` | `OverlayTextConfig` | - | Legacy title card text config | +| `titleCardOverlayStyle` | `OverlayStyleConfig` | - | Legacy title card style config | +| `titleCardFrame` | `FrameConfig` | - | Legacy title card frame config | --- @@ -283,7 +283,7 @@ Creates the template table and adds per-collection template override. ### OverlayTemplateService -Manages overlay templates — CRUD, preset seeding, import/export, defaults. +Manages overlay templates - CRUD, preset seeding, import/export, defaults. | Method | Description | | ------------------------------------------------ | ------------------------------------------------------------------------------------------ | @@ -350,7 +350,7 @@ Extends `TaskBase` for cron scheduling. | Method | Description | | ------------------------------------------- | ---------------------------------------------------------- | | `onBootstrapHook()` | Reads settings on startup, sets cron schedule | -| `executeTask(abortSignal)` | Called by cron — runs `processAllCollections()` if enabled | +| `executeTask(abortSignal)` | Called by cron - runs `processAllCollections()` if enabled | | `updateCronSchedule(cronSchedule, enabled)` | Hot-update the cron job | --- @@ -363,14 +363,14 @@ All endpoints are under `@Controller('api/overlays')`. | Method | Path | Body/Params | Response | Description | | ------ | ----------- | ----------------------- | ----------------- | ----------------------------------- | -| GET | `/settings` | — | `OverlaySettings` | Get current settings | +| GET | `/settings` | - | `OverlaySettings` | Get current settings | | PUT | `/settings` | `OverlaySettingsUpdate` | `OverlaySettings` | Update settings (also updates cron) | ### Plex Helpers | Method | Path | Params | Response | Description | | ------ | ----------------- | ----------- | --------------------------- | ----------------------------------------------- | -| GET | `/sections` | — | `Array<{key, title, type}>` | List Plex library sections | +| GET | `/sections` | - | `Array<{key, title, type}>` | List Plex library sections | | GET | `/random-item` | `sectionId` | `OverlayPreviewItem \| null` | Random movie/show from section | | GET | `/random-episode` | `sectionId` | `OverlayPreviewItem \| null` | Random episode from section | | GET | `/poster` | `itemId, mode` | `StreamableFile` (JPEG) | Proxy the item's artwork for the given mode (editor background) | @@ -379,11 +379,11 @@ All endpoints are under `@Controller('api/overlays')`. | Method | Path | Params / Body | Response | Description | | ------ | ------------------------ | -------------------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------- | -| GET | `/status` | — | `{status, lastRun, lastResult}` | Current processor status | +| GET | `/status` | - | `{status, lastRun, lastResult}` | Current processor status | | POST | `/process` | `OverlayProcessRequest` (`{force?}`) | `OverlayProcessorRunResult` | Run overlay processing (409 if busy). `force: true` bypasses the unchanged-`daysLeft` skip. | | POST | `/process/:collectionId` | `collectionId`, `{force?}` | `OverlayProcessorRunResult` | Process single collection. `force: true` bypasses the unchanged-`daysLeft` skip. | | POST | `/revert/:collectionId` | `collectionId` | `{success: true}` | Revert single collection | -| DELETE | `/reset` | — | `{success: true}` | Reset all overlays. Returns 409 if a processor run is in progress (prevents concurrent mutation). | +| DELETE | `/reset` | - | `{success: true}` | Reset all overlays. Returns 409 if a processor run is in progress (prevents concurrent mutation). | `OverlayProcessRequest` and `OverlayProcessorRunResult` (`{processed, reverted, skipped, errors}`) are defined in [`@maintainerr/contracts`](../packages/contracts/src/overlays/overlay-processor.ts). @@ -391,21 +391,21 @@ All endpoints are under `@Controller('api/overlays')`. | Method | Path | Body | Response | Description | | ------ | -------- | --------------------- | --------------------- | ------------------------------------ | -| GET | `/fonts` | — | `Array<{name, path}>` | List bundled + user fonts | +| GET | `/fonts` | - | `Array<{name, path}>` | List bundled + user fonts | | POST | `/fonts` | Multipart `font` file | `{name, path}` | Upload custom font (.ttf/.otf/.woff) | ### Templates | Method | Path | Body/Params | Response | Description | | ------ | -------------------------- | ----------------------- | ----------------------- | ------------------------------------------ | -| GET | `/templates` | — | `OverlayTemplate[]` | List all templates | -| GET | `/templates/:id` | — | `OverlayTemplate` | Get single template | +| GET | `/templates` | - | `OverlayTemplate[]` | List all templates | +| GET | `/templates/:id` | - | `OverlayTemplate` | Get single template | | POST | `/templates` | `OverlayTemplateCreate` | `OverlayTemplate` | Create new template | | PUT | `/templates/:id` | `OverlayTemplateUpdate` | `OverlayTemplate` | Update template (rejects presets) | -| DELETE | `/templates/:id` | — | `{success: true}` | Delete template (rejects presets) | -| POST | `/templates/:id/duplicate` | — | `OverlayTemplate` | Clone template | -| POST | `/templates/:id/default` | — | `OverlayTemplate` | Set as default for its mode | -| POST | `/templates/:id/export` | — | `OverlayTemplateExport` | Export template as JSON | +| DELETE | `/templates/:id` | - | `{success: true}` | Delete template (rejects presets) | +| POST | `/templates/:id/duplicate` | - | `OverlayTemplate` | Clone template | +| POST | `/templates/:id/default` | - | `OverlayTemplate` | Set as default for its mode | +| POST | `/templates/:id/export` | - | `OverlayTemplateExport` | Export template as JSON | | POST | `/templates/import` | `OverlayTemplateExport` | `OverlayTemplate` | Import template from JSON | | POST | `/templates/:id/preview` | `query: itemId` | `StreamableFile` (JPEG) | Render template preview onto actual artwork | @@ -428,7 +428,7 @@ The overlay module consumes media servers through a dedicated `IOverlayProvider` ### PlexOverlayProvider -Delegates to existing helpers on `PlexApiService` — no new Plex logic. Both provider methods operate on the item's own `thumb`, which is the correct artwork for any template mode (movies/shows use the poster `thumb`; episodes use the title-card `thumb`). +Delegates to existing helpers on `PlexApiService` - no new Plex logic. Both provider methods operate on the item's own `thumb`, which is the correct artwork for any template mode (movies/shows use the poster `thumb`; episodes use the title-card `thumb`). | Interface method | Underlying call | | ---------------------------------- | --------------------------------------------------------- | @@ -448,19 +448,19 @@ Wraps four public helpers on `JellyfinAdapterService`: | `findRandomItem(sectionIds, kinds)` | `getItems` with `ItemSortBy.Random`, configurable `BaseItemKind[]`, Virtual locations excluded | | `findRandomEpisode(sectionIds)` | Same with `includeItemTypes: [Episode]` | | `getItemImageBuffer(itemId, imageType)` | `getItemImage` with `format: Jpg` + `responseType: 'arraybuffer'`, 404 → `null` | -| `setItemImage(itemId, imageType, buffer, contentType)` | `setItemImage` with base64 body + explicit `Content-Type` (empirical workaround; Jellyfin rejects raw binary with 500 — see jellyfin/jellyfin#12447) | +| `setItemImage(itemId, imageType, buffer, contentType)` | `setItemImage` with base64 body + explicit `Content-Type` (empirical workaround; Jellyfin rejects raw binary with 500 - see jellyfin/jellyfin#12447) | Both provider methods target `ImageType.Primary` exclusively: - Movies and shows carry their poster on `Primary`. -- Episodes carry their still/screenshot on `Primary` — Jellyfin's `Thumb` is mostly unpopulated on episodes by default and serves as a 16:9 continue-watching banner at the series level, neither of which is the correct target for a titlecard overlay. +- Episodes carry their still/screenshot on `Primary` - Jellyfin's `Thumb` is mostly unpopulated on episodes by default and serves as a 16:9 continue-watching banner at the series level, neither of which is the correct target for a titlecard overlay. Keeping the `ImageType` constant inside the provider (not leaked to `IOverlayProvider` or `JellyfinAdapterService`) preserves the rule that Jellyfin SDK types don't escape `jellyfin/`. ### Server differences hidden behind the interface - **Upload semantics.** Plex uses upload → diff → select with content-addressed dedup and retries. Jellyfin replaces the image atomically with one request. -- **Artwork taxonomy.** Both servers expose the correct per-item artwork as a single image kind — Plex on the item's `thumb`, Jellyfin on `ImageType.Primary` — so the provider interface is mode-free. +- **Artwork taxonomy.** Both servers expose the correct per-item artwork as a single image kind - Plex on the item's `thumb`, Jellyfin on `ImageType.Primary` - so the provider interface is mode-free. --- @@ -473,18 +473,18 @@ The `OverlayRenderService` uses **node-canvas** for text/shape rendering and **s This is the primary render method used by the processor. It composites all visible template elements onto a poster. 1. **Read poster dimensions** via `sharp(posterBuffer).metadata()` → get actual width/height -2. **Calculate scale factors** — `scaleX = posterWidth / canvasWidth`, `scaleY = posterHeight / canvasHeight` +2. **Calculate scale factors** - `scaleX = posterWidth / canvasWidth`, `scaleY = posterHeight / canvasHeight` 3. **Sort elements** by `layerOrder` ascending (bottom-up), filter to `visible: true` 4. **For each element**, compute scaled position (`sx`, `sy`) and size (`sw`, `sh`): - Scale: `sx = Math.round(el.x * scaleX)`, `sw = Math.max(1, Math.round(el.width * scaleX))`, etc. 5. **Render element to buffer** based on type: - - `renderTextElement()` — canvas text with optional background pill, font styling - - `renderVariableElement()` — resolves segments (`{date}`, `{days}`, `{daysText}`) using per-element formatting config, then renders as text - - `renderShapeElement()` — rectangle or ellipse with fill/stroke - - `renderImageElement()` — loads image from disk, resizes with `sharp.resize()` -6. **Apply rotation** — `sharp.rotate(el.rotation)` with transparent background -7. **Apply opacity** — pixel-level alpha channel modulation via `applyOpacity()` -8. **Clamp to poster bounds** — after rotation (which can increase buffer dimensions), the code: + - `renderTextElement()` - canvas text with optional background pill, font styling + - `renderVariableElement()` - resolves segments (`{date}`, `{days}`, `{daysText}`) using per-element formatting config, then renders as text + - `renderShapeElement()` - rectangle or ellipse with fill/stroke + - `renderImageElement()` - loads image from disk, resizes with `sharp.resize()` +6. **Apply rotation** - `sharp.rotate(el.rotation)` with transparent background +7. **Apply opacity** - pixel-level alpha channel modulation via `applyOpacity()` +8. **Clamp to poster bounds** - after rotation (which can increase buffer dimensions), the code: - Reads actual layer dimensions via `sharp.metadata()` - Handles negative offsets by extracting the visible sub-region - Trims layers that extend beyond the poster edges @@ -497,10 +497,10 @@ This is the primary render method used by the processor. It composites all visib #### Variable Text Resolution -Variable elements use `segments[]` — an array of literal text and variable references, concatenated at render time. Each element carries its own formatting configuration: +Variable elements use `segments[]` - an array of literal text and variable references, concatenated at render time. Each element carries its own formatting configuration: -- `formatElementDate(el, deleteDate)` — uses `el.dateFormat` and `el.language` with `date-fns` formatting; optionally adds English ordinal suffix if `el.enableDaySuffix` is true -- `formatElementDaysText(el, daysLeft)` — returns `el.textToday` (0 days), `el.textDay` (1 day), or `el.textDays` with `{0}` substitution +- `formatElementDate(el, deleteDate)` - uses `el.dateFormat` and `el.language` with `date-fns` formatting; optionally adds English ordinal suffix if `el.enableDaySuffix` is true +- `formatElementDaysText(el, daysLeft)` - returns `el.textToday` (0 days), `el.textDay` (1 day), or `el.textDays` with `{0}` substitution ### Legacy `renderOverlay(posterBuffer, opts)` Pipeline @@ -526,7 +526,7 @@ The `getFontFamily(fontPath)` method resolves font files: - `OverlayTaskService` extends `TaskBase` and registers as a scheduled task - On bootstrap, reads `settings.cronSchedule` and configures the cron job -- Default cron is `'0 0 0 1 1 *'` (disabled — Jan 1 only) +- Default cron is `'0 0 0 1 1 *'` (disabled - Jan 1 only) - When settings are saved with a new `cronSchedule`, `updateCronSchedule()` hot-updates the job - When the cron fires, `executeTask()` calls `processAllCollections()` @@ -589,12 +589,12 @@ The main entry point for the overlay feature. Combines template management with #### Key Behaviors -- **Settings panel** — collapsed by default, toggled via "Settings" button with gear icon -- **Settings form** — React Hook Form with `zodResolver(overlaySettingsSchema)` for `enabled`, `cronSchedule` -- **Template cards** — grouped by mode (poster/titlecard), showing name, description, element count, canvas dimensions, default/preset badges -- **Actions per card** — Edit (or View for presets), Duplicate, Set Default, Export, Delete -- **Import** — hidden file input accepting `.json` files parsed as `OverlayTemplateExport` -- **Processing** — "Run Now" triggers `processAllOverlays()` (optionally with `force: true` to rebuild unchanged items), "Reset All" triggers `resetAllOverlays()` with confirmation dialog. Reset is blocked (409) while a processor run is in progress. The run result summary surfaces `processed`, `reverted`, `skipped`, and `errors`. +- **Settings panel** - collapsed by default, toggled via "Settings" button with gear icon +- **Settings form** - React Hook Form with `zodResolver(overlaySettingsSchema)` for `enabled`, `cronSchedule` +- **Template cards** - grouped by mode (poster/titlecard), showing name, description, element count, canvas dimensions, default/preset badges +- **Actions per card** - Edit (or View for presets), Duplicate, Set Default, Export, Delete +- **Import** - hidden file input accepting `.json` files parsed as `OverlayTemplateExport` +- **Processing** - "Run Now" triggers `processAllOverlays()` (optionally with `force: true` to rebuild unchanged items), "Reset All" triggers `resetAllOverlays()` with confirmation dialog. Reset is blocked (409) while a processor run is in progress. The run result summary surfaces `processed`, `reverted`, `skipped`, and `errors`. ### Template Editor Page (`apps/ui/src/pages/OverlayTemplateEditorPage.tsx`) @@ -623,15 +623,15 @@ A visual canvas editor for designing overlay templates. #### Key Behaviors -- **Top bar** — template name input, mode selector (poster/titlecard, new templates only), Plex poster background picker, undo/redo, save -- **Preview background** — section dropdown loads library sections via `getOverlaySections()`, selecting a section auto-fetches a random item via `getRandomItem()`/`getRandomEpisode()` (the latter for titlecard-mode templates). Refresh button loads a different one. Image is proxied through `GET /api/overlays/poster?itemId=...` -- **Canvas** — Konva.js `Stage` with interactive drag/transform; scales template canvas to fit display (max 600px height) -- **Element toolbox** — buttons to add text, variable, shape, or image elements with sensible defaults -- **Layer panel** — ordered layer list with visibility toggle, reorder (move up/down by swapping `layerOrder`), delete -- **Properties panel** — context-sensitive form for the selected element's properties (type-specific fields) -- **Undo/redo** — custom `useUndoRedo` hook, keyboard shortcuts: Cmd/Ctrl+Z, Cmd/Ctrl+Shift+Z, Cmd/Ctrl+Y -- **Delete** — Delete/Backspace key deletes selected element (only when body is focused, to avoid conflicts with text inputs) -- **Preset protection** — preset templates show "View" (not "Edit"), save is disabled +- **Top bar** - template name input, mode selector (poster/titlecard, new templates only), Plex poster background picker, undo/redo, save +- **Preview background** - section dropdown loads library sections via `getOverlaySections()`, selecting a section auto-fetches a random item via `getRandomItem()`/`getRandomEpisode()` (the latter for titlecard-mode templates). Refresh button loads a different one. Image is proxied through `GET /api/overlays/poster?itemId=...` +- **Canvas** - Konva.js `Stage` with interactive drag/transform; scales template canvas to fit display (max 600px height) +- **Element toolbox** - buttons to add text, variable, shape, or image elements with sensible defaults +- **Layer panel** - ordered layer list with visibility toggle, reorder (move up/down by swapping `layerOrder`), delete +- **Properties panel** - context-sensitive form for the selected element's properties (type-specific fields) +- **Undo/redo** - custom `useUndoRedo` hook, keyboard shortcuts: Cmd/Ctrl+Z, Cmd/Ctrl+Shift+Z, Cmd/Ctrl+Y +- **Delete** - Delete/Backspace key deletes selected element (only when body is focused, to avoid conflicts with text inputs) +- **Preset protection** - preset templates show "View" (not "Edit"), save is disabled ### Overlay Editor Components (`apps/ui/src/components/OverlayEditor/`) @@ -662,7 +662,7 @@ All functions in `apps/ui/src/api/overlays.ts`: | `getOverlaySections()` | `GET /overlays/sections` | List Plex library sections | | `getRandomItem(sectionId)` | `GET /overlays/random-item` | Random movie/show for preview | | `getRandomEpisode(sectionId)` | `GET /overlays/random-episode` | Random episode for preview | -| `buildItemImageUrl(itemId, mode)` | — | Construct artwork proxy URL string for the given mode | +| `buildItemImageUrl(itemId, mode)` | - | Construct artwork proxy URL string for the given mode | ### Processing @@ -691,7 +691,7 @@ All functions in `apps/ui/src/api/overlays.ts`: | `setDefaultOverlayTemplate(id)` | `POST /overlays/templates/:id/default` | Set as default | | `exportOverlayTemplate(id)` | `POST /overlays/templates/:id/export` | Export as JSON | | `importOverlayTemplate(data)` | `POST /overlays/templates/import` | Import from JSON | -| `buildTemplatePreviewUrl(id, itemId, cacheBust?)` | — | Construct preview URL | +| `buildTemplatePreviewUrl(id, itemId, cacheBust?)` | - | Construct preview URL | --- @@ -717,9 +717,9 @@ overlayTemplate: OverlayTemplateEntity | null; When processing a collection, the processor resolves the template to use via `OverlayTemplateService.resolveForCollection()`: -1. **Collection override** — if `collection.overlayTemplateId` is set and the template exists, use it -2. **Default for mode** — fall back to the default template for the collection's mode (`poster` or `titlecard`) -3. **Skip** — if no template is found, the collection is skipped with a warning +1. **Collection override** - if `collection.overlayTemplateId` is set and the template exists, use it +2. **Default for mode** - fall back to the default template for the collection's mode (`poster` or `titlecard`) +3. **Skip** - if no template is found, the collection is skipped with a warning ### Service Method diff --git a/packages/contracts/src/media-server/features.ts b/packages/contracts/src/media-server/features.ts index f308aa73b..7095b8684 100644 --- a/packages/contracts/src/media-server/features.ts +++ b/packages/contracts/src/media-server/features.ts @@ -25,7 +25,7 @@ export const MEDIA_SERVER_FEATURES: Record< // Note: COLLECTION_VISIBILITY not supported // Note: WATCHLIST not supported (no API) // Note: CENTRAL_WATCH_HISTORY not supported (requires user iteration) - // Note: COLLECTION_SORT not supported — no boxset reorder API; ForcedSortName has global side-effects. + // Note: COLLECTION_SORT not supported - no boxset reorder API; ForcedSortName has global side-effects. ]), [MediaServerType.EMBY]: new Set([ MediaServerFeature.LABELS, diff --git a/packages/contracts/src/media-server/sort-utils.ts b/packages/contracts/src/media-server/sort-utils.ts index 23f8ad1d7..94290da69 100644 --- a/packages/contracts/src/media-server/sort-utils.ts +++ b/packages/contracts/src/media-server/sort-utils.ts @@ -37,7 +37,7 @@ export interface CompareMediaItemsOptions { */ deleteSoonestDate?: (item: MediaItem) => Date | string | undefined | null /** - * Anchor for `daysLeft` bucketing — pass `now - deleteAfterDays * dayMs`. + * Anchor for `daysLeft` bucketing - pass `now - deleteAfterDays * dayMs`. * When set, items with the same overlay countdown tie even if they * straddle UTC midnight (e.g. addedAt 23:00 vs. 01:00 the next day with * the same "Leaves in 3 days" label). When omitted, items bucket by UTC @@ -61,7 +61,7 @@ const getDeleteSoonestDayBucket = ( const value = options?.deleteSoonestDate?.(item) ?? item.addedAt const referenceMs = toReferenceMs(options?.deleteSoonestReferenceTime) if (referenceMs === undefined) { - // No collection context — bucket by UTC midnight. + // No collection context - bucket by UTC midnight. return toDayBucket(value) } const ms = value instanceof Date ? value.getTime() : new Date(value).getTime() @@ -90,7 +90,7 @@ const compareByDisplayHierarchy = ( } // Numeric sort with two invariants: (1) items missing the value sort to the -// end regardless of direction — sorting "oldest air date first" must not put +// end regardless of direction - sorting "oldest air date first" must not put // an item with no air date ahead of one from 1995; and (2) within-group ties // fall back to the show-aware title order so the listing stays stable A→Z. const compareNumericWithTitleFallback = ( diff --git a/packages/contracts/src/overlays/overlay-element.ts b/packages/contracts/src/overlays/overlay-element.ts index f2cd1e914..471ce517e 100644 --- a/packages/contracts/src/overlays/overlay-element.ts +++ b/packages/contracts/src/overlays/overlay-element.ts @@ -58,9 +58,9 @@ export const shapeTypeValues = ['rectangle', 'ellipse'] as const * - `type: 'variable'` → substituted at render time * * Supported variable fields: - * {date} – formatted deletion date - * {days} – integer days remaining - * {daysText} – localised "today" / "in 1 day" / "in X days" + * {date} - formatted deletion date + * {days} - integer days remaining + * {daysText} - localised "today" / "in 1 day" / "in X days" */ export const variableSegmentSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('text'), value: z.string() }), diff --git a/packages/contracts/src/overlays/overlay-provider-dtos.ts b/packages/contracts/src/overlays/overlay-provider-dtos.ts index 0c248e8e2..9617bacb3 100644 --- a/packages/contracts/src/overlays/overlay-provider-dtos.ts +++ b/packages/contracts/src/overlays/overlay-provider-dtos.ts @@ -1,7 +1,7 @@ /** * DTOs used by the overlay provider abstraction and the /api/overlays * editor-helper endpoints. Intentionally narrower than MediaLibrary / - * MediaItem — the overlay UI only needs a handful of fields. + * MediaItem - the overlay UI only needs a handful of fields. */ export interface OverlayLibrarySection { diff --git a/packages/contracts/src/overlays/overlay-template.ts b/packages/contracts/src/overlays/overlay-template.ts index 34669bc83..78f812bdd 100644 --- a/packages/contracts/src/overlays/overlay-template.ts +++ b/packages/contracts/src/overlays/overlay-template.ts @@ -32,7 +32,7 @@ export const overlayTemplateCreateSchema = overlayTemplateSchema.omit({ // For updates: make isDefault and description optional *without* applying defaults. // Zod v4's `.partial()` preserves `.default()` on each field, so an omitted key -// would still parse to `false` / `''` — silently clobbering the stored value. +// would still parse to `false` / `''` - silently clobbering the stored value. // Overriding these two fields here yields `undefined` when omitted, which lets // the service distinguish "not provided" from "explicitly set to false/empty". // Keep this list in sync with any new defaulted fields added above. @@ -77,7 +77,7 @@ export interface PresetTemplate { /** Built-in preset definitions. Seeded on first run. */ export const PRESET_TEMPLATES: PresetTemplate[] = [ - // 1. Classic Pill — poster, top-left + // 1. Classic Pill - poster, top-left { name: 'Classic Pill', description: 'Rounded pill in the top-left corner showing "Leaving "', @@ -139,7 +139,7 @@ export const PRESET_TEMPLATES: PresetTemplate[] = [ ], }, - // 2. Countdown Bar — poster, full-width bar at bottom + // 2. Countdown Bar - poster, full-width bar at bottom { name: 'Countdown Bar', description: 'Full-width bar at the bottom with a countdown in days', @@ -198,7 +198,7 @@ export const PRESET_TEMPLATES: PresetTemplate[] = [ ], }, - // 3. Corner Badge — poster, small circle in top-right + // 3. Corner Badge - poster, small circle in top-right { name: 'Corner Badge', description: 'Small circular badge in the top-right with days remaining', @@ -257,7 +257,7 @@ export const PRESET_TEMPLATES: PresetTemplate[] = [ ], }, - // 4. Title Card Pill — titlecard, same concept as Classic Pill + // 4. Title Card Pill - titlecard, same concept as Classic Pill { name: 'Title Card Pill', description: diff --git a/packages/contracts/src/settings/download-client/downloadClientSetting.ts b/packages/contracts/src/settings/download-client/downloadClientSetting.ts index b1333a4e2..9d6e74adc 100644 --- a/packages/contracts/src/settings/download-client/downloadClientSetting.ts +++ b/packages/contracts/src/settings/download-client/downloadClientSetting.ts @@ -8,7 +8,7 @@ import { serviceUrlSchema } from '../serviceUrl' */ export const downloadClientSettingSchema = z.object({ download_client_url: serviceUrlSchema, - // Credentials are optional — a client may allow unauthenticated access + // Credentials are optional - a client may allow unauthenticated access // (e.g. a localhost WebUI bypass). Not trimmed: the download client compares // them verbatim, so trimming would silently corrupt a credential that has // leading/trailing whitespace. diff --git a/packages/contracts/src/settings/servarr/arrTag.ts b/packages/contracts/src/settings/servarr/arrTag.ts index 1fc4a9cbf..c2b5a280c 100644 --- a/packages/contracts/src/settings/servarr/arrTag.ts +++ b/packages/contracts/src/settings/servarr/arrTag.ts @@ -1,5 +1,5 @@ /** - * Radarr and Sonarr restrict tag labels to `^[a-z0-9-]+$` — lowercase letters, + * Radarr and Sonarr restrict tag labels to `^[a-z0-9-]+$` - lowercase letters, * digits and hyphens only. `POST /api/v3/tag` returns HTTP 400 * ("Allowed characters a-z, 0-9 and -") for anything else. * @@ -17,7 +17,7 @@ export const ARR_TAG_LABEL_HINT = * True when `label` is a valid *arr tag label the server applies verbatim: it's * non-empty and already in normalized form. `normalizeArrTagLabel` lowercases and * collapses/strips every disallowed character, so a label it leaves unchanged - * contains only `a-z0-9-` with no leading/trailing/doubled hyphens — exactly what + * contains only `a-z0-9-` with no leading/trailing/doubled hyphens - exactly what * Radarr/Sonarr accept. This rejects e.g. `Tag`, `my--tag`, `-dnd`, `dnd-`, `--`. */ export function isValidArrTagLabel(label: string): boolean { diff --git a/packages/contracts/src/streamystats/watchlists.ts b/packages/contracts/src/streamystats/watchlists.ts index 3c130c467..7c5042c10 100644 --- a/packages/contracts/src/streamystats/watchlists.ts +++ b/packages/contracts/src/streamystats/watchlists.ts @@ -1,7 +1,7 @@ import z from 'zod' // A Streamystats "watchlist" is a user-created, named curated list of media -// items with its own visibility flags — NOT a Plex-style personal "want to +// items with its own visibility flags - NOT a Plex-style personal "want to // watch" queue. Maintainerr authenticates to Streamystats with a Jellyfin // server API key, which Streamystats resolves to its "system-api-key" // pseudo-user; the watchlist endpoints therefore only ever surface PUBLIC diff --git a/packages/contracts/src/uploads/index.ts b/packages/contracts/src/uploads/index.ts index fc4e2a47e..261142b4c 100644 --- a/packages/contracts/src/uploads/index.ts +++ b/packages/contracts/src/uploads/index.ts @@ -1,5 +1,5 @@ /** - * Shared cap for user-uploaded image assets across the app — collection + * Shared cap for user-uploaded image assets across the app - collection * posters and overlay image elements both share this ceiling. 500 KB is a * pragmatic limit: real cover artwork at typical poster dimensions * (~1000×1500 JPEG) lands well under this, while anything larger is almost @@ -10,7 +10,7 @@ export const IMAGE_UPLOAD_MAX_LABEL = '500 KB' /** * Accepted file types for the overlay-image upload pipeline. Single source - * of truth — the server uses these to validate uploads and populate the + * of truth - the server uses these to validate uploads and populate the * Content-Type header on GET, the UI uses them to populate the file * picker's `accept` attribute and to render the helper text under the * picker. Add a new entry here and both sides pick it up. diff --git a/tools/dev/fake-emby.mjs b/tools/dev/fake-emby.mjs index b76736415..f8ad1829c 100644 --- a/tools/dev/fake-emby.mjs +++ b/tools/dev/fake-emby.mjs @@ -56,7 +56,7 @@ function show(id, name) { const SHOWS = [show('emby-show-1', 'Mock Show Alpha')]; // The shared manual ("custom name") BoxSet. Server-global, but reported only under -// libraries whose content it holds — and it holds movies only. +// libraries whose content it holds - and it holds movies only. const SHARED_BOXSET = { Id: 'emby-boxset-shared', Name: 'Franchise A Collection', @@ -71,7 +71,7 @@ const SHARED_BOXSET = { // Off unless FAKE_SCALE>0 (see lib/scale-library.mjs). Real tmdb ProviderId, no // ProductionYear (so the metadata resolver accepts the id without a year check). // Shared with fake-plex/fake-jellyfin so the item set is identical across -// backends — note this is the only movie content this Emby mock serves. +// backends - note this is the only movie content this Emby mock serves. const SCALE = buildScaleLibrary(); const scaleMovie = (it) => ({ Id: it.key, @@ -168,7 +168,7 @@ const server = http.createServer((req, res) => { return send(res, 200, ITEMS_BY_ID.get(id) ?? show(id, `Mock item ${id}`)); } - // Item list: /Items or /Users/{userId}/Items — Emby sends PascalCase params. + // Item list: /Items or /Users/{userId}/Items - Emby sends PascalCase params. if ( req.method === 'GET' && (path === '/Items' || /^\/Users\/[^/]+\/Items$/.test(path)) @@ -186,7 +186,7 @@ const server = http.createServer((req, res) => { const parentId = u.searchParams.get('ParentId'); const itemTypes = u.searchParams.get('IncludeItemTypes'); // BoxSet listing: server-global but surfaced only under libraries whose - // content the boxset holds. This one holds movies only — the #3026 condition. + // content the boxset holds. This one holds movies only - the #3026 condition. if (itemTypes && itemTypes.includes('BoxSet')) { if (parentId === 'emby-movies') { return send(res, 200, itemsResponse([SHARED_BOXSET])); diff --git a/tools/dev/fake-jellyfin.mjs b/tools/dev/fake-jellyfin.mjs index 4d4edfe09..49ca9240b 100644 --- a/tools/dev/fake-jellyfin.mjs +++ b/tools/dev/fake-jellyfin.mjs @@ -10,7 +10,7 @@ * handful of endpoints those flows need so the whole thing can be exercised without * a real Jellyfin. * - * It is intentionally minimal and invented — no real media names (repo rule). The + * It is intentionally minimal and invented - no real media names (repo rule). The * dataset is small and deterministic so rule evaluation is predictable. * * Usage @@ -21,7 +21,7 @@ * * The dev seed (tools/dev/seed-db.mjs) already points settings.jellyfin_url at * http://localhost:8096 with a fixed api key + user id, so no settings change is - * needed — just start this before (or alongside) `yarn dev`. + * needed - just start this before (or alongside) `yarn dev`. */ import http from 'node:http'; import { buildScaleLibrary } from './lib/scale-library.mjs'; @@ -89,7 +89,7 @@ const MOVIES = [ // Show counterpart, used to drive Sonarr/show-side flows (e.g. the // metadata-fallback path when a series is absent from Sonarr). Provider IDs -// are synthetic — they won't resolve against real TMDB/TVDB, which is fine +// are synthetic - they won't resolve against real TMDB/TVDB, which is fine // for any flow that doesn't depend on year-validation passing. function series(id, name, addDate, providerIds = {}) { return { @@ -130,7 +130,7 @@ const SHOWS = [ // A manual ("custom name") collection the user created in Jellyfin/Emby. BoxSets // are server-global, but the server only reports one under libraries whose content // it currently holds. This one holds movies only, so it is visible under the movie -// library and INVISIBLE under the show library — exactly the #3026 reproduction. +// library and INVISIBLE under the show library - exactly the #3026 reproduction. const SHARED_BOXSET = { Id: 'mock-boxset-shared', Name: 'Franchise A Collection', diff --git a/tools/dev/fake-plex.mjs b/tools/dev/fake-plex.mjs index 072427496..67971a078 100644 --- a/tools/dev/fake-plex.mjs +++ b/tools/dev/fake-plex.mjs @@ -11,14 +11,14 @@ * people/genres/labels, file media, normal + smart collections, watch history, * accounts, playlists, and a show with seasons/episodes for the sw_* rules. * - * It is intentionally minimal and invented — no real media names (repo rule). + * It is intentionally minimal and invented - no real media names (repo rule). * The dataset is deterministic so rule evaluation is predictable. * * NOT covered (need plex.tv, not the local PMS): the watchlist rules * (watchlist_isWatchlisted / watchlist_isListedByUsers) call plex.tv's * Discover/Community API and degrade to "not watchlisted" here. * - * Highlight — case-sensitive smart-collection dedupe (rule ids 41/42): + * Highlight - case-sensitive smart-collection dedupe (rule ids 41/42): * movie p1 owns Collection tags [' Saga ', 'saga'] and is a child of the SMART * collection 'Saga'. id 42 aggregates [' Saga ', 'saga', smart 'Saga'] and, * after case-sensitive dedupe, yields ['Saga', 'saga']: the smart 'Saga' @@ -163,7 +163,7 @@ const SHOW = baseItem('sh1', 'show', 'Mock Series One', { }); // Four seasons drive the #3153 part_of_latest_season repro: fake-sonarr dates // S0/S1/S2 episode 1 in the past and S3 in the future, so the latest aired season -// is S2. Evaluated together in one run they share a memoized series object — the +// is S2. Evaluated together in one run they share a memoized series object - the // case the in-place season reverse corrupted. Season 1 keeps the episodes the // sw_* episode rules use. const SEASONS = [0, 1, 2, 3].map((n) => @@ -232,7 +232,7 @@ const CHILDREN = { }; // --- Collections (section-level; getCollections reads .smart) ---------------- -// Plex ratingKeys are numeric — collections too (updateCollection does +id). +// Plex ratingKeys are numeric - collections too (updateCollection does +id). const COLLECTIONS_BY_SECTION = { 1: [ { ratingKey: '90001', key: '/library/collections/90001/children', type: 'collection', title: 'Saga', smart: true, subtype: 'movie', childCount: 2 }, @@ -285,7 +285,7 @@ const list = (key, items) => container({ size: items.length, totalSize: items.length, [key]: items }); // Honors X-Plex-Container-Start/Size so the adapter's pagination loop (plexApi.ts, // size 120, loops while totalSize > size*(page+1)) terminates instead of -// re-fetching the full set per page — only matters once a library exceeds 120. +// re-fetching the full set per page - only matters once a library exceeds 120. function sendPaged(res, req, items) { const start = Number(req.headers['x-plex-container-start']) || 0; const size = Number(req.headers['x-plex-container-size']) || items.length; diff --git a/tools/dev/fake-radarr.mjs b/tools/dev/fake-radarr.mjs index 123aaf8e0..3050f77b0 100644 --- a/tools/dev/fake-radarr.mjs +++ b/tools/dev/fake-radarr.mjs @@ -16,7 +16,7 @@ * added") on a duplicate tmdbId. Maintainerr therefore treats that 400 as * "already excluded" (#3084). * - * It is intentionally minimal and invented — no real media names (repo rule). Any + * It is intentionally minimal and invented - no real media names (repo rule). Any * tmdbId queried resolves to a deterministic movie (movie id == tmdbId), so it pairs * with collection_media rows seeded with a tmdbId. * @@ -27,7 +27,7 @@ * FAKE_RADARR_LOG=0 node tools/dev/fake-radarr.mjs # silence the per-request log * * The dev seed (tools/dev/seed-db.mjs) already points radarr_settings.url at - * http://localhost:7878, so no settings change is needed — just start this before + * http://localhost:7878, so no settings change is needed - just start this before * (or alongside) `yarn dev`, then trigger a run with `POST /api/collections/handle`. */ import http from "node:http"; @@ -49,7 +49,7 @@ let nextTagId = 1; const movieTags = new Map(); // Radarr restricts tag labels to ^[a-z0-9-]+$ (lowercase alnum + hyphen) and -// 400s otherwise — mirror it so the label-charset path is exercised offline. +// 400s otherwise - mirror it so the label-charset path is exercised offline. const isValidTagLabel = (label) => /^[a-z0-9-]+$/.test(String(label)); const ensureTag = (label) => { @@ -62,7 +62,7 @@ const ensureTag = (label) => { return tag; }; -// A deterministic movie for any requested id/tmdbId — id and tmdbId are kept equal +// A deterministic movie for any requested id/tmdbId - id and tmdbId are kept equal // so a collection_media.tmdbId resolves straight through to a Radarr "movie". const movieFor = (tmdbId) => ({ id: tmdbId, diff --git a/tools/dev/fake-seerr.mjs b/tools/dev/fake-seerr.mjs index 8081da706..a70ce7520 100644 --- a/tools/dev/fake-seerr.mjs +++ b/tools/dev/fake-seerr.mjs @@ -14,7 +14,7 @@ * Seerr-seeded rules silently match almost nothing. The fix replaces those with * ONE bulk GET /request sweep per run. To prove that, this mock can be put in a * "flaky" mode (FAKE_SEERR_FLAKY=1) where the per-item /movie and /tv endpoints - * fail with 429/503 while /request stays healthy — so the bulk path keeps + * fail with 429/503 while /request stays healthy - so the bulk path keeps * working and the per-item path collapses. * * Faithful to a real Seerr's /request shape (verified against a live instance): @@ -23,7 +23,7 @@ * - each TV request carries request.seasons[] ({ seasonNumber, status }) * - media.requests is NOT populated on the list endpoint (would be circular) * - * It is intentionally minimal and invented — no real media names (repo rule). + * It is intentionally minimal and invented - no real media names (repo rule). * Titles are generic; tmdbIds are plain numbers (like fake-radarr). The set of * "requested" tmdbIds is configurable so it can be paired with whatever media * server library is under test. @@ -37,7 +37,7 @@ * FAKE_SEERR_LOG=0 node tools/dev/fake-seerr.mjs # silence the request log * * The dev seed (tools/dev/seed-db.mjs) points settings.seerr_url at - * http://localhost:5055, so no settings change is needed — just start this + * http://localhost:5055, so no settings change is needed - just start this * before (or alongside) `yarn dev`. */ import http from 'node:http'; @@ -106,7 +106,7 @@ const MEDIA_PROCESSING = 3; // 7th gets a second request from another user (so amountRequested > 1 and // addUser de-dupes/aggregates). Roughly half are "available" (media.status 5, // so approvalDate/mediaAddedAt resolve) and half still processing (status 3, -// so those date props are null) — exercises the status gate. +// so those date props are null) - exercises the status gate. function buildRequests() { const requests = []; let requestId = 1; @@ -138,7 +138,7 @@ function buildRequests() { media, }; if (isShow) { - // First request covers seasons 1-2, the second (if any) season 3 — so a + // First request covers seasons 1-2, the second (if any) season 3 - so a // season-scoped rule can match a specific season's request. const seasonNumbers = r === 0 ? [1, 2] : [3]; requests.push({ @@ -216,7 +216,7 @@ const server = http.createServer((req, res) => { // --- Bulk request sweep (the fix path; stays healthy even when flaky) --- } else if (method === 'GET' && path === '/request') { - // Honor `take` as-is — real Seerr imposes no upper bound (default 10). + // Honor `take` as-is - real Seerr imposes no upper bound (default 10). const take = Number(url.searchParams.get('take')) || 10; const skip = Number(url.searchParams.get('skip')) || 0; const pageItems = ALL_REQUESTS.slice(skip, skip + take); @@ -231,7 +231,7 @@ const server = http.createServer((req, res) => { results: pageItems, }); - // --- Per-item detail (releaseDate fallback) — flaky to reproduce #3152 -- + // --- Per-item detail (releaseDate fallback) - flaky to reproduce #3152 -- } else if (method === 'GET' && /^\/movie\/\d+$/.test(path)) { if (FLAKY) { status = send(res, 429, { message: 'Rate limit exceeded (fake-seerr flaky)' }); diff --git a/tools/dev/fake-sonarr.mjs b/tools/dev/fake-sonarr.mjs index 8e973e41b..8072ca2cf 100644 --- a/tools/dev/fake-sonarr.mjs +++ b/tools/dev/fake-sonarr.mjs @@ -11,7 +11,7 @@ * It exists to reproduce #3153: the `part_of_latest_season` getter scans a show's * seasons newest-first to find the latest *aired* season, probing season episode 1's * airDateUtc. The seeded show therefore has four seasons whose episode-1 air dates are - * past, past, past, future — so the latest aired season is S2 and only the live full + * past, past, past, future - so the latest aired season is S2 and only the live full * run (which shares one memoized series object across the show's seasons) ever * mis-evaluated it. The two endpoints the getter actually calls are answered here: * - GET /series?tvdbId= -> the resolved series (getSeriesByTvdbId) @@ -19,7 +19,7 @@ * * Like fake-radarr, any tvdbId resolves to a deterministic series (series id == tvdbId) * so a media item carrying any tvdb id pairs straight through. It is intentionally - * minimal and invented — no real media names (repo rule). + * minimal and invented - no real media names (repo rule). * * Usage * ----- @@ -28,7 +28,7 @@ * FAKE_SONARR_LOG=0 node tools/dev/fake-sonarr.mjs # silence the per-request log * * The dev seed (tools/dev/seed-db.mjs) points sonarr_settings.url at - * http://localhost:8989, so no settings change is needed — start this before (or + * http://localhost:8989, so no settings change is needed - start this before (or * alongside) `yarn dev`, then trigger a run with `POST /api/rules/:id/execute` or a * single-item check with `POST /api/rules/test`. */ @@ -62,7 +62,7 @@ const seasonsFor = () => }, })); -// A deterministic series for any requested tvdbId — id and tvdbId are kept equal so +// A deterministic series for any requested tvdbId - id and tvdbId are kept equal so // getEpisodes(seriesId=...) resolves straight back to the same show. const seriesFor = (tvdbId) => ({ id: tvdbId, diff --git a/tools/dev/lib/scale-library.mjs b/tools/dev/lib/scale-library.mjs index 0142d2470..dee005530 100644 --- a/tools/dev/lib/scale-library.mjs +++ b/tools/dev/lib/scale-library.mjs @@ -9,7 +9,7 @@ * Why these exact ids: * - tmdbIds are REAL. The metadata resolver (metadata.service) validates each * direct id against TMDB before the Seerr lookup, so synthetic ids would 404 - * and the item would be skipped. Items therefore also OMIT year — the + * and the item would be skipped. Items therefore also OMIT year - the * resolver accepts a direct id without a year cross-check when the item has * no year (avoids false rejects on year drift). * - MATCH_* ids are actually requested in the dev Seerr (seeded via the API), diff --git a/tools/dev/seed-db.mjs b/tools/dev/seed-db.mjs index d863db895..94012a42b 100644 --- a/tools/dev/seed-db.mjs +++ b/tools/dev/seed-db.mjs @@ -10,22 +10,22 @@ * * This is the only one of the dev scripts that touches the DB; the companion * mocks are stateless HTTP servers: - * - tools/dev/fake-jellyfin.mjs (mock Jellyfin, :8096) — pairs with MEDIA_SERVER=jellyfin - * - tools/dev/fake-emby.mjs (mock Emby, :8097) — pairs with MEDIA_SERVER=emby - * - tools/dev/fake-plex.mjs (mock Plex, :32400) — pairs with MEDIA_SERVER=plex - * - tools/dev/fake-seerr.mjs (mock Seerr, :5055) — evaluates the Seerr rule groups + * - tools/dev/fake-jellyfin.mjs (mock Jellyfin, :8096) - pairs with MEDIA_SERVER=jellyfin + * - tools/dev/fake-emby.mjs (mock Emby, :8097) - pairs with MEDIA_SERVER=emby + * - tools/dev/fake-plex.mjs (mock Plex, :32400) - pairs with MEDIA_SERVER=plex + * - tools/dev/fake-seerr.mjs (mock Seerr, :5055) - evaluates the Seerr rule groups * * Notes * ----- * - Media titles are NOT stored on collection_media (they resolve at runtime * from TMDB / the media server), so the seeded cards show posters only. * Posters use picsum.photos seeded URLs (placeholder photography) via the - * collection_media.image_path "absolute URL" fast-path — no real media is + * collection_media.image_path "absolute URL" fast-path - no real media is * referenced, satisfying the repo's no-real-media-names rule. * - All names below are invented for testing. * - Rules use action EXISTS so they stay property-agnostic (any item with the * value matches) while still exercising every getter. Inspect a getter's - * actual output with POST /api/rules/test {"mediaId","rulegroupId"} — e.g. + * actual output with POST /api/rules/test {"mediaId","rulegroupId"} - e.g. * the Plex case-sensitive smart-collection dedupe (rule ids 41/42). * - Watchlist rules (need plex.tv, not the local mock) are intentionally left out. * - This RESETS the collection/rule/servarr tables, then re-seeds, so it is @@ -105,7 +105,7 @@ const COVERAGE = { // Jellyfin application as Application.EMBY), so reuse the same maps. TYPES.emby = TYPES.jellyfin; COVERAGE.emby = COVERAGE.jellyfin; -const EXISTS = 18; // RulePossibility.EXISTS — valid for every RuleType +const EXISTS = 18; // RulePossibility.EXISTS - valid for every RuleType const ruleType = TYPES[TARGET]; const ruleJson = (id, i) => JSON.stringify({ @@ -240,11 +240,11 @@ const run = db.transaction(() => { }); } - // 2b) Seerr (request service) — points at tools/dev/fake-seerr.mjs (same for + // 2b) Seerr (request service) - points at tools/dev/fake-seerr.mjs (same for // every target). Pair with `node tools/dev/fake-seerr.mjs`; the Seerr rule // groups below evaluate against it. The Seerr getter resolves each library // item to a tmdbId, so matching needs items that resolve to a tmdbId Seerr - // has a request for — point fake-seerr's FAKE_SEERR_TMDB_IDS at the + // has a request for - point fake-seerr's FAKE_SEERR_TMDB_IDS at the // library's real tmdbIds (and use the built-in TMDB key, i.e. leave // tmdb_api_key empty, so those ids resolve). db.prepare( @@ -363,7 +363,7 @@ const run = db.transaction(() => { const results = [ seedCollection({ title: "Stale Movies", - description: "Movies untouched for a while — up for cleanup.", + description: "Movies untouched for a while - up for cleanup.", libraryId: LIB.movie, type: "movie", deleteAfterDays: 30, @@ -405,7 +405,7 @@ const run = db.transaction(() => { // is NOT part of the latest aired season. Pairs tools/dev/fake-plex.mjs's // four-season show with tools/dev/fake-sonarr.mjs. The latest aired season // (S2) must be excluded; the rest swept. A full run evaluates the seasons - // together (shared memoized series) where Test Media evaluates one — the + // together (shared memoized series) where Test Media evaluates one - the // two must now agree. if (TARGET === "plex") { const seasonColId = insCollection.run({ @@ -450,7 +450,7 @@ const run = db.transaction(() => { // 4c) #3152 repro: Seerr-seeded rule groups. The Seerr getter now reads ONE // bulk /request sweep per run (tools/dev/fake-seerr.mjs) instead of a - // per-item /movie|/tv call per item — those rate-limited under a whole + // per-item /movie|/tv call per item - those rate-limited under a whole // library run and made Seerr-seeded rules match almost nothing. Run with // FAKE_SEERR_FLAKY=1 to reproduce the rate-limiting the bulk sweep is // immune to. A full run and POST /api/rules/test must agree (the issue's @@ -530,7 +530,7 @@ const run = db.transaction(() => { // One movie group per remaining request-derived property (and the per-item // releaseDate fallback), each EXISTS so the getter resolves every property the - // bulk index feeds — matching the property list in the #3152 commit and making + // bulk index feeds - matching the property list in the #3152 commit and making // the live POST /api/rules/test matrix reproducible from a clean seed. const seerrMovieProps = [ [0, 'addUser'], @@ -557,7 +557,7 @@ const run = db.transaction(() => { customVal: { ruleTypeId: 0, value: '' }, operator: null, firstVal: [SEERR, propId], - action: 18, // EXISTS — resolves the property without a comparison operand + action: 18, // EXISTS - resolves the property without a comparison operand section: 0, }), ); @@ -574,7 +574,7 @@ const run = db.transaction(() => { ); // 6) Notifications (agents + links to rule groups). Endpoints are local - // placeholders — these never fire in dev, they just populate the UI. + // placeholders - these never fire in dev, they just populate the UI. const insNotification = db.prepare( `INSERT INTO notification (name, agent, enabled, types, options, aboutScale) VALUES (@name, @agent, @enabled, @types, @options, @aboutScale)`, @@ -669,7 +669,7 @@ console.log( "Also seeded: Seerr rule groups (#3152), notifications, cron schedules, collection logs, exclusions, overlays.", ); console.log( - "Seerr points at tools/dev/fake-seerr.mjs (http://localhost:5055) — start it to evaluate the Seerr rule groups.", + "Seerr points at tools/dev/fake-seerr.mjs (http://localhost:5055) - start it to evaluate the Seerr rule groups.", ); console.log("Restart `yarn dev` and open http://localhost:3000/collections"); db.close(); diff --git a/tools/docs-drift-ai.mjs b/tools/docs-drift-ai.mjs index 9a640526a..67bdae6e8 100644 --- a/tools/docs-drift-ai.mjs +++ b/tools/docs-drift-ai.mjs @@ -248,7 +248,7 @@ for (const pr of prs) { ); } catch (e) { log(` gh pr view failed: ${e.message}`); - lines.push(`**[PR #${pr.number}](${pr.url}) — ${pr.title}**`); + lines.push(`**[PR #${pr.number}](${pr.url}) - ${pr.title}**`); lines.push(""); lines.push(`_Could not fetch PR detail: ${e.message}_`); lines.push(""); @@ -310,7 +310,7 @@ for (const pr of prs) { const issueRefs = pr.issues.map((i) => `[#${i.number}](${i.url})`).join(", "); lines.push( - `**[PR #${pr.number}](${pr.url}) — ${detail.title}** \nResolves: ${issueRefs}`, + `**[PR #${pr.number}](${pr.url}) - ${detail.title}** \nResolves: ${issueRefs}`, ); lines.push(""); diff --git a/tools/docs-drift.mjs b/tools/docs-drift.mjs index fe6368d07..a626c4daf 100644 --- a/tools/docs-drift.mjs +++ b/tools/docs-drift.mjs @@ -109,8 +109,8 @@ const featCommits = () => { // A `fix:` commit is worth a docs second-look when it touches a doc-worthy // surface. Rather than an allowlist that silently misses newly added modules, -// we flag everything under these roots — the whole UI and every server -// module — and subtract a small denylist of internal-only surfaces below. +// we flag everything under these roots - the whole UI and every server +// module - and subtract a small denylist of internal-only surfaces below. const FIX_PATH_ROOTS = [ "apps/ui/", "apps/server/src/modules/", @@ -119,13 +119,13 @@ const FIX_PATH_ROOTS = [ // Internal-only surfaces with no user- or API-facing behavior. Fixes that // touch *only* these would add noise, so they're subtracted from the roots -// above. Keep this list tight — when in doubt, leave a module flaggable. +// above. Keep this list tight - when in doubt, leave a module flaggable. const FIX_PATH_DENYLIST = [ "apps/server/src/modules/events/", "apps/server/src/modules/logging/", ]; -// Controllers are an HTTP surface wherever they live — always doc-worthy, +// Controllers are an HTTP surface wherever they live - always doc-worthy, // even under a denylisted directory. const USER_VISIBLE_FIX_PATH_SUFFIXES = [".controller.ts"]; @@ -170,7 +170,7 @@ const fixCommits = () => { // Items a human has explicitly tagged for documentation. Anyone can apply the // `documentation` label to an issue or PR; the drift flow then picks it up so -// it isn't lost. Needs a GitHub token + `gh` on PATH — degrades gracefully +// it isn't lost. Needs a GitHub token + `gh` on PATH - degrades gracefully // when run locally without either. const ghJson = (args) => { try { @@ -237,7 +237,7 @@ lines.push(""); lines.push("## 📚 Docs drift report"); lines.push(""); lines.push( - `Comparing \`${baseRef}\` → \`HEAD\` against [Maintainerr_docs](https://github.com/Maintainerr/Maintainerr_docs). Informational only — maintainers decide what needs doc updates before release.`, + `Comparing \`${baseRef}\` → \`HEAD\` against [Maintainerr_docs](https://github.com/Maintainerr/Maintainerr_docs). Informational only - maintainers decide what needs doc updates before release.`, ); lines.push(""); @@ -330,7 +330,7 @@ if (controllers.length) { for (const f of controllers) lines.push(`- [ ] \`${f}\``); lines.push(""); lines.push( - "_New controllers almost always mean new routes — check `docs/API.md` and the OpenAPI spec._", + "_New controllers almost always mean new routes - check `docs/API.md` and the OpenAPI spec._", ); } else { lines.push("_No new controllers._"); @@ -357,7 +357,7 @@ if (behavioralFixes.length) { } lines.push(""); lines.push( - "_`fix:` commits that touched a doc-worthy surface — the UI, any server module except internal-only `events`/`logging`, any controller, or the README. Worth scanning to decide whether observable behavior changed enough to warrant a docs note._", + "_`fix:` commits that touched a doc-worthy surface - the UI, any server module except internal-only `events`/`logging`, any controller, or the README. Worth scanning to decide whether observable behavior changed enough to warrant a docs note._", ); } else { lines.push("_No user-facing `fix:` commits detected._"); @@ -368,7 +368,7 @@ lines.push("### Documentation-labeled issues & PRs"); lines.push(""); if (!docLabeled.available) { lines.push( - "_Skipped — no GitHub token available to query the `documentation` label._", + "_Skipped - no GitHub token available to query the `documentation` label._", ); } else if (!docLabeled.prs.length && !docLabeled.issues.length) { lines.push( @@ -380,7 +380,7 @@ if (!docLabeled.available) { `**Merged PRs labeled \`documentation\` (${docLabeled.prs.length}):**`, ); for (const pr of docLabeled.prs) { - lines.push(`- [ ] [#${pr.number}](${pr.url}) — ${pr.title}`); + lines.push(`- [ ] [#${pr.number}](${pr.url}) - ${pr.title}`); } lines.push(""); } @@ -389,12 +389,12 @@ if (!docLabeled.available) { `**Open issues labeled \`documentation\` (${docLabeled.issues.length}):**`, ); for (const iss of docLabeled.issues) { - lines.push(`- [ ] [#${iss.number}](${iss.url}) — ${iss.title}`); + lines.push(`- [ ] [#${iss.number}](${iss.url}) - ${iss.title}`); } lines.push(""); } lines.push( - "_Manually tagged with the `documentation` label — confirm each is reflected in Maintainerr_docs before release._", + "_Manually tagged with the `documentation` label - confirm each is reflected in Maintainerr_docs before release._", ); } lines.push(""); diff --git a/tools/fider-invite-codeowners.mjs b/tools/fider-invite-codeowners.mjs index 2382f378f..2cb9a5470 100644 --- a/tools/fider-invite-codeowners.mjs +++ b/tools/fider-invite-codeowners.mjs @@ -16,7 +16,7 @@ You've been invited to join the Maintainerr feature-request board (Fider) becaus Click here to accept: %invite% -— automated`, +- automated`, } = process.env; const dryRun = DRY_RUN === 'true'; @@ -47,7 +47,7 @@ const ghApi = async (path) => { }; // CODEOWNERS lines look like " @user1 @user2 @org/team". Extract the -// individual @user handles (skip @org/team — those are GitHub teams, not +// individual @user handles (skip @org/team - those are GitHub teams, not // people we can email-invite). const parseCodeowners = () => { const text = readFileSync(CODEOWNERS_PATH, 'utf8'); @@ -98,7 +98,7 @@ const main = async () => { } // Skip CODEOWNERS who are already Collaborator (role=2) or Administrator - // (role=1) on Fider — they're fully onboarded. Visitors and people not in + // (role=1) on Fider - they're fully onboarded. Visitors and people not in // Fider at all still get an invite. Fider doesn't expose user email // through the public users endpoint, so we match on display-name // (case-insensitive); a false negative is just a re-sent invite. diff --git a/tools/fider-move-enhancements.mjs b/tools/fider-move-enhancements.mjs index 03e53a9fb..47c11d880 100644 --- a/tools/fider-move-enhancements.mjs +++ b/tools/fider-move-enhancements.mjs @@ -5,7 +5,7 @@ // // Idempotent: each Fider post embeds an HTML-comment marker tying it back to // the source issue. The next run scans every Fider post (any status, both -// views) for those markers and skips anything already mirrored — even if the +// views) for those markers and skips anything already mirrored - even if the // GitHub issue was reopened or relabelled in the meantime. // // DRY_RUN defaults to true for safety when invoked outside the workflow; the @@ -58,7 +58,7 @@ const ghApi = async (path, init = {}) => { return text ? JSON.parse(text) : null; }; -// Same parser as fider-invite-codeowners.mjs — kept inline rather than shared +// Same parser as fider-invite-codeowners.mjs - kept inline rather than shared // because this is a one-off and shouldn't add surface area to fider-shared. const parseCodeowners = () => { const text = readFileSync(CODEOWNERS_PATH, 'utf8'); @@ -127,7 +127,7 @@ const buildFiderDescription = (issue) => { }; const buildClosingComment = (fiderUrl) => - `Thanks for the request! Feature ideas now live on the Maintainerr feature board, which is the proper place for them:\n\n${fiderUrl}\n\nHead over to follow progress, vote, and join the discussion — community votes help us prioritise what to build next. Closing here; this request continues on Fider.`; + `Thanks for the request! Feature ideas now live on the Maintainerr feature board, which is the proper place for them:\n\n${fiderUrl}\n\nHead over to follow progress, vote, and join the discussion - community votes help us prioritise what to build next. Closing here; this request continues on Fider.`; const mirrorOne = async (issue) => { const issueRef = `${GITHUB_REPOSITORY}#${issue.number}`; @@ -143,7 +143,7 @@ const mirrorOne = async (issue) => { body: JSON.stringify({ title: issue.title, description }), }); // Fider returns at minimum { id }; some versions also include number/slug. - // Fall through gracefully — `/posts/N` redirects to the canonical URL so an + // Fall through gracefully - `/posts/N` redirects to the canonical URL so an // empty slug is fine. const postNumber = created?.number ?? created?.id; if (!postNumber) { diff --git a/tools/fider-shared.mjs b/tools/fider-shared.mjs index 816b004e2..b045d23f5 100644 --- a/tools/fider-shared.mjs +++ b/tools/fider-shared.mjs @@ -4,7 +4,7 @@ export const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); -// Headers safe to log on every model response — quota/rate-limit signals only. +// Headers safe to log on every model response - quota/rate-limit signals only. // Anything that could leak infra topology, trace IDs, or session state is // deliberately omitted. const RATE_LIMIT_HEADERS = [ @@ -101,12 +101,12 @@ export const createModelCaller = ({ Number.isFinite(retryAfterRaw) && retryAfterRaw > 0 ? retryAfterRaw * 1000 : 0; if (retryAfterMs > maxHonouredRetryAfterMs) { const text = await res.text().catch(() => ''); - log(`models ${res.status} with retry-after=${retryAfterRaw}s exceeds ${Math.round(maxHonouredRetryAfterMs / 1000)}s cap — likely daily quota; giving up on this post`); + log(`models ${res.status} with retry-after=${retryAfterRaw}s exceeds ${Math.round(maxHonouredRetryAfterMs / 1000)}s cap - likely daily quota; giving up on this post`); log(`models headers on final failure: ${summariseRateHeaders(res.headers)}`); throw new Error(`GitHub Models ${res.status}: retry-after ${retryAfterRaw}s; ${text}`); } const wait = retryAfterMs > 0 ? retryAfterMs : retryDelaysMs[attempt]; - log(`models ${res.status}, retrying in ${Math.round(wait / 1000)}s (attempt ${attempt + 1}/${retryDelaysMs.length}) — ${summariseRateHeaders(res.headers)}`); + log(`models ${res.status}, retrying in ${Math.round(wait / 1000)}s (attempt ${attempt + 1}/${retryDelaysMs.length}) - ${summariseRateHeaders(res.headers)}`); await sleep(wait); attempt += 1; } @@ -147,13 +147,13 @@ export const createFider = ({ host, apiKey }) => { }; // Tags on a post are returned either as bare slug strings or as objects -// depending on Fider version / endpoint — this handles both shapes. +// depending on Fider version / endpoint - this handles both shapes. export const postHasTag = (post, slug) => Array.isArray(post.tags) && post.tags.some((t) => (typeof t === 'string' ? t === slug : t.slug === slug)); // Idempotently create a set of Fider tags. Skips ones that already exist. -// Tag creation requires Administrator role (per docs.fider.io/api/tags) — on +// Tag creation requires Administrator role (per docs.fider.io/api/tags) - on // 403 we throw a clear instructional error instead of the raw HTTP failure // so the maintainer knows about the one-time promote/demote dance. export const ensureTags = async ({ fider, log, dryRun, host, tags }) => { @@ -204,7 +204,7 @@ const DISCORD_COLOURS = { // Post a single embed to a Discord webhook. Silent no-op if webhookUrl is empty // so workflows without the secret configured still complete cleanly. Discord -// webhook errors are logged but never thrown — Discord is best-effort, the +// webhook errors are logged but never thrown - Discord is best-effort, the // Fider work has already happened by the time we notify. // // pingRoleId (optional): a Discord snowflake for a role to @-mention. Goes in @@ -229,7 +229,7 @@ export const notifyDiscord = async ({ webhookUrl, log, host, kind, post, fields : { parse: [] }, embeds: [ { - title: `Fider — ${kind}: #${post.number} ${post.title || ''}`.slice(0, 256), + title: `Fider - ${kind}: #${post.number} ${post.title || ''}`.slice(0, 256), url: postUrl || undefined, description: description ? description.slice(0, 4000) : undefined, color: DISCORD_COLOURS[kind] || 0x7f8c8d, diff --git a/tools/fider-stale.mjs b/tools/fider-stale.mjs index 597aa2ff7..a56e2a409 100644 --- a/tools/fider-stale.mjs +++ b/tools/fider-stale.mjs @@ -5,7 +5,7 @@ const { FIDER_API_KEY, DRY_RUN = 'false', // Optional Discord webhook for maintainer notifications. Silent no-op when - // unset — the stale sweep still happens, just nothing posted to chat. + // unset - the stale sweep still happens, just nothing posted to chat. DISCORD_FIDER_BOT_WEBHOOK = '', // Optional Discord role ID (snowflake) to @-mention in notifications. DISCORD_PING_ROLE_ID = '', @@ -25,7 +25,7 @@ const COMMENT_MARKER_STALE_WARN = ''; // Mirrors the spirit of the GitHub stale-bot's exempt-issue-labels list. const EXEMPT_TAGS = new Set(['never-stale', 'planned', 'started', 'bug', 'enhancement', 'possibly-completed']); -// Statuses that should NEVER be touched by the stale sweep — terminal states +// Statuses that should NEVER be touched by the stale sweep - terminal states // or in-flight engagement that means a maintainer is on it. const SWEEPABLE_STATUSES = new Set(['open']); @@ -82,7 +82,7 @@ const buildStaleWarnComment = () => { '', 'Maintainers can suppress this check on a specific post by adding the `never-stale` tag.', '', - '_Automated cleanup — this comment is informational, not a deletion._', + '_Automated cleanup - this comment is informational, not a deletion._', '', COMMENT_MARKER_STALE_WARN, ].join('\n'); @@ -124,11 +124,11 @@ const sweepPost = async (post) => { // Phase 2: stale tag already present. Look for our warn comment to see how // long ago we warned. If the maintainer or the user has commented since - // (anything that ISN'T our warn marker), skip — they've engaged. + // (anything that ISN'T our warn marker), skip - they've engaged. const comments = await fider(`/api/v1/posts/${post.number}/comments`); const warn = (comments || []).find((c) => typeof c.content === 'string' && c.content.includes(COMMENT_MARKER_STALE_WARN)); if (!warn) { - // Stale tag without our warn comment — probably manually tagged. Don't + // Stale tag without our warn comment - probably manually tagged. Don't // auto-decline; that requires explicit bot warning first. return { skipped: 'stale-no-warn' }; } @@ -196,7 +196,7 @@ const main = async () => { }, }); } - // Skips are quiet — most posts don't qualify and we don't want noise. + // Skips are quiet - most posts don't qualify and we don't want noise. } catch (err) { log(`#${post.number} error: ${err.message}`); } diff --git a/tools/fider-triage.mjs b/tools/fider-triage.mjs index b65324c8f..69ecf7752 100644 --- a/tools/fider-triage.mjs +++ b/tools/fider-triage.mjs @@ -129,7 +129,7 @@ const tagPost = async (post, slug) => { // Per-comment-type marker so a re-evaluation can leave a NEW kind of comment // (e.g. completion now, after previously commenting only as duplicate) without -// double-posting the same kind. Hidden HTML comment — invisible in Fider's +// double-posting the same kind. Hidden HTML comment - invisible in Fider's // rendered Markdown. const COMMENT_MARKERS = { completed: '', @@ -186,16 +186,16 @@ const buildBotComment = ({ header, quote, footer, marker }) => { }; const COMPLETED_FOOTER = - '_Automated triage — please verify and mark this post as Completed if the PR delivers what was requested, or remove the `possibly-completed` tag if not._'; + '_Automated triage - please verify and mark this post as Completed if the PR delivers what was requested, or remove the `possibly-completed` tag if not._'; const DUPLICATE_FOOTER = - '_Automated triage — please verify and either close as duplicate (with a link to the original) or remove the `possibly-duplicate` tag if these are distinct requests._'; + '_Automated triage - please verify and either close as duplicate (with a link to the original) or remove the `possibly-duplicate` tag if these are distinct requests._'; // Author handle is supplied by /api/v1/posts (each post carries a `user` // object). Used to address the OP directly in the pre-existing comment so // they see the nudge to split multi-ask requests. const opMention = (post) => (post.user?.name ? `@${post.user.name}` : 'the original poster'); const preExistingFooter = (post) => - `_Automated triage — ${opMention(post)}, if this request bundles multiple feature asks, please split them into separate posts so each can be tracked independently. A maintainer will verify the match._`; + `_Automated triage - ${opMention(post)}, if this request bundles multiple feature asks, please split them into separate posts so each can be tracked independently. A maintainer will verify the match._`; const commentOnPost = async (post, verdict, candidates) => { const candidate = candidates.find((c) => c.url === verdict.evidence_url); @@ -317,7 +317,7 @@ const filterPRs = ({ candidates, post, dateGate, requireFeaturePrefix }) => { }; // PRs merged AFTER the FR was filed are the only ones that can have -// implemented it. Always require the feat:/fix: prefix here — this path is +// implemented it. Always require the feat:/fix: prefix here - this path is // tuned for precision. const filterCandidates = (candidates, post) => filterPRs({ @@ -386,7 +386,7 @@ const runJudge = async ({ post, system, userPayload, defaultVerdict, validate, l return validate(parsed) ?? defaultVerdict; }; -// Helper — true when the model's quote is ≥10 chars and appears verbatim +// Helper - true when the model's quote is ≥10 chars and appears verbatim // (case-insensitively) somewhere in the candidate haystack. const quoteFoundIn = (verdict, haystack) => { const quote = (verdict.quote || '').toLowerCase().trim(); @@ -413,7 +413,7 @@ const judgeWithModel = (post, candidates) => defaultVerdict: { status: 'not_done', confidence: 'low' }, validate: (parsed) => { if (parsed.status === 'completed' && !quoteFoundIn(parsed, candidatesHaystack(candidates))) { - log(`#${post.number}: rejecting completed verdict — quote not found in candidates`); + log(`#${post.number}: rejecting completed verdict - quote not found in candidates`); return { status: 'not_done', confidence: 'low' }; } return parsed; @@ -427,7 +427,7 @@ const judgePreExistingWithModel = (post, candidates) => system: 'You decide whether a feature request is ALREADY supported by an EXISTING feature that shipped BEFORE this request was filed. ' + 'Reply ONLY with compact JSON: {"status":"pre_existing"|"not_supported","confidence":"high"|"low","evidence_url":string,"quote":string}. ' + - 'Use status="pre_existing" ONLY when one earlier PR clearly delivered exactly the behaviour the user is now asking for — i.e. the user simply may not know the feature exists. ' + + 'Use status="pre_existing" ONLY when one earlier PR clearly delivered exactly the behaviour the user is now asking for - i.e. the user simply may not know the feature exists. ' + 'Be MORE conservative than for completion: if the existing behaviour is similar but not the specific ask, return status="not_supported". ' + 'You MUST copy a verbatim phrase from that PR\'s title or body into "quote" that proves the existing feature does what is asked. ' + 'If you cannot quote a phrase that directly matches the request, return status="not_supported".', @@ -438,7 +438,7 @@ const judgePreExistingWithModel = (post, candidates) => defaultVerdict: { status: 'not_supported', confidence: 'low' }, validate: (parsed) => { if (parsed.status === 'pre_existing' && !quoteFoundIn(parsed, candidatesHaystack(candidates))) { - log(`#${post.number}: rejecting pre_existing verdict — quote not found in candidates`); + log(`#${post.number}: rejecting pre_existing verdict - quote not found in candidates`); return { status: 'not_supported', confidence: 'low' }; } return parsed; @@ -452,7 +452,7 @@ const judgeDuplicateWithModel = (post, candidates) => system: 'You decide whether a feature request is a duplicate of an EARLIER feature request from the same project. ' + 'Reply ONLY with compact JSON: {"status":"duplicate"|"unique","confidence":"high"|"low","original_number":number,"quote":string}. ' + - 'Use status="duplicate" ONLY when one of the earlier candidates clearly asks for the same behaviour as the new request — not merely the same area of the product. ' + + 'Use status="duplicate" ONLY when one of the earlier candidates clearly asks for the same behaviour as the new request - not merely the same area of the product. ' + 'Different requests that touch the same feature but ask for different behaviour are NOT duplicates. ' + 'You MUST copy a verbatim phrase from the matching earlier candidate that proves the overlap, and set original_number to that candidate\'s number. ' + 'If you cannot do both, return status="unique".', @@ -471,12 +471,12 @@ const judgeDuplicateWithModel = (post, candidates) => // candidate list AND the quote must appear in its title/description. const original = candidates.find((c) => c.number === parsed.original_number); if (!original) { - log(`#${post.number}: rejecting duplicate verdict — original_number not in candidates`); + log(`#${post.number}: rejecting duplicate verdict - original_number not in candidates`); return { status: 'unique', confidence: 'low' }; } const haystack = `${original.title}\n${original.description || ''}`.toLowerCase(); if (!quoteFoundIn(parsed, haystack)) { - log(`#${post.number}: rejecting duplicate verdict — quote not found in #${original.number}`); + log(`#${post.number}: rejecting duplicate verdict - quote not found in #${original.number}`); return { status: 'unique', confidence: 'low' }; } return parsed; @@ -503,7 +503,7 @@ const triagePost = async (post, allOpen) => { try { prVerdict = await judgeWithModel(post, candidates); } catch (err) { - // Budget-exhausted is a hard stop — let it propagate so main() + // Budget-exhausted is a hard stop - let it propagate so main() // aborts cleanly with the deferred-posts log instead of treating it // as a per-post failure. if (err instanceof models.BudgetExhaustedError) throw err; @@ -520,7 +520,7 @@ const triagePost = async (post, allOpen) => { } // Step 1b (opt-in): pre-existing-feature check. Inverts the date filter to - // look at PRs merged BEFORE the FR was filed — catches "user didn't know + // look at PRs merged BEFORE the FR was filed - catches "user didn't know // feature X already exists". Lower precision than the post-creation // completion check, hence opt-in and a separate tag for maintainer review. if (checkPreExisting) { @@ -634,7 +634,7 @@ const main = async () => { log(`#${post.number} '${post.title}' → error`); consecutiveModelFailures += 1; if (consecutiveModelFailures >= MAX_CONSECUTIVE_MODEL_FAILURES) { - log(`aborting run: ${consecutiveModelFailures} consecutive model failures — likely API outage or quota exhausted. Remaining posts will be retried on the next scheduled run.`); + log(`aborting run: ${consecutiveModelFailures} consecutive model failures - likely API outage or quota exhausted. Remaining posts will be retried on the next scheduled run.`); aborted = true; break; } diff --git a/tools/generate-release-notes.mjs b/tools/generate-release-notes.mjs index 9ff07faf2..2690f322f 100644 --- a/tools/generate-release-notes.mjs +++ b/tools/generate-release-notes.mjs @@ -445,16 +445,16 @@ const buildPrompt = ({ main, deps, syncs, prMeta, migrationDetails }) => { "1. Never invent items. Only describe entries present below.", "2. Deduplicate aggressively. Collapse bullets describing the same change into one. Grouping signals (weakest to strongest):", " a. Weaker: same conventional-commit scope (e.g. `storage-metrics`, `overlays`) usually groups bullets within a section.", - " b. Stronger: PR bodies that explicitly reference each other (`Part of #NNN`, `Related to #NNN`, `Depends on #NNN`) — merge these into one bullet.", - "3. PR attribution rule (CRITICAL): a bullet may cite `(#NNN)` only if that PR number appears in the Commits list below AND its commit subject or PR body clearly describes the same change as the bullet. If no listed commit carries a `(#NNN)` suffix for that change, OMIT the citation — do not guess or reuse a nearby PR number. Do NOT cite a PR merely because it shares a conventional-commit scope, label, or touches the same files. Max 2 PR citations per bullet.", + " b. Stronger: PR bodies that explicitly reference each other (`Part of #NNN`, `Related to #NNN`, `Depends on #NNN`) - merge these into one bullet.", + "3. PR attribution rule (CRITICAL): a bullet may cite `(#NNN)` only if that PR number appears in the Commits list below AND its commit subject or PR body clearly describes the same change as the bullet. If no listed commit carries a `(#NNN)` suffix for that change, OMIT the citation - do not guess or reuse a nearby PR number. Do NOT cite a PR merely because it shares a conventional-commit scope, label, or touches the same files. Max 2 PR citations per bullet.", "4. Highlights: 1-3 MOST impactful user-facing or breaking changes. An item in Highlights MUST NOT reappear in Features/Fixes/Performance/Internal. Pick one section per concrete change.", - "5. Breaking Changes classification: only flag something as breaking if it changes behavior, an environment variable, config key, setting, API, CLI flag, or DB column that ALREADY SHIPPED in a previous release. The commits below are the entire range since the last release — net them out. If an identifier is BOTH introduced and renamed/removed/changed within this range, it never shipped under its old form: describe only its FINAL state under the normal section (e.g. a brand-new environment variable belongs under Features, not Breaking Changes) and never as a breaking change. Treat a `rename X -> Y` commit as breaking only when no other commit in the range introduces X. When unsure whether the old form shipped, do NOT classify it as breaking.", - "6. For each database migration below, write ONE plain-English sentence: describe net schema effect (new tables, columns added to existing tables, indexes, data backfills). Ignore intermediate TypeORM `temporary_*` rename tables — describe the end state.", + "5. Breaking Changes classification: only flag something as breaking if it changes behavior, an environment variable, config key, setting, API, CLI flag, or DB column that ALREADY SHIPPED in a previous release. The commits below are the entire range since the last release - net them out. If an identifier is BOTH introduced and renamed/removed/changed within this range, it never shipped under its old form: describe only its FINAL state under the normal section (e.g. a brand-new environment variable belongs under Features, not Breaking Changes) and never as a breaking change. Treat a `rename X -> Y` commit as breaking only when no other commit in the range introduces X. When unsure whether the old form shipped, do NOT classify it as breaking.", + "6. For each database migration below, write ONE plain-English sentence: describe net schema effect (new tables, columns added to existing tables, indexes, data backfills). Ignore intermediate TypeORM `temporary_*` rename tables - describe the end state.", "7. Section order (omit empty): Highlights, Breaking Changes, Features, Fixes, Performance, Database migrations, Internal, Dependencies.", "8. One line per bullet. No emoji.", "9. For dependency bumps, emit ONE bullet under Dependencies summarizing count and notable packages.", '10. Output GitHub-flavored Markdown only. The first line MUST be the first `##
` heading. Do NOT emit a top-level `#` title, version string, "Release Notes" header, introductory prose, trailing commentary, or wrapping code fences.', - "11. Do NOT include `[label]` tags, `pr#… body:` lines, `body:` lines, or raw commit subjects in the output — they are input hints only.", + "11. Do NOT include `[label]` tags, `pr#… body:` lines, `body:` lines, or raw commit subjects in the output - they are input hints only.", "", `## Commits (${main.length} user-facing${syncs.length ? `, ${syncs.length} sync-back merges already filtered` : ""})`, main.map(commitLine).join("\n"), diff --git a/typeorm_instructions.txt b/typeorm_instructions.txt index d4c089407..3bdf4aedf 100644 --- a/typeorm_instructions.txt +++ b/typeorm_instructions.txt @@ -1,5 +1,5 @@ // TypeORM 1.x (better-sqlite3). Repository code uses object-form -// `relations`/`select` ({ rel: true }) and IsNull() to match SQL NULL — see +// `relations`/`select` ({ rel: true }) and IsNull() to match SQL NULL - see // project-notes.instructions.md. // Generate migration after entity changes @@ -40,7 +40,7 @@ yarn workspace @maintainerr/server migration:create src/database/migrations/ // // 3. Interpret the result: -// - "No changes in database schema were found" — schema is in sync, no migration needed. -// - A migration file is generated — review it, the changes are real schema drift. +// - "No changes in database schema were found" - schema is in sync, no migration needed. +// - A migration file is generated - review it, the changes are real schema drift. // // 4. Clean up: remove or empty data/maintainerr.sqlite so it doesn't get committed.