mirror of
https://github.com/jorenn92/Maintainerr.git
synced 2026-06-01 18:48:13 +02:00
docs: unify agent instructions into a single index, add handoff notes (#2980)
- AGENTS.md is the single documentation index; the Claude, Copilot, Cursor, and Codex entrypoints route to it and name the standing rules directly so they can't be missed. - Split standing rules (read every session) from task-specific docs (read on demand); scope release-review's Copilot applyTo to release artifacts so it no longer loads on every interaction. - Add project-notes.instructions.md (non-obvious project knowledge and conventions for handoff) and README_AGENTS.md (the wiring map). - Move dev mocks + DB seed to tools/dev/ (fix seed-db repo-root resolution); add the seeded-DB + Playwright step to the release-review checklist.
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
At the start of every conversation, read `.github/instructions/implementation.instructions.md` and follow it for implementation-specific guidance.
|
||||
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.
|
||||
|
||||
Read [ARCHITECTURE.md](../../ARCHITECTURE.md) for the system architecture overview before changing cross-module boundaries.
|
||||
Before writing any code, read and follow the standing rules: [.github/instructions/implementation.instructions.md](../../.github/instructions/implementation.instructions.md) and [.github/instructions/project-notes.instructions.md](../../.github/instructions/project-notes.instructions.md). Other docs in `.github/instructions/` are task-specific — read them when the task calls for it (AGENTS.md lists which).
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
---
|
||||
description: Implementation-specific guidance
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
At the start of every conversation, read `.github/instructions/implementation.instructions.md` and follow it for implementation-specific guidance.
|
||||
|
||||
Read [ARCHITECTURE.md](../../ARCHITECTURE.md) for the system architecture overview before changing cross-module boundaries.
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
description: Project overview and coding standards
|
||||
description: Project entrypoint and standing rules
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
At the start of every conversation, read `AGENTS.md` for the full project overview, technology stack, coding standards, and contribution guidelines. Read [ARCHITECTURE.md](../../ARCHITECTURE.md) for the system architecture overview.
|
||||
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).
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
At the start of every conversation, read `.github/instructions/implementation.instructions.md` and follow it for implementation-specific guidance.
|
||||
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.
|
||||
|
||||
Also read `AGENTS.md` for repository conventions, workspace commands, tech stack, and coding standards.
|
||||
|
||||
Read [ARCHITECTURE.md](../ARCHITECTURE.md) for the system architecture overview.
|
||||
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).
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
---
|
||||
applyTo: "**"
|
||||
---
|
||||
|
||||
# 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,
|
||||
GitHub Copilot, and Codex.
|
||||
|
||||
Read [ARCHITECTURE.md](../../ARCHITECTURE.md) for the system map and
|
||||
[AGENTS.md](../../AGENTS.md) for the command/workflow reference first; this file
|
||||
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
|
||||
conversational chat.
|
||||
|
||||
- **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
|
||||
upstream's source, write "Seerr" in the output. Use a product-specific name
|
||||
only in code provably specific to it (none exists today).
|
||||
|
||||
- **Never use real media titles** (movies, shows, books, games) in any committed
|
||||
artifact. Use generic placeholders: "Sample Series", "Franchise A Collection",
|
||||
"a movie collection". Brand/franchise names create IP-association noise and
|
||||
date the code.
|
||||
|
||||
- **Prefer manual string ops over regex.** Default to `charCodeAt`, `slice`,
|
||||
`startsWith`/`endsWith`, `.toLowerCase()`, char-index while-loops. Use regex
|
||||
only when manual ops would be materially more complex (multi-line, lookahead).
|
||||
For trailing/leading slashes or whitespace, write a small char-by-char helper
|
||||
rather than `.replace(/\/+$/, '')`. Rationale: explicit, no hidden engine cost,
|
||||
no surprises on empty input or unicode.
|
||||
|
||||
- **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.
|
||||
|
||||
- **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
|
||||
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
|
||||
distinction from scope discipline: real-bug-in-scope → fix it; benign-noise →
|
||||
leave it. For data changes, prefer **minimal behavioral change** — preserve how
|
||||
existing data evaluates today, making values explicit rather than flipping
|
||||
behavior.
|
||||
|
||||
### Working-style preferences of the prior maintainer
|
||||
|
||||
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 —
|
||||
one was 20 commits behind by the time edits finished), fold into one clean
|
||||
commit (`git reset --soft` then a single commit), run the **full** repo suite
|
||||
(`yarn turbo test`, not just affected specs), then push. Verify
|
||||
`git rev-list --left-right --count origin/<branch>...HEAD` is `0 N` (clean
|
||||
fast-forward, no force-push). Keep diffs minimal — don't rename existing
|
||||
variables without cause.
|
||||
|
||||
---
|
||||
|
||||
## UI (apps/ui)
|
||||
|
||||
### Tailwind CSS v4 (CSS-first)
|
||||
|
||||
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`.
|
||||
- `apps/ui/styles/globals.css` is the single source of truth: `@import
|
||||
'tailwindcss'`, `@plugin "@tailwindcss/forms" { strategy: base; }`,
|
||||
`@plugin "@tailwindcss/typography";`, and a `@theme` block defining the custom
|
||||
palettes (error, success, info→zinc, warning→amber, maintainerr,
|
||||
maintainerrdark), `--font-sans`, `--breakpoint-xs`. Custom transition
|
||||
utilities: `@utility transition-width` / `transition-max-height`.
|
||||
- Prettier uses `tailwindStylesheet: './styles/globals.css'` (not
|
||||
`tailwindConfig`).
|
||||
|
||||
**v4 constraints (apply whenever you write UI):**
|
||||
|
||||
- `bg-opacity-*` / `text-opacity-*` don't exist → use the `/<n>` slash syntax
|
||||
(`bg-zinc-900/80`). The old opacity classes render fully opaque, silently.
|
||||
- `@tailwindcss/forms` is loaded with `strategy: base` on purpose — its default
|
||||
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`.
|
||||
- `@tailwindcss/typography` prose colors must be set via `--tw-prose-*` vars in an
|
||||
**unlayered** `.prose` rule (layered rules lose to the plugin's own `.prose`).
|
||||
- v4 class names: `shadow-sm` (not `shadow`), `rounded-sm` (not `rounded`),
|
||||
`outline-hidden` (not `outline-none`), `bg-linear-to-*` (not `bg-gradient-to-*`).
|
||||
- **Checkbox standard:** there is one `.checkbox` class in globals.css
|
||||
(`@layer components`). Every `<input type="checkbox">` uses
|
||||
`className="checkbox"` and nothing else — it bundles size/rounding/maintainerr
|
||||
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
|
||||
dark; don't re-add per-control dark hacks.
|
||||
- The `ul.cards-vertical` grid breakpoint is a **`@container` query** (440px);
|
||||
Overview/Content wraps the grid in `<div className="@container">`. It sizes to
|
||||
the wrapper, not the viewport.
|
||||
- `field-sizing-content` on the rule-group Description (AddModal) and overlay Text
|
||||
(PropertiesPanel) textareas (auto-grow).
|
||||
- `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`.
|
||||
|
||||
### UI implementation conventions
|
||||
|
||||
- **Always prefer the shared UI primitives first.** Before building bespoke form
|
||||
or settings chrome, check `apps/ui/src/components/Forms/` and
|
||||
`apps/ui/src/components/Common/`. Today that means using shared field
|
||||
components such as `Input`, `InputGroup`, `InputAdornment`, `FieldJoin`,
|
||||
`Select`, `SelectGroup`, `SelectAdornment`, plus shared action controls like
|
||||
`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
|
||||
`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
|
||||
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
|
||||
object each render) causes infinite render loops → vitest OOM/SIGTERM in CI.
|
||||
- **Loading spinners:** full `LoadingSpinner` = delayed (only when a wait is
|
||||
expected); `SmallLoadingSpinner` = immediate inline feedback.
|
||||
- **Layout stability:** reserve space for late-loading UI; keep tab/card structure
|
||||
stable; placeholders must not change active state or shift surrounding UI.
|
||||
- Favor reusable, consistent components and solid React patterns; avoid
|
||||
unnecessary abstraction; match existing patterns to avoid regressions.
|
||||
|
||||
---
|
||||
|
||||
## Rule engine (apps/server, modules/rules)
|
||||
|
||||
### Comparator: keep EXISTS / value-comparison orthogonal
|
||||
|
||||
Value-comparison operators (BEFORE, AFTER, EQUALS, NOT_EQUALS, …) stay
|
||||
**fail-closed** when the operand is missing. Existence is handled separately by
|
||||
**EXISTS / NOT_EXISTS**. Do **not** silently expand a comparison to also mean
|
||||
"never happened."
|
||||
|
||||
Why it matters: special-casing BEFORE-on-null for temporal properties
|
||||
(`lastViewedAt`, `sw_lastWatched`) makes `NOT_EQUALS <date>` match every
|
||||
never-watched item — a semantic expansion, not a fix. The correct layer for
|
||||
"never watched" is the **getter contract** (null = confirmed absent,
|
||||
undefined = error), not the comparator.
|
||||
Users get "never watched OR older than X days" by composing
|
||||
`NOT_EXISTS OR BEFORE X` explicitly. Push back on rule-engine fixes that change
|
||||
what BEFORE/AFTER/EQUALS/NOT_EQUALS do for null/undefined inputs.
|
||||
|
||||
### Section operator semantics
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
### Rule evaluation performance
|
||||
|
||||
- **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
|
||||
mutation loop. Knob: `RULE_EVALUATION_CONCURRENCY` in `rules.constants.ts`
|
||||
(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,
|
||||
triggering axios retries that pin CPU and stall the run. There is intentionally
|
||||
no user-facing knob.
|
||||
- **`ArrLookupCache`** (`modules/rules/helpers/arr-lookup-cache.ts`): a run-scoped
|
||||
memo created in the executor for the eval loop only, never passed to
|
||||
`handleCollection`/actions, so empty-show cleanup still reads fresh. Used only
|
||||
by the sonarr/radarr getters (others already cache at the API layer); the API
|
||||
lookup itself stays `getWithoutCache`.
|
||||
- **Do not retain full comparison stats for every scanned item.**
|
||||
`RuleExecutorService` should keep detailed `IComparisonStatistics` only for
|
||||
items that may be newly added to a collection. Holding per-item stats across
|
||||
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) —
|
||||
runs are cold-start by design; only SQLite persists.
|
||||
|
||||
### 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
|
||||
unreachable with the auth Maintainerr has (see below).
|
||||
|
||||
Non-obvious facts:
|
||||
|
||||
- A Streamystats "watchlist" is a user-created **named curated list** (tables
|
||||
`watchlists` / `watchlist_items`), NOT a Plex-style personal want-to-watch
|
||||
flag. Its `itemId` equals the Jellyfin item ID, so membership maps directly.
|
||||
Do not reuse the Plex watchlist property semantics.
|
||||
- The watchlist endpoints authenticate with
|
||||
`Authorization: MediaBrowser Token="<jellyfin_api_key>"`, **not** `Bearer`
|
||||
(only the item-details endpoint accepts Bearer). `getWatchlistMembership()`
|
||||
overrides the Authorization header per request.
|
||||
- Maintainerr authenticates with a Jellyfin **server API key**, which Streamystats
|
||||
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
|
||||
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;
|
||||
`false`/`[]` only when genuinely fetched. This prevents a failed lookup from
|
||||
flipping negative list comparisons and matching protected items.
|
||||
- Gating mirrors Tautulli: UI gate folded into the Jellyfin line in RuleInput
|
||||
`shouldFilterApplication`; server gate in `getRuleConstants()` removes the
|
||||
Application when `streamystats_url` / `jellyfin_api_key` are unset. Emby stays
|
||||
unsupported (Streamystats is Jellyfin-only).
|
||||
|
||||
### Rule regression harness
|
||||
|
||||
`apps/server/test/rules-test-matrix.e2e.ts` boots a real Nest app + real
|
||||
`RuleComparatorServiceFactory` / `RulesController` / `RulesService` and POSTs to
|
||||
`/api/rules/test`, with `MediaServerFactory` + `ValueGetterService` mocked so the
|
||||
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
|
||||
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.)
|
||||
|
||||
---
|
||||
|
||||
## Cross-server media abstraction
|
||||
|
||||
- **Emby/Jellyfin set a movie's `parentId` to its library-folder id** (Emby's
|
||||
`getParentId` falls back to `item.ParentId`); Plex leaves a top-level movie's
|
||||
parent empty. So any logic that infers "this item is a season/episode" from
|
||||
`parentId`/`grandparentId` **presence** misclassifies Emby/Jellyfin movies.
|
||||
**Always switch on the server-agnostic `item.type`**
|
||||
(`'movie' | 'show' | 'season' | 'episode'`), never on
|
||||
`parentId`/`grandparentId` truthiness. (`notifications.service.ts` `getTitle`
|
||||
is one place this matters.)
|
||||
- **Emby user-scoped reads differ from writes.** Collection metadata reads and
|
||||
full-library size scans should prefer `/Users/{userId}/Items...` when
|
||||
`emby_user_id` is configured; the plain `/Items/...` path can miss or 404 in
|
||||
user-authenticated flows even though the update write endpoint remains
|
||||
`POST /Items/{itemId}`.
|
||||
|
||||
---
|
||||
|
||||
## Contracts migration direction (@maintainerr/contracts)
|
||||
|
||||
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
|
||||
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.
|
||||
- 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
|
||||
migrate schemas.
|
||||
- **Build contracts before trusting downstream errors.** When shared types change
|
||||
in `packages/contracts`, run the contracts build first. Server/UI diagnostics
|
||||
can disagree while one side still resolves generated `dist` types and the
|
||||
other resolves source files.
|
||||
|
||||
---
|
||||
|
||||
## Build, test & migrations
|
||||
|
||||
### 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<>`
|
||||
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
|
||||
will turn it into a runtime TDZ `ReferenceError: Cannot access 'X' before
|
||||
initialization`.
|
||||
|
||||
`apps/server` also runs under **Yarn PnP**, so debug scripts cannot assume
|
||||
`node_modules/jest/bin/jest.js` exists locally. For direct Jest-under-Node
|
||||
entrypoints, use `yarn node $(yarn bin jest) ...`.
|
||||
|
||||
### Writing DATA migrations (backfills, no schema change)
|
||||
|
||||
- `migration:generate` **cannot** produce them — it diffs entity metadata vs DB
|
||||
and reports "No changes in database schema were found." Scaffold with
|
||||
`migration:create src/database/migrations/<Name>`.
|
||||
- Write `up()` using TypeORM's **QueryBuilder**
|
||||
(`queryRunner.manager.createQueryBuilder()…`), **not** raw
|
||||
`queryRunner.query('SELECT/UPDATE…')` — the implementation rules forbid manually
|
||||
crafted SQL.
|
||||
- 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).
|
||||
- Test data migrations with **in-memory SQLite**: `new DataSource({
|
||||
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
|
||||
`git add -A` blindly after `test:e2e`.
|
||||
|
||||
### TypeORM migration CLI workaround
|
||||
|
||||
`yarn workspace @maintainerr/server migration:run` / `migration:generate` (the
|
||||
`typeorm-ts-node-commonjs` CLI) does **not** run out-of-the-box: ts-node errors
|
||||
with `TS5011` (`apps/server/tsconfig.json` sets `declaration:true`/`outDir` but no
|
||||
explicit `rootDir`) and "Cannot find name 'process'" (no node types for ts-node).
|
||||
Workaround:
|
||||
|
||||
```jsonc
|
||||
// apps/server/tsconfig.migrate.json (throwaway; safe to delete, gitignore-able)
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"declaration": false,
|
||||
"declarationMap": false,
|
||||
"composite": false,
|
||||
"incremental": false,
|
||||
"sourceMap": false,
|
||||
"rootDir": "./",
|
||||
"types": ["node"],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Then: `cd apps/server && TS_NODE_PROJECT=./tsconfig.migrate.json yarn migration:run`
|
||||
(or `migration:generate <path>`).
|
||||
|
||||
**Verifying a migration:** the app uses `synchronize:false` + `migrationsRun:true`
|
||||
(`typeOrmConfig.ts`), so `migration:run` ≡ `yarn dev` for schema. Definitive sync
|
||||
check: build the branch DB from empty (`rm data/maintainerr.sqlite*` then
|
||||
`migration:run`), then `migration:generate` must report **"No changes in database
|
||||
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
|
||||
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
|
||||
real `@jellyfin/sdk` paths the adapter calls: `GET /System/Info/Public`,
|
||||
`GET /Users` (must include a `Policy.IsAdministrator` user), `GET
|
||||
/Library/MediaFolders` (library ids must be `jellyfin-movies`/`jellyfin-shows`
|
||||
to match the seed), and `GET /Items?…&ids=<id>` (the LIST form — getMetadata
|
||||
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
|
||||
getter paths.
|
||||
- `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`
|
||||
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
|
||||
stale getter logic. Watchlist / plex.tv user enrichment can't be mocked locally
|
||||
(they hit plex.tv) and degrade gracefully.
|
||||
|
||||
---
|
||||
|
||||
## Tooling: MCP servers
|
||||
|
||||
The workspace configures MCP servers (kept in sync across `.codex/config.toml`,
|
||||
`.mcp.json`, and `.vscode/mcp.json`):
|
||||
|
||||
- **github** (HTTP, read-only): use for GitHub queries (issues, PRs, repo
|
||||
metadata) instead of shelling out to `gh` when an MCP tool is available.
|
||||
- **playwright** (stdio, `--headless --isolated`): use for browser-driven testing
|
||||
/ verification of UI changes instead of asking for manual verification. Save
|
||||
screenshots under `.playwright-mcp/`.
|
||||
@@ -1,5 +1,9 @@
|
||||
---
|
||||
applyTo: "**"
|
||||
# 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.
|
||||
applyTo: "CHANGELOG.md,apps/*/CHANGELOG.md,.github/workflows/release_*.yml"
|
||||
---
|
||||
|
||||
## Release review — how to audit a release candidate before tagging
|
||||
@@ -159,6 +163,36 @@ scope is broad.
|
||||
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
|
||||
always against the **seeded dev DB + mock media server**, never a hand-set
|
||||
or empty database, so every reviewer hits the same deterministic dataset.
|
||||
|
||||
1. Start the matching mock media server:
|
||||
- `node tools/dev/fake-jellyfin.mjs` (`:8096`), or
|
||||
- `node tools/dev/fake-plex.mjs` (`:32400`) for the Plex-only getter paths.
|
||||
2. Stop `yarn dev` (SQLite is single-writer), seed, then restart:
|
||||
- `node tools/dev/seed-db.mjs` (Jellyfin, default) or
|
||||
`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
|
||||
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
|
||||
for the report.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
### 6. Write the report
|
||||
|
||||
Use severity levels, in this order:
|
||||
|
||||
@@ -20,6 +20,21 @@ Maintainerr is a media management application that helps users automatically man
|
||||
|
||||
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):**
|
||||
|
||||
- [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):**
|
||||
|
||||
- [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).
|
||||
|
||||
## Repository Structure
|
||||
|
||||
This is a **TypeScript monorepo** managed with **Turborepo** and **Yarn workspaces**:
|
||||
@@ -289,15 +304,15 @@ These specifications provide comprehensive type definitions and endpoint documen
|
||||
### Local dev mocks & seeding (manual / Playwright testing)
|
||||
|
||||
For end-to-end checks of media-server-dependent flows (rules, collections,
|
||||
overview, calendar, storage) without a real Plex/Jellyfin, the `dev/` folder has
|
||||
three scripts that **complement Playwright** — Playwright drives the UI, these
|
||||
overview, calendar, storage) without a real Plex/Jellyfin, the `tools/dev/` folder
|
||||
has three scripts that **complement Playwright** — Playwright drives the UI, these
|
||||
provide the backend data:
|
||||
|
||||
- `dev/fake-jellyfin.mjs` — stateless mock Jellyfin (`:8096`).
|
||||
- `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.
|
||||
- `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. Target a server with
|
||||
`MEDIA_SERVER=plex|jellyfin` (default `jellyfin`).
|
||||
|
||||
@@ -25,6 +25,7 @@ Maintainerr/
|
||||
|-- docs/ # Feature-level technical notes
|
||||
|-- docker/ # Docker helper configuration
|
||||
|-- tools/ # Release and maintenance scripts
|
||||
| `-- dev/ # Local dev mocks (fake Plex/Jellyfin) and DB seed
|
||||
|-- .codex/config.toml # Codex project MCP server config
|
||||
|-- .mcp.json # Claude Code project MCP server config
|
||||
|-- .vscode/mcp.json # VS Code MCP server config mirror
|
||||
@@ -224,6 +225,10 @@ Testing conventions:
|
||||
- Project MCP server config lives in `.codex/config.toml`, `.mcp.json`, and
|
||||
`.vscode/mcp.json`; keep them in sync. The GitHub MCP server is read-only,
|
||||
and Playwright screenshots should be saved under `.playwright-mcp/`.
|
||||
- End-to-end checks of media-server-dependent flows use the dev mocks and DB
|
||||
seed under `tools/dev/` (`fake-plex.mjs` / `fake-jellyfin.mjs` +
|
||||
`seed-db.mjs`) to drive the UI with Playwright against deterministic data;
|
||||
see `AGENTS.md` for the workflow.
|
||||
|
||||
See `CONTRIBUTING.md` for setup, branching, and pull request expectations.
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Agent instruction wiring
|
||||
|
||||
How this repo's AI coding agents (Claude, GitHub Copilot, Cursor, Codex) load
|
||||
their instructions. Each agent **auto-loads a different entrypoint**, but they
|
||||
all converge on **`AGENTS.md`** as the single documentation index, and each
|
||||
entrypoint *also names the two standing rules directly* so they can't be missed.
|
||||
|
||||
This file is the single source for the wiring; `AGENTS.md` links here.
|
||||
|
||||
```
|
||||
CLAUDE
|
||||
auto-loads → .claude/rules/implementation.md
|
||||
├─→ AGENTS.md ........................ (doc index)
|
||||
│ ├─→ implementation.instructions.md [standing]
|
||||
│ ├─→ project-notes.instructions.md [standing]
|
||||
│ ├─→ release-review.instructions.md [task-specific]
|
||||
│ └─→ ARCHITECTURE.md [task-specific]
|
||||
└─→ implementation.instructions.md + project-notes.instructions.md
|
||||
(named directly → read before any code, can't be missed)
|
||||
also: SessionStart hook injects AGENTS.md (belt-and-suspenders)
|
||||
|
||||
COPILOT
|
||||
auto-loads → .github/copilot-instructions.md
|
||||
├─→ AGENTS.md → (same index as above)
|
||||
└─→ implementation.instructions.md + project-notes.instructions.md (named)
|
||||
also auto-applies via applyTo:"**" → implementation.instructions.md, project-notes.instructions.md
|
||||
release-review → applyTo scoped to CHANGELOGs/release workflows (not every file)
|
||||
|
||||
CURSOR
|
||||
auto-loads → .cursor/rules/project.mdc (alwaysApply: true)
|
||||
├─→ AGENTS.md → (same index as above)
|
||||
└─→ implementation.instructions.md + project-notes.instructions.md (named)
|
||||
|
||||
CODEX
|
||||
auto-loads → AGENTS.md (the index itself)
|
||||
├─→ implementation.instructions.md [standing]
|
||||
├─→ project-notes.instructions.md [standing]
|
||||
├─→ release-review.instructions.md [task-specific]
|
||||
└─→ ARCHITECTURE.md [task-specific]
|
||||
|
||||
|
||||
all four entrypoints ──────────────→ AGENTS.md (single doc index)
|
||||
project-notes.instructions.md ──→ ARCHITECTURE.md, AGENTS.md ✓
|
||||
implementation.instructions.md ─→ ARCHITECTURE.md ✓
|
||||
```
|
||||
|
||||
## Rules of the structure (keep it working)
|
||||
|
||||
- **`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.
|
||||
- **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.
|
||||
When you change the wiring, update all four entrypoints together:
|
||||
`.claude/rules/implementation.md`, `.github/copilot-instructions.md`,
|
||||
`.cursor/rules/project.mdc`, and `AGENTS.md`.
|
||||
@@ -15,11 +15,11 @@
|
||||
*
|
||||
* Usage
|
||||
* -----
|
||||
* node dev/fake-jellyfin.mjs # listens on :8096 (matches dev seed)
|
||||
* FAKE_JELLYFIN_PORT=8096 node dev/fake-jellyfin.mjs
|
||||
* FAKE_JELLYFIN_LOG=1 node dev/fake-jellyfin.mjs # log every request
|
||||
* node tools/dev/fake-jellyfin.mjs # listens on :8096 (matches dev seed)
|
||||
* FAKE_JELLYFIN_PORT=8096 node tools/dev/fake-jellyfin.mjs
|
||||
* FAKE_JELLYFIN_LOG=1 node tools/dev/fake-jellyfin.mjs # log every request
|
||||
*
|
||||
* The dev seed (dev/seed-db.mjs) already points settings.jellyfin_url at
|
||||
* 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`.
|
||||
*/
|
||||
@@ -26,9 +26,9 @@
|
||||
*
|
||||
* Usage
|
||||
* -----
|
||||
* node dev/fake-plex.mjs # listens on :32400
|
||||
* FAKE_PLEX_PORT=32400 node dev/fake-plex.mjs
|
||||
* FAKE_PLEX_LOG=1 node dev/fake-plex.mjs # log every request
|
||||
* node tools/dev/fake-plex.mjs # listens on :32400
|
||||
* FAKE_PLEX_PORT=32400 node tools/dev/fake-plex.mjs
|
||||
* FAKE_PLEX_LOG=1 node tools/dev/fake-plex.mjs # log every request
|
||||
*
|
||||
* Point Maintainerr at it (settings): plex_hostname=localhost, plex_port=32400,
|
||||
* plex_ssl=0, any non-empty plex_auth_token, media_server_type='plex'. The
|
||||
@@ -10,8 +10,8 @@
|
||||
*
|
||||
* This is the only one of the three dev scripts that touches the DB; the
|
||||
* companion mocks are stateless HTTP servers:
|
||||
* - dev/fake-jellyfin.mjs (mock Jellyfin, :8096) — pairs with MEDIA_SERVER=jellyfin
|
||||
* - dev/fake-plex.mjs (mock Plex, :32400) — pairs with MEDIA_SERVER=plex
|
||||
* - tools/dev/fake-jellyfin.mjs (mock Jellyfin, :8096) — pairs with MEDIA_SERVER=jellyfin
|
||||
* - tools/dev/fake-plex.mjs (mock Plex, :32400) — pairs with MEDIA_SERVER=plex
|
||||
*
|
||||
* Notes
|
||||
* -----
|
||||
@@ -31,10 +31,10 @@
|
||||
*
|
||||
* Usage
|
||||
* -----
|
||||
* 1. Start the matching mock (dev/fake-jellyfin.mjs or dev/fake-plex.mjs).
|
||||
* 1. Start the matching mock (tools/dev/fake-jellyfin.mjs or tools/dev/fake-plex.mjs).
|
||||
* 2. Stop `yarn dev` (SQLite allows a single writer).
|
||||
* 3. node dev/seed-db.mjs # Jellyfin (default)
|
||||
* MEDIA_SERVER=plex node dev/seed-db.mjs # Plex
|
||||
* 3. node tools/dev/seed-db.mjs # Jellyfin (default)
|
||||
* MEDIA_SERVER=plex node tools/dev/seed-db.mjs # Plex
|
||||
* 4. Start `yarn dev` and open http://localhost:3000/collections
|
||||
*/
|
||||
import { createRequire } from "node:module";
|
||||
@@ -42,7 +42,8 @@ import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(__dirname, "..");
|
||||
// This script lives in tools/dev/, so the repo root is two levels up.
|
||||
const repoRoot = resolve(__dirname, "..", "..");
|
||||
// better-sqlite3 lives in the server workspace; resolve it from there.
|
||||
const require = createRequire(resolve(repoRoot, "apps/server/package.json"));
|
||||
const Database = require("better-sqlite3");
|
||||
@@ -69,8 +70,8 @@ const TARGET = process.env.MEDIA_SERVER === "plex" ? "plex" : "jellyfin";
|
||||
const APP = TARGET === "plex" ? 0 : 6; // Application id: Plex=0, Jellyfin=6
|
||||
const LIB =
|
||||
TARGET === "plex"
|
||||
? { movie: "1", show: "2" } // dev/fake-plex.mjs section ids
|
||||
: { movie: "jellyfin-movies", show: "jellyfin-shows" }; // dev/fake-jellyfin.mjs
|
||||
? { movie: "1", show: "2" } // tools/dev/fake-plex.mjs section ids
|
||||
: { movie: "jellyfin-movies", show: "jellyfin-shows" }; // tools/dev/fake-jellyfin.mjs
|
||||
|
||||
// ruleTypeId: NUMBER=0 DATE=1 TEXT=2 BOOL=3 TEXT_LIST=4. Property ids/types
|
||||
// differ per server, so keep one map each (mirrors RuleConstants).
|
||||
@@ -163,7 +164,7 @@ const run = db.transaction(() => {
|
||||
|
||||
// 2) Configure the active media server + metadata + integrations.
|
||||
if (TARGET === "plex") {
|
||||
// Points at dev/fake-plex.mjs. The fixed machine id lets the primary
|
||||
// Points at tools/dev/fake-plex.mjs. The fixed machine id lets the primary
|
||||
// connection succeed without plex.tv re-discovery.
|
||||
db.prepare(
|
||||
`UPDATE settings SET
|
||||
Reference in New Issue
Block a user