diff --git a/CHANGELOG.md b/CHANGELOG.md index df3510b..a909b77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +- Cleared current high-severity production and development dependency advisories while preserving the immutable beta.1 release artifacts. +- Kept session authentication read-only under SQLite writer contention, moved expired-session cleanup to session creation, and rejected unsafe port or sync-interval configuration before startup. +- Added bounded, redacted client error handling plus a recoverable server-bootstrap state that preserves confirmed sessions during transient background refresh failures. +- Reconciled compatibility and release documentation with the narrower beta.1 publication decision and made issue #32 the authoritative evidence and follow-up ledger. + ## 0.1.0-beta.1 - Advances Moodarr's target from public alpha to an early public beta for external self-hosters, with the Linux `amd64` web/server container as the supported product surface and the iOS client explicitly experimental. diff --git a/SUPPORT.md b/SUPPORT.md index 4174eda..1529afb 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -2,6 +2,8 @@ Moodarr is maintained as open-source self-hosted software. Published public betas are intended for real external use, but support is best effort and does not include a response-time or resolution-time guarantee. The GitHub Releases page is authoritative for which beta, if any, is currently supported. +Support policy and release evidence are separate. `v0.1.0-beta.1` was published from `08447e87df2e1705aa9a79193a52a65fb00724c3` under a narrower early-beta gate; [issue #32](https://github.com/jremick/moodarr/issues/32) remains the authoritative ledger for its open Unraid/update, stopped catalog, real integration-write, native responsiveness, current-browser, and privacy-reviewed manual-evidence work. A supported path is eligible for support and bug reports; it does not mean beta.1 completed every planned compatibility matrix. + ## Where To Ask - Search [existing issues](https://github.com/jremick/moodarr/issues) before opening a new report. diff --git a/docs/BETA_CANDIDATE_MANUAL_VALIDATION.md b/docs/BETA_CANDIDATE_MANUAL_VALIDATION.md index 31e8491..a108251 100644 --- a/docs/BETA_CANDIDATE_MANUAL_VALIDATION.md +++ b/docs/BETA_CANDIDATE_MANUAL_VALIDATION.md @@ -1,8 +1,10 @@ # Beta Candidate Manual Validation -This runbook defines the fail-closed manual evidence required for a Moodarr web/server beta candidate. It covers the release gates that cannot be established by fixture mode or a source build alone: an exact-digest Unraid installation, the exact beta catalog and its stopped networkless import and request-attempt boundary, real Plex and Seerr/Jellyseerr behavior, native Linux responsiveness, and the supported desktop browser and accessibility matrix. +This runbook preserves the original comprehensive manual gate designed for `v0.1.0-beta.1`. It covers evidence that cannot be established by fixture mode or a source build alone: an exact-digest Unraid installation, the exact beta catalog and its stopped networkless import and request-attempt boundary, real Plex and Seerr/Jellyseerr behavior, native Linux responsiveness, and the supported desktop browser and accessibility matrix. -The machine-readable contract in [`scripts/validate-beta-manual-evidence.ts`](../scripts/validate-beta-manual-evidence.ts) is authoritative for evidence shape and acceptance. Start from the structurally valid [`beta-manual-evidence-all-false.example.json`](beta-manual-evidence-all-false.example.json). The example is intentionally failing evidence, not a completed release artifact. Never change a `false` value to `true` until the exact candidate has passed that check and any required cleanup. +Beta.1 was published from source commit `08447e87df2e1705aa9a79193a52a65fb00724c3` under an intentionally narrower early-beta gate. [GitHub issue #32](https://github.com/jremick/moodarr/issues/32) is the authoritative record of actual evidence and follow-up. The comprehensive matrix was not completed for beta.1; its Unraid/update, stopped catalog, real integration writes, native responsiveness, current-browser, and privacy-reviewed artifact rows remain open. This runbook must not be read as evidence that they passed. + +The machine-readable contract in [`scripts/validate-beta-manual-evidence.ts`](../scripts/validate-beta-manual-evidence.ts) is authoritative for this beta.1-bound evidence shape and acceptance. Start from the structurally valid [`beta-manual-evidence-all-false.example.json`](beta-manual-evidence-all-false.example.json). The example is intentionally failing evidence, not a completed release artifact. Never change a `false` value to `true` until the exact candidate has passed that check and any required cleanup. The validator and example must be version-generalized before a beta.2 candidate; do not reuse them as beta.2 evidence unchanged. This evidence supplements the automated candidate workflow and the procedures in [Release](RELEASE.md). It does not replace clean-install, upgrade/rollback, supply-chain, vulnerability, or attestation evidence. Fixture, local-image, emulated-architecture, source, and EXP runs cannot close this manual gate. @@ -332,7 +334,7 @@ Do not convert a stop into a waiver inside the JSON. The manual validator's exit ## Acceptance Checklist -A candidate passes this manual gate only when all of the following are true: +Under this original comprehensive contract, a candidate passes the manual gate only when all of the following are true: - candidate and Unraid version, full revision, and immutable digest are identical; - catalog version, asset SHA-256, total records, and request-attempt eligible records match the pinned contract, and `exactAsset`, `networklessFullSnapshotImport`, `genericSearchIsolation`, and `requestAttemptDisclosure` are all `true`; @@ -352,4 +354,4 @@ A candidate passes this manual gate only when all of the following are true: --responsiveness-report "$responsiveness_report" ``` -Link the frozen summary and its reviewed evidence artifact from the candidate release ledger. This closes only the manual validation gate; final promotion still requires every other beta criterion and maintainer approval. +For a future candidate using this gate, link the frozen summary and its reviewed evidence artifact from that candidate's ledger. It closes only the manual validation gate; it does not retroactively qualify beta.1 or replace the actual beta.1 ledger in issue #32. diff --git a/docs/BETA_RELEASE_CRITERIA.md b/docs/BETA_RELEASE_CRITERIA.md index e71279c..d1b741b 100644 --- a/docs/BETA_RELEASE_CRITERIA.md +++ b/docs/BETA_RELEASE_CRITERIA.md @@ -1,6 +1,21 @@ # Public Beta Release Criteria -This is the release gate for `v0.1.0-beta.1`. It is a planning and evidence document; its presence does not mean the beta gate has passed. +This document preserves the original comprehensive gate designed for `v0.1.0-beta.1`. It is a planning and future-hardening reference, not a claim that every row was completed. + +## Published Beta.1 Status + +`v0.1.0-beta.1` was published from source commit `08447e87df2e1705aa9a79193a52a65fb00724c3` under an intentionally narrower early-beta gate. The authoritative record of actual evidence and follow-up is [GitHub issue #32](https://github.com/jremick/moodarr/issues/32). The original comprehensive gate below did not pass as a whole and must not be read as passed. + +Six broad hardening rows remain open: + +- extra fresh Unraid install/update evidence; +- a stopped networkless catalog evidence package; +- a dedicated real Plex and Seerr/Jellyseerr write matrix; +- production native `linux/amd64` 2 CPU/2 GiB responsiveness evidence; +- a current Chrome/Edge/Firefox/Safari matrix; and +- a comprehensive privacy-reviewed manual artifact. + +Do not backfill this document to make those rows appear completed. Beta.1 release history is immutable; close or refine follow-up in issue #32 and apply the comprehensive gate to future hardening or a later candidate. The target is **Stage 3 - Public Beta**: external self-hosters can install, operate, upgrade, and report problems with clear expectations. It is not the stable `v1.0.0` contract. Stable API, longer deprecation, wider platform, and mature native-client commitments remain later work. @@ -20,9 +35,9 @@ Beta.1 includes: The beta compatibility surfaces are defined in [Compatibility](COMPATIBILITY.md). Except for the documented health semantics, the HTTP API is internal and does not become a stable third-party API in beta. -## Release Blockers +## Original Comprehensive Release Gate -Every row must pass unless this document explicitly permits a pre-candidate `Exception approved` decision. Applicable `Candidate validation`, `Pre-promotion`, and `Post-promotion` rows are non-waivable; a conditional row may be `Not applicable` only where its own criterion permits that status and the ledger records the rationale. +Under the original plan, every row had to pass unless this document explicitly permitted a pre-candidate `Exception approved` decision. Applicable `Candidate validation`, `Pre-promotion`, and `Post-promotion` rows were non-waivable; a conditional row could be `Not applicable` only where its own criterion permitted that status and the ledger recorded the rationale. These rules remain the future-hardening target, not a description of the narrower gate used to publish beta.1. | Gate | Required evidence | | --- | --- | @@ -36,7 +51,7 @@ Every row must pass unless this document explicitly permits a pre-candidate `Exc | Core integration behavior | Exact Plex and Seerr/Jellyseerr versions are recorded. Sync, Plex authentication, user capability defaults, Watchlist action, request preview, one controlled confirmed request, idempotent retry, and uncertain-outcome handling pass. | | Catalog bootstrap and request-attempt boundary | The separate `wikidata-20260622-min5-v1` asset passes its tracked whole-file manifest at SHA-256 `dd25ba6602e1bdb8e6999b0442bc40165e6d4faadd02e91e74e1a24e2b55e85a`: 90,397 unique importable records and 82,865 ambiguity-safe request-attempt eligible records, split into 70,841 movies and 12,024 TV series. Its 36 groups share a strong importer identifier across 72 imported/indexed source records, including 59 otherwise eligible records—10 movies and 49 TV series. Their ambiguous catalog materializations remain available only for provenance and diagnostics and cannot independently surface in Finder or authorize preview or creation. An independently identified available Plex item may remain Finder-visible if linked later, but catalog ambiguity still blocks every request action. The exact candidate's packaged importer completes a stopped, `--network none`, full-snapshot import with `--expected-source-records 90397 --expected-file-sha256 dd25ba6602e1bdb8e6999b0442bc40165e6d4faadd02e91e74e1a24e2b55e85a`, reports the matching post-pass file hash, commits atomically, and survives restart. Generic search and verified-requestable-only filters exclude attempt rows; explicit request-attempt intent may surface unambiguous eligible rows only as `unavailable` with Seerr availability not checked. Plex-only operation passes without the asset. | | AI-off baseline | All primary search and request flows work without an OpenAI credential. The official image's compiled policy and OCI label both equal `none`; hostile `AI_PROVIDER=openai` and key environment values, retained provider settings and keys in `/data/config.json`, authenticated Admin updates, worker restarts, scheduler restarts, and embedding-warmup requests cannot enable a provider or cause an OpenAI request. Search with `useAi: true` still reports `usedAi: false`. The compiled server artifact contains no OpenAI endpoint string. | -| Responsiveness | On the documented two-CPU/2-GiB container budget and a recorded production-sized catalog, the beta.1 run uses `--ai-mode none` against the provider-locked official image while a full Plex plus Seerr operational-state sync, continuous health probes, deterministic search, and fresh diagnostics run concurrently. Health p99 stays at or below 250 ms overall and during diagnostics, deterministic search p95 stays at or below 5 seconds, and the full source-specific sync completes without catalog loss. There are no search 5xx, `SQLITE_BUSY` failures, health-check failures, restarts, or OOM events. Provider-embedding work is not part of the beta.1 candidate gate. | +| Responsiveness | On the documented two-CPU/2-GiB container budget and a recorded production-sized catalog, a qualifying beta.1-bound run would use `--ai-mode none` against the provider-locked official image while a full Plex plus Seerr operational-state sync, continuous health probes, deterministic search, and fresh diagnostics run concurrently. Health p99 would stay at or below 250 ms overall and during diagnostics, deterministic search p95 would stay at or below 5 seconds, and the full source-specific sync would complete without catalog loss. There would be no search 5xx, `SQLITE_BUSY` failures, health-check failures, restarts, or OOM events. Provider-embedding work is not part of the beta.1 candidate gate. | | Browser and accessibility smoke | The exact current-stable Chrome, Edge, Firefox, and macOS Safari versions in the supported matrix complete sign-in, search, result actions, request confirmation, Admin access, keyboard navigation, visible-focus, responsive mobile-width, and reduced-motion checks without console errors. | | Security boundary | Admin auto-session is off by default in release packaging. Authentication, authorization, CSRF, SSRF/redirect, input-bound, session invalidation, secret-redaction, upstream response-field allowlisting, and request-confirmation tests pass. A tracked/generated secret scan passes. | | Supply chain | The lockfile audit and pre-publish built-image scan have no untriaged fixable high/critical finding. Actions and base images are immutable where supported, and workflow permissions are least-privilege. Candidate publication first requires the semantic Git tag to be absent, then fails closed unless anonymous raw-manifest reads by the full-SHA tag and emitted digest return the same OCI index with the registry-reported and recomputed digests both equal to the emitted digest, declare the expected media type, have exact bytes, and finish with the semantic GHCR version tag absent. Candidate validation then independently proves that the actual published candidate is anonymously pullable by exact digest before authenticated inspection and passes raw-manifest digest recomputation, image-label/platform checks, GitHub attestation policy, attached BuildKit SLSA provenance, non-empty SPDX 2.3 SBOM validation, and an exact-digest Trivy scan with no unsuppressed fixable high/critical finding. | @@ -45,7 +60,7 @@ Every row must pass unless this document explicitly permits a pre-candidate `Exc | Data safety | Fresh, upgrade, interrupted-start, and restart tests preserve `/data`. Backup/restore instructions are followed successfully. No migration or sync failure silently marks incomplete data unavailable. | | Public contract | [Support](../SUPPORT.md), [Security](../SECURITY.md), [Compatibility](COMPATIBILITY.md), [Catalog Bootstrap](CATALOG_BOOTSTRAP.md), [Upgrading](UPGRADING.md), [Backup And Recovery](BACKUP_AND_RECOVERY.md), [Data And Privacy](DATA_AND_PRIVACY.md), [Contributing](../CONTRIBUTING.md), [Changelog](../CHANGELOG.md), [Release](RELEASE.md), and the [manual candidate runbook](BETA_CANDIDATE_MANUAL_VALIDATION.md) are current and internally consistent. | -Run the responsiveness row with the candidate-only `npm run bench:beta-responsiveness` harness documented in [Release](RELEASE.md). The passing beta.1 artifact uses `--ai-mode none` against the provider-locked candidate; it must identify the exact digest and commit, bind the harness to a clean checkout of that commit with its source hash, prove the two-CPU/2-GiB envelope and isolated disposable volume, exercise a full Plex and Seerr operational-state sync with concurrent health probes, deterministic search, and fresh diagnostics, continuously observe Docker health, meet the applicable latency and sample-count thresholds, prove no total-item loss plus a production-sized active catalog-source baseline whose count and identity/mapping fingerprint are preserved exactly while reconciling operational source counts within their documented tolerance, and contain no raw credentials, URLs, queries, titles, responses, logs, paths, or host/container identifiers. It neither requires nor accepts `--confirm-external-processing`. OpenAI-mode harness support is retained for source/EXP and future-release analysis, but it cannot be beta.1 candidate evidence. A rehearsal against a local tag or ancestor commit does not close the candidate-validation row. +Run the responsiveness row with the candidate-only `npm run bench:beta-responsiveness` harness documented in [Release](RELEASE.md). A passing beta.1-bound artifact would use `--ai-mode none` against the provider-locked candidate; it must identify the exact digest and commit, bind the harness to a clean checkout of that commit with its source hash, prove the two-CPU/2-GiB envelope and isolated disposable volume, exercise a full Plex and Seerr operational-state sync with concurrent health probes, deterministic search, and fresh diagnostics, continuously observe Docker health, meet the applicable latency and sample-count thresholds, prove no total-item loss plus a production-sized active catalog-source baseline whose count and identity/mapping fingerprint are preserved exactly while reconciling operational source counts within their documented tolerance, and contain no raw credentials, URLs, queries, titles, responses, logs, paths, or host/container identifiers. It neither requires nor accepts `--confirm-external-processing`. OpenAI-mode harness support is retained for source/EXP and future-release analysis, but it cannot be beta.1 candidate evidence. A rehearsal against a local tag or ancestor commit does not close the candidate-validation row. Run the clean Docker/Compose mechanics with `npm run validate:beta-install` and the alpha.21 migration/cold-rollback mechanics with `npm run validate:beta-upgrade`, as documented in [Release](RELEASE.md). Passing artifacts must bind the committed harness inputs and exact candidate identity, run natively on Linux `amd64`, prove the expected runtime hardening and fresh owned resources, preserve canonical catalog relationships through restart or restore, pass SQLite integrity and foreign-key checks, clean up only owned resources, and contain only allowlisted aggregate evidence. The upgrade must additionally prove pre-refresh quarantine; run a production-adapter, Plex-only full sync that restores the exact trusted Plex result while leaving the catalog marker pending; invoke the importer packaged in that candidate image rather than mutate trusted descriptions directly; clear every refresh-required marker; restore an exact requestable catalog result through restart; and preserve the pristine alpha rollback state. OCI labels alone are not sufficient source binding: before any official candidate job runs, the expected revision must be proven reachable from the current `origin/main` and the candidate digest must pass the documented GitHub attestation policy for the exact repository, publish workflow, expected source/signer digest, `refs/heads/main`, and hosted runner. The separate `beta-supply-chain-` artifact must first prove that the exact OCI index is anonymously pullable without a GitHub credential, then prove its published digest, BuildKit provenance, SPDX SBOM, and exact-digest vulnerability policy. Protocol-stub installation evidence does not replace the real Plex/Seerr compatibility matrix. The upgrade artifact must pass every applicable check emitted by the checked-in validator; [Release](RELEASE.md) names the release-critical groups but is not a duplicate exhaustive allowlist. A smaller functional rehearsal cannot close the upgrade or rollback rows. @@ -79,9 +94,9 @@ These do not block the web/server beta unless a change regresses an already docu The experimental iOS client remains visible but must be labeled non-blocking and outside the beta support contract. Deferred native implementation and UI work is not part of the beta.1 candidate; only server-side API compatibility needed by the existing alpha client remains in scope. -## Release Evidence Ledger +## Original Planned Evidence Ledger -Create one ledger per release candidate in the release PR or release issue. Link durable CI runs, artifacts, logs, screenshots, benchmark summaries, and restore records rather than pasting secrets or private data. After the candidate is published, update that ledger without committing changes to the frozen candidate source; a source edit would require a new SHA candidate. +This template records the original comprehensive plan and is not beta.1's actual completion ledger. For beta.1, use [issue #32](https://github.com/jremick/moodarr/issues/32). For a future candidate, create one ledger in its release PR or release issue and link durable CI runs, artifacts, logs, screenshots, benchmark summaries, and restore records rather than pasting secrets or private data. After a candidate is published, update its external ledger without changing the frozen source. | Candidate metadata | Value | | --- | --- | @@ -133,7 +148,7 @@ Allowed statuses are `Pending`, `Passed`, `Failed`, `Not applicable`, and `Excep Every `Pre-candidate` row must be `Passed` or explicitly eligible for `Exception approved` before the full-SHA candidate workflow is authorized. Every applicable `Candidate validation`, `Pre-promotion`, and `Post-promotion` row must be `Passed`, not exception-approved. Candidate validation and pre-promotion must pass before the `beta-release` environment is approved; post-promotion must pass before the GitHub prerelease is published or announced. A draft may exist only long enough to stage and read back its immutable-release inputs. -## Promotion Decision +## Original Comprehensive Promotion Plan Promotion has four explicit decisions so the source commit does not need to contain evidence that can exist only after candidate publication: diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index 460bf03..0bf573d 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -4,14 +4,16 @@ This document defines the compatibility contract for published Moodarr `v0.1.0-b `Supported` means release-blocking defects can be reported against that configuration. `Best effort` means it may work but is not part of the release gate. `Experimental` means behavior and compatibility may change without a migration promise. +Compatibility is a support policy, not completed beta.1 evidence. `v0.1.0-beta.1` was published from `08447e87df2e1705aa9a79193a52a65fb00724c3` under a narrower early-beta gate. Its extra fresh Unraid/update, dedicated real Plex and Seerr/Jellyseerr write, and current Chrome/Edge/Firefox/Safari matrices remain open in the [authoritative beta.1 ledger](https://github.com/jremick/moodarr/issues/32). Supported configurations are appropriate for issue reports; this page does not claim every matrix was completed before beta.1 publication. + ## Deployment Matrix | Surface | Beta status | Contract | | --- | --- | --- | | Official container image | Supported | Linux `amd64` image published from the tagged commit. Other architectures are not published for beta. | -| Docker Engine | Supported | A current stable Docker Engine on Linux `amd64`, using the documented single-container configuration. The exact version used for each release is recorded in its release evidence. | -| Docker Compose | Supported | Docker Compose v2 with `docker-compose.example.yml`. Compose v1 is not supported. | -| Unraid | Supported | Unraid Docker Manager using the checked-in template. The exact Unraid version used for release validation is recorded with the release. | +| Docker Engine | Supported | A current stable Docker Engine on Linux `amd64`, using the documented single-container configuration. Report the exact Engine version with issues; release-specific install evidence belongs in that release's ledger. | +| Docker Compose | Supported | Docker Compose v2 with `docker-compose.example.yml`. Compose v1 is not supported. Report the exact Compose version with issues; release-specific install evidence belongs in the ledger. | +| Unraid | Supported | Unraid Docker Manager using the checked-in template. Report the exact Unraid version with issues; release-specific validation evidence belongs in that release's ledger. | | Source development | Supported for contributors | Node.js 24 or newer and `npm ci`, as declared by `package.json`. Native source deployment is not a beta production target. | | macOS or Windows host deployment | Best effort | Development and Docker Desktop may work, but the released server target is Linux `amd64`. Windows containers are not supported. | | Moodarr iOS app | Experimental | Native-client work does not block the web/server beta and is not included in the beta compatibility promise. | @@ -27,14 +29,14 @@ The beta web app supports the current stable desktop releases of: - Firefox; and - Safari on macOS. -The release candidate must complete its browser and accessibility smoke matrix against the exact recorded current-stable versions. Immediately previous major releases are best effort rather than release-blocking. Browsers on iOS and other mobile platforms, embedded webviews, and older desktop releases are also best effort for beta. The native Moodarr iOS app remains experimental under the deployment matrix above and does not expand the web compatibility promise. +This is the support target for current releases, not a claim that beta.1 completed the full matrix. Future candidates should record the exact current-stable versions used for browser and accessibility smoke testing. Immediately previous major releases are best effort rather than release-blocking. Browsers on iOS and other mobile platforms, embedded webviews, and older desktop releases are also best effort for beta. The native Moodarr iOS app remains experimental under the deployment matrix above and does not expand the web compatibility promise. ## Integration Matrix | Integration | Beta status | Contract | | --- | --- | --- | -| Plex Media Server | Supported | Library sync, Plex sign-in, poster proxying, Plex links, and signed-in-user Watchlist actions are tested against the current stable Plex release used by the release candidate. Record that exact version. | -| Seerr or Jellyseerr | Supported | Operational request-state sync and explicitly confirmed request creation are tested against the current stable release used by the release candidate. Moodarr does not use Seerr as a descriptive discovery catalog or claim an unverified catalog title is requestable. Record the product and exact version. | +| Plex Media Server | Supported | Maintained flows cover library sync, Plex sign-in, poster proxying, Plex links, and explicit signed-in-user Watchlist actions. Record the exact server version with issues; release-specific write-matrix evidence belongs in the ledger. | +| Seerr or Jellyseerr | Supported | Maintained flows cover operational request-state sync and explicitly confirmed request creation. Moodarr does not use Seerr as a descriptive discovery catalog or claim an unverified catalog title is requestable. Record the product and exact version with issues; release-specific write-matrix evidence belongs in the ledger. | | Other Seerr-compatible servers | Best effort | API-compatible deployments may work, but untested variants do not expand the beta support contract. | | Beta.1 Wikidata catalog asset | Supported and optional | Plex-only operation works without the asset. Missing-title discovery uses only `wikidata-20260622-min5-v1`, SHA-256 `dd25ba6602e1bdb8e6999b0442bc40165e6d4faadd02e91e74e1a24e2b55e85a`, imported through the stopped networkless full-snapshot procedure in [Catalog Bootstrap](CATALOG_BOOTSTRAP.md). Regenerated or newer datasets are best effort. | | Local recommendation processing | Supported | The official beta.1 image bakes in non-overridable provider policy `none`; no provider credential or provider network access is part of the release path. | @@ -43,7 +45,7 @@ The release candidate must complete its browser and accessibility smoke matrix a | Other AI providers or OpenAI-compatible endpoints | Unsupported | No compatibility promise is made unless a provider is explicitly documented. | | Fixture mode | Supported for evaluation | Fixture mode is part of development, CI, and first-look testing. It is not evidence that a real Plex/Seerr deployment has been validated. | -Third-party services do not publish perfectly synchronized compatibility contracts. Each Moodarr release therefore records the exact Plex and Seerr/Jellyseerr versions used for its integration evidence instead of implying support for every historical version. Third-party content and service terms remain separate from Moodarr's Apache License 2.0 and must be rechecked for each release. +Third-party services do not publish perfectly synchronized compatibility contracts. Candidate plans should record the exact Plex and Seerr/Jellyseerr versions used for integration evidence instead of implying support for every historical version. The absence of a completed beta.1 matrix remains visible in issue #32. Third-party content and service terms remain separate from Moodarr's Apache License 2.0 and must be rechecked for each release. The optional catalog asset contains 90,397 importable CC0 Wikidata records. Of those, 82,865 meet beta.1's ambiguity-safe local request-attempt prerequisites: 70,841 movies and 12,024 TV series. Thirty-six groups share a strong importer identifier across 72 source records. Fifty-nine of those records—10 movies and 49 TV series—otherwise meet attempt requirements; their ambiguous catalog materializations remain imported and indexed for provenance and diagnostics but cannot independently surface in Finder or authorize request preview or creation. An independently identified available Plex item remains Finder-visible if linked later, while ambiguity still blocks every request action. This is catalog coverage, not verified Seerr availability. Unambiguous eligible catalog-only rows remain `unavailable`; ordinary generic search and verified-requestable-only filters exclude them. A narrowly explicit request-attempt search may include an eligible row with **Availability not checked**, and Seerr may reject the confirmed attempt. diff --git a/docs/README.md b/docs/README.md index 1847faf..dfadf98 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,6 +2,8 @@ This is the curated entry point for Moodarr's supported web/server documentation. Start with the deployment and operations guides below; planning and research files elsewhere in this directory are contributor references, not additional beta support promises. +`v0.1.0-beta.1` is published from source commit `08447e87df2e1705aa9a79193a52a65fb00724c3`. [GitHub issue #32](https://github.com/jremick/moodarr/issues/32) is the authoritative evidence and follow-up ledger. Compatibility describes current support policy; it is not a claim that every beta.1 Unraid, integration, browser, responsiveness, catalog, or manual-evidence matrix was completed. + ## Install And Operate - [Compatibility](COMPATIBILITY.md) - supported deployment, browser, integration, storage, and network boundaries. @@ -20,9 +22,9 @@ This is the curated entry point for Moodarr's supported web/server documentation ## Release And Maintainer Guides -- [Public beta release criteria](BETA_RELEASE_CRITERIA.md) - blocking requirements and the evidence ledger. -- [Beta candidate manual validation](BETA_CANDIDATE_MANUAL_VALIDATION.md) - exact-candidate operator validation. -- [Release readiness](RELEASE.md) - automated gates, packaging, and promotion. +- [Public beta release criteria](BETA_RELEASE_CRITERIA.md) - original comprehensive beta.1 plan and future-hardening reference. +- [Beta candidate manual validation](BETA_CANDIDATE_MANUAL_VALIDATION.md) - original beta.1-bound operator runbook; not completed beta.1 evidence. +- [Release readiness](RELEASE.md) - current release truth plus the preserved comprehensive release process. - [Production plan](PRODUCTION_PLAN.md) - production architecture, security rules, and longer-term hardening. ## Design And Contribution diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 2db1f44..115e469 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -2,6 +2,12 @@ Moodarr's early-public-beta release process uses protected Git tags, immutable GitHub prereleases, and workflow-append-only GHCR version tags published from exact verified commits and bound to recorded immutable image digests. +## Current Release Truth + +`v0.1.0-beta.1` was published from source commit `08447e87df2e1705aa9a79193a52a65fb00724c3` under an intentionally narrower early-beta gate. [GitHub issue #32](https://github.com/jremick/moodarr/issues/32) is the authoritative actual evidence and follow-up ledger. Extra fresh Unraid/update, stopped networkless catalog, dedicated real Plex and Seerr/Jellyseerr writes, production native `linux/amd64` 2 CPU/2 GiB responsiveness, current Chrome/Edge/Firefox/Safari, and comprehensive privacy-reviewed manual evidence remain open; do not infer completion from the published tag. + +The comprehensive beta.1 procedure below is preserved as the original plan and future-hardening reference. It does not rewrite immutable beta.1 history or claim every planned gate passed. The beta install, upgrade, responsiveness, and manual-evidence validators remain beta.1-bound and must be version-generalized before a beta.2 candidate. + ## Local Release Gate ```bash @@ -36,7 +42,7 @@ Every candidate image includes maximum BuildKit provenance, an SPDX SBOM, and a GHCR's manifest-tag API does not provide this workflow with a guaranteed atomic create-only write. Candidate mode checks that its full-SHA tag is absent before pushing and then performs the anonymous raw-manifest self-readback, but a separately authorized package writer can still race either observation. Any candidate run that fails after the full-SHA tag appears is abandoned: do not delete, overwrite, or reuse that tag; merge and approve a new source commit instead. Promotion reads the version tag immediately before writing: a `404` permits one manifest PUT, a `200` permits no write and is accepted only when the existing registry digest, recomputed digest, media type, and raw bytes exactly match the approved candidate, and every other result fails. Registry token and manifest reads use bounded timeouts and retry-safe retries. Every token response is captured in a mode-`0600` temporary file and must contain exactly one bounded, safe-shape token before masking or use; retry-contaminated, multiline, or malformed responses fail closed without entering an authorization header. The manifest PUT is bounded but deliberately not retried automatically: an uncertain write is recovered only through a new Tier 3-approved promotion dispatch, which can adopt the exact existing bytes without rewriting them. Promotion re-reads both candidate and version manifests afterward and requires the protected semantic Git tag to stay absent through the approved job. If a PUT succeeds but a later network or read-back step fails, start a new `release_mode=promotion` dispatch with the same SHA and digest and obtain the `beta-release` approval again; the workflow will adopt the exact existing tag without rewriting it and repeat all final checks. Repository package-write permission must remain restricted. A separate privileged package writer can still race the final registry request because GHCR offers no atomic create-only condition; any mismatched pre-existing or final content fails closed. Restrict package writers and review the final digest read-back. -## Two-Stage Beta Promotion +## Original Comprehensive Two-Stage Beta Promotion 1. Freeze the release-ready source commit as the current `main` HEAD. Package version, changelog, README, Compose, Unraid template, and support/security copy must already be valid release copy, while GitHub Releases remains the source of truth for whether the version is publicly available. 2. Complete the pre-candidate evidence rows, then manually dispatch `publish-image.yml` from `main` with `release_mode=candidate`, that HEAD's full 40-character commit SHA, and an empty `candidate_digest`. If `main` advances before dispatch, review and freeze the new HEAD and publish a new candidate from it; do not move `main` backward solely for publication. Require the candidate job's pre-push semantic Git-tag absence check, anonymous full-SHA-tag/digest raw-manifest self-readback, and semantic GHCR version-tag `404` to pass, then record the full-SHA image, emitted digest, and successful workflow run. @@ -415,7 +421,7 @@ Exit status is `0` only when the beta.1 evidence passes: at least 100 health, 20 ### Candidate Manual Evidence -Use [Beta Candidate Manual Validation](BETA_CANDIDATE_MANUAL_VALIDATION.md) as the canonical fail-closed procedure for the evidence that fixture and source-built rehearsals cannot establish: exact-digest Unraid behavior, the exact catalog asset and stopped networkless full-snapshot import, request-attempt search/disclosure isolation, real Plex and Seerr/Jellyseerr writes and cleanup, the native responsiveness report hash, and the current-stable desktop browser/accessibility matrix. Start from its tracked all-false example and validate the completed privacy-reviewed file with `npm run validate:beta-manual-evidence`. The CLI binds the responsiveness harness hash to the canonical script blob at the expected Git revision, but the resulting matrix remains a structured operator attestation requiring maintainer review rather than independent automated proof. Only validator exit `0` against the same immutable candidate can close those ledger rows; local images, source/EXP runs, emulation, and evidence inherited from another digest remain ineligible. +Use [Beta Candidate Manual Validation](BETA_CANDIDATE_MANUAL_VALIDATION.md) as the original fail-closed procedure for evidence that fixture and source-built rehearsals cannot establish: exact-digest Unraid behavior, the exact catalog asset and stopped networkless full-snapshot import, request-attempt search/disclosure isolation, real Plex and Seerr/Jellyseerr writes and cleanup, the native responsiveness report hash, and the current-stable desktop browser/accessibility matrix. Start from its tracked all-false example and validate a completed privacy-reviewed file with `npm run validate:beta-manual-evidence`. The CLI binds the responsiveness harness hash to the canonical script blob at the expected Git revision, but the resulting matrix remains a structured operator attestation requiring maintainer review rather than independent automated proof. This comprehensive manual gate remains open for beta.1; validator exit `0` was the original completion rule, not a retroactive publication claim. Local images, source/EXP runs, emulation, and evidence inherited from another digest remain ineligible. ## Pre-Release Checklist @@ -439,10 +445,10 @@ Use [Beta Candidate Manual Validation](BETA_CANDIDATE_MANUAL_VALIDATION.md) as t - Repository visibility: public. - License: Apache-2.0. - Security reporting: GitHub private vulnerability reporting. -- Target release image: `ghcr.io/jremick/moodarr:v0.1.0-beta.1`. -- Target GitHub prerelease: `v0.1.0-beta.1`. +- Published release image: `ghcr.io/jremick/moodarr:v0.1.0-beta.1`. +- Published GitHub prerelease: `v0.1.0-beta.1`, source commit `08447e87df2e1705aa9a79193a52a65fb00724c3`. - Optional catalog release asset: `moodarr-wikidata-20260622-min5-v1.jsonl.gz`, catalog version `wikidata-20260622-min5-v1`, SHA-256 `dd25ba6602e1bdb8e6999b0442bc40165e6d4faadd02e91e74e1a24e2b55e85a`. -- GitHub Releases is authoritative for whether this target is available; source references alone do not mean it has been published. +- GitHub Releases is authoritative for release availability; [issue #32](https://github.com/jremick/moodarr/issues/32) is authoritative for beta.1 evidence and open follow-up. - Future changes stay under `Unreleased` until a new protected Git tag, workflow-append-only GHCR version tag, and immutable GitHub prerelease are intentionally created. ## Supply-Chain Posture diff --git a/package-lock.json b/package-lock.json index ee11eaa..7f40789 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,8 @@ "@vitejs/plugin-react": "^5.1.1", "concurrently": "^10.0.3", "eslint": "^9.39.1", - "js-yaml": "4.2.0", + "js-yaml": "4.3.0", + "mdast-util-from-markdown": "^2.0.3", "rollup": "npm:@rollup/wasm-node@^4.60.4", "tsx": "^4.20.6", "typescript": "^5.9.3", @@ -1723,6 +1724,16 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -1744,6 +1755,23 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.12.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", @@ -1774,6 +1802,13 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.59.4", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.4.tgz", @@ -1973,9 +2008,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "dev": true, "license": "MIT", "dependencies": { @@ -2384,9 +2419,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -2499,6 +2534,17 @@ "node": ">=8" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/cliui": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", @@ -2652,6 +2698,20 @@ } } }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2687,6 +2747,20 @@ "node": ">=8" } }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/dotenv": { "version": "17.4.2", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", @@ -3303,9 +3377,9 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -3462,9 +3536,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "dev": true, "funding": [ { @@ -3927,6 +4001,508 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/mime": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", @@ -4529,9 +5105,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.4", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", - "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz", + "integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==", "dev": true, "license": "MIT", "engines": { @@ -4846,6 +5422,20 @@ "dev": true, "license": "MIT" }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", diff --git a/package.json b/package.json index cac3e6b..ed7693c 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,8 @@ "overrides": { "@babel/core": "7.29.7", "esbuild": "0.28.1", - "js-yaml": "4.2.0" + "js-yaml": "4.3.0", + "shell-quote": "1.10.0" }, "devDependencies": { "@eslint/js": "^9.39.1", @@ -76,7 +77,8 @@ "@vitejs/plugin-react": "^5.1.1", "concurrently": "^10.0.3", "eslint": "^9.39.1", - "js-yaml": "4.2.0", + "js-yaml": "4.3.0", + "mdast-util-from-markdown": "^2.0.3", "rollup": "npm:@rollup/wasm-node@^4.60.4", "tsx": "^4.20.6", "typescript": "^5.9.3", diff --git a/scripts/markdown-link-targets.ts b/scripts/markdown-link-targets.ts new file mode 100644 index 0000000..53d9fa5 --- /dev/null +++ b/scripts/markdown-link-targets.ts @@ -0,0 +1,16 @@ +import type { Nodes } from "mdast"; +import { fromMarkdown } from "mdast-util-from-markdown"; + +export function renderedMarkdownLinkTargets(content: string) { + const targets = new Set(); + + function collectLinks(node: Nodes) { + if (node.type === "link") targets.add(node.url); + if ("children" in node) { + for (const child of node.children) collectLinks(child); + } + } + + collectLinks(fromMarkdown(content)); + return targets; +} diff --git a/scripts/verify-doc-contracts.ts b/scripts/verify-doc-contracts.ts index 31b6f5a..61d2977 100644 --- a/scripts/verify-doc-contracts.ts +++ b/scripts/verify-doc-contracts.ts @@ -1,5 +1,6 @@ import { existsSync, readFileSync } from "node:fs"; import { createRequire } from "node:module"; +import { renderedMarkdownLinkTargets } from "./markdown-link-targets"; import { validateBetaManualEvidence } from "./validate-beta-manual-evidence"; const require = createRequire(import.meta.url); @@ -28,6 +29,7 @@ const responsivenessHarness = read("scripts/benchmark-beta-responsiveness.ts"); const compatibility = read("docs/COMPATIBILITY.md"); const betaManualValidation = read("docs/BETA_CANDIDATE_MANUAL_VALIDATION.md"); const betaManualEvidenceExample = read("docs/beta-manual-evidence-all-false.example.json"); +const docsIndex = read("docs/README.md"); const unraidGuide = read("docs/UNRAID.md"); const changelog = read("CHANGELOG.md"); const parsedBugReport = loadYaml(bugReportTemplate) as { body?: unknown }; @@ -160,7 +162,8 @@ for (const tagRef of ['"refs/tags/$release_tag"', '"refs/tags/$release_tag^{}"'] } if (!upgradeGuide.includes("Admin > MoodRank > Catalog readiness")) failures.push("docs/UPGRADING.md does not match the redesigned Admin navigation"); if (upgradeGuide.includes("Recommendation engine > Catalog readiness")) failures.push("docs/UPGRADING.md still references the retired Admin navigation"); -if (!read("CHANGELOG.md").includes(`## ${packageVersion}`)) failures.push(`CHANGELOG.md does not contain ${packageVersion}`); +if (!changelog.includes("## Unreleased")) failures.push("CHANGELOG.md does not contain an Unreleased section for post-publication changes"); +if (!changelog.includes(`## ${packageVersion}`)) failures.push(`CHANGELOG.md does not contain ${packageVersion}`); const legacyTmdbNotice = "This product uses TMDB and the TMDB APIs but is not endorsed, certified, or otherwise approved by TMDB."; if (existsSync("public/tmdb-logo.svg")) failures.push("The strict beta must not bundle the retired TMDB logo"); @@ -256,6 +259,66 @@ for (const [path, content, phrases] of [ if (!content.includes(phrase)) failures.push(`${path} does not contain the native source validation contract: ${phrase}`); } } +const beta1SourceRevision = "08447e87df2e1705aa9a79193a52a65fb00724c3"; +const beta1LedgerUrl = "https://github.com/jremick/moodarr/issues/32"; +for (const [path, content] of [ + ["docs/BETA_RELEASE_CRITERIA.md", betaReleaseCriteria], + ["docs/BETA_CANDIDATE_MANUAL_VALIDATION.md", betaManualValidation], + ["docs/COMPATIBILITY.md", compatibility], + ["docs/RELEASE.md", releaseGuide], + ["docs/README.md", docsIndex], + ["SUPPORT.md", support] +] as const) { + if (!content.includes(beta1SourceRevision)) failures.push(`${path} does not identify the immutable beta.1 source revision`); + if (!renderedMarkdownLinkTargets(content).has(beta1LedgerUrl)) failures.push(`${path} does not link the authoritative beta.1 evidence ledger`); +} +for (const phrase of [ + "intentionally narrower early-beta gate", + "original comprehensive gate below did not pass as a whole", + "extra fresh Unraid install/update evidence", + "stopped networkless catalog evidence package", + "dedicated real Plex and Seerr/Jellyseerr write matrix", + "production native `linux/amd64` 2 CPU/2 GiB responsiveness evidence", + "current Chrome/Edge/Firefox/Safari matrix", + "comprehensive privacy-reviewed manual artifact", + "Beta.1 release history is immutable" +]) { + if (!betaReleaseCriteria.includes(phrase)) failures.push(`docs/BETA_RELEASE_CRITERIA.md does not preserve beta.1 release truth: ${phrase}`); +} +for (const staleClaim of ["the beta.1 run uses", "The passing beta.1 artifact uses"]) { + if (betaReleaseCriteria.includes(staleClaim)) failures.push(`docs/BETA_RELEASE_CRITERIA.md still claims missing beta.1 evidence passed: ${staleClaim}`); +} +for (const [path, content, phrases] of [ + [ + "docs/BETA_CANDIDATE_MANUAL_VALIDATION.md", + betaManualValidation, + ["must not be read as evidence that they passed", "version-generalized before a beta.2 candidate"] + ], + [ + "docs/COMPATIBILITY.md", + compatibility, + ["Compatibility is a support policy, not completed beta.1 evidence", "does not claim every matrix was completed before beta.1 publication"] + ], + [ + "docs/RELEASE.md", + releaseGuide, + ["do not infer completion from the published tag", "does not rewrite immutable beta.1 history", "must be version-generalized before a beta.2 candidate"] + ], + [ + "docs/README.md", + docsIndex, + ["Compatibility describes current support policy", "not a claim that every beta.1"] + ], + [ + "SUPPORT.md", + support, + ["Support policy and release evidence are separate", "does not mean beta.1 completed every planned compatibility matrix"] + ] +] as const) { + for (const phrase of phrases) { + if (!content.includes(phrase)) failures.push(`${path} does not preserve the beta.1 evidence/support boundary: ${phrase}`); + } +} if (packageScripts["bench:beta-responsiveness"] !== "tsx scripts/benchmark-beta-responsiveness.ts") { failures.push("package.json does not expose the beta responsiveness benchmark command"); } diff --git a/src/client/App.tsx b/src/client/App.tsx index ad33154..f549845 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -1,7 +1,7 @@ import { GearSix, Info, ListChecks, MagnifyingGlass, ShieldCheck, SpinnerGap, User, WarningCircle } from "@phosphor-icons/react"; import { useEffect, useMemo, useRef, useState } from "react"; import type * as React from "react"; -import { moodarrApi } from "./api"; +import { moodarrApi, unauthorizedApiEvent } from "./api"; import { finderAvailabilityGroup, type FinderAvailabilityGroup } from "./availability"; import { ExclusiveActionLock, isActionNavigationBlocked, runActionTask, settleRefreshTasks } from "./actionTask"; import { AdminAccessGate, type AdminCapability } from "./AdminAccessGate"; @@ -78,11 +78,17 @@ const groupOrder: FinderAvailabilityGroup[] = [ "unavailable" ]; +type BootstrapConnectionState = + | { phase: "checking" } + | { phase: "ready" } + | { phase: "unavailable"; message: string }; + export function App() { const [activeView, setActiveView] = useState(() => activeViewFromPathname(window.location.pathname)); const [adminCapability, setAdminCapability] = useState("unknown"); const [adminTokenDraft, setAdminTokenDraft] = useState(""); const [status, setStatus] = useState(null); + const [bootstrapConnection, setBootstrapConnection] = useState({ phase: "checking" }); const [authSession, setAuthSession] = useState(null); const [pendingPlexAuth, setPendingPlexAuth] = useState(() => loadPendingPlexAuth(window.localStorage)); const [stats, setStats] = useState(null); @@ -118,6 +124,9 @@ export function App() { const adminLoadRequestedRef = useRef(false); const baseScoreByItemIdRef = useRef>({}); const previousDefaultResultLimitRef = useRef(defaultSearchResultLimit); + const bootstrapReadyRef = useRef(false); + const statusRefreshGenerationRef = useRef(0); + const statusRefreshInFlightRef = useRef | null>(null); const searchRequestRef = useRef(null); searchRequestRef.current ??= new LatestRequestLifecycle(); const actionLockRef = useRef(null); @@ -151,13 +160,13 @@ export function App() { } = useAdminConsole(runAction, handleSyncSettled); useEffect(() => { - void refreshStatus(); + void refreshStatus({ preserveReadyOnFailure: false }).catch(() => undefined); return () => searchRequestRef.current?.abort(); }, []); useEffect(() => { const refreshVisibleSession = () => { - if (document.visibilityState === "visible") void refreshStatus(); + if (document.visibilityState === "visible") refreshStatusInBackground(); }; window.addEventListener("focus", refreshVisibleSession); document.addEventListener("visibilitychange", refreshVisibleSession); @@ -167,6 +176,24 @@ export function App() { }; }, []); + useEffect(() => { + const refreshUnauthorizedSession = () => { + statusRefreshGenerationRef.current += 1; + setAuthSession(null); + setAdminCapability("unavailable"); + const inFlight = statusRefreshInFlightRef.current; + if (inFlight) { + void inFlight.finally(() => { + if (!statusRefreshInFlightRef.current) void refreshStatus({ preserveReadyOnFailure: true }).catch(() => undefined); + }).catch(() => undefined); + return; + } + void refreshStatus({ preserveReadyOnFailure: true }).catch(() => undefined); + }; + window.addEventListener(unauthorizedApiEvent, refreshUnauthorizedSession); + return () => window.removeEventListener(unauthorizedApiEvent, refreshUnauthorizedSession); + }, []); + useEffect(() => { if (plexReturnHandledRef.current || !isPlexAuthReturnUrl(window.location.href)) return; plexReturnHandledRef.current = true; @@ -259,29 +286,82 @@ export function App() { }); }, [status?.runtime.defaultResultLimit, hasSearchSession]); - async function refreshStatus() { - const [adminSession, configStatus, session] = await Promise.all([ - moodarrApi.adminSession().catch(() => null), - moodarrApi.configStatus(), - moodarrApi.authSession().catch(() => null) - ]); - setAdminCapability((current) => (adminSession ? (adminSession.ok ? "available" : "unavailable") : current === "unknown" ? "unavailable" : current)); - const libraryStats = canLoadLibraryStats({ - adminSessionAvailable: Boolean(adminSession?.ok), - adminAuthRequired: configStatus.admin.authRequired, - userAuthenticated: Boolean(session?.authenticated) - }) - ? await moodarrApi.stats().catch(() => null) - : null; - setStatus(configStatus); - setStats(libraryStats); - setAuthSession(session); - if (session?.authenticated) { - clearPendingPlexAuth(window.localStorage); - setPendingPlexAuth(null); + function refreshStatus({ preserveReadyOnFailure = bootstrapReadyRef.current }: { preserveReadyOnFailure?: boolean } = {}) { + const request = performStatusRefresh(preserveReadyOnFailure); + statusRefreshInFlightRef.current = request; + void request.then( + () => { + if (statusRefreshInFlightRef.current === request) statusRefreshInFlightRef.current = null; + }, + () => { + if (statusRefreshInFlightRef.current === request) statusRefreshInFlightRef.current = null; + } + ); + return request; + } + + function refreshStatusInBackground() { + if (statusRefreshInFlightRef.current) return; + void refreshStatus({ preserveReadyOnFailure: true }).catch(() => undefined); + } + + async function performStatusRefresh(preserveReadyOnFailure: boolean) { + const generation = ++statusRefreshGenerationRef.current; + try { + const [adminSessionResult, configStatus, sessionResult] = await Promise.all([ + settleStatusCall(moodarrApi.adminSession()), + moodarrApi.configStatus(), + settleStatusCall(moodarrApi.authSession()) + ]); + const accessGranted = canLoadLibraryStats({ + adminSessionAvailable: adminSessionResult.ok && adminSessionResult.value.ok, + adminAuthRequired: configStatus.admin.authRequired, + userAuthenticated: sessionResult.ok && Boolean(sessionResult.value.authenticated) + }); + const accessResolved = !configStatus.admin.authRequired || (adminSessionResult.ok && sessionResult.ok); + const libraryStats = accessGranted + ? await moodarrApi.stats().catch(() => null) + : accessResolved + ? null + : undefined; + if (!isCurrentStatusRefresh(generation, statusRefreshGenerationRef.current)) return; + setAdminCapability((current) => + adminSessionResult.ok + ? adminSessionResult.value.ok + ? "available" + : "unavailable" + : current === "unknown" + ? "unavailable" + : current + ); + setStatus(configStatus); + if (libraryStats !== undefined) setStats(libraryStats); + setAuthSession((current) => settledStatusValue(current, sessionResult)); + bootstrapReadyRef.current = true; + setBootstrapConnection({ phase: "ready" }); + if (sessionResult.ok && sessionResult.value.authenticated) { + clearPendingPlexAuth(window.localStorage); + setPendingPlexAuth(null); + } + } catch (error) { + if ( + isCurrentStatusRefresh(generation, statusRefreshGenerationRef.current) + && shouldSurfaceBootstrapFailure(preserveReadyOnFailure, bootstrapReadyRef.current) + ) { + bootstrapReadyRef.current = false; + setAdminCapability((current) => current === "unknown" ? "unavailable" : current); + setBootstrapConnection({ phase: "unavailable", message: describeBootstrapFailure(error) }); + } + throw error; } } + function retryBootstrap() { + bootstrapReadyRef.current = false; + setBootstrapConnection({ phase: "checking" }); + void refreshStatus({ preserveReadyOnFailure: false }).catch(() => undefined); + } + async function handleSyncSettled(finalStatus: SyncStatus) { await refreshStatus(); if (finalStatus.lastResult && !finalStatus.lastResult.ok) { @@ -794,10 +874,12 @@ export function App() { > Skip to {activeView === "finder" ? "Finder" : activeView === "review" ? "Review Queue" : "Admin"} - {activeView !== "finder" || finderAccessBlocked ? ( + {activeView !== "finder" || finderAccessBlocked || bootstrapConnection.phase !== "ready" ? (
- +