mediatracker CLAUDE.md
33 kB · 112 lines
Markdown
at main

Mediatracker #

A self-hosted media tracker (movies/TV shows) modeled after 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 "<Section>:ApiKey" "<key>" 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/<name>/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:<name>, 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:

    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 /// <summary>text</summary>:

    /// <summary>
    /// What this is/does.
    /// </summary>
    

    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<T>() / _fixture.CreateMany<T>() for anonymous values, _fixture.Build<T>().With(...) when only a couple of properties need a specific value. Keep the resulting variable (var id = _fixture.Create<string>();) 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.:

    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<T> 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<T>, 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<TExternalClient> (the factory that validates config and throws lives inside the Lazy), inject Lazy<TExternalClient> 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<IMovieMetadataProvider>/IEnumerable<ITvShowMetadataProvider> 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, <Trans> 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 <BackToTopButton scrollRef={...} /> 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: '<target>' } (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.