# Mediatracker A self-hosted media tracker (movies/TV shows) modeled after [bonukai/MediaTracker](https://github.com/bonukai/MediaTracker). User tracking data (watch history, tracked items, ratings) lives in the user's own atproto PDS as custom lexicon records under `dev.erican.mediatracker.*` (see `lexicons/`), not in a traditional database. The backend (`dotnet/`) acts as an atproto AppView and a caching metadata proxy in front of external sources (currently TheTVDB, TMDB and MyAnimeList, designed to support more). ## Running locally **Without any account or key, use the mock backend** (`Compose Mediatracker (mocked)` / `Compose FeedStudio (mocked)` and the matching `Run ... (mocked)` configurations, see "Mock backend"): the real API and Studio with fakes, on their own ports next to the normal ones. It is the default way to look at the frontend while developing, and new features extend it. Two ways, same Postgres either way: - **Native (day-to-day dev)**: `docker compose up -d postgres` from `dotnet/src`, then `dotnet run --project src/Mediatracker.Api` from `dotnet/`. Metadata source API keys come from `dotnet user-secrets` (`TvdbConfiguration:ApiKey`, `TmdbConfiguration:ApiKey`), scoped to the `Mediatracker.Api` project - run `dotnet user-secrets set "
:ApiKey" ""` from `dotnet/src/Mediatracker.Api`. - **Full stack in containers**: copy `dotnet/src/.env.example` to `dotnet/src/.env` (gitignored) and fill in `MEDIATRACKER_TVDB_API_KEY`/`MEDIATRACKER_TMDB_API_KEY` (and `MEDIATRACKER_ADMIN_DIDS`), then `docker compose up -d` from `dotnet/src`. These are the same flat `MEDIATRACKER_*` variables as in `deploy/` (see the deployment section; the API maps them via `MediatrackerEnvironmentMapper`), and the dev compose file uses no `Section__Key` names of its own: the localhost frontend origins, the client name and the OAuth scopes come from `appsettings*.json`. `dotnet user-secrets` isn't visible inside the container, `.env` is the only place containerized runs read keys from. - All docker-related files (`docker-compose.yml`, `docker-compose.dcproj`, `.env`/`.env.example`) live in `dotnet/src/` alongside the project folders. The one exception is `.dockerignore`, which stays at `dotnet/` - it has to sit at the actual Docker build context root, and the context is `dotnet/` (`context: ..` in the compose file), not `dotnet/src/`, because the API's `Dockerfile` needs `Directory.Build.props`/`Directory.Packages.props` from `dotnet/` for the central-package-management build. Moving `.dockerignore` into `src/` would silently stop it from being applied. Both ways expose the API on the same host port, `:13000` (native via `launchSettings.json`'s `applicationUrl`, containers via the compose `api` service's port mapping to the container's internal `8080`) - so a client (Bruno, the frontend) doesn't need to know or care which one is currently running. Bruno (`bruno/`) still has two environments, `local` and `docker`, both pointing at `:13000` - they differ only in their default `source` variable, kept as two separate saved profiles for convenience. ## Mock backend (the app without any account or key) `dotnet/src/Mediatracker.Api.Mock` is the real API with everything that reaches outside replaced by the fakes in `dotnet/src/Mediatracker.Testing` (shared with the performance tests). Login works with any handle (account `did:plc:mockuser`, an admin); the `edge` profile (default) holds titles that break layouts, `full` an account the size of a real one; its database is dropped and recreated at every start. Internals: skill `mediatracker-testing`. - **Run configurations** (Rider `dotnet/.run`, WebStorm `frontend/.run`; the mocked ones use other ports so both sets can run at once): `Compose Mediatracker`/`Compose FeedStudio`/`Compose all` and the same `(mocked)` (API `:13001`, Studio API `:13101`); WebStorm `Run Mediatracker` (`:5173`), `Run FeedStudio` (`:5174`), `Run all` and the `(mocked)` ones (`:5183` -> `:13001`, `:5184` -> `:13101`). Ports come from `MEDIATRACKER_DEV_PORT`/`MEDIATRACKER_API_PROXY` and `STUDIO_DEV_PORT`/`STUDIO_API_PROXY`. Natively: `dotnet run --project dotnet/src/Mediatracker.Api.Mock` (profile `mock`). For Claude's preview: `.claude/launch.json` `frontend-mock` and `studio-frontend-mock` against the running mocked stacks. The first read of the account takes about 10 s. - Not covered: admin pages that look at real services show them as down or off, two fake sources only (TMDB and `Acme`, which is announced like a plugin's source), no images. ## Skills (topic documentation) and open tasks Open tasks and undecided questions are in `TODO.md` (delete an entry when it is done); the skills only describe the current state. The detailed documentation of the features lives in project skills in `.claude/skills//SKILL.md` and is loaded on demand: read the matching skill **before** changing the area it describes, and update it in the same change (a new setting, endpoint, capability or known gap goes into the skill, not into this file). Cross-references in them such as "see 'Tap'" name a section heading of another skill (`grep -rn '^## ' .claude/skills`). | Skill | Covers | |---|---| | `mediatracker-deployment` | `deploy/`, packaging, `MEDIATRACKER_*` variables, release/versions | | `mediatracker-metadata-sources` | `/sources`, states, rate limits, capabilities, source policy, priorities, places | | `mediatracker-plugins` | plugin system: core plugin, plugin folders and `plugin.json`, loader, `/admin/plugins` | | `mediatracker-mal-jikan` | MyAnimeList via Jikan/Jikan-Edge, local title search | | `mediatracker-detail-pages` | detail pages, discover, search, recommendations, media gallery, episodes, migration of entries | | `mediatracker-community-tap` | reviews, Popfeed, links to discussions, Tap and its handlers | | `mediatracker-anime` | AniDB, ID mappings, title resolution, AnimeThemes | | `mediatracker-feeds` | feeds, subscriptions, directory, Anime Seasons | | `mediatracker-feed-studio` | Feed Studio, rules/datasources, service identity, shared libraries | | `mediatracker-records` | record index, lexicon rules, identifiers, AT-URI references, export/restore | | `mediatracker-library` | library query/pages, Home, lists, calendar | | `mediatracker-images-cache` | image proxy, metadata cache | | `mediatracker-auth-admin` | OAuth client, admin area, status page, jobs, log | | `mediatracker-testing` | performance/load tests, mock backend internals, e2e | | `mediatracker-frontend-architecture` | workspace, shared `@mediatracker/ui` package | | `mediatracker-frontend-conventions` | sidebar, settings/admin pages, config.json, setting frame, source multi-select, Storybook details | ## dotnet/ coding guidelines - **Everything the backend asks of the outside or does on its own belongs in the administration - settings and metrics - and it is part of the feature, not an afterthought.** A new external service or data source (a metadata source, an enrichment, a review source, a dataset), and likewise a background job or a cache with limits, comes with: (1) **its settings in the administration**: a `SourceSettingsDefinition` registered by its own `Add...` method (the addresses, the switch that turns it off, keys and the like, see "The settings of the metadata sources"), read through **an options provider that asks `IConfiguration` every time it is used** (`IAniDbOptionsProvider`/`IAnimeThemesOptionsProvider`; the settings are the configuration's last source) - **never an options object bound once at start**, or a change in the administration doesn't apply; (2) **a row on the status page**: an `IServiceStatusProvider` (`Mediatracker.Api/Status`, registered in `AddServiceStatus`; an enrichment is listed with the metadata sources as `enrichment:`, also when it is off, or there is no way to switch it on again) with the state (`ok`/`preparing`/`down`), `CheckedAt`, `LastSuccessAt`, `ResponseMilliseconds` and **metrics as details**: at least what was asked since the start, how many failed, how many had a result, the last failure (**its kind, never its message**: it can carry a key or an address) - the numbers come from a small singleton counter that is fed where the requests are made (`IAnimeThemesActivity`), not from the log; (3) **log entries that name the provider** (`{Source}`, a name in `DatabaseLogSink.OtherProviders` if it is no `MetadataSource`), so the status page's problem counters and the log page's provider filter find them; (4) **the frontend's words** for its fields and hints (`SourceSettingsForm`'s `fieldLabel`/`hintOf` take the source because the same key can mean something else), its details (`detailLabel`/`detailValue` in `ServiceStatusGroup`) and the note of an enrichment row; (5) **tests** for the options provider (a change applies at once), the counter, the status provider (on, off, no address, failing) and the registration of the settings. Check this before considering a feature done, the way the performance tests are checked. - **A new feature gets performance tests.** Every new user-facing feature or expensive path comes with them: an action in `UserActions` (`Reading`/`Writing`, see "Load tests with many users") that the load tests run with many users at the same time, and, when it is expensive for one user with a large library, a scenario in `Mediatracker.Performance.Tests` (see "Performance tests") with the volume that makes it hurt. A source, a cache or a background job that changes how often external services are asked gets its limit into the fake source (`FakeSourceLatency`). Run them (`MEDIATRACKER_PERF=1 dotnet test dotnet/tests/Mediatracker.Performance.Tests`) before considering the feature done and look at the report, not only at the pass/fail: the reports are `dotnet/perf-report.md` (summary) and `dotnet/perf-reports/` (NBomber's HTML, markdown and CSV per test), both gitignored. - **One type per file.** No files collecting multiple classes/records/enums, even small ones. File name matches the type name. - **Follow the ReSharper rules in `Mediatracker.sln.DotSettings`.** Before considering C# work done, run: ```bash jb cleanupcode dotnet/Mediatracker.slnx jb inspectcode dotnet/Mediatracker.slnx --severity=WARNING ``` (`jb` is the JetBrains ReSharper CLI tool: `dotnet tool install -g JetBrains.ReSharper.GlobalTools`.) **Don't pass `--profile`** — omitting it runs the full cleanup profile, which is what actually applies the DotSettings (trailing commas, brace/body-style rules, etc.); `--profile="Built-in: Reformat Code"` only re-flows whitespace and silently skips those, which is exactly the kind of gap this workflow exists to catch. Let these tools do the finding — don't hand-audit the diff for things like unused/redundant usings, dead code, or formatting nits; that's exactly what `inspectcode`'s report and `cleanupcode`'s rewrite are for. Read the report, fix what it flags, move on. Fix real findings; a property flagged as unused (`NotAccessedPositionalProperty.*`, `UnusedAutoPropertyAccessor.*`, `UnusedMember.Local`, or similar - the exact rule ID varies by property/record shape) is an accepted false positive when the type is a DTO or plain test data record only ever touched via (de)serialization or record equality (`Assert.Equal`), never a direct property read - and can be ignored. - **Every public member needs a short XML doc comment, in English.** Always the multi-line form, never a single-line `/// text`: ```csharp /// /// What this is/does. /// ``` Keep it terse (what it is/does, not why). Applies to types, methods, properties, constructors, and public DI-registration extension methods alike. `GenerateDocumentationFile` is on, so missing docs on public members show up as `CS1591` build warnings — the build should stay warning-free. - **Avoid primary constructors.** Use one only when the constructor body needs to apply logic to the input (validation, transformation, computing a derived field). Otherwise: - Plain dependency-holding classes (services, providers, controllers, DbContexts, etc.): a regular constructor assigning `private readonly` fields. - Data-holder types (DTOs, records): explicit `{ get; init; }` properties, decorated with `required` where the value has no sensible default, instead of a positional record. - Tests are written TDD-style: test first, then the implementation that makes it pass. Mocking uses **NSubstitute** (not Moq/manual fakes). Anything backed by real infrastructure (currently: the PostgreSQL metadata cache) is integration-tested against a real instance via **Testcontainers**, not an in-memory substitute. - **Develop decoupled: inject interfaces, not concrete types.** Anything a class depends on that has (or could reasonably have) more than one implementation, or that a consumer shouldn't need to know the concrete type of, goes behind an interface (`IFoo foo` in the constructor, not `Foo foo`) — this includes services consumed by controllers, e.g. `MoviesController` depends on `IMovieMetadataService`, never on `MovieMetadataService` directly. - Exception: `DbContext` subclasses are injected concretely (`MetadataCacheDbContext`, not an `IMetadataCacheDbContext`). A `DbContext` already *is* the repository/unit-of-work abstraction; wrapping it in a custom interface duplicates that without adding a real seam (the swappable part is the EF Core provider, not the context type) and is not idiomatic EF Core. This exception is specifically about `DbContext`, not a general license to skip interfaces elsewhere. - **Use descriptive names; abbreviate only where it's genuinely idiomatic C#.** `CancellationToken cancellationToken`, not `ct`. Lambda parameters get a real name describing what they are (`provider => provider.Source == source`, not `p => p.Source == source`), including in EF Core Fluent API config. Accepted abbreviations/short forms (established C#/.NET or testing convention, not just "shorter"): `id`, `args`, loop counters (`i`, `j`), `ex` for a caught exception, the `Dto`/`Id` naming suffixes, and `sut` ("system under test") in test methods. When in doubt, spell it out. - LINQ exception: the outermost/first lambda parameter of a query may be `x` (e.g. `providers.FirstOrDefault(x => x.Source == source)`). Any lambda nested inside that one (a `Select`/`Where`/etc. operating on a property of `x`, or otherwise one level deeper) needs a real descriptive name — `x` is only for the first level. - **Prefer well-maintained existing libraries over hand-rolled equivalents**, especially for testing infrastructure. E.g. fakes for BCL abstractions: use `Microsoft.Extensions.TimeProvider.Testing`'s `FakeTimeProvider` (`Microsoft.Extensions.Time.Testing`), don't write a custom one — check for an official/widely-used package before implementing something the ecosystem already solved. - **Cluster classes into namespaces/folders by feature, and keep the hierarchy flat.** Each project gets one folder per feature (currently `Movies/`, `TvShows/`) holding every type for that feature — DTOs, interfaces, services/providers, controllers — with a matching namespace suffix (`Mediatracker.Metadata.Movies`, `Mediatracker.Api.Controllers.TvShows`, etc.). Cross-cutting things that aren't feature-specific (`MetadataSource`, `MediaSearchResultDto`, `IMetadataCache`, the `Caching/` infra, DI `ServiceCollectionExtensions`) stay at the project's namespace root, not inside a feature folder. Don't nest feature folders further (no `Movies/Details/…`) — one flat level per feature is the target; split into a new top-level feature folder instead of adding depth. - This applies across every project, including tests: a test file lives in the same feature folder as the production type it tests (`tests/.../Movies/MovieMetadataServiceTests.cs`), and shared-infra tests (e.g. the cache) stay at the test project root. - **Group by feature, not by kind, and split a folder before it grows past ~25 files.** A folder name becomes a namespace, so it must not collide with a type or namespace used in that project (`WatchProviders`, `Stream` and `Migration` were rejected for that reason). - Note for navigating the code: a file-scoped `namespace A.B.C;` can see types in `A.B` and `A` unqualified (no `using` needed) — C# treats dotted namespace segments as nested. It can **not** see sibling namespaces like `A.B.D` or `A.X.C` without an explicit `using`. Keep that in mind before adding a `using` that turns out to be redundant (or missing one that a sibling namespace needs). - **Use AutoFixture for test data**, not hand-filled object initializers, when the exact value doesn't matter to what the test is asserting. `private readonly IFixture _fixture = new Fixture();` per test class; `_fixture.Create()` / `_fixture.CreateMany()` for anonymous values, `_fixture.Build().With(...)` when only a couple of properties need a specific value. Keep the resulting variable (`var id = _fixture.Create();`) and reuse it everywhere that value needs to line up (e.g. the cache key built from it and the argument passed to the method under test), rather than re-generating it and hoping it doesn't matter. - `DateOnly` needs the `DateOnlyCustomization` in `Mediatracker.Metadata.Tests` (`new Fixture().Customize(new DateOnlyCustomization())`) — AutoFixture doesn't generate it out of the box and blows up trying naive constructor injection. Add a customization the same way for any other type that turns out to need one, instead of hand-rolling that one property. - Exception: mapping/parsing tests (e.g. the `Tvdb*MetadataProvider`/`TvdbMappers` tests) keep literal values for anything the test is specifically constrained on - a date string that must be parseable, a status name that must match a known enum mapping, TVDB's `"movie"`/`"series"` search-type parameter. Fixture-generate the free-form fields (titles, ids, overviews, URLs) on those same objects so unrelated fields can't coincidentally share a value and mask a mapping bug, but don't fight the constraint just to force AutoFixture in everywhere. - **Blank line between a block of variable declarations and the method call(s)/statement(s) that follow**, e.g.: ```csharp var foo = "bar"; var bar = "foo"; fooMethod(); return foo + bar; ``` A statement still counts as a "declaration" for this even if its initializer is itself a call (`var sut = CreateSut();` groups with other declarations, it's not a "call statement") - this is about the statement's own kind (declaration vs. expression-statement vs. control-transfer), not what's on the right-hand side. There's no DotSettings rule that auto-applies this (it needs semantic grouping `cleanupcode` can't infer), so it needs to be written that way by hand, not left to the cleanup pass. - In test methods: use `// Arrange` / `// Act` / `// Assert` comments instead of applying this rule statement-by-statement inside Arrange. Arrange commonly mixes AutoFixture declarations with NSubstitute `.Returns(...)` setup calls - the comment marks that whole block instead of splitting it up further with blank lines. Only add the comments when a method actually has multiple statements worth separating; a one-line test body doesn't need them. - **`CancellationToken` parameters are always optional**: `CancellationToken cancellationToken = default`, everywhere the parameter appears - interfaces, implementations, controller actions, the lot. Interface and implementation must both declare the default (C# resolves default values at the call site's static type, so a mismatch is a footgun, not just a style nit). This is purely additive for existing callers (an explicit argument always overrides the default), so it never requires touching call sites - only the declarations. - **Wrap a third-party client in `Lazy` if its constructor can throw** (validates an API key, opens a connection, etc.) before registering it in DI. `IMovieMetadataProvider`/`ITvShowMetadataProvider` are injected everywhere as `IEnumerable`, which means resolving that collection constructs *every* registered provider's full dependency chain - a misconfigured/missing key for provider B must not break requests that only need provider A. Concretely: register `Lazy` (the factory that validates config and throws lives inside the `Lazy`), inject `Lazy` into the adapter, and only call `.Value` inside the methods that actually hit the network - never in a constructor. This is exactly what went wrong (and was fixed) with `TmdbClientAdapter`/`TMDbClient`: TVDB doesn't need this because its auth happens lazily per-HTTP-call inside the `TvdbClient` package already; TMDbLib's `TMDbClient` validates synchronously in its own constructor, so without the `Lazy` wrapper a missing `TmdbConfiguration:ApiKey` broke *every* movie/TV request, including ones routed to `tvdb` or an invalid source. When adding a new provider, check whether its underlying client validates eagerly and wrap it if so - and add a test like `ServiceCollectionExtensionsTests.AddXMetadataProvider_does_not_construct_the_client_eagerly` that resolves `IEnumerable`/`IEnumerable` with no config present and asserts it doesn't throw. - **Metadata is language-aware; every provider must honor `language`.** The frontend sends its UI language as `Accept-Language`; `IRequestLanguageAccessor` (`Mediatracker.Api/Localization`) negotiates it against `MetadataLanguages.Supported` (ISO 639-1 codes, default `en`) and the controllers pass the result as the `language` argument through `IMovie/ITvShowMetadataService` into the providers. The metadata cache key includes the language (`movie:{source}:{language}:{id}`), so a new cached lookup must too, or one language's text gets served to another. A new provider maps the ISO 639-1 code to whatever its source expects (TMDB takes it as-is; TVDB needs ISO 639-3 via `TvdbMappers.ToTvdbLanguage`) and falls back to the source's default text when a translation is missing. Adding a language = an entry in `MetadataLanguages.Supported` plus the TVDB code mapping, alongside the frontend's `supportedLanguages`. Known gap: TVDB episode titles/overviews are not translated (the generated client's language-specific episode listing doesn't expose the episodes). - **The mock backend is how the app is run and checked without outside services**: a new feature that reaches outside (PDS, Tap, a metadata source, another service) gets its fake in `Mediatracker.Testing` and its registration in the mock hosts (`MockServices`, `StudioMockServices`), next to the performance tests' action - one fake serves both. A feature that can't be seen in the mock isn't done. ## frontend/ coding guidelines - **Develop and check the frontend against the mock backend** (see "Mock backend"): it needs no account, key or PDS, and its `edge` profile has the titles that break layouts (very long names with and without spaces, RTL, CJK, emoji, markup, missing fields, odd seasons and episodes, titles the source fails for). Start the mocked stacks and the frontend with `MEDIATRACKER_API_PROXY`/`MEDIATRACKER_DEV_PORT` (`STUDIO_*` for the Studio), sign in with any handle, and look at what you changed in the browser - on a phone width too - instead of asking the user to. A new or changed page or component is checked with those titles (text that overflows its box, a page that gets wider than the screen, a failing poster, empty fields); a long word without spaces needs `wrap-anywhere`, a poster an image with a fallback (`PosterImage`). - **A new feature extends the mocks.** Whatever a new user-facing feature needs from the outside (a new endpoint's data, a capability, a failure mode, a kind of title) goes into the fakes in `dotnet/src/Mediatracker.Testing` and the mock hosts (`EdgeCaseTitles` for odd data, `SyntheticCatalogue.Faults` for failing sources, the fake providers for new capabilities, `MockWorld`/`StudioMockServices` for everything else), so the feature can be seen and tested in the mock without a real service (the Studio's mock the same way). - **Storybook (pilot)** shows single components in all their states without a backend: `npm run storybook` in `frontend/` (`:6006`; Claude's preview: `.claude/launch.json` `storybook`). Config in `apps/mediatracker/.storybook`, stories are `*.stories.tsx` next to the component (apps and `packages/ui`); `preview.tsx` provides React Query, router, i18n and a theme/language toolbar, and a fake backend (`parameters.api` of a story says what it answers, see the skill `mediatracker-frontend-conventions`). `storybook/edgeTitles.ts` holds the odd titles (`posterOf`, `brokenPoster`): **a new component with states worth looking at gets a story, run through the odd titles.** - **Mobile-first, always.** This ships as an Android app via Capacitor eventually, and is used on phones day-to-day - design for a narrow viewport first, then progressively enhance for wider ones. Concretely with Tailwind: unprefixed (base) classes are the mobile layout; use `sm:`/`md:`/`lg:` to layer on changes for wider viewports, never the other way round (no building a desktop layout and bolting on `max-*:` overrides to shrink it down). E.g. a control group stacks full-width (`flex flex-col gap-3`) by default and only becomes a horizontal row from `sm:` up (`sm:flex-row sm:items-center`); a results grid starts at its smallest column count (`grid-cols-2`) and adds columns going up (`sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6`), never the reverse. Also keep touch targets comfortably tappable at the base (mobile) size - e.g. a bit more vertical padding on buttons/inputs by default, trimmed down again at `sm:` where a mouse pointer takes over (`py-2.5 sm:py-2`). - **No hardcoded user-facing text; everything goes through i18n.** The frontend uses `react-i18next` (setup in `src/i18n/`). Every visible string, `aria-label`, `title` and `placeholder` is `t('feature.key')` with the key defined in **both** `src/i18n/locales/en.json` and `de.json` - `en.json` is the source of truth for the typed keys (`i18next.d.ts`), and `de` is checked against it with `satisfies`, so a missing key fails `tsc`. Group keys by feature/page, use interpolation (`{{name}}`) instead of string concatenation, `` when a sentence contains a link/markup, and format dates via `utils/formatDate.ts` (uses `Intl` with the active language) instead of printing raw API values. Brand names (TMDB, TVDB) and example placeholders (URLs, handles) stay literal. Adding a language = new `locales/xx.json` + an entry in `supportedLanguages` (`src/i18n/index.ts`). - **Regional format (date, time, week) is its own setting, separate from the language of the texts.** `regional/regionalSettings.ts` (in `localStorage`, `useRegional()` hook over `useSyncExternalStore`) holds `region` (a locale such as `en-GB`), `timeFormat` (12h/24h), `firstDayOfWeek` and the `watchRegion` of "where to watch"; "auto" for each means **the client's system default**: `navigator.language` for the locale (so English texts on a German system still show German dates), that locale's own hour cycle and week start (`Intl.Locale.getWeekInfo`). They are edited in Settings (`DateTimeSettingsFields`, `WatchRegionSettingsField`). Everything that writes a date, time or weekday takes `locale` (and `hourCycle` for times of day) from `useRegional()` instead of `i18n.language`, and the calendar's `firstDay` too; a new place that formats dates has to do the same. Relative days ("tomorrow") and numbers stay in the language of the texts. - **Whatever sticks while the content scrolls gets the round button at the bottom right that takes the reader back up, and the shared components make that automatic.** `components/stickyOffsets.ts` holds where things stick (`STICKY_HEADING_TOP`, `STICKY_TOOLBAR_TOP`: under the top bar's fixed 57/53 px - change them together with it; use them instead of writing `top-[57px]`); **`StickyPageHeader`** is the header of a page that sticks (the log) and renders `BackToTopButton` with it, so a page can't have the one without the other; `Collapsible` sticks its heading and toolbar with the same offsets and has its own button (`FloatingJumpButton`, back to the start of its content); a table or list that scrolls inside a box of its own and has a sticky heading (`LibraryTable`) puts `` as the last child of that box (placement `area`). A new sticky header or toolbar = one of these, never a bare `sticky`. (`WatchHistoryDialog`'s heading doesn't stick in fact - its table sits in a box that only scrolls sideways.) - **Every action that deletes something asks first** (`ConfirmDialog`, red for what can't be got back): deleting entries, watches, lists, the account's data, admins, the log and caches, turning a calendar link off, resetting settings, and leaving a feed (`useConfirmedUnsubscribe`). Toggles that are undone with one click (tracking, a list's checkbox, a rating) and the discarding of an upload that was never started don't. A new delete action gets the dialog. - **Large numbers are written with the thousands separator of the region** (`utils/formatNumber`: `formatNumber(value, locale, digits)`, the component `FormattedNumber`; `formatBytes` and the cache sizes use it; a narrow space was tried and looked wrong). In the texts only the `count` of a text gets it (`i18n/countFormatter`, an i18next formatter module with `alwaysFormat`; years, ids and seasons that are passed under other names stay plain), so `{{count}} entries` reads `12,345 entries` (English) or `12.345 Einträge` (German) everywhere. A new place that shows a count outside a text uses `formatNumber`, not `toLocaleString`. - **Everything loaded via the API shows a skeleton while loading and a toast when loading fails.** While a query is loading, render a placeholder shaped like the final content (`components/Skeleton.tsx` for the blocks, `MediaGridSkeleton`/`HorizontalSectionSkeleton`/`DetailPageSkeleton` for the recurring layouts) inside a `LoadingRegion`, which announces "loading" to screen readers - never a bare "Loading..." text. Use `isLoading`, not `isFetching`, so a background refresh doesn't flash the skeleton over data that is already there. Failures are reported centrally instead of per page: give the `useQuery` a `meta: { loadError: '' }` (targets are the `LoadTarget` union in `errors/loadError.ts`, with a sentence per target under `loadError.target` in the locales) and the `QueryCache` in `App.tsx` shows a toast with a retry button, whose text names what failed and why (unreachable/offline/server/...) in plain words rather than the raw error message. A query without `meta.loadError` fails silently, so don't leave it out. The movie/TV show detail pages are the exception, and the model for anything about a media item: **they are always shown in full and every item stays editable**, whatever the source says. If the details are missing (the source has no such id any more - TMDB really does drop ids - or asking it failed) the page keeps its structure with placeholders (dashes, an icon for the poster), a `DetailUnavailableNotice` (why, with "try again" for a failure; no toast on top), the title from the user's own records (tracked entry, watches) or `SOURCE id`, and tracking (in lists the same items carry `hasMetadata: false` from the library endpoint, shown as an amber warning badge on the poster and an icon before the title in the table), rating and the watch history keep working; only what needs the details (episodes, "mark several as watched") is disabled with the reason. The placeholder skeleton is for the first load only (`errorUpdateCount === 0`), so trying again doesn't swap the page for it. **One wording for every failed request** (`errors/failureReason`): `failureReasonOf(error)` sorts a failure into `offline`, `unreachable`, `rateLimited` (429), `sourceUnavailable` (502 with the problem's `source`: a metadata source that doesn't answer, which is not the backend's own problem), `server`, `notFound` or `unknown`, and `failureReasonText`/`failureReasonClause` (without the closing full stop, `failureReasonClauseOf` for an error that may be absent) say it in the user's words (`loadError.reason.*`, the source named by its display name - `ApiError.source` comes from the problem details, `useSources` registers the names; a 429 that names a source says that source limits the requests, one that doesn't is the app's own limit, e.g. the PDS). **Toasts of failed loads (`reportLoadError`), the notice on a detail page that couldn't load (`DetailUnavailableNotice`: what couldn't be loaded, why, that tracking still works), and every message of a failed mutation (`{{message}}` in settings, the mark-range and history dialogs, ratings, lists, the migration, the delete toast, ...) use it - never the raw `error.message` of a response**; a new place that shows a failure does the same, so a rate limit reads the same on every page.