diff --git a/.github/triggers/nuvio-client-lab.json b/.github/triggers/nuvio-client-lab.json
index b653fde2e..438b75708 100644
--- a/.github/triggers/nuvio-client-lab.json
+++ b/.github/triggers/nuvio-client-lab.json
@@ -1,104 +1,51 @@
{
"fixtures": [
{
- "slug": "interstellar",
- "providers": [
- "4khdhubnew", "CASTLE", "GOATED", "HDGHARTV", "HINDMOVIEZ", "kisskh", "MOVIEBLAST", "MOVIEBOX", "MOVIX", "playimdb", "VIDLOVE", "ANIME-SAMA", "PURSTREAM", "FLEMMIX", "PAPADUSTREAM", "frenchstream", "streamzo", "MUGIWARASTREAM", "VOIRANIME", "toflix", "sekai", "ANIMESAMA-CO", "ANIMESULTRA", "ANIMEVOSTFR", "ANIMOFLIX", "COFLIX", "FRENCH-MANGA", "NAKIOS", "VOIRANIME-RIP", "VOSTFREE", "WOOKAFR", "movieshunt", "cineby", "videasy", "VIXSRC", "1SHOWS", "allanime", "vidsrc"
- ],
+ "slug": "sinners-2025",
+ "providers": ["cineby", "movieshunt", "videasy", "VIXSRC", "MOVIESDRIVE", "MOVIESMOD", "4khdhub"],
"fixture": {
- "tmdbId": "157336",
+ "tmdbId": "1233413",
"mediaType": "movie",
- "title": "Interstellar",
- "year": 2014,
+ "title": "Sinners",
+ "year": 2025,
"category": "movie",
- "expectedDurationMinutes": 169
+ "expectedDurationMinutes": 137,
+ "aliases": ["Sinners"]
}
},
+ {
+ "slug": "interstellar",
+ "providers": ["cineby", "videasy", "VIXSRC", "HINDMOVIEZ", "4khdhub", "CASTLE", "GOATED", "HDGHARTV", "kisskh", "MOVIEBLAST", "MOVIEBOX", "MOVIX", "playimdb", "VIDLOVE", "ANIME-SAMA", "PURSTREAM", "FLEMMIX", "PAPADUSTREAM", "frenchstream", "streamzo", "MUGIWARASTREAM", "VOIRANIME", "toflix", "sekai", "ANIMESAMA-CO", "ANIMESULTRA", "ANIMEVOSTFR", "ANIMOFLIX", "COFLIX", "FRENCH-MANGA", "NAKIOS", "VOIRANIME-RIP", "VOSTFREE", "WOOKAFR", "movieshunt", "1SHOWS", "allanime", "vidsrc"],
+ "fixture": {"tmdbId": "157336", "mediaType": "movie", "title": "Interstellar", "year": 2014, "category": "movie", "expectedDurationMinutes": 169}
+ },
{
"slug": "mon-ninja-et-moi-3",
- "providers": [
- "CASTLE", "MOVIEBOX", "streamzo", "videasy", "VIDLOVE", "VIXSRC", "ANIME-SAMA", "PURSTREAM", "GOATED", "FLEMMIX", "PAPADUSTREAM", "frenchstream", "MUGIWARASTREAM", "VOIRANIME", "toflix", "sekai", "ANIMESAMA-CO", "ANIMESULTRA", "ANIMEVOSTFR", "ANIMOFLIX", "COFLIX", "FRENCH-MANGA", "MOVIX", "NAKIOS", "VOIRANIME-RIP", "VOSTFREE", "WOOKAFR", "movieshunt", "HINDMOVIEZ", "cineby", "HDGHARTV", "playimdb", "MOVIEBLAST", "4khdhubnew", "1SHOWS", "allanime", "vidsrc", "kisskh"
- ],
- "fixture": {
- "tmdbId": "1215638",
- "mediaType": "movie",
- "title": "Mon ninja et moi 3",
- "year": 2025,
- "category": "movie",
- "expectedDurationMinutes": 88,
- "aliases": ["Mon ninja et moi 3", "Checkered Ninja 3", "Ternet Ninja 3"],
- "forbiddenAliases": ["Enola Holmes 2"]
- }
+ "providers": ["cineby", "videasy", "VIXSRC", "HINDMOVIEZ", "CASTLE", "MOVIEBOX", "streamzo", "VIDLOVE", "ANIME-SAMA", "PURSTREAM", "GOATED", "FLEMMIX", "PAPADUSTREAM", "frenchstream", "MUGIWARASTREAM", "VOIRANIME", "toflix", "sekai", "ANIMESAMA-CO", "ANIMESULTRA", "ANIMEVOSTFR", "ANIMOFLIX", "COFLIX", "FRENCH-MANGA", "MOVIX", "NAKIOS", "VOIRANIME-RIP", "VOSTFREE", "WOOKAFR", "movieshunt", "HDGHARTV", "playimdb", "MOVIEBLAST", "4khdhub", "1SHOWS", "allanime", "vidsrc", "kisskh"],
+ "fixture": {"tmdbId": "1215638", "mediaType": "movie", "title": "Mon ninja et moi 3", "year": 2025, "category": "movie", "expectedDurationMinutes": 88, "aliases": ["Mon ninja et moi 3", "Checkered Ninja 3", "Ternet Ninja 3"], "forbiddenAliases": ["Enola Holmes 2"]}
},
{
"slug": "breaking-bad-s01e01",
- "providers": [
- "1SHOWS", "CASTLE", "GOATED", "HINDMOVIEZ", "kisskh", "MOVIX", "PAPADUSTREAM", "persianstremio", "streamzo", "videasy", "VIDLOVE", "VIXSRC", "PURSTREAM", "FLEMMIX", "frenchstream", "toflix", "COFLIX", "DULOURD", "NAKIOS", "WOOKAFR", "DESIFLIX", "cineby", "HDGHARTV", "playimdb", "4khdhubnew", "vidsrc"
- ],
- "fixture": {
- "tmdbId": "1396",
- "mediaType": "tv",
- "title": "Breaking Bad",
- "year": 2008,
- "season": 1,
- "episode": 1,
- "category": "tv",
- "expectedDurationMinutes": 58
- }
+ "providers": ["HINDMOVIEZ", "cineby", "videasy", "VIXSRC", "1SHOWS", "CASTLE", "GOATED", "kisskh", "MOVIX", "PAPADUSTREAM", "persianstremio", "streamzo", "VIDLOVE", "PURSTREAM", "FLEMMIX", "frenchstream", "toflix", "COFLIX", "DULOURD", "NAKIOS", "WOOKAFR", "DESIFLIX", "HDGHARTV", "playimdb", "4khdhub", "vidsrc"],
+ "fixture": {"tmdbId": "1396", "mediaType": "tv", "title": "Breaking Bad", "year": 2008, "season": 1, "episode": 1, "category": "tv", "expectedDurationMinutes": 58}
},
{
"slug": "revenant-s01e01",
- "providers": [
- "CASTLE", "cineby", "DVDPLAY", "GOATED", "HINDMOVIEZ", "kisskh", "MOVIEBOX", "PAPADUSTREAM", "playimdb", "streamzo", "VIXSRC", "PURSTREAM", "FLEMMIX", "frenchstream", "toflix", "COFLIX", "DULOURD", "MOVIX", "NAKIOS", "WOOKAFR", "DESIFLIX", "videasy", "HDGHARTV", "4khdhubnew", "1SHOWS", "vidsrc", "VIDLOVE"
- ],
- "fixture": {
- "tmdbId": "210702",
- "mediaType": "tv",
- "title": "Revenant",
- "year": 2023,
- "season": 1,
- "episode": 1,
- "category": "tv",
- "expectedDurationMinutes": 77,
- "aliases": ["Revenant", "The Devil", "악귀"]
- }
+ "providers": ["cineby", "videasy", "VIXSRC", "HINDMOVIEZ", "CASTLE", "DVDPLAY", "GOATED", "kisskh", "MOVIEBOX", "PAPADUSTREAM", "playimdb", "streamzo", "PURSTREAM", "FLEMMIX", "frenchstream", "toflix", "COFLIX", "DULOURD", "MOVIX", "NAKIOS", "WOOKAFR", "DESIFLIX", "HDGHARTV", "4khdhub", "1SHOWS", "vidsrc", "VIDLOVE"],
+ "fixture": {"tmdbId": "210702", "mediaType": "tv", "title": "Revenant", "year": 2023, "season": 1, "episode": 1, "category": "tv", "expectedDurationMinutes": 77, "aliases": ["Revenant", "The Devil", "악귀"]}
},
{
"slug": "jujutsu-kaisen-s01e01",
- "providers": [
- "anikototv", "ANIME-SAMA", "ANIMESAMA-CO", "ANIMEVOSTFR", "hianime", "MOVIX", "MUGIWARASTREAM", "PAPADUSTREAM", "VOIRANIME-RIP", "PURSTREAM", "frenchstream", "streamzo", "VOIRANIME", "toflix", "sekai", "ANIMESULTRA", "ANIMOFLIX", "COFLIX", "DULOURD", "FRENCH-MANGA", "NAKIOS", "VOSTFREE"
- ],
- "fixture": {
- "tmdbId": "95479",
- "mediaType": "tv",
- "title": "Jujutsu Kaisen",
- "year": 2020,
- "season": 1,
- "episode": 1,
- "category": "anime",
- "expectedDurationMinutes": 24
- }
+ "providers": ["hianime", "anikototv", "ANIME-SAMA", "MOVIX", "ANIMESAMA-CO", "ANIMEVOSTFR", "MUGIWARASTREAM", "PAPADUSTREAM", "VOIRANIME-RIP", "PURSTREAM", "frenchstream", "streamzo", "VOIRANIME", "toflix", "sekai", "ANIMESULTRA", "ANIMOFLIX", "COFLIX", "DULOURD", "FRENCH-MANGA", "NAKIOS", "VOSTFREE"],
+ "fixture": {"tmdbId": "95479", "mediaType": "tv", "title": "Jujutsu Kaisen", "year": 2020, "season": 1, "episode": 1, "category": "anime", "expectedDurationMinutes": 24}
},
{
"slug": "mushoku-tensei-s01e01",
- "providers": [
- "anikototv", "ANIME-SAMA", "ANIMESALT", "ANIMESAMA-CO", "hianime", "MOVIX", "MUGIWARASTREAM", "PAPADUSTREAM", "VOIRANIME-RIP", "PURSTREAM", "frenchstream", "streamzo", "VOIRANIME", "toflix", "sekai", "ANIMESULTRA", "ANIMEVOSTFR", "ANIMOFLIX", "COFLIX", "DULOURD", "FRENCH-MANGA", "NAKIOS", "VOSTFREE"
- ],
- "fixture": {
- "tmdbId": "94664",
- "mediaType": "tv",
- "title": "Mushoku Tensei: Jobless Reincarnation",
- "year": 2021,
- "season": 1,
- "episode": 1,
- "category": "anime",
- "expectedDurationMinutes": 24,
- "aliases": ["Mushoku Tensei", "Mushoku Tensei: Jobless Reincarnation"]
- }
+ "providers": ["hianime", "anikototv", "ANIME-SAMA", "MOVIX", "ANIMESALT", "ANIMESAMA-CO", "MUGIWARASTREAM", "PAPADUSTREAM", "VOIRANIME-RIP", "PURSTREAM", "frenchstream", "streamzo", "VOIRANIME", "toflix", "sekai", "ANIMESULTRA", "ANIMEVOSTFR", "ANIMOFLIX", "COFLIX", "DULOURD", "FRENCH-MANGA", "NAKIOS", "VOSTFREE"],
+ "fixture": {"tmdbId": "94664", "mediaType": "tv", "title": "Mushoku Tensei: Jobless Reincarnation", "year": 2021, "season": 1, "episode": 1, "category": "anime", "expectedDurationMinutes": 24, "aliases": ["Mushoku Tensei", "Mushoku Tensei: Jobless Reincarnation"]}
}
],
"clients": ["tv", "desktop", "mobile"],
- "run_revision": "real-coverage-diagnostic-v11-reactivation-proof",
+ "run_revision": "native-reader-diagnostics-v14-all-providers-including-inactive",
"policy": {
"target_total": 10,
"minimum_vf": 3,
@@ -115,5 +62,13 @@
"provider_timeout_ms": 70000,
"retry_provider_timeouts": true,
"retry_provider_timeout_ms": 70000,
- "playback_timeout_ms": 18000
+ "playback_timeout_ms": 18000,
+ "native_reader_acceptance": {
+ "provider_scope": "all",
+ "include_disabled": true,
+ "stream_scope": "all",
+ "primary_fixture": "sinners-2025",
+ "regression_fixtures": ["interstellar", "mon-ninja-et-moi-3", "breaking-bad-s01e01", "revenant-s01e01", "jujutsu-kaisen-s01e01", "mushoku-tensei-s01e01"],
+ "publication_requires_fresh_reader_proof": true
+ }
}
diff --git a/.github/workflows/brain-learning-lab.yml b/.github/workflows/brain-learning-lab.yml
index 8270e01d7..f6662c318 100644
--- a/.github/workflows/brain-learning-lab.yml
+++ b/.github/workflows/brain-learning-lab.yml
@@ -27,7 +27,6 @@ jobs:
timeout-minutes: 55
permissions:
contents: read
- actions: read
steps:
- name: Checkout trusted production baseline with history
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
@@ -69,20 +68,6 @@ jobs:
fi
node -e "const x=require('./brain-learning-input/previous.json'); if(x.productionWritesAllowed===true||x.publicationAllowed===true) process.exit(1)"
- - name: Import latest official native-client feedback when available
- env:
- GH_TOKEN: ${{ github.token }}
- shell: bash
- run: |
- set -euo pipefail
- mkdir -p brain-learning-input/native
- RUN_ID="$(gh run list --workflow native-corpus-device-lab.yml --branch main --limit 20 --json databaseId,status --jq '[.[] | select(.status=="completed")][0].databaseId // empty' || true)"
- if [ -n "$RUN_ID" ]; then
- gh run download "$RUN_ID" --name "native-corpus-engine-summary-$RUN_ID" --dir brain-learning-input/native || true
- fi
- [ -f brain-learning-input/native/native-corpus-summary.json ] || printf '{}\n' > brain-learning-input/native/native-corpus-summary.json
- [ -f brain-learning-input/native/provider-portfolio.json ] || printf '{}\n' > brain-learning-input/native/provider-portfolio.json
-
- name: Materialize historical 5.20.63 audit anchor from Git history
shell: bash
run: |
@@ -149,14 +134,20 @@ jobs:
--repair-report brain-sandbox/health-output/repair-report.json \
--historical-training brain-sandbox/historical-training.json \
--previous-state brain-learning-input/previous.json \
- --native-summary brain-learning-input/native/native-corpus-summary.json \
- --provider-portfolio brain-learning-input/native/provider-portfolio.json \
--overrides provider-overrides.json
node engine_v2/tests/repair-brain.test.mjs
node engine_v2/tests/recipe-memory.test.mjs
node engine_v2/tests/learning-lab-memory.test.mjs
python -m py_compile scripts/run_brain_learning_sandbox.py scripts/build_brain_repair_proposal.py
node -e "const x=require('./brain-learning-output/latest.json'); if(x.productionWritesAllowed!==false||x.publicationAllowed!==false||x.sandboxExecutedProviders!==true) process.exit(1)"
+ node - <<'NODE'
+ const fs=require('fs');
+ const previous=JSON.parse(fs.readFileSync('brain-learning-input/previous.json','utf8'));
+ const next=JSON.parse(fs.readFileSync('brain-learning-output/latest.json','utf8'));
+ if (previous.nativeReaderRepairMemory && JSON.stringify(next.nativeReaderRepairMemory)!==JSON.stringify(previous.nativeReaderRepairMemory)) {
+ throw new Error('native reader repair memory was not preserved across Brain sandbox learning');
+ }
+ NODE
if grep -Eiq 'https?://|authorization[=:]|cookie[=:]|token[=:]' brain-learning-output/latest.json; then
echo 'persistent Brain state contains forbidden raw endpoint/credential material' >&2
exit 1
diff --git a/.github/workflows/canonical-media-types.yml b/.github/workflows/canonical-media-types.yml
new file mode 100644
index 000000000..1d38d2c82
--- /dev/null
+++ b/.github/workflows/canonical-media-types.yml
@@ -0,0 +1,133 @@
+name: Canonical media type, playback core and native evidence gate
+
+on:
+ pull_request:
+ branches: [main]
+ paths:
+ - "provider_catalog.json"
+ - "manifest.json"
+ - "vf/manifest.json"
+ - "package.json"
+ - "package-lock.json"
+ - ".github/triggers/nuvio-client-lab.json"
+ - "automation/native-human-ux-policy.json"
+ - "engine_v2/src/**"
+ - "engine_v2/tests/**"
+ - "engine_v2/scripts/diagnose-native-reader.mjs"
+ - "engine_v2/scripts/diagnose-native-media-capabilities.mjs"
+ - "engine_v2/scripts/learning-lab.mjs"
+ - "engine_v2/scripts/watch-upstream-providers.mjs"
+ - "scripts/check_nuvio_client_upstreams.py"
+ - "scripts/resolve_nuvio_lab_heads.py"
+ - "scripts/scope_native_reader_learning_runtime.py"
+ - "scripts/prepare_native_corpus_validation.py"
+ - "scripts/native_player_diagnostics_codegen.py"
+ - "scripts/augment_native_corpus_request_contract.py"
+ - "scripts/augment_native_provider_loading.py"
+ - "scripts/prepare_native_candidate_repository.py"
+ - "scripts/resolve_native_repository.sh"
+ - "scripts/native_evidence_completeness.cjs"
+ - "scripts/analyze_native_corpus_collection.cjs"
+ - "scripts/analyze_native_corpus_results.cjs"
+ - "scripts/analyze_native_media_type_capabilities.cjs"
+ - "scripts/gate_native_reader_result.cjs"
+ - "scripts/gate_native_reader_coverage.cjs"
+ - "scripts/instrument_native_client_evidence.py"
+ - "scripts/instrument_native_repository_http_evidence.py"
+ - "scripts/instrument_native_desktop_evidence.py"
+ - "scripts/capture_native_device_frontend.sh"
+ - "scripts/watch_native_device_frontend.sh"
+ - "scripts/augment_native_desktop_player.py"
+ - "scripts/complete_native_desktop_frontend_phases.py"
+ - "scripts/run_native_corpus_tv_suite.sh"
+ - "scripts/run_native_corpus_mobile_suite.sh"
+ - "scripts/run_native_corpus_desktop_suite.sh"
+ - "scripts/merge_native_reader_repair_learning.py"
+ - "scripts/merge_native_reader_backlog.py"
+ - ".github/workflows/native-android-route-reader.yml"
+ - ".github/workflows/native-desktop-reader-acceptance.yml"
+ - ".github/workflows/native-reader-learning-sync.yml"
+ - ".github/workflows/brain-learning-lab.yml"
+ - "tests/canonical_media_types_test.py"
+ - "tests/native_evidence_contract_test.py"
+ - "tests/native_evidence_completeness_test.cjs"
+ - "tests/native_evidence_codegen_pipeline_test.py"
+ - "tests/native_player_diagnostics_codegen_test.py"
+ - "tests/native_lab_observational_purity_test.py"
+ - "tests/native_human_ux_policy_lock_test.py"
+ - "tests/native_repository_cache_contract_test.py"
+ - "tests/native_repository_http_instrumentation_test.py"
+ - "tests/native_media_type_capability_test.py"
+ - "tests/native_reader_learning_memory_test.py"
+ - "tests/native_reader_runtime_scope_test.py"
+ - "tests/nuvio_lab_head_resolver_test.py"
+ - "tests/native_reader_backlog_test.py"
+ - ".github/workflows/canonical-media-types.yml"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: canonical-media-types-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ canonical-media-types:
+ runs-on: ubuntu-latest
+ timeout-minutes: 18
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
+ with: {persist-credentials: false}
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
+ with: {node-version: "24", package-manager-cache: false}
+ - name: Install exact dependency tree
+ run: npm ci --ignore-scripts --no-audit --no-fund
+ - name: Audit non-optional production dependencies
+ run: npm audit --audit-level=high --omit=optional
+ - name: Verify upgraded runtime imports
+ run: node -e "require('axios'); require('cheerio'); console.log('dependency imports ok')"
+ - name: Validate canonical catalog and manifest projections
+ run: python3 tests/canonical_media_types_test.py
+ - name: Validate canonical stream contract
+ run: node engine_v2/tests/contracts.test.mjs
+ - name: Validate shared stream presentation and TMDB fallback
+ run: node engine_v2/tests/stream-presentation.test.mjs
+ - name: Validate weekly upstream provider comparator
+ run: node engine_v2/tests/upstream-provider-watch.test.mjs
+ - name: Validate deep HLS playback chain and ranking
+ run: node engine_v2/tests/media-validator.test.mjs
+ - name: Validate resolver Core
+ run: node engine_v2/tests/resolver-core.test.mjs
+ - name: Validate Brain v4 reader causality
+ run: node engine_v2/tests/native-reader-brain-v4.test.mjs
+ - name: Validate exact official Nuvio Lab HEAD resolver
+ run: python3 tests/nuvio_lab_head_resolver_test.py
+ - name: Validate full native evidence architecture
+ run: python3 tests/native_evidence_contract_test.py
+ - name: Validate native evidence execution floor
+ run: node tests/native_evidence_completeness_test.cjs
+ - name: Validate native reader codegen purity
+ run: python3 tests/native_player_diagnostics_codegen_test.py
+ - name: Execute native evidence Kotlin codegen pipeline
+ run: python3 tests/native_evidence_codegen_pipeline_test.py
+ - name: Audit observational purity of native labs
+ run: python3 tests/native_lab_observational_purity_test.py
+ - name: Enforce locked human UX harness policy
+ run: python3 tests/native_human_ux_policy_lock_test.py
+ - name: Validate repository HTTP instrumentation contracts
+ run: python3 tests/native_repository_http_instrumentation_test.py
+ - name: Validate persistent native repository cache safety
+ run: python3 tests/native_repository_cache_contract_test.py
+ - name: Validate repository and reader Brain diagnosis
+ run: node engine_v2/tests/native-reader-diagnosis.test.mjs
+ - name: Validate native reader repair-memory merge
+ run: python3 tests/native_reader_learning_memory_test.py
+ - name: Validate reader memory isolation across Nuvio runtime drift
+ run: python3 tests/native_reader_runtime_scope_test.py
+ - name: Validate automatic native reader bug backlog
+ run: python3 tests/native_reader_backlog_test.py
+ - name: Validate Brain cross-day reader-memory preservation
+ run: node engine_v2/tests/learning-lab-memory.test.mjs
+ - name: Validate non-blocking tv/anime capability learning
+ run: python3 tests/native_media_type_capability_test.py
diff --git a/.github/workflows/dependency-gate.yml b/.github/workflows/dependency-gate.yml
deleted file mode 100644
index a92e17a5f..000000000
--- a/.github/workflows/dependency-gate.yml
+++ /dev/null
@@ -1,70 +0,0 @@
-name: Dependency gate
-
-on:
- pull_request:
- branches: [main]
- paths:
- - "package.json"
- - "package-lock.json"
- - ".github/workflows/dependency-gate.yml"
- workflow_dispatch:
-
-permissions:
- contents: read
-
-concurrency:
- group: dependency-gate-${{ github.event.pull_request.number || github.ref }}
- cancel-in-progress: true
-
-jobs:
- validate:
- name: Install lockfile and run repository tests
- runs-on: ubuntu-latest
- timeout-minutes: 45
- steps:
- - name: Checkout repository
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- persist-credentials: false
-
- - name: Set up Node.js
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- with:
- node-version: "24"
- package-manager-cache: false
-
- - name: Install exact dependency tree
- run: npm ci --ignore-scripts --no-audit --no-fund
-
- - name: Audit non-optional production dependencies
- run: npm audit --audit-level=high --omit=optional
-
- - name: Verify upgraded runtime imports
- run: node -e "require('axios'); require('cheerio'); console.log('dependency imports ok')"
-
- - name: Run deterministic repository tests
- shell: bash
- run: |
- set -euo pipefail
- npm run pretest
- python3 - <<'PY'
- import json
- import shlex
- import subprocess
- from pathlib import Path
-
- package = json.loads(Path('package.json').read_text(encoding='utf-8'))
- commands = [part.strip() for part in package['scripts']['test'].split('&&') if part.strip()]
- live_client_policy = {
- 'python3 scripts/validate_platform_runtime_policy.py',
- 'python3 scripts/validate_mobile_runtime_policy.py',
- 'python3 scripts/validate_nuvio_tv_runtime_policy.py',
- }
- deterministic = [command for command in commands if command not in live_client_policy]
- skipped = [command for command in commands if command in live_client_policy]
- assert len(skipped) == 3, skipped
- for command in deterministic:
- print(f'FIELD_DEPENDENCY_GATE_TEST command={command}', flush=True)
- subprocess.run(shlex.split(command), check=True)
- PY
- npm run posttest
diff --git a/.github/workflows/final-native-client-validation-v2.yml b/.github/workflows/final-native-client-validation-v2.yml
deleted file mode 100644
index 8e628d046..000000000
--- a/.github/workflows/final-native-client-validation-v2.yml
+++ /dev/null
@@ -1,307 +0,0 @@
-name: Final native Nuvio client validation v2
-
-on:
- workflow_dispatch:
- pull_request:
- branches: [main]
- paths:
- - ".github/workflows/final-native-client-validation-v2.yml"
- - "scripts/export_nuvio_client_refs.py"
- - "scripts/isolate_native_android_tests.py"
- - "scripts/native_mobile_playback_proof.sh"
- - "scripts/native_tv_playback_proof.sh"
- - "scripts/prepare_native_client_validation.py"
- - "scripts/harden_nuvio_mobile_device_test.py"
- - "scripts/provider_patches/**"
- - "tests/**native**"
- - "providers/**"
- - "manifest.json"
- - "vf/manifest.json"
- - "PROVENANCE.json"
- - "sources.json"
- push:
- branches: [main]
- paths:
- - ".github/triggers/final-native-client-validation-v2"
- - ".github/workflows/final-native-client-validation-v2.yml"
- - "scripts/export_nuvio_client_refs.py"
- - "scripts/isolate_native_android_tests.py"
- - "scripts/native_mobile_playback_proof.sh"
- - "scripts/native_tv_playback_proof.sh"
- - "scripts/prepare_native_client_validation.py"
- - "scripts/harden_nuvio_mobile_device_test.py"
- - "scripts/provider_patches/**"
- - "providers/**"
- - "manifest.json"
- - "vf/manifest.json"
- - "PROVENANCE.json"
- - "sources.json"
-
-permissions:
- contents: read
-
-concurrency:
- group: final-native-client-validation-v2-${{ github.ref }}
- cancel-in-progress: true
-
-jobs:
- desktop-native-runtime:
- runs-on: ubuntu-latest
- timeout-minutes: 35
- steps:
- - name: Checkout NiakVIO
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- path: niakvio
- persist-credentials: false
-
- - name: Resolve accepted Nuvio client revisions
- run: python3 niakvio/scripts/export_nuvio_client_refs.py --sources niakvio/sources.json >> "$GITHUB_ENV"
-
- - name: Set up Java
- uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
- with:
- distribution: temurin
- java-version: "17"
-
- - name: Set up Gradle cache
- uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c
-
- - name: Install official Linux player dependencies
- run: |
- sudo apt-get update
- sudo apt-get install -y --no-install-recommends \
- libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
-
- - name: Checkout accepted NuvioDesktop and inject current generation
- run: |
- git clone --quiet --filter=blob:none --no-checkout https://github.com/NuvioMedia/NuvioDesktop.git nuvio-desktop
- git -C nuvio-desktop checkout "$NUVIO_DESKTOP_SHA"
- touch nuvio-desktop/local.properties
- printf 'Accepted NuvioDesktop: %s\n' "$NUVIO_DESKTOP_SHA"
- python3 niakvio/scripts/prepare_native_client_validation.py desktop --workspace "$GITHUB_WORKSPACE"
-
- - name: Require real Desktop StreamZo HLS
- working-directory: nuvio-desktop
- shell: bash
- run: |
- proof_ok() {
- grep -Eq 'FIELD_DESKTOP_NATIVE provider=streamzo .* count=[1-9][0-9]*' "$GITHUB_WORKSPACE/desktop-native-results.log" &&
- grep -Eq 'FIELD_DESKTOP_NATIVE_ROW provider=streamzo .* type=hls([[:space:]]|$)' "$GITHUB_WORKSPACE/desktop-native-results.log" &&
- grep -Fq 'FIELD_DESKTOP_STREAMZO_SENTINEL status=resolved expected=resolved' "$GITHUB_WORKSPACE/desktop-native-results.log"
- }
-
- STATUS=1
- for ATTEMPT in 1 2; do
- rm -f "$GITHUB_WORKSPACE/desktop-native-results.log"
- echo "FIELD_DESKTOP_PROOF_ATTEMPT attempt=$ATTEMPT max=2"
- set +e
- if [[ "$ATTEMPT" -eq 1 ]]; then
- ./gradlew :composeApp:desktopTest --tests 'com.nuvio.app.features.plugins.NiakvioFinalNativeDesktopTest' --no-daemon --console=plain
- else
- ./gradlew :composeApp:desktopTest --tests 'com.nuvio.app.features.plugins.NiakvioFinalNativeDesktopTest' --rerun-tasks --no-daemon --console=plain
- fi
- STATUS=$?
- set -e
- cat "$GITHUB_WORKSPACE/desktop-native-results.log" 2>/dev/null || true
- if [[ "$STATUS" -eq 0 ]] && proof_ok; then
- STATUS=0
- break
- fi
- STATUS=1
- [[ "$ATTEMPT" -ge 2 ]] || sleep 3
- done
- exit "$STATUS"
-
- - name: Upload Desktop proof
- if: always()
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0
- with:
- name: final-native-desktop-${{ github.run_id }}
- path: |
- desktop-native-results.log
- nuvio-desktop/composeApp/build/reports/tests/**
- nuvio-desktop/composeApp/build/test-results/**
- retention-days: 7
- if-no-files-found: warn
-
- mobile-native-runtime:
- runs-on: ubuntu-latest
- timeout-minutes: 65
- steps:
- - name: Checkout NiakVIO
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- path: niakvio
- persist-credentials: false
-
- - name: Resolve accepted Nuvio client revisions
- run: python3 niakvio/scripts/export_nuvio_client_refs.py --sources niakvio/sources.json >> "$GITHUB_ENV"
-
- - name: Set up Java
- uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
- with:
- distribution: temurin
- java-version: "17"
-
- - name: Checkout accepted Mobile/TV runtimes and inject current generation
- run: |
- git clone --quiet --filter=blob:none --no-checkout https://github.com/NuvioMedia/NuvioMobile.git nuvio-mobile
- git -C nuvio-mobile checkout "$NUVIO_MOBILE_SHA"
- touch nuvio-mobile/local.properties
- git clone --quiet --filter=blob:none --no-checkout https://github.com/NuvioMedia/NuvioTV.git nuvio-tv
- git -C nuvio-tv checkout "$NUVIO_TV_SHA"
- touch nuvio-tv/local.properties nuvio-tv/local.dev.properties
- printf 'Accepted NuvioMobile: %s\nAccepted NuvioTV: %s\n' "$NUVIO_MOBILE_SHA" "$NUVIO_TV_SHA"
- python3 niakvio/scripts/prepare_native_client_validation.py android --workspace "$GITHUB_WORKSPACE"
- python3 niakvio/scripts/harden_nuvio_mobile_device_test.py "$GITHUB_WORKSPACE/nuvio-mobile"
- python3 niakvio/scripts/isolate_native_android_tests.py mobile "$GITHUB_WORKSPACE/nuvio-mobile"
-
- - name: Set up Gradle cache
- uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c
-
- - name: Precompile Mobile device test before emulator boot
- working-directory: nuvio-mobile
- run: ./gradlew :composeApp:compileAndroidDeviceTest -Pnuvio.android.distribution=full --build-cache --no-daemon --console=plain
-
- - name: Enable KVM
- run: if [ -e /dev/kvm ]; then sudo chmod 666 /dev/kvm || true; fi
-
- - name: Restore Mobile AVD snapshot
- id: avd-cache-mobile
- uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25
- with:
- path: |
- ~/.android/avd/*
- ~/.android/adb*
- key: avd-v1-${{ runner.os }}-mobile-api35-google_apis-x86_64-pixel_2
-
- - name: Create Mobile AVD snapshot on cache miss
- if: steps.avd-cache-mobile.outputs.cache-hit != 'true'
- uses: ReactiveCircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d
- with:
- api-level: 35
- target: google_apis
- arch: x86_64
- profile: pixel_2
- cores: 2
- ram-size: 2048M
- force-avd-creation: false
- emulator-boot-timeout: 600
- emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim
- disable-animations: false
- script: echo "Generated clean Mobile AVD snapshot."
-
- - name: Execute official Mobile PluginRuntime on phone AVD
- uses: ReactiveCircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d
- with:
- api-level: 35
- target: google_apis
- arch: x86_64
- profile: pixel_2
- cores: 2
- ram-size: 2048M
- force-avd-creation: false
- emulator-boot-timeout: 600
- emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim
- script: bash "$GITHUB_WORKSPACE/niakvio/scripts/native_mobile_playback_proof.sh"
-
- - name: Upload Mobile proof
- if: always()
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0
- with:
- name: final-native-mobile-${{ github.run_id }}
- path: |
- mobile-final-native-results.log
- nuvio-mobile/composeApp/build/reports/androidTests/**
- nuvio-mobile/composeApp/build/outputs/androidTest-results/**
- retention-days: 7
- if-no-files-found: warn
-
- tv-native-runtime:
- runs-on: ubuntu-latest
- timeout-minutes: 65
- steps:
- - name: Checkout NiakVIO
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- path: niakvio
- persist-credentials: false
-
- - name: Resolve accepted Nuvio client revisions
- run: python3 niakvio/scripts/export_nuvio_client_refs.py --sources niakvio/sources.json >> "$GITHUB_ENV"
-
- - name: Set up Java
- uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
- with:
- distribution: temurin
- java-version: "17"
-
- - name: Checkout accepted Mobile/TV runtimes and inject current generation
- run: |
- git clone --quiet --filter=blob:none --no-checkout https://github.com/NuvioMedia/NuvioMobile.git nuvio-mobile
- git -C nuvio-mobile checkout "$NUVIO_MOBILE_SHA"
- touch nuvio-mobile/local.properties
- git clone --quiet --filter=blob:none --no-checkout https://github.com/NuvioMedia/NuvioTV.git nuvio-tv
- git -C nuvio-tv checkout "$NUVIO_TV_SHA"
- touch nuvio-tv/local.properties nuvio-tv/local.dev.properties
- printf 'Accepted NuvioMobile: %s\nAccepted NuvioTV: %s\n' "$NUVIO_MOBILE_SHA" "$NUVIO_TV_SHA"
- python3 niakvio/scripts/prepare_native_client_validation.py android --workspace "$GITHUB_WORKSPACE"
- python3 niakvio/scripts/isolate_native_android_tests.py tv "$GITHUB_WORKSPACE/nuvio-tv"
-
- - name: Set up Gradle cache
- uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c
-
- - name: Enable KVM
- run: if [ -e /dev/kvm ]; then sudo chmod 666 /dev/kvm || true; fi
-
- - name: Restore TV AVD snapshot
- id: avd-cache-tv
- uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25
- with:
- path: |
- ~/.android/avd/*
- ~/.android/adb*
- key: avd-v1-${{ runner.os }}-tv-api31-android-tv-x86-tv_1080p
-
- - name: Create TV AVD snapshot on cache miss
- if: steps.avd-cache-tv.outputs.cache-hit != 'true'
- uses: ReactiveCircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d
- with:
- api-level: 31
- target: android-tv
- arch: x86
- profile: tv_1080p
- cores: 2
- ram-size: 2048M
- force-avd-creation: false
- emulator-boot-timeout: 600
- emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim
- disable-animations: false
- script: echo "Generated clean TV AVD snapshot."
-
- - name: Execute official NuvioTV PluginRuntime on Android TV AVD
- uses: ReactiveCircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d
- with:
- api-level: 31
- target: android-tv
- arch: x86
- profile: tv_1080p
- cores: 2
- ram-size: 2048M
- force-avd-creation: false
- emulator-boot-timeout: 600
- emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim
- script: bash "$GITHUB_WORKSPACE/niakvio/scripts/native_tv_playback_proof.sh"
-
- - name: Upload TV proof
- if: always()
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0
- with:
- name: final-native-tv-${{ github.run_id }}
- path: |
- tv-final-native-results.log
- nuvio-tv/app/build/reports/androidTests/**
- nuvio-tv/app/build/outputs/androidTest-results/**
- retention-days: 7
- if-no-files-found: warn
diff --git a/.github/workflows/native-android-route-reader.yml b/.github/workflows/native-android-route-reader.yml
new file mode 100644
index 000000000..519a51eac
--- /dev/null
+++ b/.github/workflows/native-android-route-reader.yml
@@ -0,0 +1,495 @@
+name: Native Android route reader acceptance
+
+on:
+ pull_request:
+ branches: [main]
+ paths:
+ - ".github/workflows/native-android-route-reader.yml"
+ - ".github/triggers/nuvio-client-lab.json"
+ - "manifest.json"
+ - "vf/manifest.json"
+ - "provider_catalog.json"
+ - "provider-overrides.json"
+ - "providers/**"
+ - "sources.json"
+ - "automation/nuvio-client-upstreams.json"
+ - "automation/platform-runtime-contracts.json"
+ - "automation/nuvio-tv-runtime-contract.json"
+ - "engine_v2/**"
+ - "scripts/check_nuvio_client_upstreams.py"
+ - "scripts/resolve_nuvio_lab_heads.py"
+ - "scripts/scope_native_reader_learning_runtime.py"
+ - "scripts/prepare_native_reader_acceptance.py"
+ - "scripts/augment_native_corpus_request_contract.py"
+ - "scripts/augment_native_provider_loading.py"
+ - "scripts/resolve_native_repository.sh"
+ - "scripts/prepare_native_candidate_repository.py"
+ - "scripts/instrument_native_client_evidence.py"
+ - "scripts/instrument_native_repository_http_evidence.py"
+ - "scripts/capture_native_device_frontend.sh"
+ - "scripts/watch_native_device_frontend.sh"
+ - "scripts/native_player_diagnostics_codegen.py"
+ - "scripts/native_player_diagnostics.cjs"
+ - "scripts/native_evidence_completeness.cjs"
+ - "scripts/run_native_corpus_tv_suite.sh"
+ - "scripts/run_native_corpus_mobile_suite.sh"
+ - "scripts/gate_native_reader_coverage.cjs"
+ - "scripts/gate_native_reader_result.cjs"
+ - "scripts/build_native_reader_brain_repair.py"
+ - "scripts/compare_native_reader_brain_repair.py"
+ - "scripts/provider_patches/global_media_enrichment_v1.py"
+ push:
+ branches: [main]
+ paths:
+ - "manifest.json"
+ - "vf/manifest.json"
+ - "provider_catalog.json"
+ - "provider-overrides.json"
+ - "providers/**"
+ - "sources.json"
+ - "automation/nuvio-client-upstreams.json"
+ - "automation/platform-runtime-contracts.json"
+ - "automation/nuvio-tv-runtime-contract.json"
+ - "engine_v2/**"
+ - "scripts/check_nuvio_client_upstreams.py"
+ - "scripts/resolve_nuvio_lab_heads.py"
+ - "scripts/scope_native_reader_learning_runtime.py"
+ - "scripts/prepare_native_reader_acceptance.py"
+ - "scripts/augment_native_corpus_request_contract.py"
+ - "scripts/augment_native_provider_loading.py"
+ - "scripts/resolve_native_repository.sh"
+ - "scripts/prepare_native_candidate_repository.py"
+ - "scripts/instrument_native_client_evidence.py"
+ - "scripts/instrument_native_repository_http_evidence.py"
+ - "scripts/capture_native_device_frontend.sh"
+ - "scripts/watch_native_device_frontend.sh"
+ - "scripts/native_player_diagnostics_codegen.py"
+ - "scripts/native_player_diagnostics.cjs"
+ - "scripts/native_evidence_completeness.cjs"
+ - "scripts/run_native_corpus_tv_suite.sh"
+ - "scripts/run_native_corpus_mobile_suite.sh"
+ - "scripts/gate_native_reader_coverage.cjs"
+ - "scripts/gate_native_reader_result.cjs"
+ - "scripts/build_native_reader_brain_repair.py"
+ - "scripts/compare_native_reader_brain_repair.py"
+ - "scripts/provider_patches/global_media_enrichment_v1.py"
+ - ".github/workflows/native-android-route-reader.yml"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: native-android-route-reader-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+env:
+ NIAKVIO_REPRESENTATIVE_FIXTURES: "sinners-2025 breaking-bad-s01e01 jujutsu-kaisen-s01e01"
+
+jobs:
+ resolve:
+ runs-on: ubuntu-latest
+ timeout-minutes: 8
+ outputs:
+ mobile_sha: ${{ steps.refs.outputs.mobile_sha }}
+ tv_sha: ${{ steps.refs.outputs.tv_sha }}
+ runtime_fingerprint: ${{ steps.refs.outputs.runtime_fingerprint }}
+ mobile_drift_status: ${{ steps.refs.outputs.mobile_drift_status }}
+ tv_drift_status: ${{ steps.refs.outputs.tv_drift_status }}
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
+ with:
+ fetch-depth: 1
+ persist-credentials: false
+ ref: ${{ github.event.pull_request.head.sha || github.sha }}
+ - name: Inspect current official Nuvio client drift
+ shell: bash
+ run: |
+ set -euo pipefail
+ python3 scripts/check_nuvio_client_upstreams.py \
+ --no-fail \
+ --output health-output/nuvio-client-upstream-status.json
+ - id: refs
+ name: Resolve latest official Android client HEADs
+ shell: bash
+ run: |
+ set -euo pipefail
+ python3 scripts/resolve_nuvio_lab_heads.py \
+ --report health-output/nuvio-client-upstream-status.json \
+ --clients nuvio-tv nuvio-mobile \
+ --github-output "$GITHUB_OUTPUT" \
+ --output health-output/nuvio-android-lab-heads.json
+ - name: Publish Nuvio Android runtime audit context
+ if: always()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
+ with:
+ name: nuvio-android-lab-heads-${{ github.run_id }}
+ path: |
+ health-output/nuvio-client-upstream-status.json
+ health-output/nuvio-android-lab-heads.json
+ retention-days: 7
+ if-no-files-found: error
+
+ tv-route-reader:
+ needs: resolve
+ runs-on: ubuntu-latest
+ timeout-minutes: ${{ github.event_name == 'pull_request' && 55 || 180 }}
+ steps:
+ - name: Checkout NiakVIO candidate
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
+ with:
+ path: niakvio
+ persist-credentials: false
+ ref: ${{ github.event.pull_request.head.sha || github.sha }}
+ - uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165
+ with: {distribution: temurin, java-version: "17"}
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
+ with: {node-version: "24", package-manager-cache: false}
+ - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c
+ - name: Checkout latest official NuvioTV HEAD and stage first route
+ shell: bash
+ env: {NUVIO_TV_SHA: "${{ needs.resolve.outputs.tv_sha }}"}
+ run: |
+ set -euxo pipefail
+ git clone --filter=blob:none --no-checkout https://github.com/NuvioMedia/NuvioTV.git nuvio-tv
+ git -C nuvio-tv checkout "$NUVIO_TV_SHA"
+ touch nuvio-tv/local.properties nuvio-tv/local.dev.properties
+ python3 niakvio/scripts/prepare_native_reader_acceptance.py tv --fixture sinners-2025 --workspace "$GITHUB_WORKSPACE" --manifest manifest.json --provider all --streams all --initial
+ - name: Enable KVM
+ run: if [ -e /dev/kvm ]; then sudo chmod 666 /dev/kvm || true; fi
+ - name: Restore TV AVD snapshot
+ id: avd-cache
+ uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25
+ with:
+ path: |
+ ~/.android/avd/*
+ ~/.android/adb*
+ key: avd-v1-${{ runner.os }}-tv-api31-android-tv-x86-tv_1080p
+ - name: Create TV AVD snapshot on cache miss
+ if: steps.avd-cache.outputs.cache-hit != 'true'
+ uses: ReactiveCircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d
+ with:
+ api-level: 31
+ target: android-tv
+ arch: x86
+ profile: tv_1080p
+ cores: 2
+ ram-size: 2048M
+ force-avd-creation: false
+ emulator-boot-timeout: 600
+ emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim
+ disable-animations: false
+ script: echo "TV route AVD ready"
+ - name: Execute representative routes in one TV boot
+ uses: ReactiveCircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d
+ env:
+ NIAKVIO_TARGET_FIXTURES: "sinners-2025 breaking-bad-s01e01 jujutsu-kaisen-s01e01"
+ NIAKVIO_TARGET_PROVIDER: all
+ NIAKVIO_TARGET_MANIFEST: manifest.json
+ NIAKVIO_READER_ACCEPTANCE: "1"
+ NIAKVIO_PRIMARY_FIXTURE: sinners-2025
+ NIAKVIO_PRIMARY_STREAM_SCOPE: all
+ NIAKVIO_REGRESSION_STREAM_SCOPE: all
+ NIAKVIO_REQUIRE_READER_SUCCESS: "1"
+ with:
+ api-level: 31
+ target: android-tv
+ arch: x86
+ profile: tv_1080p
+ cores: 2
+ ram-size: 2048M
+ force-avd-creation: false
+ emulator-boot-timeout: 600
+ emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim
+ script: bash "$GITHUB_WORKSPACE/niakvio/scripts/run_native_corpus_tv_suite.sh"
+ - name: Diagnose complete TV evidence
+ if: always()
+ shell: bash
+ run: |
+ set -euo pipefail
+ for fixture in $NIAKVIO_REPRESENTATIVE_FIXTURES; do
+ LOG="$GITHUB_WORKSPACE/tv-native-corpus-${fixture}.log"
+ test -s "$LOG"
+ node niakvio/engine_v2/scripts/diagnose-native-reader.mjs --output "$GITHUB_WORKSPACE/tv-route-${fixture}-brain.json" "$LOG"
+ done
+ - name: Upload TV route evidence
+ if: always()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
+ with:
+ name: native-tv-route-sinners-2025-${{ github.run_id }}
+ path: |
+ tv-native-corpus-*.log
+ tv-native-frontend-*.log
+ tv-route-*-brain.json
+ native-evidence/tv/**
+ retention-days: 7
+ if-no-files-found: warn
+
+ mobile-route-reader:
+ needs: resolve
+ runs-on: ubuntu-latest
+ timeout-minutes: ${{ github.event_name == 'pull_request' && 55 || 180 }}
+ steps:
+ - name: Checkout NiakVIO candidate
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
+ with:
+ path: niakvio
+ persist-credentials: false
+ ref: ${{ github.event.pull_request.head.sha || github.sha }}
+ - uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165
+ with: {distribution: temurin, java-version: "17"}
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
+ with: {node-version: "24", package-manager-cache: false}
+ - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c
+ - name: Checkout latest official NuvioMobile HEAD and stage first route
+ shell: bash
+ env: {NUVIO_MOBILE_SHA: "${{ needs.resolve.outputs.mobile_sha }}"}
+ run: |
+ set -euxo pipefail
+ git clone --filter=blob:none --no-checkout https://github.com/NuvioMedia/NuvioMobile.git nuvio-mobile
+ git -C nuvio-mobile checkout "$NUVIO_MOBILE_SHA"
+ touch nuvio-mobile/local.properties
+ python3 niakvio/scripts/prepare_native_reader_acceptance.py mobile --fixture sinners-2025 --workspace "$GITHUB_WORKSPACE" --manifest manifest.json --provider all --streams all --initial
+ python3 niakvio/scripts/harden_nuvio_mobile_device_test.py "$GITHUB_WORKSPACE/nuvio-mobile"
+ - name: Enable KVM
+ run: if [ -e /dev/kvm ]; then sudo chmod 666 /dev/kvm || true; fi
+ - name: Restore Mobile AVD snapshot
+ id: avd-cache
+ uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25
+ with:
+ path: |
+ ~/.android/avd/*
+ ~/.android/adb*
+ key: avd-v1-${{ runner.os }}-mobile-api35-google_apis-x86_64-pixel_2
+ - name: Create Mobile AVD snapshot on cache miss
+ if: steps.avd-cache.outputs.cache-hit != 'true'
+ uses: ReactiveCircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d
+ with:
+ api-level: 35
+ target: google_apis
+ arch: x86_64
+ profile: pixel_2
+ cores: 2
+ ram-size: 2048M
+ force-avd-creation: false
+ emulator-boot-timeout: 600
+ emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim
+ disable-animations: false
+ script: echo "Mobile route AVD ready"
+ - name: Execute representative routes in one Mobile boot
+ uses: ReactiveCircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d
+ env:
+ NIAKVIO_TARGET_FIXTURES: "sinners-2025 breaking-bad-s01e01 jujutsu-kaisen-s01e01"
+ NIAKVIO_TARGET_PROVIDER: all
+ NIAKVIO_TARGET_MANIFEST: manifest.json
+ NIAKVIO_READER_ACCEPTANCE: "1"
+ NIAKVIO_PRIMARY_FIXTURE: sinners-2025
+ NIAKVIO_PRIMARY_STREAM_SCOPE: all
+ NIAKVIO_REGRESSION_STREAM_SCOPE: all
+ NIAKVIO_REQUIRE_READER_SUCCESS: "1"
+ with:
+ api-level: 35
+ target: google_apis
+ arch: x86_64
+ profile: pixel_2
+ cores: 2
+ ram-size: 2048M
+ force-avd-creation: false
+ emulator-boot-timeout: 600
+ emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim
+ script: bash "$GITHUB_WORKSPACE/niakvio/scripts/run_native_corpus_mobile_suite.sh"
+ - name: Diagnose complete Mobile evidence
+ if: always()
+ shell: bash
+ run: |
+ set -euo pipefail
+ for fixture in $NIAKVIO_REPRESENTATIVE_FIXTURES; do
+ LOG="$GITHUB_WORKSPACE/mobile-native-corpus-${fixture}.log"
+ test -s "$LOG"
+ node niakvio/engine_v2/scripts/diagnose-native-reader.mjs --output "$GITHUB_WORKSPACE/mobile-route-${fixture}-brain.json" "$LOG"
+ done
+ - name: Upload Mobile route evidence
+ if: always()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
+ with:
+ name: native-mobile-route-representative-${{ github.run_id }}
+ path: |
+ mobile-native-corpus-*.log
+ mobile-native-frontend-*.log
+ mobile-route-*-brain.json
+ native-evidence/mobile/**
+ retention-days: 7
+ if-no-files-found: warn
+
+ brain-reader-repair-sandbox:
+ name: Brain reader repair sandbox and official TV retest
+ needs: [resolve, tv-route-reader, mobile-route-reader]
+ if: ${{ always() && needs.resolve.result == 'success' }}
+ runs-on: ubuntu-latest
+ timeout-minutes: ${{ github.event_name == 'pull_request' && 55 || 150 }}
+ steps:
+ - name: Checkout exact NiakVIO candidate
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
+ with:
+ path: niakvio
+ persist-credentials: false
+ ref: ${{ github.event.pull_request.head.sha || github.sha }}
+ - uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165
+ with: {distribution: temurin, java-version: "17"}
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
+ with: {node-version: "24", package-manager-cache: false}
+ - name: Download Sinners TV evidence from this exact run
+ continue-on-error: true
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
+ with:
+ name: native-tv-route-sinners-2025-${{ github.run_id }}
+ path: baseline-reader/tv
+ - name: Download representative Mobile evidence from this exact run
+ continue-on-error: true
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
+ with:
+ name: native-mobile-route-representative-${{ github.run_id }}
+ path: baseline-reader/mobile
+ - name: Restore prior sanitized reader repair memory
+ shell: bash
+ env:
+ NIAKVIO_RUNTIME_FINGERPRINT: ${{ needs.resolve.outputs.runtime_fingerprint }}
+ run: |
+ set -euo pipefail
+ git -C niakvio fetch origin brain-learning/proposals:refs/remotes/origin/brain-learning/proposals || true
+ if git -C niakvio cat-file -e refs/remotes/origin/brain-learning/proposals:engine_v2/learning/latest.json 2>/dev/null; then
+ git -C niakvio show refs/remotes/origin/brain-learning/proposals:engine_v2/learning/latest.json > niakvio/native-reader-learning-previous.json
+ else
+ printf '{}\n' > niakvio/native-reader-learning-previous.json
+ fi
+ python3 niakvio/scripts/scope_native_reader_learning_runtime.py filter \
+ --state niakvio/native-reader-learning-previous.json \
+ --runtime-fingerprint "$NIAKVIO_RUNTIME_FINGERPRINT" \
+ --output niakvio/native-reader-learning-current-runtime.json
+ - name: Materialize bounded generic Brain mutations
+ id: repair
+ shell: bash
+ run: |
+ set -euo pipefail
+ TV_LOG="$GITHUB_WORKSPACE/baseline-reader/tv/tv-native-corpus-sinners-2025.log"
+ MOBILE_LOG="$GITHUB_WORKSPACE/baseline-reader/mobile/mobile-native-corpus-sinners-2025.log"
+ BEFORE="$GITHUB_WORKSPACE/baseline-reader/sinners-cross-client-brain.json"
+ if [ ! -s "$TV_LOG" ] || [ ! -s "$MOBILE_LOG" ]; then
+ echo "No complete cross-client Sinners TV+Mobile evidence; provider mutation is forbidden."
+ echo "proposals=0" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+ node niakvio/engine_v2/scripts/diagnose-native-reader.mjs \
+ --output "$BEFORE" "$TV_LOG" "$MOBILE_LOG"
+ python3 niakvio/scripts/build_native_reader_brain_repair.py \
+ --diagnosis "$BEFORE" \
+ --manifest niakvio/manifest.json \
+ --learning-state niakvio/native-reader-learning-current-runtime.json \
+ --output-dir niakvio/native-reader-repair \
+ --fixture sinners-2025 \
+ --max-providers 24
+ COUNT="$(python3 - <<'PY'
+ import json
+ print(json.load(open('niakvio/native-reader-repair/repair-report.json'))['proposalCount'])
+ PY
+ )"
+ echo "proposals=$COUNT" >> "$GITHUB_OUTPUT"
+ cat niakvio/native-reader-repair/repair-report.json
+ - name: Checkout latest official NuvioTV HEAD for candidate retest
+ if: steps.repair.outputs.proposals != '0'
+ shell: bash
+ env: {NUVIO_TV_SHA: "${{ needs.resolve.outputs.tv_sha }}"}
+ run: |
+ set -euxo pipefail
+ git clone --filter=blob:none --no-checkout https://github.com/NuvioMedia/NuvioTV.git nuvio-tv
+ git -C nuvio-tv checkout "$NUVIO_TV_SHA"
+ touch nuvio-tv/local.properties nuvio-tv/local.dev.properties
+ python3 niakvio/scripts/prepare_native_reader_acceptance.py tv --fixture sinners-2025 --workspace "$GITHUB_WORKSPACE" --manifest native-reader-repair/manifest.json --provider all --streams all --initial
+ - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c
+ if: steps.repair.outputs.proposals != '0'
+ - name: Enable KVM for candidate retest
+ if: steps.repair.outputs.proposals != '0'
+ run: if [ -e /dev/kvm ]; then sudo chmod 666 /dev/kvm || true; fi
+ - name: Restore TV AVD snapshot for candidate retest
+ if: steps.repair.outputs.proposals != '0'
+ id: repair-avd-cache
+ uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25
+ with:
+ path: |
+ ~/.android/avd/*
+ ~/.android/adb*
+ key: avd-v1-${{ runner.os }}-tv-api31-android-tv-x86-tv_1080p
+ - name: Create TV AVD snapshot on candidate cache miss
+ if: steps.repair.outputs.proposals != '0' && steps.repair-avd-cache.outputs.cache-hit != 'true'
+ uses: ReactiveCircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d
+ with:
+ api-level: 31
+ target: android-tv
+ arch: x86
+ profile: tv_1080p
+ cores: 2
+ ram-size: 2048M
+ force-avd-creation: false
+ emulator-boot-timeout: 600
+ emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim
+ disable-animations: false
+ script: echo "TV repair AVD ready"
+ - name: Re-read every Sinners provider and stream after Brain mutation
+ if: steps.repair.outputs.proposals != '0'
+ continue-on-error: true
+ uses: ReactiveCircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d
+ env:
+ NIAKVIO_TARGET_FIXTURE: sinners-2025
+ NIAKVIO_TARGET_PROVIDER: all
+ NIAKVIO_TARGET_MANIFEST: native-reader-repair/manifest.json
+ NIAKVIO_READER_ACCEPTANCE: "1"
+ NIAKVIO_PRIMARY_FIXTURE: sinners-2025
+ NIAKVIO_PRIMARY_STREAM_SCOPE: all
+ NIAKVIO_REGRESSION_STREAM_SCOPE: all
+ NIAKVIO_REQUIRE_READER_SUCCESS: "1"
+ with:
+ api-level: 31
+ target: android-tv
+ arch: x86
+ profile: tv_1080p
+ cores: 2
+ ram-size: 2048M
+ force-avd-creation: false
+ emulator-boot-timeout: 600
+ emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim
+ script: bash "$GITHUB_WORKSPACE/niakvio/scripts/run_native_corpus_tv_suite.sh"
+ - name: Diagnose and compare Brain mutation with fresh reader proof
+ if: steps.repair.outputs.proposals != '0'
+ env:
+ NIAKVIO_RUNTIME_FINGERPRINT: ${{ needs.resolve.outputs.runtime_fingerprint }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ LOG="$GITHUB_WORKSPACE/tv-native-corpus-sinners-2025.log"
+ BEFORE="$GITHUB_WORKSPACE/baseline-reader/sinners-cross-client-brain.json"
+ test -s "$LOG"
+ test -s "$BEFORE"
+ node niakvio/engine_v2/scripts/diagnose-native-reader.mjs --output "$GITHUB_WORKSPACE/tv-reader-repair-after.json" "$LOG"
+ python3 niakvio/scripts/compare_native_reader_brain_repair.py \
+ --before "$BEFORE" \
+ --after "$GITHUB_WORKSPACE/tv-reader-repair-after.json" \
+ --repair-report niakvio/native-reader-repair/repair-report.json \
+ --fixture sinners-2025 \
+ --runtime-fingerprint "$NIAKVIO_RUNTIME_FINGERPRINT" \
+ --output "$GITHUB_WORKSPACE/tv-reader-repair-comparison.json"
+ cat "$GITHUB_WORKSPACE/tv-reader-repair-comparison.json"
+ - name: Upload Brain mutation and reader comparison
+ if: always()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
+ with:
+ name: native-reader-brain-repair-${{ github.run_id }}
+ path: |
+ baseline-reader/sinners-cross-client-brain.json
+ niakvio/native-reader-repair/manifest.json
+ niakvio/native-reader-repair/repair-report.json
+ niakvio/native-reader-repair/providers/*.js
+ tv-native-corpus-sinners-2025.log
+ tv-reader-repair-after.json
+ tv-reader-repair-comparison.json
+ retention-days: 7
+ if-no-files-found: warn
diff --git a/.github/workflows/native-corpus-device-lab.yml b/.github/workflows/native-corpus-device-lab.yml
deleted file mode 100644
index acd626e9b..000000000
--- a/.github/workflows/native-corpus-device-lab.yml
+++ /dev/null
@@ -1,356 +0,0 @@
-name: Native corpus device lab
-
-on:
- workflow_run:
- workflows:
- - Niakvio provider pipeline
- types:
- - completed
- branches:
- - main
- push:
- branches: [main]
- paths:
- - ".github/triggers/native-corpus-device-lab"
- - ".github/workflows/native-corpus-device-lab.yml"
- - "scripts/prepare_native_corpus_client.py"
- - "scripts/restage_native_corpus_client.py"
- - "scripts/analyze_native_corpus_collection.cjs"
- - "scripts/run_native_corpus_mobile_suite.sh"
- - "scripts/run_native_corpus_tv_suite.sh"
- - "sources.json"
-
-permissions:
- contents: read
-
-concurrency:
- group: native-corpus-device-lab-${{ github.event_name }}-${{ github.sha }}
- cancel-in-progress: true
-
-jobs:
- publication-gate:
- runs-on: ubuntu-latest
- timeout-minutes: 8
- outputs:
- run: ${{ steps.resolve.outputs.run }}
- target_sha: ${{ steps.resolve.outputs.target_sha }}
- desktop_sha: ${{ steps.resolve.outputs.desktop_sha }}
- mobile_sha: ${{ steps.resolve.outputs.mobile_sha }}
- tv_sha: ${{ steps.resolve.outputs.tv_sha }}
- steps:
- - name: Checkout current Niakvio main
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- with:
- ref: main
- fetch-depth: 2
-
- - name: Resolve exact published generation and accepted official clients
- id: resolve
- shell: bash
- env:
- EVENT_NAME: ${{ github.event_name }}
- UPSTREAM_CONCLUSION: ${{ github.event.workflow_run.conclusion }}
- UPSTREAM_HEAD: ${{ github.event.workflow_run.head_sha }}
- run: |
- set -euo pipefail
- TARGET_SHA="$(git rev-parse HEAD)"
- SHOULD_RUN=true
- if [ "$EVENT_NAME" = "workflow_run" ]; then
- SHOULD_RUN=false
- PARENT_SHA="$(git rev-parse HEAD^ 2>/dev/null || true)"
- MESSAGE="$(git log -1 --format=%s)"
- if [ "$UPSTREAM_CONCLUSION" = "success" ] \
- && [ "$PARENT_SHA" = "$UPSTREAM_HEAD" ] \
- && [ "$MESSAGE" = "chore: publish validated ARCHI2 provider transaction" ]; then
- SHOULD_RUN=true
- fi
- fi
- echo "run=$SHOULD_RUN" >> "$GITHUB_OUTPUT"
- echo "target_sha=$TARGET_SHA" >> "$GITHUB_OUTPUT"
- python3 - <<'PY' >> "$GITHUB_OUTPUT"
- import json
- from pathlib import Path
- data = json.loads(Path('sources.json').read_text(encoding='utf-8'))
- clients = data.get('nuvio_client_compatibility', {}).get('clients', {})
- for output, key in {'desktop_sha':'nuvio-desktop','mobile_sha':'nuvio-mobile','tv_sha':'nuvio-tv'}.items():
- ref = str((clients.get(key) or {}).get('accepted_ref') or '').strip()
- if len(ref) != 40:
- raise SystemExit(f'missing accepted_ref for {key}')
- print(f'{output}={ref}')
- PY
-
- desktop-native-corpus:
- needs: publication-gate
- if: needs.publication-gate.outputs.run == 'true'
- runs-on: ubuntu-latest
- timeout-minutes: 220
- steps:
- - name: Checkout exact published Niakvio generation
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- with:
- ref: ${{ needs.publication-gate.outputs.target_sha }}
- path: niakvio
- - name: Set up JDK
- uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165
- with: {distribution: temurin, java-version: "17"}
- - name: Set up Node
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
- with: {node-version: "24", package-manager-cache: false}
- - name: Install official Desktop player dependencies
- run: |
- sudo apt-get update
- sudo apt-get install -y --no-install-recommends libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
- - name: Checkout accepted official NuvioDesktop and stage corpus once
- shell: bash
- env:
- NUVIO_DESKTOP_SHA: ${{ needs.publication-gate.outputs.desktop_sha }}
- run: |
- set -euxo pipefail
- git clone --filter=blob:none --no-checkout https://github.com/NuvioMedia/NuvioDesktop.git nuvio-desktop
- git -C nuvio-desktop checkout "$NUVIO_DESKTOP_SHA"
- touch nuvio-desktop/local.properties
- python3 niakvio/scripts/prepare_native_corpus_client.py desktop --fixture interstellar --workspace "$GITHUB_WORKSPACE"
- - name: Execute six works across every provider in official Desktop runtime
- shell: bash
- run: |
- set -u
- FIXTURES=(interstellar mon-ninja-et-moi-3 breaking-bad-s01e01 revenant-s01e01 jujutsu-kaisen-s01e01 mushoku-tensei-s01e01)
- STATUS=0
- for fixture in "${FIXTURES[@]}"; do
- echo "===== DESKTOP CORPUS FIXTURE: $fixture ====="
- python3 niakvio/scripts/restage_native_corpus_client.py desktop --fixture "$fixture" --workspace "$GITHUB_WORKSPACE" || { STATUS=1; continue; }
- LOG="$GITHUB_WORKSPACE/desktop-native-corpus-${fixture}.log"
- rm -f "$LOG"
- RUNTIME_STATUS=0
- ./nuvio-desktop/gradlew -p nuvio-desktop :composeApp:desktopTest --tests 'com.nuvio.app.features.plugins.NiakvioNativeCorpusDesktopTest' --no-daemon --console=plain || RUNTIME_STATUS=$?
- cat "$LOG" || true
- COLLECTION_STATUS=0
- node niakvio/scripts/analyze_native_corpus_collection.cjs "$fixture" "$LOG" || COLLECTION_STATUS=$?
- echo "FIELD_NATIVE_CORPUS_DESKTOP_STATUS fixture=$fixture runtime=$RUNTIME_STATUS collection=$COLLECTION_STATUS"
- if [ "$RUNTIME_STATUS" -ne 0 ] || [ "$COLLECTION_STATUS" -ne 0 ]; then STATUS=1; fi
- done
- echo "FIELD_NATIVE_CORPUS_DESKTOP_SUITE_STATUS status=$STATUS fixtures=${#FIXTURES[@]} clients=1"
- exit "$STATUS"
- - name: Upload sanitized Desktop native corpus logs
- if: always()
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
- with:
- name: native-corpus-desktop-${{ github.run_id }}
- path: desktop-native-corpus-*.log
- retention-days: 7
- if-no-files-found: warn
-
- mobile-native-corpus:
- needs: publication-gate
- if: needs.publication-gate.outputs.run == 'true'
- runs-on: ubuntu-latest
- timeout-minutes: 220
- steps:
- - name: Checkout exact published Niakvio generation
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- with:
- ref: ${{ needs.publication-gate.outputs.target_sha }}
- path: niakvio
- - name: Set up JDK
- uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165
- with: {distribution: temurin, java-version: "17"}
- - name: Set up Node
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
- with: {node-version: "24", package-manager-cache: false}
- - name: Checkout accepted official NuvioMobile and stage corpus once
- shell: bash
- env:
- NUVIO_MOBILE_SHA: ${{ needs.publication-gate.outputs.mobile_sha }}
- run: |
- set -euxo pipefail
- git clone --filter=blob:none --no-checkout https://github.com/NuvioMedia/NuvioMobile.git nuvio-mobile
- git -C nuvio-mobile checkout "$NUVIO_MOBILE_SHA"
- touch nuvio-mobile/local.properties
- python3 niakvio/scripts/prepare_native_corpus_client.py mobile --fixture interstellar --workspace "$GITHUB_WORKSPACE"
- python3 niakvio/scripts/harden_nuvio_mobile_device_test.py "$GITHUB_WORKSPACE/nuvio-mobile"
- - name: Set up Gradle cache
- uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c
- - name: Enable Linux KVM when available
- shell: bash
- run: |
- if [ -e /dev/kvm ]; then sudo chmod 666 /dev/kvm || true; ls -l /dev/kvm || true; fi
- - name: Restore Mobile AVD snapshot
- id: avd-cache-mobile
- uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25
- with:
- path: |
- ~/.android/avd/*
- ~/.android/adb*
- key: avd-v1-${{ runner.os }}-mobile-api35-google_apis-x86_64-pixel_2
- - name: Create Mobile AVD snapshot on cache miss
- if: steps.avd-cache-mobile.outputs.cache-hit != 'true'
- uses: ReactiveCircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d
- with:
- api-level: 35
- target: google_apis
- arch: x86_64
- profile: pixel_2
- cores: 2
- ram-size: 2048M
- force-avd-creation: false
- emulator-boot-timeout: 600
- emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim
- disable-animations: false
- script: echo "Generated clean Mobile AVD snapshot."
- - name: Execute six works across every provider in isolated Mobile emulator
- uses: ReactiveCircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d
- with:
- api-level: 35
- target: google_apis
- arch: x86_64
- profile: pixel_2
- cores: 2
- ram-size: 2048M
- force-avd-creation: false
- emulator-boot-timeout: 600
- emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim
- script: bash "$GITHUB_WORKSPACE/niakvio/scripts/run_native_corpus_mobile_suite.sh"
- - name: Upload sanitized Mobile native corpus logs
- if: always()
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
- with:
- name: native-corpus-mobile-${{ github.run_id }}
- path: mobile-native-corpus-*.log
- retention-days: 7
- if-no-files-found: warn
-
- tv-native-corpus:
- needs: publication-gate
- if: needs.publication-gate.outputs.run == 'true'
- runs-on: ubuntu-latest
- timeout-minutes: 220
- steps:
- - name: Checkout exact published Niakvio generation
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- with:
- ref: ${{ needs.publication-gate.outputs.target_sha }}
- path: niakvio
- - name: Set up JDK
- uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165
- with: {distribution: temurin, java-version: "17"}
- - name: Set up Node
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
- with: {node-version: "24", package-manager-cache: false}
- - name: Checkout accepted official NuvioTV and stage corpus once
- shell: bash
- env:
- NUVIO_TV_SHA: ${{ needs.publication-gate.outputs.tv_sha }}
- run: |
- set -euxo pipefail
- git clone --filter=blob:none --no-checkout https://github.com/NuvioMedia/NuvioTV.git nuvio-tv
- git -C nuvio-tv checkout "$NUVIO_TV_SHA"
- touch nuvio-tv/local.properties nuvio-tv/local.dev.properties
- python3 niakvio/scripts/prepare_native_corpus_client.py tv --fixture interstellar --workspace "$GITHUB_WORKSPACE"
- - name: Set up Gradle cache
- uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c
- - name: Enable Linux KVM when available
- shell: bash
- run: |
- if [ -e /dev/kvm ]; then sudo chmod 666 /dev/kvm || true; ls -l /dev/kvm || true; fi
- - name: Restore TV AVD snapshot
- id: avd-cache-tv
- uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25
- with:
- path: |
- ~/.android/avd/*
- ~/.android/adb*
- key: avd-v1-${{ runner.os }}-tv-api31-android-tv-x86-tv_1080p
- - name: Create TV AVD snapshot on cache miss
- if: steps.avd-cache-tv.outputs.cache-hit != 'true'
- uses: ReactiveCircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d
- with:
- api-level: 31
- target: android-tv
- arch: x86
- profile: tv_1080p
- cores: 2
- ram-size: 2048M
- force-avd-creation: false
- emulator-boot-timeout: 600
- emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim
- disable-animations: false
- script: echo "Generated clean TV AVD snapshot."
- - name: Execute six works across every provider in isolated Android TV emulator
- uses: ReactiveCircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d
- with:
- api-level: 31
- target: android-tv
- arch: x86
- profile: tv_1080p
- cores: 2
- ram-size: 2048M
- force-avd-creation: false
- emulator-boot-timeout: 600
- emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim
- script: bash "$GITHUB_WORKSPACE/niakvio/scripts/run_native_corpus_tv_suite.sh"
- - name: Upload sanitized TV native corpus logs
- if: always()
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
- with:
- name: native-corpus-tv-${{ github.run_id }}
- path: tv-native-corpus-*.log
- retention-days: 7
- if-no-files-found: warn
-
- native-corpus-engine-summary:
- if: ${{ always() && needs.publication-gate.outputs.run == 'true' }}
- needs: [publication-gate, desktop-native-corpus, mobile-native-corpus, tv-native-corpus]
- runs-on: ubuntu-latest
- timeout-minutes: 12
- steps:
- - name: Checkout exact published Niakvio generation
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- with:
- ref: ${{ needs.publication-gate.outputs.target_sha }}
- path: niakvio
- - name: Set up Node
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
- with: {node-version: "24", package-manager-cache: false}
- - name: Download Desktop corpus logs
- continue-on-error: true
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
- with: {name: "native-corpus-desktop-${{ github.run_id }}", path: corpus-logs/desktop}
- - name: Download Mobile corpus logs
- continue-on-error: true
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
- with: {name: "native-corpus-mobile-${{ github.run_id }}", path: corpus-logs/mobile}
- - name: Download TV corpus logs
- continue-on-error: true
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
- with: {name: "native-corpus-tv-${{ github.run_id }}", path: corpus-logs/tv}
- - name: Build cross-device engine feedback and provider portfolio
- shell: bash
- run: |
- set -euo pipefail
- node niakvio/scripts/summarize_native_corpus_suite.cjs --dir corpus-logs --output native-corpus-summary.json
- node niakvio/scripts/rank_provider_portfolio.cjs --dir corpus-logs --output provider-portfolio.json
- node niakvio/scripts/protect_provider_coverage.cjs --dir corpus-logs --portfolio provider-portfolio.json
- python3 - <<'PY' >> "$GITHUB_STEP_SUMMARY"
- import json
- from pathlib import Path
- data=json.loads(Path('native-corpus-summary.json').read_text())
- portfolio=json.loads(Path('provider-portfolio.json').read_text())
- print('## Native corpus engine feedback\n')
- print(f"- Providers observed: **{data.get('providersObserved',0)}**")
- print(f"- Executions: **{data.get('executions',0)}**")
- print(f"- Identity contradictions: **{data.get('contradictions',0)}**")
- print(f"- Transport failures: **{data.get('transportFailures',0)}**")
- print(f"- Runtime errors: **{data.get('runtimeErrors',0)}**")
- print(f"- Recommended active providers: **{portfolio.get('recommendedActive',0)}**")
- print(f"- Recommended VF providers: **{portfolio.get('recommendedVf',0)}**")
- PY
- - name: Upload cross-device engine feedback and provider portfolio
- if: always()
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
- with:
- name: native-corpus-engine-summary-${{ github.run_id }}
- path: |
- native-corpus-summary.json
- provider-portfolio.json
- retention-days: 14
- if-no-files-found: warn
diff --git a/.github/workflows/native-corpus-device-targeted.yml b/.github/workflows/native-corpus-device-targeted.yml
index c0246ceb9..c0f378b76 100644
--- a/.github/workflows/native-corpus-device-targeted.yml
+++ b/.github/workflows/native-corpus-device-targeted.yml
@@ -13,22 +13,12 @@ on:
- mobile
- desktop
- all
- push:
- branches: [main]
- paths:
- - ".github/workflows/native-corpus-device-targeted.yml"
- - "scripts/prepare_native_corpus_client.py"
- - "scripts/prepare_native_corpus_validation.py"
- - "scripts/restage_native_corpus_client.py"
- - "scripts/run_native_corpus_mobile_suite.sh"
- - "scripts/run_native_corpus_tv_suite.sh"
- - "scripts/analyze_native_corpus_collection.cjs"
permissions:
contents: read
concurrency:
- group: native-corpus-device-targeted-${{ github.event_name }}-${{ github.ref }}-${{ inputs.device || 'tv' }}
+ group: native-corpus-device-targeted-${{ github.ref }}-${{ inputs.device || 'tv' }}
cancel-in-progress: true
jobs:
@@ -51,15 +41,10 @@ jobs:
id: resolve
shell: bash
env:
- EVENT_NAME: ${{ github.event_name }}
INPUT_DEVICE: ${{ inputs.device }}
run: |
set -euo pipefail
- if [ "$EVENT_NAME" = "push" ]; then
- DEVICE="tv"
- else
- DEVICE="${INPUT_DEVICE:-tv}"
- fi
+ DEVICE="${INPUT_DEVICE:-tv}"
DEVICE="$DEVICE" python3 - <<'PY' >> "$GITHUB_OUTPUT"
import json
import os
@@ -118,7 +103,7 @@ jobs:
node-version: "24"
package-manager-cache: false
- - name: Install official Desktop player dependencies
+ - name: Install official Desktop runtime dependencies
if: matrix.device == 'desktop'
run: |
sudo apt-get update
diff --git a/.github/workflows/native-corpus-visual-sublab.yml b/.github/workflows/native-corpus-visual-sublab.yml
deleted file mode 100644
index a47c55257..000000000
--- a/.github/workflows/native-corpus-visual-sublab.yml
+++ /dev/null
@@ -1,64 +0,0 @@
-name: Device Lab visual sub-lab
-
-on:
- workflow_dispatch:
- workflow_run:
- workflows:
- - Native corpus device lab
- types:
- - completed
- branches:
- - main
-
-permissions:
- contents: read
- actions: read
-
-concurrency:
- group: native-corpus-device-visual-sublab
- cancel-in-progress: true
-
-jobs:
- timeline:
- runs-on: ubuntu-latest
- timeout-minutes: 12
- steps:
- - name: Checkout NiakVIO tools
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- with:
- ref: main
- fetch-depth: 1
-
- - name: Set up Node.js
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
- with:
- node-version: "24"
- package-manager-cache: false
-
- - name: Download parent Device Lab sanitized artifacts
- if: github.event_name == 'workflow_run'
- env:
- GH_TOKEN: ${{ github.token }}
- RUN_ID: ${{ github.event.workflow_run.id }}
- shell: bash
- run: |
- mkdir -p corpus-logs
- gh run download "$RUN_ID" -D corpus-logs || true
-
- - name: Build visual Nuvio process timeline
- shell: bash
- run: |
- mkdir -p visual-sublab
- node scripts/render_native_corpus_timeline.cjs \
- --dir corpus-logs \
- --json visual-sublab/device-lab-timeline.json \
- --html visual-sublab/device-lab-timeline.html
-
- - name: Upload visual sub-lab
- if: always()
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
- with:
- name: native-corpus-device-visual-${{ github.run_id }}
- path: visual-sublab/
- retention-days: 14
- if-no-files-found: warn
diff --git a/.github/workflows/native-desktop-reader-acceptance.yml b/.github/workflows/native-desktop-reader-acceptance.yml
new file mode 100644
index 000000000..6642b134b
--- /dev/null
+++ b/.github/workflows/native-desktop-reader-acceptance.yml
@@ -0,0 +1,256 @@
+name: Native Desktop reader acceptance
+
+on:
+ pull_request:
+ branches: [main]
+ paths:
+ - ".github/workflows/native-desktop-reader-acceptance.yml"
+ - ".github/triggers/nuvio-client-lab.json"
+ - "manifest.json"
+ - "vf/manifest.json"
+ - "provider_catalog.json"
+ - "provider-overrides.json"
+ - "providers/**"
+ - "sources.json"
+ - "automation/nuvio-client-upstreams.json"
+ - "automation/platform-runtime-contracts.json"
+ - "scripts/check_nuvio_client_upstreams.py"
+ - "scripts/resolve_nuvio_lab_heads.py"
+ - "scripts/prepare_native_corpus_client.py"
+ - "scripts/restage_native_corpus_client.py"
+ - "scripts/augment_native_corpus_request_contract.py"
+ - "scripts/augment_native_provider_loading.py"
+ - "scripts/resolve_native_repository.sh"
+ - "scripts/prepare_native_candidate_repository.py"
+ - "scripts/augment_native_desktop_player.py"
+ - "scripts/complete_native_desktop_frontend_phases.py"
+ - "scripts/instrument_native_client_evidence.py"
+ - "scripts/instrument_native_repository_http_evidence.py"
+ - "scripts/instrument_native_desktop_evidence.py"
+ - "scripts/run_native_corpus_desktop_suite.sh"
+ - "scripts/analyze_native_corpus_collection.cjs"
+ - "scripts/analyze_native_corpus_results.cjs"
+ - "scripts/gate_native_reader_coverage.cjs"
+ - "scripts/gate_native_reader_result.cjs"
+ - "scripts/gate_native_player_reached.cjs"
+ - "scripts/native_evidence_completeness.cjs"
+ - "engine_v2/scripts/diagnose-native-reader.mjs"
+ push:
+ branches: [main]
+ paths:
+ - "manifest.json"
+ - "vf/manifest.json"
+ - "provider_catalog.json"
+ - "provider-overrides.json"
+ - "providers/**"
+ - "sources.json"
+ - "automation/nuvio-client-upstreams.json"
+ - "automation/platform-runtime-contracts.json"
+ - "scripts/check_nuvio_client_upstreams.py"
+ - "scripts/resolve_nuvio_lab_heads.py"
+ - "scripts/prepare_native_corpus_client.py"
+ - "scripts/restage_native_corpus_client.py"
+ - "scripts/augment_native_corpus_request_contract.py"
+ - "scripts/augment_native_provider_loading.py"
+ - "scripts/resolve_native_repository.sh"
+ - "scripts/prepare_native_candidate_repository.py"
+ - "scripts/augment_native_desktop_player.py"
+ - "scripts/complete_native_desktop_frontend_phases.py"
+ - "scripts/instrument_native_client_evidence.py"
+ - "scripts/instrument_native_repository_http_evidence.py"
+ - "scripts/instrument_native_desktop_evidence.py"
+ - "scripts/run_native_corpus_desktop_suite.sh"
+ - "scripts/analyze_native_corpus_collection.cjs"
+ - "scripts/analyze_native_corpus_results.cjs"
+ - "scripts/gate_native_reader_coverage.cjs"
+ - "scripts/gate_native_reader_result.cjs"
+ - "scripts/gate_native_player_reached.cjs"
+ - "scripts/native_evidence_completeness.cjs"
+ - "engine_v2/scripts/diagnose-native-reader.mjs"
+ - ".github/workflows/native-desktop-reader-acceptance.yml"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: native-desktop-reader-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ resolve:
+ runs-on: ubuntu-latest
+ timeout-minutes: 8
+ outputs:
+ desktop_sha: ${{ steps.refs.outputs.desktop_sha }}
+ runtime_fingerprint: ${{ steps.refs.outputs.runtime_fingerprint }}
+ desktop_drift_status: ${{ steps.refs.outputs.desktop_drift_status }}
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
+ with:
+ fetch-depth: 1
+ persist-credentials: false
+ ref: ${{ github.event.pull_request.head.sha || github.sha }}
+ - name: Inspect current official Nuvio client drift
+ shell: bash
+ run: |
+ set -euo pipefail
+ python3 scripts/check_nuvio_client_upstreams.py \
+ --no-fail \
+ --output health-output/nuvio-client-upstream-status.json
+ - id: refs
+ name: Resolve latest official Desktop client HEAD
+ shell: bash
+ run: |
+ set -euo pipefail
+ python3 scripts/resolve_nuvio_lab_heads.py \
+ --report health-output/nuvio-client-upstream-status.json \
+ --clients nuvio-desktop \
+ --github-output "$GITHUB_OUTPUT" \
+ --output health-output/nuvio-desktop-lab-head.json
+ - name: Publish Nuvio Desktop runtime audit context
+ if: always()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
+ with:
+ name: nuvio-desktop-lab-head-${{ github.run_id }}
+ path: |
+ health-output/nuvio-client-upstream-status.json
+ health-output/nuvio-desktop-lab-head.json
+ retention-days: 7
+ if-no-files-found: error
+
+ desktop-reader:
+ needs: resolve
+ timeout-minutes: ${{ github.event_name == 'pull_request' && 55 || 240 }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - runner: macos-15
+ os_name: macos
+ - runner: windows-2022
+ os_name: windows
+ runs-on: ${{ matrix.runner }}
+ env:
+ WEBVIEW2_VERSION: 1.0.4078.44
+ NIAKVIO_TARGET_PROVIDER: all
+ NIAKVIO_TARGET_MANIFEST: manifest.json
+ NIAKVIO_PRIMARY_STREAM_SCOPE: all
+ NIAKVIO_REGRESSION_STREAM_SCOPE: all
+ NIAKVIO_PR_PROVIDER_LIMIT: "8"
+ # Playback outcomes are evidence to repair after the smoke. The smoke only
+ # blocks if the production player is never reached at all.
+ NIAKVIO_REQUIRE_READER_SUCCESS: "0"
+ steps:
+ - name: Checkout NiakVIO candidate
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
+ with:
+ path: niakvio
+ persist-credentials: false
+ ref: ${{ github.event.pull_request.head.sha || github.sha }}
+
+ - uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165
+ with: {distribution: temurin, java-version: "17"}
+
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
+ with: {node-version: "24", package-manager-cache: false}
+
+ - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c
+
+ - name: Checkout latest official NuvioDesktop HEAD and stage initial route
+ shell: bash
+ env:
+ NUVIO_DESKTOP_SHA: ${{ needs.resolve.outputs.desktop_sha }}
+ run: |
+ set -euxo pipefail
+ git clone --filter=blob:none --no-checkout https://github.com/NuvioMedia/NuvioDesktop.git nuvio-desktop
+ git -C nuvio-desktop checkout "$NUVIO_DESKTOP_SHA"
+ git -C nuvio-desktop lfs pull --include="composeApp/src/desktopMain/native/macos/runtime/**,composeApp/src/desktopMain/native/windows/runtime/**" --exclude=""
+ touch nuvio-desktop/local.properties
+ python3 niakvio/scripts/prepare_native_corpus_client.py desktop --fixture sinners-2025 --workspace "$GITHUB_WORKSPACE" --manifest manifest.json
+
+ - name: Install official Windows WebView2 build dependency
+ if: matrix.os_name == 'windows'
+ shell: pwsh
+ run: |
+ nuget install Microsoft.Web.WebView2 `
+ -Version $env:WEBVIEW2_VERSION `
+ -OutputDirectory "$env:RUNNER_TEMP\nuget" `
+ -DirectDownload `
+ -NonInteractive `
+ -NoCache
+ $root = Join-Path $env:RUNNER_TEMP "nuget\Microsoft.Web.WebView2.$env:WEBVIEW2_VERSION"
+ if (-not (Test-Path (Join-Path $root "build\native\x64\WebView2Loader.dll.lib"))) {
+ throw "WebView2Loader.dll.lib missing from $root"
+ }
+ "WEBVIEW2_ROOT=$root" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
+
+ - name: Build official macOS native player bridge once
+ if: matrix.os_name == 'macos'
+ shell: bash
+ run: |
+ set -euxo pipefail
+ ./nuvio-desktop/gradlew -p nuvio-desktop \
+ :composeApp:buildMacosPlayerBridge \
+ :composeApp:prepareMacosPlayerRuntime \
+ --no-configuration-cache --console=plain
+
+ - name: Build official Windows native player bridge once
+ if: matrix.os_name == 'windows'
+ shell: pwsh
+ run: |
+ & .\nuvio-desktop\gradlew.bat -p nuvio-desktop `
+ :composeApp:buildWindowsPlayerBridge `
+ :composeApp:prepareWindowsPlayerRuntime `
+ :composeApp:generateWindowsPlayerRuntimeIndex `
+ "-Pnuvio.webview2.dir=$env:WEBVIEW2_ROOT" `
+ --no-configuration-cache --console=plain
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+
+ - name: Execute representative routes and require one real player reach
+ shell: bash
+ run: |
+ set -u
+ FIXTURES=(sinners-2025 breaking-bad-s01e01 jujutsu-kaisen-s01e01)
+ for fixture in "${FIXTURES[@]}"; do
+ echo "===== DESKTOP ROUTE: $fixture (${{ matrix.os_name }}) ====="
+ NIAKVIO_TARGET_FIXTURE="$fixture" \
+ NIAKVIO_PRIMARY_FIXTURE="$fixture" \
+ bash "$GITHUB_WORKSPACE/niakvio/scripts/run_native_corpus_desktop_suite.sh" || true
+ done
+ LOGS=()
+ for fixture in "${FIXTURES[@]}"; do
+ LOG="$GITHUB_WORKSPACE/desktop-native-corpus-${{ matrix.os_name }}-${fixture}.log"
+ [[ -f "$LOG" ]] && LOGS+=("$LOG")
+ done
+ node "$GITHUB_WORKSPACE/niakvio/scripts/gate_native_player_reached.cjs" "${LOGS[@]}"
+
+ - name: Build Desktop Brain diagnoses from captured evidence
+ if: always()
+ continue-on-error: true
+ shell: bash
+ run: |
+ set -u
+ for fixture in sinners-2025 breaking-bad-s01e01 jujutsu-kaisen-s01e01; do
+ LOG="$GITHUB_WORKSPACE/desktop-native-corpus-${{ matrix.os_name }}-${fixture}.log"
+ if [ ! -s "$LOG" ]; then
+ echo "Missing Desktop reader evidence: $LOG" >&2
+ continue
+ fi
+ node niakvio/engine_v2/scripts/diagnose-native-reader.mjs \
+ --output "$GITHUB_WORKSPACE/desktop-reader-${{ matrix.os_name }}-${fixture}-brain.json" \
+ "$LOG" || true
+ done
+
+ - name: Upload Desktop reader evidence
+ if: always()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
+ with:
+ name: native-desktop-reader-${{ matrix.os_name }}-routes-${{ github.run_id }}
+ path: |
+ desktop-native-corpus-${{ matrix.os_name }}-*.log
+ desktop-native-gradle-${{ matrix.os_name }}-*.log
+ desktop-reader-${{ matrix.os_name }}-*-brain.json
+ native-evidence/desktop/**
+ retention-days: 7
+ if-no-files-found: warn
diff --git a/.github/workflows/native-reader-learning-sync.yml b/.github/workflows/native-reader-learning-sync.yml
new file mode 100644
index 000000000..bccd863e2
--- /dev/null
+++ b/.github/workflows/native-reader-learning-sync.yml
@@ -0,0 +1,226 @@
+name: Native reader learning sync
+
+on:
+ workflow_run:
+ workflows:
+ - Native Android route reader acceptance
+ - Native Desktop reader acceptance
+ types: [completed]
+ workflow_dispatch:
+ inputs:
+ run_id:
+ description: Exact completed native reader run ID to import
+ required: true
+ type: string
+
+permissions:
+ contents: write
+ actions: read
+
+concurrency:
+ group: niakvio-brain-learning-memory-writer
+ cancel-in-progress: false
+
+jobs:
+ sync-reader-learning:
+ if: ${{ github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion != 'cancelled' && github.event.workflow_run.event == 'push' && github.event.workflow_run.head_branch == 'main') }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ steps:
+ - name: Checkout trusted main implementation
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
+ with:
+ ref: main
+ fetch-depth: 0
+
+ - name: Set up Python
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97
+ with:
+ python-version: "3.12"
+
+ - name: Restore current sanitized Brain state
+ shell: bash
+ run: |
+ set -euo pipefail
+ mkdir -p reader-learning-input/diagnostics reader-learning-output
+ git fetch origin brain-learning/proposals:refs/remotes/origin/brain-learning/proposals || true
+ if git cat-file -e refs/remotes/origin/brain-learning/proposals:engine_v2/learning/latest.json 2>/dev/null; then
+ git show refs/remotes/origin/brain-learning/proposals:engine_v2/learning/latest.json > reader-learning-input/latest.json
+ else
+ printf '{"publicationAllowed":false,"productionWritesAllowed":false,"proposals":[]}\n' > reader-learning-input/latest.json
+ fi
+ if git cat-file -e refs/remotes/origin/brain-learning/proposals:engine_v2/learning/latest.md 2>/dev/null; then
+ git show refs/remotes/origin/brain-learning/proposals:engine_v2/learning/latest.md > reader-learning-input/latest.md
+ else
+ printf '# NiakVIO Brain Learning\n' > reader-learning-input/latest.md
+ fi
+ cp reader-learning-input/latest.json reader-learning-output/latest.json
+ cp reader-learning-input/latest.md reader-learning-output/latest.md
+
+ - name: Resolve exact reader run and repair-memory duplicate state
+ id: source
+ env:
+ EVENT_NAME: ${{ github.event_name }}
+ EVENT_RUN_ID: ${{ github.event.workflow_run.id }}
+ EVENT_WORKFLOW: ${{ github.event.workflow_run.name }}
+ INPUT_RUN_ID: ${{ inputs.run_id }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ if [ "$EVENT_NAME" = "workflow_run" ]; then
+ RUN_ID="$EVENT_RUN_ID"
+ SOURCE_WORKFLOW="$EVENT_WORKFLOW"
+ else
+ RUN_ID="$INPUT_RUN_ID"
+ SOURCE_WORKFLOW="manual"
+ fi
+ [[ "$RUN_ID" =~ ^[0-9]+$ ]] || { echo "invalid reader run id: $RUN_ID" >&2; exit 2; }
+ echo "run_id=$RUN_ID" >> "$GITHUB_OUTPUT"
+ echo "source_workflow=$SOURCE_WORKFLOW" >> "$GITHUB_OUTPUT"
+ RUN_ID="$RUN_ID" python3 - <<'PY' >> "$GITHUB_OUTPUT"
+ import json, os
+ state=json.load(open('reader-learning-input/latest.json'))
+ memory=state.get('nativeReaderRepairMemory') if isinstance(state.get('nativeReaderRepairMemory'), dict) else {}
+ imported={str(v) for v in memory.get('importedRunIds', [])}
+ print('repair_duplicate=' + ('true' if os.environ['RUN_ID'] in imported else 'false'))
+ PY
+
+ - name: Download exact native reader diagnosis artifacts
+ id: diagnoses
+ env:
+ GH_TOKEN: ${{ github.token }}
+ RUN_ID: ${{ steps.source.outputs.run_id }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ mkdir -p reader-learning-input/diagnostics
+ for pattern in \
+ "native-tv-route-*-$RUN_ID" \
+ "native-mobile-route-*-$RUN_ID" \
+ "native-desktop-reader-*-$RUN_ID"; do
+ gh run download "$RUN_ID" --pattern "$pattern" --dir reader-learning-input/diagnostics || true
+ done
+ if find reader-learning-input/diagnostics -type f -name '*brain.json' -print -quit | grep -q .; then
+ echo "has_diagnostics=true" >> "$GITHUB_OUTPUT"
+ find reader-learning-input/diagnostics -type f -name '*brain.json' -print | sort
+ else
+ echo "has_diagnostics=false" >> "$GITHUB_OUTPUT"
+ echo "No reader diagnosis artifact was materialized for run $RUN_ID."
+ fi
+
+ - name: Download exact Android reader-repair comparison
+ id: repair_evidence
+ if: steps.source.outputs.repair_duplicate != 'true'
+ env:
+ GH_TOKEN: ${{ github.token }}
+ RUN_ID: ${{ steps.source.outputs.run_id }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ mkdir -p reader-learning-input/acceptance
+ if gh run download "$RUN_ID" --name "native-reader-brain-repair-$RUN_ID" --dir reader-learning-input/acceptance \
+ && [ -s reader-learning-input/acceptance/tv-reader-repair-comparison.json ]; then
+ echo "has_comparison=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "has_comparison=false" >> "$GITHUB_OUTPUT"
+ rm -rf reader-learning-input/acceptance
+ echo "No materialized reader-repair comparison for run $RUN_ID; repair memory remains importable after a rerun."
+ fi
+
+ - name: Merge sanitized reader repair memory once for the exact Nuvio runtime
+ if: steps.source.outputs.repair_duplicate != 'true' && steps.repair_evidence.outputs.has_comparison == 'true'
+ env:
+ RUN_ID: ${{ steps.source.outputs.run_id }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ RUNTIME_FINGERPRINT="$(python3 - <<'PY'
+ import json
+ value=str(json.load(open('reader-learning-input/acceptance/tv-reader-repair-comparison.json')).get('runtimeFingerprint') or '').strip()
+ print(value)
+ PY
+ )"
+ if [ -z "$RUNTIME_FINGERPRINT" ]; then
+ echo "Reader comparison has no exact Nuvio runtime fingerprint; refusing to learn from an unscoped run." >&2
+ exit 2
+ fi
+ python scripts/scope_native_reader_learning_runtime.py merge \
+ --state reader-learning-input/latest.json \
+ --comparison reader-learning-input/acceptance/tv-reader-repair-comparison.json \
+ --runtime-fingerprint "$RUNTIME_FINGERPRINT" \
+ --output reader-learning-output/latest.json \
+ --markdown-input reader-learning-input/latest.md \
+ --markdown-output reader-learning-output/latest.md
+ python3 - <<'PY'
+ import json, os
+ path='reader-learning-output/latest.json'
+ state=json.load(open(path))
+ memory=state.setdefault('nativeReaderRepairMemory', {})
+ ids=[str(v) for v in memory.get('importedRunIds', [])]
+ run_id=os.environ['RUN_ID']
+ ids=[v for v in ids if v != run_id] + [run_id]
+ memory['importedRunIds']=ids[-100:]
+ with open(path,'w') as fh:
+ json.dump(state, fh, ensure_ascii=False, indent=2)
+ fh.write('\n')
+ PY
+
+ - name: Merge automatic native reader bug backlog
+ if: steps.diagnoses.outputs.has_diagnostics == 'true'
+ env:
+ RUN_ID: ${{ steps.source.outputs.run_id }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ python3 scripts/merge_native_reader_backlog.py \
+ --state reader-learning-output/latest.json \
+ --previous-state reader-learning-input/latest.json \
+ --diagnostics-root reader-learning-input/diagnostics \
+ --run-id "$RUN_ID" \
+ --output reader-learning-output/latest.json \
+ --markdown-input reader-learning-output/latest.md \
+ --markdown-output reader-learning-output/latest.md
+
+ - name: Enforce sanitized fail-closed learning state
+ shell: bash
+ run: |
+ set -euo pipefail
+ node -e "const x=require('./reader-learning-output/latest.json');if(x.productionWritesAllowed===true||x.publicationAllowed===true)process.exit(1)"
+ if grep -Eiq 'https?://|authorization[=:]|cookie[=:]|token[=:]|secret[=:]' reader-learning-output/latest.json; then
+ echo "refusing unsafe native reader learning state" >&2
+ exit 1
+ fi
+ node - <<'NODE'
+ const fs=require('fs');
+ const state=JSON.parse(fs.readFileSync('reader-learning-output/latest.json','utf8'));
+ const memory=state.nativeReaderRepairMemory || {};
+ const backlog=memory.readerBacklog || {};
+ if (backlog.openCount != null && !Array.isArray(backlog.entries)) throw new Error('invalid reader backlog shape');
+ if (memory.runtimeAware === true && memory.runtimeScope?.reusePolicy !== 'exact-runtime-fingerprint-only') {
+ throw new Error('runtime-aware reader memory lost its exact-runtime reuse policy');
+ }
+ NODE
+
+ - name: Report reader sync outcome
+ shell: bash
+ run: |
+ echo "Reader run ${{ steps.source.outputs.run_id }} source=${{ steps.source.outputs.source_workflow }} repair_duplicate=${{ steps.source.outputs.repair_duplicate }} diagnostics=${{ steps.diagnoses.outputs.has_diagnostics }} comparison=${{ steps.repair_evidence.outputs.has_comparison || 'false' }}"
+
+ - name: Update the single Brain memory branch
+ shell: bash
+ run: |
+ set -euo pipefail
+ BRANCH="brain-learning/proposals"
+ git switch -C "$BRANCH" main
+ mkdir -p engine_v2/learning
+ cp reader-learning-output/latest.json engine_v2/learning/latest.json
+ cp reader-learning-output/latest.md engine_v2/learning/latest.md
+ git config user.name "niakvio-brain"
+ git config user.email "actions@users.noreply.github.com"
+ git add engine_v2/learning/latest.json engine_v2/learning/latest.md
+ if git diff --cached --quiet; then
+ echo "No native reader learning/backlog change."
+ else
+ git commit -m "brain: sync native reader learning and bug backlog"
+ git push --force-with-lease origin HEAD:"$BRANCH"
+ fi
diff --git a/.github/workflows/nuvio-client-lab.yml b/.github/workflows/nuvio-client-lab.yml
deleted file mode 100644
index 538061dac..000000000
--- a/.github/workflows/nuvio-client-lab.yml
+++ /dev/null
@@ -1,252 +0,0 @@
-name: Nuvio client media transport lab
-
-on:
- workflow_dispatch:
- inputs:
- provider_ids:
- description: Comma-separated provider IDs from manifest.json
- required: true
- default: "PURSTREAM,GOATED,MOVIX"
- tmdb_id:
- description: TMDb ID
- required: true
- default: "157336"
- media_type:
- description: Nuvio media type
- required: true
- default: "movie"
- title:
- description: Fixture title used only as provider metadata fallback
- required: false
- default: "Interstellar"
- year:
- description: Fixture year
- required: false
- default: "2014"
- push:
- branches:
- - main
- paths:
- - ".github/triggers/nuvio-client-lab.json"
- - ".github/workflows/nuvio-client-lab.yml"
- - "manifest.json"
- - "vf/manifest.json"
- - "provider-overrides.json"
- - "providers/**"
- - "scripts/nuvio_client_lab.cjs"
- - "scripts/provider_worker.cjs"
- - "scripts/build_nuvio_client_lab_matrix.py"
- - "scripts/provider_patches/**"
- - "tests/nuvio_client_lab.test.cjs"
- - "tests/nuvio_client_lab_dynamic_selection_test.py"
- - "package.json"
- - "package-lock.json"
- - "PROVENANCE.json"
- - "FILE-HASHES.json"
- - "PATCH-SHA256SUMS.txt"
- - "SHA256SUMS.json"
-
-permissions:
- contents: read
-
-concurrency:
- group: nuvio-client-playback-lab-${{ github.ref }}
- cancel-in-progress: true
-
-jobs:
- prepare:
- name: Build work matrix
- runs-on: ubuntu-latest
- timeout-minutes: 5
- outputs:
- matrix: ${{ steps.matrix.outputs.matrix }}
-
- steps:
- - name: Checkout Niakvio without credentials
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- fetch-depth: 1
- persist-credentials: false
-
- - name: Validate repository release integrity
- run: python3 scripts/validate_release_integrity.py
-
- - name: Build matrix configuration
- id: matrix
- shell: bash
- env:
- EVENT_NAME: ${{ github.event_name }}
- INPUT_PROVIDER_IDS: ${{ github.event.inputs.provider_ids }}
- INPUT_TMDB_ID: ${{ github.event.inputs.tmdb_id }}
- INPUT_MEDIA_TYPE: ${{ github.event.inputs.media_type }}
- INPUT_TITLE: ${{ github.event.inputs.title }}
- INPUT_YEAR: ${{ github.event.inputs.year }}
- run: |
- set -euo pipefail
- python3 - <<'PY' >> "$GITHUB_OUTPUT"
- import json, os, re, sys
- sys.path.insert(0, "scripts")
- from build_nuvio_client_lab_matrix import expand_push_source
-
- if os.environ.get("EVENT_NAME") == "push":
- with open(".github/triggers/nuvio-client-lab.json", encoding="utf-8") as handle:
- source = json.load(handle)
- with open("manifest.json", encoding="utf-8") as handle:
- manifest = json.load(handle)
- source = expand_push_source(source, manifest)
- else:
- title = os.environ.get("INPUT_TITLE", "").strip() or "manual-fixture"
- year = os.environ.get("INPUT_YEAR", "").strip()
- source = {
- "fixtures": [{
- "slug": re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") or "manual-fixture",
- "providers": [x.strip() for x in os.environ.get("INPUT_PROVIDER_IDS", "").split(",") if x.strip()],
- "fixture": {
- "tmdbId": os.environ.get("INPUT_TMDB_ID", "").strip(),
- "mediaType": os.environ.get("INPUT_MEDIA_TYPE", "movie").strip() or "movie",
- "title": title,
- "year": int(year) if year.isdigit() else None,
- },
- }],
- "clients": ["tv", "desktop", "mobile"],
- "policy": {"target_total": 10, "minimum_vf": 3, "require_identity_match": True, "blocking": False},
- "enforce_policy": False,
- }
-
- fixtures = source.get("fixtures") or []
- if not fixtures:
- raise SystemExit("at least one fixture is required")
- common = {key: value for key, value in source.items() if key != "fixtures"}
- cases = []
- for index, fixture in enumerate(fixtures):
- case = dict(common)
- case.update(fixture)
- case.setdefault("slug", f"fixture-{index + 1}")
- if not case.get("providers"):
- raise SystemExit(f"fixture {case['slug']} has no providers")
- cases.append(case)
- print("matrix=" + json.dumps({"include": cases}, separators=(",", ":"), ensure_ascii=True))
- PY
-
- - name: Verify dynamic provider selection
- if: github.event_name == 'push'
- run: python3 tests/nuvio_client_lab_dynamic_selection_test.py
-
- lab:
- name: ${{ matrix.slug }} — probe media transport
- needs: prepare
- runs-on: ubuntu-latest
- timeout-minutes: 45
- strategy:
- fail-fast: false
- max-parallel: 3
- matrix: ${{ fromJson(needs.prepare.outputs.matrix) }}
-
- steps:
- - name: Checkout Niakvio without credentials
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- fetch-depth: 1
- persist-credentials: false
-
- - name: Set up Node.js
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- with:
- node-version: "24"
- package-manager-cache: false
-
- - name: Install pinned provider runtime dependencies
- run: npm ci --ignore-scripts --no-audit --no-fund
-
- - name: Run lab unit tests
- run: node tests/nuvio_client_lab.test.cjs
-
- - name: Materialize fixture configuration
- id: config
- shell: bash
- env:
- MATRIX_CASE_JSON: ${{ toJson(matrix) }}
- run: |
- set -euo pipefail
- LAB_ROOT="$RUNNER_TEMP/nuvio-client-lab/${{ matrix.slug }}"
- mkdir -p "$LAB_ROOT"
- python3 - "$LAB_ROOT/config.json" <<'PY'
- import json, os, sys
- data = json.loads(os.environ["MATRIX_CASE_JSON"])
- data.pop("slug", None)
- with open(sys.argv[1], "w", encoding="utf-8") as handle:
- json.dump(data, handle, ensure_ascii=False, indent=2)
- handle.write("\n")
- PY
- echo "lab_root=$LAB_ROOT" >> "$GITHUB_OUTPUT"
-
- - name: Sparse checkout official Nuvio client runtimes
- shell: bash
- env:
- LAB_ROOT: ${{ steps.config.outputs.lab_root }}
- run: |
- set -euo pipefail
- CLIENTS="$LAB_ROOT/clients"
- mkdir -p "$CLIENTS"
-
- clone_runtime() {
- local repo="$1" branch="$2" dest="$3"; shift 3
- git clone --quiet --depth 1 --filter=blob:none --sparse --branch "$branch" "https://github.com/$repo.git" "$dest"
- git -C "$dest" sparse-checkout set "$@"
- git -C "$dest" rev-parse HEAD > "$dest/.nuvio-lab-head"
- }
-
- clone_runtime NuvioMedia/NuvioTV dev "$CLIENTS/tv" \
- app/src/full/java/com/nuvio/tv/core/plugin \
- app/src/main/java/com/nuvio/tv/core/player \
- app/src/main/java/com/nuvio/tv/ui/screens/player
-
- clone_runtime NuvioMedia/NuvioDesktop Dev "$CLIENTS/desktop" \
- composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime \
- composeApp/src/commonMain/kotlin/com/nuvio/app/features/player \
- composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player
-
- clone_runtime NuvioMedia/NuvioMobile cmp-rewrite "$CLIENTS/mobile" \
- composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime \
- composeApp/src/commonMain/kotlin/com/nuvio/app/features/player \
- composeApp/src/androidMain/kotlin/com/nuvio/app/features/player \
- composeApp/src/iosMain/kotlin/com/nuvio/app/features/player
-
- printf 'TV %s\n' "$(cat "$CLIENTS/tv/.nuvio-lab-head")"
- printf 'Desktop %s\n' "$(cat "$CLIENTS/desktop/.nuvio-lab-head")"
- printf 'Mobile %s\n' "$(cat "$CLIENTS/mobile/.nuvio-lab-head")"
-
- - name: Run client playback lab
- id: labrun
- continue-on-error: true
- shell: bash
- env:
- LAB_ROOT: ${{ steps.config.outputs.lab_root }}
- run: |
- set +e
- node scripts/nuvio_client_lab.cjs \
- --config "$LAB_ROOT/config.json" \
- --clients-root "$LAB_ROOT/clients" \
- --require-client-sources \
- --out "$LAB_ROOT/report.json" \
- --markdown "$LAB_ROOT/report.md"
- status=$?
- if [ -f "$LAB_ROOT/report.md" ]; then
- cat "$LAB_ROOT/report.md" >> "$GITHUB_STEP_SUMMARY"
- fi
- exit "$status"
-
- - name: Upload sanitized fixture report
- if: always()
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0
- with:
- name: nuvio-client-lab-${{ matrix.slug }}-${{ github.run_id }}
- path: |
- ${{ steps.config.outputs.lab_root }}/report.json
- ${{ steps.config.outputs.lab_root }}/report.md
- retention-days: 7
- if-no-files-found: error
-
- - name: Fail on lab execution or content safety errors
- if: steps.labrun.outcome == 'failure'
- run: exit 1
diff --git a/.github/workflows/permanent-android-real-client.yml b/.github/workflows/permanent-android-real-client.yml
deleted file mode 100644
index 50a623130..000000000
--- a/.github/workflows/permanent-android-real-client.yml
+++ /dev/null
@@ -1,290 +0,0 @@
-name: Permanent Android real-client lab
-
-on:
- workflow_dispatch:
- push:
- branches: [main]
- paths:
- - ".github/triggers/permanent-android-real-client"
-
-permissions:
- contents: read
-
-concurrency:
- group: permanent-android-real-client
- cancel-in-progress: false
-
-env:
- NIAKVIO_MOBILE_SHA: "0d6e30e4566e6f9f2051d5f80a1e3f1e0fcb1f18"
-
-jobs:
- android-real-runtime:
- runs-on: ubuntu-latest
- timeout-minutes: 30
- steps:
- - name: Checkout Niakvio
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- with:
- ref: main
- path: niakvio
-
- - name: Set up JDK
- uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165
- with:
- distribution: temurin
- java-version: '17'
-
- - name: Checkout exact official NuvioMobile runtime
- shell: bash
- run: |
- git clone --filter=blob:none --no-checkout https://github.com/NuvioMedia/NuvioMobile.git nuvio-mobile
- cd nuvio-mobile
- git checkout "$NIAKVIO_MOBILE_SHA"
- touch local.properties
-
- - name: Enable Android device-test compilation
- shell: bash
- run: |
- python3 - <<'PY'
- from pathlib import Path
- path = Path('nuvio-mobile/composeApp/build.gradle.kts')
- text = path.read_text()
- compilation_needle = ' withHostTest {}\n\n compilerOptions {'
- compilation_replacement = ''' withHostTest {}
- withDeviceTest {
- instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
- execution = "HOST"
- }
- packaging {
- jniLibs {
- pickFirsts.add("lib/*/libc++_shared.so")
- }
- }
-
- compilerOptions {'''
- if text.count(compilation_needle) != 1:
- raise SystemExit(f'Expected one Android host-test compilation block, found {text.count(compilation_needle)}')
- text = text.replace(compilation_needle, compilation_replacement, 1)
- source_needle = ' commonMain.dependencies {'
- source_replacement = ''' val androidDeviceTest by getting {
- dependencies {
- implementation("junit:junit:4.13.2")
- implementation("androidx.test.ext:junit:1.3.0")
- implementation("androidx.test:runner:1.7.0")
- }
- }
- commonMain.dependencies {'''
- if text.count(source_needle) != 1:
- raise SystemExit(f'Expected one commonMain dependency block, found {text.count(source_needle)}')
- path.write_text(text.replace(source_needle, source_replacement, 1))
- PY
- cat > nuvio-mobile/composeApp/src/androidMain/AndroidManifest.xml <<'XML'
-
-
-
-
- XML
-
- - name: Inject Mon Ninja 3 provider tests
- shell: bash
- run: |
- TEST_ROOT=nuvio-mobile/composeApp/src/androidDeviceTest
- mkdir -p "$TEST_ROOT/kotlin/com/nuvio/app/features/plugins" "$TEST_ROOT/assets/niakvio"
- cat > "$TEST_ROOT/AndroidManifest.xml" <<'XML'
-
-
-
-
-
-
-
- XML
- cp niakvio/providers/moviebox--published-baseline--1c0c9c423a094e0d.js "$TEST_ROOT/assets/niakvio/"
- cp niakvio/providers/netmirror--published-baseline--ccbd35984c20fc8b.js "$TEST_ROOT/assets/niakvio/"
- cp niakvio/providers/streamzo--published-baseline--bc19a7586f9f3bb8.js "$TEST_ROOT/assets/niakvio/"
- cat > "$TEST_ROOT/kotlin/com/nuvio/app/features/plugins/NiakvioRealProviderMobileTest.kt" <<'KOTLIN'
- package com.nuvio.app.features.plugins
-
- import android.util.Log
- import androidx.test.platform.app.InstrumentationRegistry
- import com.nuvio.app.features.plugins.runtime.PluginRuntime
- import kotlinx.coroutines.runBlocking
- import org.junit.Assert.assertEquals
- import org.junit.Assert.assertTrue
- import org.junit.Test
-
- class NiakvioRealProviderMobileTest {
- private val providers = listOf(
- "moviebox" to "niakvio/moviebox--published-baseline--1c0c9c423a094e0d.js",
- "netmirror" to "niakvio/netmirror--published-baseline--ccbd35984c20fc8b.js",
- "streamzo" to "niakvio/streamzo--published-baseline--bc19a7586f9f3bb8.js",
- )
-
- private fun emit(message: String) {
- println(message)
- Log.i("NiakvioRealLab", message)
- }
-
- private fun providerCode(relative: String): String =
- InstrumentationRegistry.getInstrumentation().context.assets.open(relative)
- .bufferedReader().use { it.readText() }
-
- @Test
- fun exactMonNinjaProviders() = runBlocking {
- providers.forEach { (id, relative) ->
- val started = System.currentTimeMillis()
- val rows = PluginRuntime.executePlugin(
- code = providerCode(relative),
- tmdbId = "1215638",
- mediaType = "movie",
- season = null,
- episode = null,
- scraperId = id,
- )
- emit("FIELD_ANDROID_RESULT provider=$id duration_ms=${System.currentTimeMillis()-started} count=${rows.size}")
- rows.forEachIndexed { index, row ->
- emit("FIELD_ANDROID_ROW provider=$id index=$index title=${row.title} name=${row.name} quality=${row.quality} language=${row.language} type=${row.type} url=${row.url} headers=${row.headers}")
- }
- if (id == "streamzo") {
- assertTrue("StreamZo must resolve Mon Ninja 3 in official Android runtime", rows.isNotEmpty())
- assertTrue("StreamZo must expose HLS on Android", rows.any { it.url.contains(".m3u8") })
- }
- }
- }
-
- @Test
- fun missingOptionalMetadataIsAccepted() = runBlocking {
- val rows = PluginRuntime.executePlugin(
- code = "module.exports.getStreams=async()=>[{title:'Metadata control',url:'https://example.test/video.mp4'}];",
- tmdbId = "1215638",
- mediaType = "movie",
- season = null,
- episode = null,
- scraperId = "metadata-control",
- )
- assertEquals(1, rows.size)
- assertEquals(null, rows.single().quality)
- assertEquals(null, rows.single().language)
- emit("FIELD_ANDROID_METADATA_NULL_ACCEPTED=true")
- }
- }
- KOTLIN
-
- - name: Start Android emulator
- shell: bash
- run: |
- set -euxo pipefail
- export ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-$ANDROID_HOME}"
- export ANDROID_USER_HOME="$HOME/.android"
- export ANDROID_AVD_HOME="$ANDROID_USER_HOME/avd"
- unset ANDROID_SDK_HOME || true
- mkdir -p "$ANDROID_AVD_HOME"
-
- SDKMANAGER="$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager"
- AVDMANAGER="$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/avdmanager"
- export PATH="$ANDROID_SDK_ROOT/platform-tools:$ANDROID_SDK_ROOT/emulator:$(dirname "$SDKMANAGER"):$PATH"
- echo "$ANDROID_SDK_ROOT/platform-tools" >> "$GITHUB_PATH"
-
- yes | "$SDKMANAGER" --licenses >/dev/null || true
- "$SDKMANAGER" "platform-tools" "emulator" "system-images;android-35;google_apis;x86_64"
- echo no | "$AVDMANAGER" create avd \
- --force \
- --name niakvio-ci \
- --package "system-images;android-35;google_apis;x86_64" \
- --device "pixel_2" \
- --path "$ANDROID_AVD_HOME/niakvio-ci.avd"
-
- "$ANDROID_SDK_ROOT/emulator/emulator" -list-avds | grep -Fx niakvio-ci
- sudo chmod 666 /dev/kvm
- "$ANDROID_SDK_ROOT/emulator/emulator" -accel-check
- adb kill-server || true
- adb start-server
-
- rm -f /tmp/niakvio-emulator.log
- nohup "$ANDROID_SDK_ROOT/emulator/emulator" \
- -avd niakvio-ci \
- -port 5554 \
- -no-window \
- -no-audio \
- -no-boot-anim \
- -no-snapshot \
- -wipe-data \
- -gpu swiftshader_indirect \
- -accel on \
- -cores 2 \
- -memory 2048 \
- >/tmp/niakvio-emulator.log 2>&1 &
- EMU_PID=$!
-
- SERIAL=""
- for attempt in $(seq 1 90); do
- if ! kill -0 "$EMU_PID" 2>/dev/null; then
- echo "Emulator exited before adb registration" >&2
- cat /tmp/niakvio-emulator.log >&2 || true
- exit 1
- fi
- SERIAL=$(adb devices | awk '$1 ~ /^emulator-/ && ($2 == "device" || $2 == "offline") {print $1; exit}')
- if [ -n "$SERIAL" ]; then break; fi
- sleep 2
- done
- if [ -z "$SERIAL" ]; then
- echo "Emulator did not register with adb" >&2
- cat /tmp/niakvio-emulator.log >&2 || true
- exit 1
- fi
-
- for attempt in $(seq 1 90); do
- if ! kill -0 "$EMU_PID" 2>/dev/null; then
- echo "Emulator exited during boot" >&2
- cat /tmp/niakvio-emulator.log >&2 || true
- exit 1
- fi
- STATE=$(adb -s "$SERIAL" get-state 2>/dev/null || true)
- BOOT=$(adb -s "$SERIAL" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r' || true)
- echo "boot attempt=$attempt state=$STATE completed=$BOOT"
- if [ "$STATE" = "device" ] && [ "$BOOT" = "1" ]; then break; fi
- sleep 2
- done
-
- STATE=$(adb -s "$SERIAL" get-state 2>/dev/null || true)
- BOOT=$(adb -s "$SERIAL" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r' || true)
- if [ "$STATE" != "device" ] || [ "$BOOT" != "1" ]; then
- echo "Emulator failed to boot" >&2
- cat /tmp/niakvio-emulator.log >&2 || true
- exit 1
- fi
- echo "ANDROID_SERIAL=$SERIAL" >> "$GITHUB_ENV"
- adb -s "$SERIAL" logcat -c
- adb devices -l
-
- - name: Execute official Android Full runtime test
- working-directory: nuvio-mobile
- shell: bash
- run: |
- set -o pipefail
- TASKS=$(./gradlew :composeApp:tasks --all -Pnuvio.android.distribution=full --console=plain)
- TASK=$(printf '%s\n' "$TASKS" | awk 'tolower($1) ~ /connected.*device.*test/ {print $1; exit}')
- if [ -z "$TASK" ]; then
- TASK=$(printf '%s\n' "$TASKS" | awk 'tolower($1) ~ /device.*test/ && tolower($0) ~ /connected/ {print $1; exit}')
- fi
- if [ -z "$TASK" ]; then
- printf '%s\n' "$TASKS" | grep -i 'deviceTest' || true
- echo "Unable to resolve connected Android device-test task" >&2
- exit 1
- fi
- echo "Resolved Android device test task: $TASK"
- set +e
- ./gradlew ":composeApp:$TASK" -Pnuvio.android.distribution=full --no-daemon --console=plain
- STATUS=$?
- set -e
- echo "===== EXACT ANDROID PROVIDER RESULTS ====="
- "${ANDROID_HOME}/platform-tools/adb" -s "$ANDROID_SERIAL" logcat -d -s NiakvioRealLab:I '*:S' || true
- if [ "$STATUS" -ne 0 ]; then
- echo "===== EMULATOR LOG TAIL ====="
- tail -n 200 /tmp/niakvio-emulator.log || true
- fi
- exit "$STATUS"
diff --git a/.github/workflows/permanent-real-client-labs.yml b/.github/workflows/permanent-real-client-labs.yml
deleted file mode 100644
index 5e35bc93a..000000000
--- a/.github/workflows/permanent-real-client-labs.yml
+++ /dev/null
@@ -1,392 +0,0 @@
-name: Permanent real-client labs
-
-on:
- workflow_dispatch:
- push:
- branches: [main]
- paths:
- - ".github/triggers/permanent-real-client-labs"
-
-permissions:
- contents: read
-
-concurrency:
- group: permanent-real-client-labs
- cancel-in-progress: false
-
-env:
- NIAKVIO_DESKTOP_SHA: "8977fda639f5791947e6d8b36025635a77aed5da"
- NIAKVIO_MOBILE_SHA: "0d6e30e4566e6f9f2051d5f80a1e3f1e0fcb1f18"
-
-jobs:
- desktop-real-runtime:
- if: ${{ !contains(github.event.head_commit.message, '[mobile-only]') }}
- runs-on: ubuntu-latest
- timeout-minutes: 25
- steps:
- - name: Checkout Niakvio
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- with:
- ref: main
- path: niakvio
- - name: Set up JDK
- uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165
- with:
- distribution: temurin
- java-version: '17'
- - name: Install official Linux player dependencies
- shell: bash
- run: |
- sudo apt-get update
- sudo apt-get install -y --no-install-recommends \
- libgstreamer1.0-dev \
- libgstreamer-plugins-base1.0-dev
- - name: Checkout exact official NuvioDesktop runtime
- shell: bash
- run: |
- git clone --filter=blob:none --no-checkout https://github.com/NuvioMedia/NuvioDesktop.git nuvio-desktop
- cd nuvio-desktop
- git checkout "$NIAKVIO_DESKTOP_SHA"
- touch local.properties
- - name: Inject exact Mon Ninja provider test
- shell: bash
- run: |
- mkdir -p nuvio-desktop/composeApp/src/desktopTest/kotlin/com/nuvio/app/features/plugins
- cat > nuvio-desktop/composeApp/src/desktopTest/kotlin/com/nuvio/app/features/plugins/NiakvioRealProviderDesktopTest.kt <<'KOTLIN'
- package com.nuvio.app.features.plugins
-
- import com.nuvio.app.features.plugins.runtime.PluginRuntime
- import java.io.File
- import kotlinx.coroutines.runBlocking
- import kotlin.test.Test
- import kotlin.test.assertEquals
- import kotlin.test.assertTrue
-
- class NiakvioRealProviderDesktopTest {
- private val workspace = File(System.getenv("GITHUB_WORKSPACE"))
- private val root = File(workspace, "niakvio")
- private val resultFile = File(workspace, "desktop-real-client-results.log")
- private val providers = listOf(
- "moviebox" to "providers/moviebox--published-baseline--1c0c9c423a094e0d.js",
- "netmirror" to "providers/netmirror--published-baseline--ccbd35984c20fc8b.js",
- "streamzo" to "providers/streamzo--published-baseline--bc19a7586f9f3bb8.js",
- )
-
- private fun emit(message: String) {
- println(message)
- resultFile.appendText(message + "\n")
- }
-
- @Test
- fun exactMonNinjaProviders() = runBlocking {
- providers.forEach { (id, relative) ->
- val started = System.currentTimeMillis()
- val rows = PluginRuntime.executePlugin(
- code = File(root, relative).readText(),
- tmdbId = "1215638",
- mediaType = "movie",
- season = null,
- episode = null,
- scraperId = id,
- )
- emit("FIELD_DESKTOP_RESULT provider=$id duration_ms=${System.currentTimeMillis()-started} count=${rows.size}")
- rows.forEachIndexed { index, row ->
- emit("FIELD_DESKTOP_ROW provider=$id index=$index title=${row.title} name=${row.name} quality=${row.quality} language=${row.language} type=${row.type} url=${row.url} headers=${row.headers}")
- }
- if (id == "streamzo") {
- assertTrue(rows.isNotEmpty(), "StreamZo must resolve Mon Ninja 3 in official desktop runtime")
- assertTrue(rows.any { it.url.contains(".m3u8") }, "StreamZo must expose HLS on desktop")
- }
- }
- }
-
- @Test
- fun missingOptionalMetadataIsAccepted() = runBlocking {
- val rows = PluginRuntime.executePlugin(
- code = "module.exports.getStreams=async()=>[{title:'Metadata control',url:'https://example.test/video.mp4'}];",
- tmdbId = "1215638",
- mediaType = "movie",
- season = null,
- episode = null,
- scraperId = "metadata-control",
- )
- assertEquals(1, rows.size)
- assertEquals(null, rows.single().quality)
- assertEquals(null, rows.single().language)
- emit("FIELD_DESKTOP_METADATA_NULL_ACCEPTED=true")
- }
- }
- KOTLIN
- - name: Execute official NuvioDesktop runtime test
- working-directory: nuvio-desktop
- shell: bash
- run: |
- rm -f "$GITHUB_WORKSPACE/desktop-real-client-results.log"
- ./gradlew :composeApp:desktopTest --tests 'com.nuvio.app.features.plugins.NiakvioRealProviderDesktopTest' --no-daemon --console=plain
- echo "===== EXACT DESKTOP PROVIDER RESULTS ====="
- cat "$GITHUB_WORKSPACE/desktop-real-client-results.log"
-
- mobile-real-runtime:
- if: ${{ !contains(github.event.head_commit.message, '[desktop-only]') }}
- runs-on: ubuntu-latest
- timeout-minutes: 30
- steps:
- - name: Checkout Niakvio
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- with:
- ref: main
- path: niakvio
- - name: Set up JDK
- uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165
- with:
- distribution: temurin
- java-version: '17'
- - name: Checkout exact official NuvioMobile runtime
- shell: bash
- run: |
- git clone --filter=blob:none --no-checkout https://github.com/NuvioMedia/NuvioMobile.git nuvio-mobile
- cd nuvio-mobile
- git checkout "$NIAKVIO_MOBILE_SHA"
- touch local.properties
- - name: Enable official Android device-test compilation
- shell: bash
- run: |
- python3 - <<'PY'
- from pathlib import Path
- path = Path('nuvio-mobile/composeApp/build.gradle.kts')
- text = path.read_text()
-
- compilation_needle = ' withHostTest {}\n\n compilerOptions {'
- compilation_replacement = ''' withHostTest {}
- withDeviceTest {
- instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
- execution = "HOST"
- }
-
- compilerOptions {'''
- if text.count(compilation_needle) != 1:
- raise SystemExit(f'Expected one Android host-test compilation block, found {text.count(compilation_needle)}')
- text = text.replace(compilation_needle, compilation_replacement, 1)
-
- source_needle = ' commonMain.dependencies {'
- source_replacement = ''' val androidDeviceTest by getting {
- dependencies {
- implementation("junit:junit:4.13.2")
- implementation("androidx.test.ext:junit:1.3.0")
- implementation("androidx.test:runner:1.7.0")
- }
- }
- commonMain.dependencies {'''
- if text.count(source_needle) != 1:
- raise SystemExit(f'Expected one commonMain dependency block, found {text.count(source_needle)}')
- text = text.replace(source_needle, source_replacement, 1)
- path.write_text(text)
- PY
- cat > nuvio-mobile/composeApp/src/androidMain/AndroidManifest.xml <<'XML'
-
-
-
-
- XML
- - name: Inject exact Mon Ninja Android device test
- shell: bash
- run: |
- TEST_ROOT=nuvio-mobile/composeApp/src/androidDeviceTest
- mkdir -p "$TEST_ROOT/kotlin/com/nuvio/app/features/plugins" "$TEST_ROOT/assets/niakvio"
- cp niakvio/providers/moviebox--published-baseline--1c0c9c423a094e0d.js "$TEST_ROOT/assets/niakvio/"
- cp niakvio/providers/netmirror--published-baseline--ccbd35984c20fc8b.js "$TEST_ROOT/assets/niakvio/"
- cp niakvio/providers/streamzo--published-baseline--bc19a7586f9f3bb8.js "$TEST_ROOT/assets/niakvio/"
- cat > "$TEST_ROOT/kotlin/com/nuvio/app/features/plugins/NiakvioRealProviderMobileTest.kt" <<'KOTLIN'
- package com.nuvio.app.features.plugins
-
- import android.util.Log
- import androidx.test.platform.app.InstrumentationRegistry
- import com.nuvio.app.features.plugins.runtime.PluginRuntime
- import kotlinx.coroutines.runBlocking
- import org.junit.Assert.assertEquals
- import org.junit.Assert.assertTrue
- import org.junit.Test
-
- class NiakvioRealProviderMobileTest {
- private val providers = listOf(
- "moviebox" to "niakvio/moviebox--published-baseline--1c0c9c423a094e0d.js",
- "netmirror" to "niakvio/netmirror--published-baseline--ccbd35984c20fc8b.js",
- "streamzo" to "niakvio/streamzo--published-baseline--bc19a7586f9f3bb8.js",
- )
-
- private fun emit(message: String) {
- println(message)
- Log.i("NiakvioRealLab", message)
- }
-
- private fun providerCode(relative: String): String =
- InstrumentationRegistry.getInstrumentation().context.assets.open(relative)
- .bufferedReader().use { it.readText() }
-
- @Test
- fun exactMonNinjaProviders() = runBlocking {
- providers.forEach { (id, relative) ->
- val started = System.currentTimeMillis()
- val rows = PluginRuntime.executePlugin(
- code = providerCode(relative),
- tmdbId = "1215638",
- mediaType = "movie",
- season = null,
- episode = null,
- scraperId = id,
- )
- emit("FIELD_MOBILE_RESULT provider=$id duration_ms=${System.currentTimeMillis()-started} count=${rows.size}")
- rows.forEachIndexed { index, row ->
- emit("FIELD_MOBILE_ROW provider=$id index=$index title=${row.title} name=${row.name} quality=${row.quality} language=${row.language} type=${row.type} url=${row.url} headers=${row.headers}")
- }
- if (id == "streamzo") {
- assertTrue("StreamZo must resolve Mon Ninja 3 in official Android runtime", rows.isNotEmpty())
- assertTrue("StreamZo must expose HLS on Android", rows.any { it.url.contains(".m3u8") })
- }
- }
- }
-
- @Test
- fun missingOptionalMetadataIsAccepted() = runBlocking {
- val rows = PluginRuntime.executePlugin(
- code = "module.exports.getStreams=async()=>[{title:'Metadata control',url:'https://example.test/video.mp4'}];",
- tmdbId = "1215638",
- mediaType = "movie",
- season = null,
- episode = null,
- scraperId = "metadata-control",
- )
- assertEquals(1, rows.size)
- assertEquals(null, rows.single().quality)
- assertEquals(null, rows.single().language)
- emit("FIELD_MOBILE_METADATA_NULL_ACCEPTED=true")
- }
- }
- KOTLIN
- - name: Start Android emulator fail-fast
- shell: bash
- run: |
- set -euxo pipefail
- export ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-$ANDROID_HOME}"
- SDKMANAGER="$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager"
- AVDMANAGER="$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/avdmanager"
- if [ ! -x "$SDKMANAGER" ]; then
- SDKMANAGER=$(find "$ANDROID_SDK_ROOT/cmdline-tools" -type f -name sdkmanager -perm -u+x | head -n1)
- fi
- if [ ! -x "$AVDMANAGER" ]; then
- AVDMANAGER=$(find "$ANDROID_SDK_ROOT/cmdline-tools" -type f -name avdmanager -perm -u+x | head -n1)
- fi
- test -x "$SDKMANAGER"
- test -x "$AVDMANAGER"
- export PATH="$ANDROID_SDK_ROOT/platform-tools:$ANDROID_SDK_ROOT/emulator:$(dirname "$SDKMANAGER"):$PATH"
-
- yes | "$SDKMANAGER" --licenses >/dev/null || true
- "$SDKMANAGER" "platform-tools" "emulator" "system-images;android-35;google_apis;x86_64"
- echo no | "$AVDMANAGER" create avd --force --name niakvio-ci --package "system-images;android-35;google_apis;x86_64" --device "pixel_2"
-
- ACCEL=off
- if [ -e /dev/kvm ]; then
- sudo chmod 666 /dev/kvm || true
- if "$ANDROID_SDK_ROOT/emulator/emulator" -accel-check >/tmp/niakvio-accel.log 2>&1; then
- ACCEL=on
- fi
- fi
- echo "Android emulator acceleration: $ACCEL"
- cat /tmp/niakvio-accel.log 2>/dev/null || true
-
- adb kill-server || true
- adb start-server
- rm -f /tmp/niakvio-emulator.log
- nohup "$ANDROID_SDK_ROOT/emulator/emulator" \
- -avd niakvio-ci \
- -port 5554 \
- -no-window \
- -no-audio \
- -no-boot-anim \
- -no-snapshot \
- -wipe-data \
- -gpu swiftshader_indirect \
- -accel "$ACCEL" \
- -cores 2 \
- -memory 2048 \
- >/tmp/niakvio-emulator.log 2>&1 &
- EMU_PID=$!
- echo "Android emulator pid: $EMU_PID"
-
- SERIAL=""
- for attempt in $(seq 1 90); do
- if ! kill -0 "$EMU_PID" 2>/dev/null; then
- echo "Android emulator exited before adb registration" >&2
- cat /tmp/niakvio-emulator.log >&2 || true
- exit 1
- fi
- adb devices || true
- SERIAL=$(adb devices | awk '$1 ~ /^emulator-/ && ($2 == "device" || $2 == "offline") {print $1; exit}')
- if [ -n "$SERIAL" ]; then
- echo "Android emulator registered as $SERIAL"
- break
- fi
- sleep 2
- done
- if [ -z "$SERIAL" ]; then
- echo "Android emulator did not register with adb within 180 seconds" >&2
- cat /tmp/niakvio-emulator.log >&2 || true
- exit 1
- fi
-
- for attempt in $(seq 1 90); do
- if ! kill -0 "$EMU_PID" 2>/dev/null; then
- echo "Android emulator exited during boot" >&2
- cat /tmp/niakvio-emulator.log >&2 || true
- exit 1
- fi
- STATE=$(adb -s "$SERIAL" get-state 2>/dev/null || true)
- BOOT=$(adb -s "$SERIAL" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r' || true)
- echo "boot attempt=$attempt state=$STATE sys.boot_completed=$BOOT"
- if [ "$STATE" = "device" ] && [ "$BOOT" = "1" ]; then
- break
- fi
- sleep 2
- done
- STATE=$(adb -s "$SERIAL" get-state 2>/dev/null || true)
- BOOT=$(adb -s "$SERIAL" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r' || true)
- if [ "$STATE" != "device" ] || [ "$BOOT" != "1" ]; then
- echo "Android emulator failed to finish boot within 180 seconds" >&2
- cat /tmp/niakvio-emulator.log >&2 || true
- adb -s "$SERIAL" shell getprop >&2 || true
- exit 1
- fi
-
- echo "ANDROID_SERIAL=$SERIAL" >> "$GITHUB_ENV"
- adb -s "$SERIAL" shell settings put global window_animation_scale 0
- adb -s "$SERIAL" shell settings put global transition_animation_scale 0
- adb -s "$SERIAL" shell settings put global animator_duration_scale 0
- adb -s "$SERIAL" logcat -c
- adb devices -l
- - name: Execute official NuvioMobile Android Full device runtime test
- working-directory: nuvio-mobile
- shell: bash
- run: |
- set -o pipefail
- TASKS=$(./gradlew :composeApp:tasks --all -Pnuvio.android.distribution=full --console=plain)
- TASK=$(printf '%s\n' "$TASKS" | awk 'tolower($1) ~ /connected.*device.*test/ {print $1; exit}')
- if [ -z "$TASK" ]; then
- TASK=$(printf '%s\n' "$TASKS" | awk 'tolower($1) ~ /device.*test/ && tolower($0) ~ /connected/ {print $1; exit}')
- fi
- if [ -z "$TASK" ]; then
- printf '%s\n' "$TASKS" | grep -i 'deviceTest' || true
- echo "Unable to resolve connected Android device-test task" >&2
- exit 1
- fi
- echo "Resolved mobile device test task: $TASK"
- set +e
- ./gradlew ":composeApp:$TASK" -Pnuvio.android.distribution=full --no-daemon --console=plain
- STATUS=$?
- set -e
- echo "===== EXACT MOBILE PROVIDER RESULTS ====="
- adb -s "$ANDROID_SERIAL" logcat -d -s NiakvioRealLab:I '*:S' || true
- if [ "$STATUS" -ne 0 ]; then
- echo "===== EMULATOR LOG TAIL ====="
- tail -n 200 /tmp/niakvio-emulator.log || true
- fi
- exit "$STATUS"
diff --git a/.github/workflows/provider-engine-v2.yml b/.github/workflows/provider-engine-v2.yml
deleted file mode 100644
index 693a44d4a..000000000
--- a/.github/workflows/provider-engine-v2.yml
+++ /dev/null
@@ -1,129 +0,0 @@
-name: Provider Engine V2
-
-on:
- pull_request:
- branches:
- - main
- paths:
- - 'engine_v2/**'
- - 'provider_catalog.json'
- - 'manifest.json'
- - 'vf/manifest.json'
- - '.github/workflows/provider-engine-v2.yml'
- push:
- branches:
- - main
- paths:
- - 'engine_v2/**'
- - 'provider_catalog.json'
- - 'manifest.json'
- - 'vf/manifest.json'
- - '.github/workflows/provider-engine-v2.yml'
- workflow_dispatch:
-
-permissions:
- contents: read
-
-concurrency:
- group: provider-engine-v2-${{ github.event.pull_request.number || github.ref }}
- cancel-in-progress: true
-
-jobs:
- architecture:
- name: Validate V2 core
- runs-on: ubuntu-latest
- timeout-minutes: 25
- steps:
- - name: Checkout repository
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
-
- - name: Setup Node
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- with:
- node-version: '24'
- package-manager-cache: false
-
- - name: Canonical catalog and manifest projection tests
- run: node engine_v2/tests/provider-catalog.test.mjs
-
- - name: Canonical contract tests
- run: node engine_v2/tests/contracts.test.mjs
-
- - name: Runtime adapter tests
- run: node engine_v2/tests/runtime-adapters.test.mjs
-
- - name: Strict media validator tests
- run: node engine_v2/tests/media-validator.test.mjs
-
- - name: Resolver core tests
- run: node engine_v2/tests/resolver-core.test.mjs
-
- - name: Repair brain tests
- run: node engine_v2/tests/repair-brain.test.mjs
-
- - name: Repair recipe memory tests
- run: node engine_v2/tests/recipe-memory.test.mjs
-
- - name: Provider decision tests
- run: node engine_v2/tests/decision-engine.test.mjs
-
- - name: Evidence matrix tests
- run: node engine_v2/tests/evidence.test.mjs
-
- - name: Contract watcher unit tests
- run: node engine_v2/tests/contract-watcher.test.mjs
-
- - name: Provider ingestion unit tests
- run: node engine_v2/tests/provider-ingest.test.mjs
-
- - name: Provider JS analysis unit tests
- run: node engine_v2/tests/provider-analysis.test.mjs
-
- - name: Domain discovery unit tests
- run: node engine_v2/tests/domain-discovery.test.mjs
-
- - name: Provider spec builder tests
- run: node engine_v2/tests/provider-spec-builder.test.mjs
-
- - name: Purstream clean adapter tests
- run: node engine_v2/tests/purstream-adapter.test.mjs
-
- - name: Observe current Nuvio contract drift
- env:
- GITHUB_TOKEN: ${{ github.token }}
- run: node engine_v2/scripts/check-nuvio-contracts.mjs --output engine_v2/reports/nuvio-contract-drift.json
-
- - name: Build live three-upstream provider inventory
- env:
- GITHUB_TOKEN: ${{ github.token }}
- run: node engine_v2/scripts/ingest-provider-upstreams.mjs --output engine_v2/reports/provider-upstream-inventory.json
-
- - name: Analyze original provider JS variants
- env:
- GITHUB_TOKEN: ${{ github.token }}
- run: node engine_v2/scripts/build-provider-knowledge.mjs --inventory engine_v2/reports/provider-upstream-inventory.json --output engine_v2/reports/provider-knowledge-seed.json
-
- - name: Probe provider hubs and domain candidates
- continue-on-error: true
- run: node engine_v2/scripts/probe-provider-domains.mjs --knowledge engine_v2/reports/provider-knowledge-seed.json --output engine_v2/reports/provider-domain-observations.json --max-candidates 4 --concurrency 12 --timeout-ms 4500
-
- - name: Build canonical unverified ProviderSpecs
- run: node engine_v2/scripts/build-provider-specs.mjs --inventory engine_v2/reports/provider-upstream-inventory.json --knowledge engine_v2/reports/provider-knowledge-seed.json --domains engine_v2/reports/provider-domain-observations.json --output engine_v2/reports/provider-specs-v2.json
-
- - name: Purstream V2 live movie and series proof
- continue-on-error: true
- run: node engine_v2/scripts/live-purstream-v2.mjs --domains engine_v2/reports/provider-domain-observations.json --output engine_v2/reports/purstream-live-v2.json
-
- - name: Upload V2 discovery reports
- if: always()
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0
- with:
- name: provider-engine-v2-discovery-reports
- path: |
- engine_v2/reports/nuvio-contract-drift.json
- engine_v2/reports/provider-upstream-inventory.json
- engine_v2/reports/provider-knowledge-seed.json
- engine_v2/reports/provider-domain-observations.json
- engine_v2/reports/provider-specs-v2.json
- engine_v2/reports/purstream-live-v2.json
- if-no-files-found: warn
diff --git a/.github/workflows/upstream-provider-watch.yml b/.github/workflows/upstream-provider-watch.yml
new file mode 100644
index 000000000..53f208d90
--- /dev/null
+++ b/.github/workflows/upstream-provider-watch.yml
@@ -0,0 +1,50 @@
+name: Weekly upstream provider watch
+
+on:
+ schedule:
+ - cron: "17 5 * * 1"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: upstream-provider-watch-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ discover:
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - name: Checkout NiakVIO
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
+ with:
+ fetch-depth: 1
+ persist-credentials: false
+ - name: Set up Node
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
+ with:
+ node-version: "24"
+ package-manager-cache: false
+ - name: Contract test
+ run: node engine_v2/tests/upstream-provider-watch.test.mjs
+ - name: Compare Gowaru, All-in-One-Nuvio and Yoru
+ run: node engine_v2/scripts/watch-upstream-providers.mjs
+ - name: Publish readable summary
+ if: always()
+ shell: bash
+ run: |
+ if [ -s health-output/upstream-provider-watch.md ]; then
+ cat health-output/upstream-provider-watch.md >> "$GITHUB_STEP_SUMMARY"
+ fi
+ - name: Upload discovery report
+ if: always()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
+ with:
+ name: upstream-provider-watch-${{ github.run_id }}
+ path: |
+ health-output/upstream-provider-watch.json
+ health-output/upstream-provider-watch.md
+ retention-days: 30
+ if-no-files-found: error
diff --git a/.github/workflows/validate-desktop-runtime-compat.yml b/.github/workflows/validate-desktop-runtime-compat.yml
deleted file mode 100644
index 1b1c43603..000000000
--- a/.github/workflows/validate-desktop-runtime-compat.yml
+++ /dev/null
@@ -1,134 +0,0 @@
-name: Validate Desktop runtime compatibility
-
-on:
- pull_request:
- paths:
- - "manifest.json"
- - "vf/manifest.json"
- - "provider-overrides.json"
- - "scripts/provider_patches/desktop_runtime_compat_v1.py"
- - "scripts/publish_desktop_runtime_compat.py"
- - "tests/desktop_runtime_compat_test.py"
- - "tests/deep_repair_rollback_test.py"
- - ".github/workflows/validate-desktop-runtime-compat.yml"
-
-permissions:
- contents: read
-
-concurrency:
- group: desktop-runtime-compat-${{ github.event.pull_request.number || github.ref }}
- cancel-in-progress: true
-
-jobs:
- validate:
- runs-on: macos-14
- timeout-minutes: 58
- steps:
- - name: Checkout repository
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
-
- - name: Set up Python
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
- with:
- python-version: "3.12"
-
- - name: Set up Node.js
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- with:
- node-version: "24"
- package-manager-cache: false
-
- - name: Install dependencies
- run: npm ci --ignore-scripts --no-audit --no-fund
-
- - name: Validate compatibility tooling
- run: |
- python -m py_compile scripts/provider_patches/desktop_runtime_compat_v1.py
- python -m py_compile scripts/publish_desktop_runtime_compat.py
- python -m py_compile tests/desktop_runtime_compat_test.py
- python tests/desktop_runtime_compat_test.py
-
- - name: Stage runtime-compatible bundles from canonical sources
- run: |
- cp manifest.json "$RUNNER_TEMP/manifest.before.json"
- cp vf/manifest.json "$RUNNER_TEMP/vf-manifest.before.json"
- cp PROVENANCE.json "$RUNNER_TEMP/provenance.before.json"
- python scripts/publish_desktop_runtime_compat.py
- python - <<'PY'
- import json
- import os
- from pathlib import Path
-
- expected = {
- 'coflix', 'frenchstream', 'movix', 'streamzo',
- 'flemmix', 'wookafr', 'hindmoviez', 'purstream',
- }
- runner_temp = Path(os.environ['RUNNER_TEMP'])
- report = json.loads(Path('automation/desktop-runtime-compat-v1.json').read_text(encoding='utf-8'))
- manifest = json.loads(Path('manifest.json').read_text(encoding='utf-8'))
- before = json.loads((runner_temp / 'manifest.before.json').read_text(encoding='utf-8'))
- vf_manifest = json.loads(Path('vf/manifest.json').read_text(encoding='utf-8'))
- vf_before = json.loads((runner_temp / 'vf-manifest.before.json').read_text(encoding='utf-8'))
- provenance = json.loads(Path('PROVENANCE.json').read_text(encoding='utf-8'))
- provenance_before = json.loads((runner_temp / 'provenance.before.json').read_text(encoding='utf-8'))
-
- rows = {str(row.get('id', '')).casefold(): row for row in manifest.get('scrapers', [])}
- before_rows = {str(row.get('id', '')).casefold(): row for row in before.get('scrapers', [])}
- vf_rows = {str(row.get('id', '')).casefold(): row for row in vf_manifest.get('scrapers', [])}
- vf_before_rows = {str(row.get('id', '')).casefold(): row for row in vf_before.get('scrapers', [])}
- enabled_targets = {pid for pid in expected if before_rows[pid].get('enabled') is True}
- disabled_targets = expected - enabled_targets
-
- assert enabled_targets == set(report.get('published', [])), report
- assert disabled_targets == set(report.get('validated_disabled', [])), report
- assert not report.get('preserved'), report
-
- for provider_id in enabled_targets:
- row = rows[provider_id]
- assert '--desktop-runtime-v1--' in row['filename'], row
- bundle = Path(row['filename'])
- assert bundle.is_file(), bundle
- text = bundle.read_text(encoding='utf-8', errors='replace')
- assert 'NUVIO_DESKTOP_RUNTIME_COMPAT_V1' in text, provider_id
- if provider_id in vf_rows:
- assert vf_rows[provider_id]['filename'] == '../' + row['filename'], (provider_id, vf_rows[provider_id])
-
- # Disabled providers may be bound to an exact content-addressed safety
- # finding. Compatibility is validated in memory only; staging must not
- # alter their publication/quarantine identity anywhere.
- before_providers = {str(k).casefold(): v for k, v in (provenance_before.get('providers') or {}).items()}
- after_providers = {str(k).casefold(): v for k, v in (provenance.get('providers') or {}).items()}
- for provider_id in disabled_targets:
- assert rows[provider_id] == before_rows[provider_id], (provider_id, rows[provider_id], before_rows[provider_id])
- if provider_id in vf_before_rows:
- assert vf_rows.get(provider_id) == vf_before_rows[provider_id], (provider_id, vf_rows.get(provider_id), vf_before_rows[provider_id])
- assert after_providers.get(provider_id) == before_providers.get(provider_id), provider_id
- detail = report['providers'][provider_id]
- assert detail.get('ok') is True and detail.get('canonical_unchanged') is True, detail
- assert len(str(detail.get('validation_sha256') or '')) == 64, detail
-
- purstream = Path(rows['purstream']['filename']).read_text(encoding='utf-8', errors='replace')
- assert '"hostPrefixes":["api.purstream","purstream"]' in purstream
- assert '"suffixes":["club","mx","ch","ac","cx","art","co","me","to","store"]' in purstream
- assert '"api.purstream.club":"api.purstream.art"' not in purstream
-
- hindmoviez = Path(rows['hindmoviez']['filename']).read_text(encoding='utf-8', errors='replace')
- assert '"maxSeriesStreams":24' in hindmoviez
- assert '"filterEpisodeLabels":true' in hindmoviez
-
- if rows.get('4khdhubnew', {}).get('enabled'):
- assert rows.get('4khdhub', {}).get('enabled') is False, rows.get('4khdhub')
- if 'nakios' in rows:
- assert rows['nakios']['enabled'] is True, rows['nakios']
- print('desktop runtime publication staging validated')
- PY
-
- - name: Validate release after staged publication
- run: |
- python scripts/sync_release_versions.py --manifest manifest.json --previous "$RUNNER_TEMP/manifest.before.json"
- python scripts/validate_vf_projection.py
- npm test
- python scripts/generate_release_hashes.py
- python scripts/validate_release_integrity.py
- node scripts/validate_provider_semantics.cjs
- python scripts/validate_activation_preservation.py
diff --git a/README.md b/README.md
index fadf57d97..db2247109 100644
--- a/README.md
+++ b/README.md
@@ -142,17 +142,19 @@ VF/VOSTFR n'est jamais déduite d'un seul indice. Selon les informations disponi
## Compatibilité Nuvio
-Les commits clients acceptés sont épinglés dans [`sources.json`](sources.json) et les validations natives utilisent les repositories officiels.
+Les commits clients acceptés sont épinglés dans [`sources.json`](sources.json) et les validations natives utilisent les repositories officiels à ces révisions exactes.
-| Client | Repository | Preuve |
+| Client | Repository | Preuve native retenue |
|---|---|---|
-| Nuvio Mobile | [`NuvioMedia/NuvioMobile`](https://github.com/NuvioMedia/NuvioMobile) | runtime Android/iOS et contrat Mobile |
-| Nuvio Desktop | [`NuvioMedia/NuvioDesktop`](https://github.com/NuvioMedia/NuvioDesktop) | runtime Desktop Windows/macOS/Linux |
-| NuvioTV | [`NuvioMedia/NuvioTV`](https://github.com/NuvioMedia/NuvioTV) | runtime Android TV |
+| Nuvio Mobile | [`NuvioMedia/NuvioMobile`](https://github.com/NuvioMedia/NuvioMobile) | chemin Android officiel et stack de lecture du client |
+| Nuvio Desktop | [`NuvioMedia/NuvioDesktop`](https://github.com/NuvioMedia/NuvioDesktop) | bridges/lecteurs natifs **macOS et Windows** ; le stub Linux n'est pas une preuve lecteur |
+| NuvioTV | [`NuvioMedia/NuvioTV`](https://github.com/NuvioMedia/NuvioTV) | Android TV officiel avec Media3/ExoPlayer |
Le contrat logique ARCHI 2 est commun, mais **une preuve Desktop ne vaut jamais automatiquement preuve Mobile ou TV**.
-Le corpus natif couvre actuellement plusieurs œuvres représentatives de films, séries et anime et exécute chaque provider dans le runtime du client concerné. Les jobs ciblés permettent de retester un seul device — et, pour TV, une seule fixture — sans relancer inutilement tout le parc.
+Les labs natifs parcourent les lignes du manifest compatibles avec la plateforme, **y compris les providers `enabled:false`**, et lisent **chaque stream retourné** sur des routes représentatives film, série et anime. Les routes incompatibles sont comptabilisées comme skips explicites ; les probes `tv/anime` non déclarés restent des preuves de capacité et ne déclenchent pas de réparation provider sur un simple échec.
+
+Les profils Nuvio, snapshots AVD, caches providers et caches Gradle sont conservés lorsque c'est sûr afin d'éviter de reconstruire inutilement l'environnement. Les retests ciblés par device restent disponibles manuellement.
---
@@ -183,7 +185,7 @@ NiakVIO repose sur **Provider Engine V2 / ARCHI 2**.
Evidence Matrix
│
▼
- Repair Brain V2
+ Repair Brain v4
│
▼
média + identité + langue + contexte
@@ -254,14 +256,14 @@ hypothèse de réparation
↓
mutation en sandbox
↓
-retest
+retest lecteur officiel
↓
acceptation ou mémoire d'échec
```
Une stratégie échouée peut être mémorisée pour éviter de répéter mécaniquement le même repair. Une stratégie réussie n'est réutilisable automatiquement qu'après les preuves prévues par la politique du moteur.
-Le **Brain Learning Lab** est séparé de la publication : il travaille en sandbox, produit une mémoire sanitizée et n'a pas le droit de publier directement un provider ou un manifest.
+Le **Brain Learning Lab** est séparé de la publication : il travaille en sandbox, produit une mémoire sanitizée et n'a pas le droit de publier directement un provider ou un manifest. La mémoire de réparation lecteur conserve les résultats des retests officiels et les IDs de runs déjà importés afin d'éviter le double apprentissage d'une même preuve. L'import automatique est limité aux runs lecteurs issus de `main` ; une preuve de PR ne modifie pas silencieusement la mémoire persistante.
Un audit historique 5.20.63 sert de bootstrap de départ afin de confronter les nouvelles observations à un état antérieur riche. Les apprentissages futurs doivent ensuite être portés par les preuves du moteur, les corpus natifs et la mémoire d'expérience du Brain — pas par une liste humaine de providers à forcer.
@@ -291,12 +293,13 @@ Cette cible n'autorise aucun faux positif : mauvaise œuvre, mauvais épisode, d
Le dispositif comprend :
-- corpus complet Desktop ;
-- corpus complet Mobile ;
-- corpus complet TV ;
-- jobs ciblés par device ;
-- fixtures TV parallélisées pour diagnostiquer et retester uniquement l'œuvre concernée ;
-- synthèse cross-device destinée au moteur et au Brain.
+- un lab **NuvioTV Android TV** officiel sur des routes film/TV/anime, tous providers compatibles — actifs ou inactifs — et tous les streams retournés ;
+- un lab **Nuvio Mobile Android** officiel avec le même contrat de traversal et de lecture ;
+- un lab **Nuvio Desktop natif macOS/Windows**, Linux étant explicitement exclu comme preuve lecteur ;
+- une preuve repository → provider → HTTP → stream → lecteur, plus des phases frontend capturées ;
+- des retests ciblés par device disponibles **manuellement** sans relancer toute la matrice ;
+- un sandbox Brain v4 qui peut matérialiser jusqu'à 24 mutations provider génériques justifiées, puis rejoue Sinners sur NuvioTV avec tous les providers et tous les streams avant comparaison ;
+- une mémoire lecteur fail-closed : preuve incomplète = pas d'apprentissage et pas de plan de réparation.
---
@@ -334,21 +337,21 @@ Lorsqu'une transaction change réellement une donnée visible côté client :
| Workflow | Rôle |
|---|---|
| `sync.yml` | discovery → repair → validation → publication Quick/Deep |
-| `provider-engine-v2.yml` | tests et observation ARCHI 2 |
+| `canonical-media-types.yml` | contrats media, evidence native, cache et mémoire Brain |
+| `github-actions-gate.yml` | sécurité et invariants des workflows |
+| `native-android-route-reader.yml` | preuve native exhaustive NuvioTV + Mobile et retest Brain v4 |
+| `native-desktop-reader-acceptance.yml` | preuve lecteur officielle Desktop macOS/Windows |
+| `native-corpus-device-targeted.yml` | retests device à la demande uniquement |
+| `native-reader-learning-sync.yml` | import idempotent des résultats lecteur validés de `main` |
| `brain-learning-lab.yml` | expérimentation et mémoire du Repair Brain en sandbox |
| `availability.yml` | disponibilité des providers publiés |
| `domain-refresh.yml` | observation des domaines |
| `engine-regression-offline.yml` | non-régressions moteur hors réseau |
| `provider-rebuild-offline.yml` | reconstruction hors réseau |
-| `final-native-client-validation-v2.yml` | validation native Mobile/Desktop/TV |
-| `native-corpus-device-lab.yml` | corpus natif cross-device complet |
-| `native-corpus-device-targeted.yml` | retests ciblés Desktop/Mobile/TV |
| `provider-catalogue-breadth-lab.yml` | largeur de catalogue |
-| `permanent-real-client-labs.yml` | reproductions réelles Desktop/Mobile |
-| `permanent-android-real-client.yml` | banc Android isolé |
| `provider-status-export.yml` | snapshot diagnostic |
-Les workflows temporaires, one-shot et orchestrateurs superseded ne font pas partie de l'architecture cible.
+Les anciens labs à refs clientes mutables, les preuves Desktop Linux, les workflows provider-spécifiques et les orchestrateurs superseded ne font pas partie de l'architecture cible.
---
@@ -400,7 +403,8 @@ Les tests locaux ne remplacent pas la validation native lorsqu'un changement tou
## Politique de branches
- `main` : état stable et publiable ;
-- `lab/desktop-mobile-real` et `lab/tv-real` : labs permanents ;
+- `brain-learning/proposals` : mémoire sanitizée persistante du Brain ;
+- `lab/desktop-mobile-real` et `lab/tv-real` : labs permanents, à ne pas nettoyer automatiquement ;
- les branches temporaires `fix/*`, `ci/*`, `proof/*`, `tmp/*`, `chore/*` et `refactor/*` doivent disparaître après intégration ou abandon vérifié.
Une branche n'est jamais supprimée avant comparaison avec `main`. Du contenu unique utile doit être intégré ou explicitement abandonné.
@@ -413,4 +417,4 @@ Le moteur applique des budgets de workers, des protections réseau/SSRF, des con
NiakVIO est un projet communautaire indépendant, non affilié aux développeurs de Nuvio ni aux services tiers référencés. Le projet ne contrôle pas la disponibilité, le contenu, les droits ou les pratiques de sites tiers. L'utilisation doit respecter la législation applicable et les conditions des services concernés.
-Voir [`DISCLAIMER.md`](DISCLAIMER.md), [`LICENSE`](LICENSE), [`NOTICE`](NOTICE), [`SECURITY.md`](SECURITY.md), [`CONTRIBUTING.md`](CONTRIBUTING.md) et [`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md).
+Voir [`DISCLAIMER.md`](DISCLAIMER.md), [`LICENSE`](LICENSE), [`NOTICE`](NOTICE), [`SECURITY.md`](SECURITY.md), [`CONTRIBUTING.md`](CONTRIBUTING.md) et [`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md).
\ No newline at end of file
diff --git a/automation/native-human-ux-policy.json b/automation/native-human-ux-policy.json
new file mode 100644
index 000000000..1bae024b8
--- /dev/null
+++ b/automation/native-human-ux-policy.json
@@ -0,0 +1,329 @@
+{
+ "version": 5,
+ "mode": "human-ux-observation-only",
+ "purpose": "Native labs reproduce the real Nuvio user path and observe launch, stream selection, player opening and playback failures without repairing Nuvio, the OS or the lab environment to improve outcomes.",
+ "immutable_principles": [
+ "The harness exists to observe the user path, not to improve it.",
+ "Do not modify Nuvio production runtime code to make tests pass or to gain visibility.",
+ "Do not modify the OS or emulator security/network/runtime policy to make playback easier.",
+ "Playback/player failures are evidence first; only a demonstrated NiakVIO-owned cause may become a Brain repair target.",
+ "If the official Nuvio client or OS blocks faithful observation before playback, classify and retain the external limitation instead of repairing the environment.",
+ "A component-level player or provider probe is diagnostic evidence only and can never satisfy human-UX acceptance by itself.",
+ "Validated native-lab profiles and known job blockers are persistent memory and must be consulted before changing the harness."
+ ],
+ "ownership": {
+ "repairable": [
+ "NiakVIO Core",
+ "NiakVIO providers and adapters",
+ "NiakVIO Brain logic",
+ "NiakVIO manifests and generated provider artifacts"
+ ],
+ "external_read_only": [
+ "NuvioMobile",
+ "NuvioTV",
+ "NuvioDesktop",
+ "Android emulator and Android OS",
+ "macOS",
+ "Windows",
+ "native player implementations owned by Nuvio"
+ ]
+ },
+ "harness_change_control": {
+ "default": "locked-do-not-modify",
+ "change_policy": "only-when-faithful-observation-is-blocked",
+ "rule": "Do not improve, refactor, harden, tune or make the harness more permissive while it can still faithfully observe the real user path. If the harness itself prevents observation, apply only the smallest behavior-neutral change required to resume observation.",
+ "required_before_change": [
+ "consult persistent_profiles and job_blocker_memory",
+ "identify the exact harness blocker",
+ "show that the blocker prevents observation rather than merely producing a bad playback result",
+ "show that the proposed change cannot make Nuvio, the player, networking or the OS more permissive",
+ "keep the change outside Nuvio-owned runtime code whenever technically possible"
+ ],
+ "forbidden_reasons": [
+ "make CI green",
+ "increase playback success rate",
+ "work around a Nuvio player failure",
+ "work around an OS or emulator playback/network limitation",
+ "obtain richer evidence by changing Nuvio runtime semantics",
+ "re-diagnose a resolved blocker without a changed signature or invalidated profile"
+ ]
+ },
+ "persistent_profiles": {
+ "schema_version": 1,
+ "reuse_rule": "reuse-before-rediagnosis",
+ "invalidation_rule": "A validated profile may be reopened only when its declared invalidation trigger is observed. A red playback result or unrelated CI failure does not invalidate a harness profile.",
+ "common": {
+ "status": "validated-and-reusable",
+ "production_player_first": true,
+ "transport_probe_after_player_only": true,
+ "preserve_warm_profile_plugin_cache_state": true,
+ "nuvio_runtime_source_read_only": true,
+ "os_network_security_policy_read_only": true,
+ "component_probes_are_diagnostic_only": true,
+ "human_ux_acceptance_requires_real_ui_path": true,
+ "provider_transaction": "materialize current NiakVIO Brain/runtime overrides before staging; keep candidate repositories content-addressed",
+ "invalidated_by": [
+ "official Nuvio public user path changes so the recorded path can no longer be executed",
+ "test-owned staging itself prevents Nuvio from launching or reaching the real user path",
+ "a policy requirement becomes impossible to satisfy without changing the observation mechanism"
+ ],
+ "not_invalidated_by": [
+ "stream playback failure",
+ "decoder/player failure",
+ "provider returns zero streams",
+ "external DNS/network failure",
+ "a stale regression-test assertion",
+ "GitHub Actions log blob not yet available while a job is running"
+ ]
+ },
+ "tv": {
+ "status": "validated-and-reusable",
+ "client": "official NuvioTV HEAD",
+ "runner_profile": "Android TV API 31 x86 tv_1080p",
+ "allowed_test_plumbing": [
+ "app/src/androidTest/",
+ "app/build.gradle.kts test runner and test dependencies",
+ "debug signing only when hosted CI lacks the official release key",
+ "local.properties",
+ "local.dev.properties"
+ ],
+ "forbidden_retouch": [
+ "production AndroidManifest.xml",
+ "player/network runtime",
+ "cleartext or INTERNET permission changes",
+ "forced playback headers",
+ "lab player replacement"
+ ],
+ "known_entry": "native_client_test_bootstrap.enable_tv_tests"
+ },
+ "mobile": {
+ "status": "validated-and-reusable",
+ "client": "official NuvioMobile HEAD",
+ "runner_profile": "Android API 35 google_apis x86_64 pixel_2",
+ "launcher_package": "com.nuviodebug.com",
+ "allowed_test_plumbing": [
+ "composeApp/src/androidDeviceTest/",
+ "composeApp/build.gradle.kts device-test runner and test dependencies",
+ "composeApp/build.gradle.kts device-test-only duplicate libc++ packaging selection",
+ "local.properties"
+ ],
+ "forbidden_retouch": [
+ "production AndroidManifest.xml",
+ "player/network runtime",
+ "cleartext or INTERNET permission changes",
+ "forced playback headers",
+ "lab player replacement"
+ ],
+ "known_entry": "native_client_test_bootstrap.enable_mobile_device_tests"
+ },
+ "desktop": {
+ "status": "validated-and-reusable",
+ "clients": [
+ "official NuvioDesktop HEAD on macOS",
+ "official NuvioDesktop HEAD on Windows"
+ ],
+ "execution_policy": "ordinary-user",
+ "required_production_context": [
+ "NuvioTheme",
+ "PlatformPlayerSurface"
+ ],
+ "forbidden_retouch": [
+ "root execution",
+ "sudo",
+ "--add-opens or ALL-UNNAMED JVM privilege relaxation",
+ "decoder priority overrides",
+ "RTX/player setting overrides",
+ "lab-native player/controller replacement"
+ ]
+ }
+ },
+ "job_blocker_memory": {
+ "schema_version": 1,
+ "consult_before_harness_change": true,
+ "resolved_rule": "Do not reapply the old workaround for a resolved blocker unless the failure signature materially changes and the relevant profile is invalidated.",
+ "open_rule": "When an open blocker signature appears, follow its next_action first; do not make Nuvio/OS/player more permissive.",
+ "entries": [
+ {
+ "id": "stale-repository-http-instrumentation-contract",
+ "status": "resolved",
+ "signature": "native_evidence_contract_test.py AssertionError FIELD_NATIVE_REPOSITORY_HTTP_REQUEST",
+ "cause": "architecture test still required runtime-injected repository HTTP markers after runtime instrumentation had correctly been disabled",
+ "resolution": "contract now asserts audited non-mutating compatibility shims instead of requiring injected HTTP markers",
+ "resolved_commit": "6a1c174c4cb554fd0b43e1fdf10438b5087b0e4e",
+ "never_repeat": "do not re-enable Nuvio repository/network runtime instrumentation"
+ },
+ {
+ "id": "stale-tv-bootstrap-wrapper-contract",
+ "status": "resolved",
+ "signature": "native_reader_runtime_bootstrap_test.py AssertionError defaultConfig in tv_bootstrap",
+ "cause": "test inspected the compatibility wrapper although bootstrap implementation had moved to native_client_test_bootstrap.py",
+ "resolution": "validate the shared bootstrap and require the old wrapper to remain non-mutating",
+ "resolved_commit": "9a9ed7f7d3c66ba774b76f94350e7f9d6c50be0b",
+ "never_repeat": "do not move runtime/test bootstrap logic back into nuvio_tv_test_bootstrap.py"
+ },
+ {
+ "id": "stale-tv-bootstrap-alias-contract",
+ "status": "resolved",
+ "signature": "native_reader_runtime_bootstrap_test.py AssertionError enable_tv_test_bootstrap(repo)",
+ "cause": "test expected an obsolete alias while preparation code directly imports enable_tv_tests from the shared bootstrap",
+ "resolution": "assert enable_tv_tests(repo/tv) directly",
+ "resolved_commit": "7912065bedfcd4dd760a454722f82ef3b4c9335c",
+ "never_repeat": "do not recreate the obsolete alias to satisfy a stale test"
+ },
+ {
+ "id": "mobile-device-test-libcxx-packaging-conflict",
+ "status": "resolved",
+ "signature": "mergeAndroidDeviceTestNativeLibs duplicate libc++_shared.so from upstream native dependencies",
+ "cause": "the generated NuvioMobile instrumentation APK merges more than one copy of the same C++ runtime before the real application/player can launch",
+ "resolution": "allow one idempotent device-test-only Gradle packaging pickFirst for lib/*/libc++_shared.so in the ephemeral checkout; do not modify application runtime, player, provider, networking or OS policy",
+ "never_repeat": "do not remove the test-only packaging exception while this upstream dependency conflict exists, and never replace it with player/network/runtime changes"
+ },
+ {
+ "id": "reader-run-cancellation-churn",
+ "status": "operational-guard",
+ "signature": "new head commit while native reader jobs are queued or running causes concurrency cancellation/restart",
+ "cause": "frequent small commits prevent expensive reader jobs from reaching evidence collection",
+ "resolution": "batch non-reader corrections; once a reader run is executing on the intended head, freeze the head until evidence is collected unless a known deterministic pre-reader blocker makes completion impossible",
+ "never_repeat": "do not push cosmetic or speculative harness changes while waiting for reader evidence"
+ },
+ {
+ "id": "repository-http-evidence-gap-after-instrumentation-disable",
+ "status": "watch",
+ "signature": "missing_repository_http:: after a successful fresh repository load",
+ "cause": "native_evidence_completeness may still expect repository HTTP request/terminal markers even though production runtime injection is forbidden",
+ "next_action": "if observed, fix the NiakVIO-owned evidence/completeness contract or obtain passive/test-owned evidence; never restore Nuvio runtime HTTP instrumentation",
+ "never_repeat": "do not patch Nuvio repository/network loaders"
+ },
+ {
+ "id": "actions-log-blob-not-ready",
+ "status": "operational-guard",
+ "signature": "GitHub job-log fetch returns BlobNotFound/404 while job is still in progress",
+ "cause": "GitHub has not materialized the downloadable job log yet",
+ "resolution": "inspect job step status and retry log retrieval only after completion or later in the same investigation",
+ "never_repeat": "do not classify this as a Nuvio/NiakVIO failure or change the harness"
+ }
+ ]
+ },
+ "human_ux_acceptance_path": [
+ "launch the official Nuvio client",
+ "install or open the NiakVIO addon through a Nuvio-supported public flow",
+ "open the target title through a Nuvio-supported public flow or equivalent external user input",
+ "reach the real Nuvio stream-selection surface",
+ "select the returned stream through the real Nuvio UI",
+ "let the real Nuvio player attempt playback before any media transport diagnostic",
+ "record first-frame success or the visible/native player failure",
+ "classify the evidence before any NiakVIO repair is proposed"
+ ],
+ "evidence_classes": {
+ "human_ux_proof": {
+ "can_gate_acceptance": true,
+ "requires_real_ui_path": true,
+ "requires_production_player": true,
+ "may_patch_nuvio_runtime": false
+ },
+ "component_probe": {
+ "can_gate_acceptance": false,
+ "role": "diagnostic-only",
+ "examples": [
+ "direct provider runtime invocation",
+ "direct production player component invocation",
+ "transport probe",
+ "generated instrumentation test"
+ ]
+ }
+ },
+ "allowed_checkout_changes": {
+ "mobile": [
+ "composeApp/build.gradle.kts",
+ "composeApp/src/androidDeviceTest/",
+ "local.properties"
+ ],
+ "tv": [
+ "app/build.gradle.kts",
+ "app/src/androidTest/",
+ "local.properties",
+ "local.dev.properties"
+ ],
+ "desktop": [
+ "composeApp/src/desktopTest/",
+ "local.properties"
+ ]
+ },
+ "allowed_gradle_additions": {
+ "mobile": [
+ "withDeviceTest {",
+ "instrumentationRunner = \"androidx.test.runner.AndroidJUnitRunner\"",
+ "execution = \"HOST\"",
+ "val androidDeviceTest by getting {",
+ "dependencies {",
+ "implementation(\"junit:junit:4.13.2\")",
+ "implementation(\"androidx.test.ext:junit:1.3.0\")",
+ "implementation(\"androidx.test:runner:1.7.0\")",
+ "packaging {",
+ "jniLibs {",
+ "pickFirsts.add(\"lib/*/libc++_shared.so\")"
+ ],
+ "tv": [
+ "signingConfig = signingConfigs.getByName(\"debug\")",
+ "testInstrumentationRunner = \"androidx.test.runner.AndroidJUnitRunner\"",
+ "dependencies {",
+ "androidTestImplementation(\"androidx.test.ext:junit:1.3.0\")",
+ "androidTestImplementation(\"androidx.test:runner:1.7.0\")"
+ ],
+ "desktop": []
+ },
+ "allowed_change_intent": [
+ "add or enable test source sets for diagnostic probes only",
+ "add instrumentation runner declarations for diagnostic probes only",
+ "add test-only dependencies for diagnostic probes only",
+ "resolve duplicate C++ runtime packaging in the instrumentation APK only without changing application runtime, player, provider, network or OS behavior",
+ "use a debug signing configuration when hosted CI cannot access an official release signing key",
+ "stage NiakVIO provider bundles as diagnostic test assets",
+ "add test code that invokes production Nuvio entry points only as component diagnostics, never as human-UX acceptance proof"
+ ],
+ "forbidden_checkout_tokens": [
+ "android.permission.INTERNET",
+ "usesCleartextTraffic",
+ "networkSecurityConfig",
+ "cleartextTrafficPermitted",
+ "setDefaultRequestProperties",
+ "setRequestProperty(\"Referer\"",
+ "setRequestProperty(\"Origin\"",
+ "setRequestProperty(\"User-Agent\"",
+ "PlayerPlaybackNetworking",
+ "PlatformPlaybackDataSourceFactory",
+ "ExoPlayer.Builder",
+ "NativePlayerController(",
+ "decoderPriority",
+ "nvidiaRtxSuperResolutionEnabled",
+ "PluginRepository.clearLocalState",
+ "FIELD_NATIVE_HTTP_REQUEST client=",
+ "FIELD_NATIVE_REPOSITORY_HTTP_REQUEST client="
+ ],
+ "forbidden_behaviors": [
+ "edit any production AndroidManifest.xml",
+ "grant network or cleartext capabilities",
+ "change DNS, proxy, headers, redirects, cache policy or HTTP semantics",
+ "replace or wrap the production player with a friendlier lab player",
+ "change decoder or playback settings to obtain a green result",
+ "clear or rewrite the real user profile, plugin state or cache to hide failures",
+ "patch Nuvio source code for logging or instrumentation",
+ "patch Nuvio repository/network loaders to inject evidence interceptors",
+ "patch OS permissions, security policy or runtime privileges",
+ "classify an external Nuvio or OS limitation as a NiakVIO provider repair target",
+ "count a direct player/provider component probe as human-UX acceptance",
+ "retouch the harness merely because playback or player tests are failing"
+ ],
+ "evidence_rule": "Human-UX acceptance must come from the real Nuvio user path and production player. Component probes may supplement diagnosis but cannot replace that path. Diagnostics must not patch Nuvio production runtime code to gain visibility.",
+ "failure_rule": "If faithful observation cannot reach playback because the official Nuvio client or OS blocks earlier, record the condition as external evidence. Do not repair the external environment merely to continue the test.",
+ "brain_rule": "Brain may learn and repair only from complete evidence that demonstrates a NiakVIO-owned cause. External Nuvio/OS failures must not generate provider mutations.",
+ "change_control": {
+ "policy_file_is_source_of_truth": true,
+ "audit_must_read_this_file": true,
+ "persistent_profiles_are_source_of_truth": true,
+ "job_blocker_memory_is_source_of_truth": true,
+ "policy_changes_require_canonical_gate": true,
+ "policy_changes_require_explicit_diff_review": true,
+ "default_on_ambiguity": "fail-closed"
+ }
+}
diff --git a/engine_v2/providers/purstream.mjs b/engine_v2/providers/purstream.mjs
index 5b8836744..b2beabe2a 100644
--- a/engine_v2/providers/purstream.mjs
+++ b/engine_v2/providers/purstream.mjs
@@ -206,15 +206,28 @@ function normalizeSource(item, endpoint, userAgent, request) {
const explicitFormat = cleanText(item.format)?.toLowerCase();
const direct = lower.endsWith(".m3u8") || lower.endsWith(".mp4") || explicitFormat === "m3u8" || explicitFormat === "mp4";
if (!direct) return null;
+
const label = cleanText(item.source_name ?? item.name ?? item.label) ?? "Purstream";
- const language = parseLanguage(label);
- const quality = parseQuality(label);
+ const declaredQuality = cleanText(item.quality ?? item.resolution);
+ const declaredLanguage = cleanText(item.language ?? item.lang ?? item.audio_language);
+ const declaredCodec = cleanText(item.codec ?? item.video_codec ?? item.videoCodec);
+ const declaredAudio = cleanText(item.audio ?? item.audio_codec ?? item.audioCodec);
+ const declaredDuration = item.duration ?? item.duration_minutes ?? item.durationMinutes ?? item.runtime ?? null;
+ const declaredSourceType = cleanText(item.source_type ?? item.sourceType ?? item.release_type ?? item.releaseType);
+ const declaredAgeRating = cleanText(item.age_rating ?? item.ageRating ?? item.certification);
+
return {
- title: `Purstream ${quality} | ${language}`,
+ // Purstream supplies facts only. The Core owns all user-facing presentation.
+ title: "Purstream",
name: "Purstream",
url,
- quality,
- language,
+ quality: declaredQuality ?? parseQuality(label),
+ language: declaredLanguage ?? parseLanguage(label),
+ codec: declaredCodec ?? parseCodec(label),
+ audio: declaredAudio ?? parseAudio(label),
+ duration: declaredDuration,
+ sourceType: declaredSourceType ?? parseSourceType(label),
+ ageRating: declaredAgeRating,
format: lower.endsWith(".mp4") || explicitFormat === "mp4" ? "mp4" : "m3u8",
headers: {
"User-Agent": userAgent,
@@ -231,18 +244,51 @@ function parseLanguage(value) {
if (text.includes("VOSTFR")) return "VOSTFR";
if (text.includes("VFQ")) return "VFQ";
if (text.includes("VFF")) return "VFF";
- if (text.includes("VF")) return "VF";
+ if (/\bVF\b/.test(text)) return "VF";
if (text.includes("MULTI") || text.includes("DUAL")) return "MULTI";
- return "MULTI";
+ if (/\bVO\b/.test(text)) return "VO";
+ return null;
}
function parseQuality(value) {
const text = String(value ?? "").toUpperCase();
- if (text.includes("2160") || text.includes("4K")) return "4K";
+ if (text.includes("2160") || /\b4K\b/.test(text) || text.includes("UHD")) return "4K";
+ if (text.includes("1440")) return "1440p";
if (text.includes("1080")) return "1080p";
if (text.includes("720")) return "720p";
+ if (text.includes("576")) return "576p";
if (text.includes("480")) return "480p";
- return "HD";
+ return null;
+}
+
+function parseCodec(value) {
+ const text = String(value ?? "").toUpperCase();
+ if (/\b(?:HEVC|H\.?265|X265)\b/.test(text)) return "HEVC";
+ if (/\b(?:AVC|H\.?264|X264)\b/.test(text)) return "H.264";
+ if (/\bAV1\b/.test(text)) return "AV1";
+ if (/\bVP9\b/.test(text)) return "VP9";
+ return null;
+}
+
+function parseAudio(value) {
+ const text = String(value ?? "").toUpperCase();
+ const codec = text.match(/\b(?:E-?AC-?3|DDP|DD\+|AC-?3|AAC|DTS(?:-HD)?|TRUEHD|ATMOS|FLAC|OPUS)\b/);
+ const channels = text.match(/\b(?:7\.1|5\.1|2\.0|2\.1|1\.0)\b/);
+ if (!codec && !channels) return null;
+ let name = codec?.[0] ?? "";
+ name = name.replace(/^DDP$/i, "E-AC3").replace(/^DD\+$/i, "E-AC3").replace(/^E-?AC-?3$/i, "E-AC3").replace(/^AC-?3$/i, "AC3");
+ return [name, channels?.[0]].filter(Boolean).join(" ");
+}
+
+function parseSourceType(value) {
+ const text = String(value ?? "").toUpperCase();
+ if (/BLU[ ._-]?RAY|BDRIP|BRRIP/.test(text)) return "BLU-RAY";
+ if (/WEB[ ._-]?DL/.test(text)) return "WEB-DL";
+ if (/WEB[ ._-]?RIP/.test(text)) return "WEBRIP";
+ if (/\bHDTV\b/.test(text)) return "HDTV";
+ if (/\bDVD(?:RIP)?\b/.test(text)) return "DVD";
+ if (/\bCAM\b|\bTELESYNC\b/.test(text)) return "CAM";
+ return null;
}
async function resolveMetadata(request, resolver) {
@@ -250,6 +296,10 @@ async function resolveMetadata(request, resolver) {
title: cleanText(request.title),
aliases: [],
year: asYear(request.year),
+ runtime: null,
+ durationMinutes: null,
+ certification: null,
+ ageRating: null,
};
if (!resolver) {
if (!base.title) throw new Error("Purstream search requires title or metadataResolver");
@@ -260,6 +310,11 @@ async function resolveMetadata(request, resolver) {
title: cleanText(resolved?.title ?? resolved?.fr ?? base.title),
aliases: unique([...(resolved?.aliases ?? []), resolved?.originalTitle, resolved?.orig, base.title].map(cleanText).filter(Boolean)),
year: asYear(resolved?.year ?? base.year),
+ runtime: resolved?.runtime ?? resolved?.duration ?? null,
+ durationMinutes: resolved?.durationMinutes ?? resolved?.duration_minutes ?? null,
+ certification: cleanText(resolved?.certification ?? resolved?.contentRating ?? resolved?.content_rating),
+ ageRating: cleanText(resolved?.ageRating ?? resolved?.age_rating),
+ releaseDate: cleanText(resolved?.releaseDate ?? resolved?.release_date ?? resolved?.firstAirDate ?? resolved?.first_air_date),
};
}
diff --git a/engine_v2/scripts/diagnose-native-media-capabilities.mjs b/engine_v2/scripts/diagnose-native-media-capabilities.mjs
new file mode 100644
index 000000000..d172b9020
--- /dev/null
+++ b/engine_v2/scripts/diagnose-native-media-capabilities.mjs
@@ -0,0 +1,73 @@
+#!/usr/bin/env node
+import fs from 'node:fs';
+import path from 'node:path';
+import process from 'node:process';
+import { createRequire } from 'node:module';
+
+const require = createRequire(import.meta.url);
+const { assessNativeEvidence } = require('../../scripts/native_evidence_completeness.cjs');
+const { analyzeMediaTypeCapabilities } = require('../../scripts/analyze_native_media_type_capabilities.cjs');
+
+const args = process.argv.slice(2);
+const outputIndex = args.indexOf('--output');
+const outputPath = outputIndex >= 0 && args[outputIndex + 1]
+ ? path.resolve(args[outputIndex + 1])
+ : path.resolve('native-media-capabilities-brain.json');
+const logPaths = args
+ .filter((value, index) => index !== outputIndex && index !== outputIndex + 1 && value !== '--output')
+ .map((value) => path.resolve(value));
+
+if (!logPaths.length) {
+ console.error('usage: node engine_v2/scripts/diagnose-native-media-capabilities.mjs [--output file.json] [log ...]');
+ process.exit(64);
+}
+
+function fields(line) {
+ const out = {};
+ const re = /([A-Za-z0-9_]+)=([^\s]+)/g;
+ let match;
+ while ((match = re.exec(line)) !== null) out[match[1]] = match[2];
+ return out;
+}
+
+const evidence = assessNativeEvidence(logPaths);
+const fixtures = new Set();
+for (const file of logPaths) {
+ if (!fs.existsSync(file)) continue;
+ for (const raw of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
+ const marker = raw.indexOf('FIELD_NATIVE_');
+ if (marker < 0) continue;
+ const f = fields(raw.slice(marker));
+ if (f.fixture) fixtures.add(f.fixture);
+ }
+}
+
+const reports = evidence.complete
+ ? [...fixtures].sort().map((fixture) => analyzeMediaTypeCapabilities(fixture, logPaths))
+ : [];
+const proposals = reports.flatMap((report) => report.proposals || []);
+
+const payload = {
+ schemaVersion: 1,
+ generatedAt: new Date().toISOString(),
+ evidenceComplete: evidence.complete,
+ evidenceProblems: evidence.problems,
+ evidenceStats: evidence.stats,
+ fixtureReports: reports,
+ proposals,
+ policy: {
+ learningAllowed: evidence.complete,
+ productionManifestMutationAllowed: false,
+ requireIdentityProof: true,
+ requireEveryReturnedStreamHealthy: true,
+ requireCrossDeviceConfirmation: true,
+ },
+};
+
+fs.mkdirSync(path.dirname(outputPath), { recursive: true });
+fs.writeFileSync(outputPath, JSON.stringify(payload, null, 2) + '\n');
+console.log(`FIELD_NATIVE_MEDIA_CAPABILITY_BRAIN evidence_complete=${payload.evidenceComplete} fixtures=${reports.length} proposals=${proposals.length}`);
+for (const proposal of proposals.slice(0, 80)) {
+ console.log(`FIELD_NATIVE_MEDIA_CAPABILITY_BRAIN_PROPOSAL ${JSON.stringify(proposal)}`);
+}
+if (!evidence.complete) process.exitCode = 2;
diff --git a/engine_v2/scripts/diagnose-native-reader.mjs b/engine_v2/scripts/diagnose-native-reader.mjs
new file mode 100755
index 000000000..33429e60c
--- /dev/null
+++ b/engine_v2/scripts/diagnose-native-reader.mjs
@@ -0,0 +1,392 @@
+#!/usr/bin/env node
+import fs from 'node:fs';
+import path from 'node:path';
+import process from 'node:process';
+import { createRequire } from 'node:module';
+import { BRAIN_CONTROL_PLANE_VERSION, planRepair } from '../src/repair-brain.mjs';
+
+const require = createRequire(import.meta.url);
+const {
+ readerFailureClass,
+ readerFailureDomain,
+ providerMutationEligible,
+ readerSignature,
+ isReaderFailure,
+} = require('../../scripts/native_player_diagnostics.cjs');
+const { assessNativeEvidence } = require('../../scripts/native_evidence_completeness.cjs');
+
+const args = process.argv.slice(2);
+const outputIndex = args.indexOf('--output');
+const outputPath = outputIndex >= 0 && args[outputIndex + 1]
+ ? path.resolve(args[outputIndex + 1])
+ : path.resolve('targeted-reader-brain.json');
+const logPaths = args.filter((value, index) => index !== outputIndex && index !== outputIndex + 1 && value !== '--output').map((value) => path.resolve(value));
+const nonblockingSmoke = process.env.NIAKVIO_BRAIN_NONBLOCKING === '1';
+
+function decode(value) {
+ if (!value) return '';
+ let text = String(value).replace(/-/g, '+').replace(/_/g, '/');
+ while (text.length % 4) text += '=';
+ try { return Buffer.from(text, 'base64').toString('utf8'); } catch { return ''; }
+}
+function fields(line) {
+ const out = {};
+ const re = /([A-Za-z0-9_]+)=([^\s]+)/g;
+ let match;
+ while ((match = re.exec(line)) !== null) out[match[1]] = match[2];
+ return out;
+}
+function safeText(value, max = 420) {
+ return String(value || '')
+ .replace(/https?:\/\/\S+/gi, '')
+ .replace(/(?:(?:authorization|cookie|token|secret)\s*[:=]\s*)\S+/gi, 'credential=')
+ .replace(/\s+/g, ' ')
+ .trim()
+ .slice(0, max);
+}
+function providerLoadFailureClass(reason) {
+ const value = String(reason || '').trim().toLowerCase();
+ if (value === 'missing_after_repository_install') return 'provider_repository_load_missing';
+ if (value === 'metadata_mismatch' || value === 'metadata_or_code_mismatch') return 'provider_repository_metadata_mismatch';
+ return 'provider_repository_load_error';
+}
+function providerLoadRepair(issue) {
+ if (issue.failureClass === 'provider_repository_load_missing') {
+ return {
+ layer: 'repository',
+ providerJsMutationAllowed: false,
+ coreOrManifestProposalAllowed: true,
+ actions: [
+ 'verify the provider filename is reachable from the exact pinned manifest SHA',
+ 'compare the official client platform filter and provider id reconstruction',
+ 'verify Nuvio downloaded and cached the provider code',
+ 'repair repository/Core loading before touching provider JS',
+ 're-run the same official repository installation on the affected device',
+ ],
+ };
+ }
+ if (issue.failureClass === 'provider_repository_metadata_mismatch') {
+ return {
+ layer: 'manifest_contract',
+ providerJsMutationAllowed: false,
+ coreOrManifestProposalAllowed: true,
+ actions: [
+ 'diff canonical manifest enabled/types against the model reconstructed by Nuvio',
+ 'inspect manifest parser normalization and cached provider reconstruction',
+ 'repair manifest/Core adapter semantics instead of provider stream logic',
+ 're-run cross-device repository loading before publication',
+ ],
+ };
+ }
+ return {
+ layer: 'repository',
+ providerJsMutationAllowed: false,
+ coreOrManifestProposalAllowed: true,
+ actions: [
+ 'inspect the first observed repository/provider loading failure',
+ 'verify manifest download, provider download and cache reconstruction independently',
+ 'repair the loading layer before provider JS mutation',
+ 're-run official repository loading on the same device',
+ ],
+ };
+}
+
+const evidence = assessNativeEvidence(logPaths);
+const readerRows = [];
+const providerLoadObservations = [];
+for (const file of logPaths) {
+ if (!fs.existsSync(file)) continue;
+ for (const raw of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
+ const loadAt = raw.indexOf('FIELD_NATIVE_PROVIDER_LOAD_ERROR ');
+ if (loadAt >= 0) {
+ const f = fields(raw.slice(loadAt).trim());
+ const reason = safeText(f.reason || decode(f.error64) || 'unknown', 240);
+ const failureClass = providerLoadFailureClass(reason);
+ providerLoadObservations.push({
+ client: f.client || 'unknown',
+ fixture: f.fixture || 'unknown',
+ provider: decode(f.provider64).toLowerCase(),
+ reason,
+ failureClass,
+ });
+ }
+
+ const marker = raw.indexOf('FIELD_NATIVE_PLAYER ');
+ if (marker < 0) continue;
+ const f = fields(raw.slice(marker).trim());
+ const row = {
+ client: f.client || 'unknown', fixture: f.fixture || 'unknown', provider: decode(f.provider64),
+ requestType: String(f.request_type || 'unknown').toLowerCase(),
+ routeMode: String(f.route_mode || 'declared').toLowerCase(),
+ index: Number(f.index || 0), state: f.state || 'unknown', engine: f.engine || 'unknown',
+ httpStatus: Number(f.http_status || 0), failureStage: f.failure_stage || 'unknown',
+ durationSeconds: Number(f.duration_seconds || 0) || null, host: decode(f.host64),
+ errorClass: safeText(decode(f.error_class64), 180), errorCode: safeText(decode(f.error_code64), 120),
+ exceptionChain: safeText(decode(f.exception_chain64), 800), responseHeaderNames: safeText(decode(f.response_header_names64), 360),
+ loadBytes: Math.max(0, Number(f.load_bytes || 0) || 0),
+ loadDurationMs: Math.max(0, Number(f.load_duration_ms || 0) || 0),
+ mediaDataType: Number.isFinite(Number(f.media_data_type)) ? Number(f.media_data_type) : -1,
+ trackType: Number.isFinite(Number(f.track_type)) ? Number(f.track_type) : -1,
+ };
+ row.failureClass = readerFailureClass(row);
+ row.failureDomain = readerFailureDomain(row);
+ row.providerMutationEligible = providerMutationEligible(row);
+ row.signature = readerSignature({ ...row, requestType: row.requestType });
+ readerRows.push(row);
+ }
+}
+
+const declaredRows = readerRows.filter((row) => row.routeMode !== 'capability_probe');
+const capabilityRows = readerRows.filter((row) => row.routeMode === 'capability_probe');
+const failures = declaredRows.filter(isReaderFailure);
+const providerEligibleFailures = failures.filter((row) => row.providerMutationEligible);
+const clientRuntimeFailures = failures.filter((row) => !row.providerMutationEligible);
+const declaredHealthy = declaredRows.filter((row) => !isReaderFailure(row));
+const capabilityFailures = capabilityRows.filter(isReaderFailure);
+const capabilityHealthy = capabilityRows.filter((row) => !isReaderFailure(row));
+
+const plans = evidence.complete ? providerEligibleFailures.map((row) => {
+ const failureEvidence = {
+ invoked: true,
+ signature: row.signature,
+ request: { mediaType: row.requestType },
+ stages: {
+ reader: {
+ attempted: true,
+ observed: true,
+ state: row.state,
+ failureClass: row.failureClass,
+ failureStage: row.failureStage,
+ httpStatus: row.httpStatus,
+ errorCode: row.errorCode,
+ errorClass: row.errorClass,
+ durationSeconds: row.durationSeconds,
+ loadBytes: row.loadBytes,
+ loadDurationMs: row.loadDurationMs,
+ mediaDataType: row.mediaDataType,
+ trackType: row.trackType,
+ },
+ },
+ };
+ const plan = planRepair(failureEvidence, { signature: row.signature, maxHypotheses: 3 });
+ return {
+ provider: String(row.provider || '').toLowerCase(), client: row.client, fixture: row.fixture,
+ requestType: row.requestType, routeMode: row.routeMode, index: row.index,
+ state: row.state, failureClass: row.failureClass, failureDomain: row.failureDomain,
+ providerMutationEligible: true, failureStage: row.failureStage,
+ httpStatus: row.httpStatus, errorCode: row.errorCode, errorClass: row.errorClass,
+ host: row.host, durationSeconds: row.durationSeconds,
+ loadBytes: row.loadBytes, loadDurationMs: row.loadDurationMs,
+ mediaDataType: row.mediaDataType, trackType: row.trackType,
+ signature: row.signature, action: plan.action, exitReason: plan.exitReason,
+ hypotheses: plan.hypotheses.map((hypothesis) => ({
+ id: hypothesis.id,
+ capabilities: [...(hypothesis.capabilities || [])],
+ actions: [...(hypothesis.actions || [])],
+ })),
+ };
+}) : [];
+
+const providerLoadIssues = evidence.complete ? providerLoadObservations.map((row) => ({
+ ...row,
+ ...providerLoadRepair(row),
+})) : [];
+
+const healthyByRoute = new Map();
+for (const row of declaredHealthy) {
+ const key = `${String(row.provider || '').toLowerCase()}\u0000${row.requestType}\u0000${row.fixture}`;
+ if (!healthyByRoute.has(key)) healthyByRoute.set(key, new Set());
+ healthyByRoute.get(key).add(row.client);
+}
+const consensusGrouped = new Map();
+for (const plan of plans) {
+ const key = `${plan.provider}\u0000${plan.requestType}\u0000${plan.fixture}\u0000${plan.failureClass}`;
+ if (!consensusGrouped.has(key)) consensusGrouped.set(key, { plan, clients: new Set() });
+ consensusGrouped.get(key).clients.add(plan.client);
+}
+const crossClientProviderFailures = [...consensusGrouped.values()].filter(({ plan, clients }) => {
+ const routeKey = `${plan.provider}\u0000${plan.requestType}\u0000${plan.fixture}`;
+ return clients.size >= 2 && !(healthyByRoute.get(routeKey)?.size > 0);
+});
+const providerLearningAllowed = evidence.complete && crossClientProviderFailures.length > 0;
+
+const grouped = new Map();
+for (const plan of plans) {
+ const key = `${plan.provider}\u0000${plan.requestType}\u0000${plan.failureClass}`;
+ if (!grouped.has(key)) grouped.set(key, []);
+ grouped.get(key).push(plan);
+}
+const priorities = [...grouped.values()]
+ .map((rows) => ({
+ provider: rows[0].provider,
+ requestType: rows[0].requestType,
+ failureClass: rows[0].failureClass,
+ occurrences: rows.length,
+ clients: [...new Set(rows.map((row) => row.client))].sort(),
+ fixtures: [...new Set(rows.map((row) => row.fixture))].sort(),
+ firstHypothesis: rows[0].hypotheses[0]?.id || null,
+ signatures: [...new Set(rows.map((row) => row.signature))].slice(0, 12),
+ }))
+ .sort((a, b) => b.occurrences - a.occurrences || a.provider.localeCompare(b.provider));
+
+const loadGrouped = new Map();
+for (const issue of providerLoadIssues) {
+ const key = `${issue.provider}\u0000${issue.failureClass}`;
+ if (!loadGrouped.has(key)) loadGrouped.set(key, []);
+ loadGrouped.get(key).push(issue);
+}
+const providerLoadPriorities = [...loadGrouped.values()]
+ .map((rows) => ({
+ provider: rows[0].provider,
+ failureClass: rows[0].failureClass,
+ layer: rows[0].layer,
+ occurrences: rows.length,
+ clients: [...new Set(rows.map((row) => row.client))].sort(),
+ fixtures: [...new Set(rows.map((row) => row.fixture))].sort(),
+ providerJsMutationAllowed: false,
+ coreOrManifestProposalAllowed: evidence.complete,
+ actions: rows[0].actions,
+ }))
+ .sort((a, b) => b.occurrences - a.occurrences || a.provider.localeCompare(b.provider));
+
+const observations = readerRows.map((row) => ({
+ provider: String(row.provider || '').toLowerCase(), client: row.client, fixture: row.fixture,
+ requestType: row.requestType, routeMode: row.routeMode, index: row.index,
+ state: row.state, failureClass: row.failureClass, failureDomain: row.failureDomain,
+ providerMutationEligible: row.providerMutationEligible, failureStage: row.failureStage,
+ httpStatus: row.httpStatus, errorCode: row.errorCode, host: row.host,
+ durationSeconds: row.durationSeconds, loadBytes: row.loadBytes, loadDurationMs: row.loadDurationMs,
+}));
+const providerMap = new Map();
+function ensureProviderOutcome(provider) {
+ const key = String(provider || '').toLowerCase();
+ if (!providerMap.has(key)) {
+ providerMap.set(key, {
+ provider: key, observed: 0, healthy: 0, failures: 0,
+ providerEligibleFailures: 0, clientRuntimeFailures: 0,
+ capabilityProbes: 0, capabilityHealthy: 0, capabilityFailures: 0,
+ loadFailures: 0, loadFailureClasses: {},
+ failureClasses: {}, clients: new Set(), fixtures: new Set(), requestTypes: new Set(),
+ });
+ }
+ return providerMap.get(key);
+}
+for (const row of observations) {
+ const current = ensureProviderOutcome(row.provider);
+ current.observed += 1;
+ current.clients.add(row.client);
+ current.fixtures.add(row.fixture);
+ current.requestTypes.add(row.requestType);
+ if (row.routeMode === 'capability_probe') {
+ current.capabilityProbes += 1;
+ if (row.failureClass === 'healthy') current.capabilityHealthy += 1;
+ else current.capabilityFailures += 1;
+ } else if (row.failureClass === 'healthy') {
+ current.healthy += 1;
+ } else {
+ current.failures += 1;
+ if (row.providerMutationEligible) current.providerEligibleFailures += 1;
+ else current.clientRuntimeFailures += 1;
+ current.failureClasses[row.failureClass] = Number(current.failureClasses[row.failureClass] || 0) + 1;
+ }
+}
+for (const issue of providerLoadIssues) {
+ const current = ensureProviderOutcome(issue.provider);
+ current.loadFailures += 1;
+ current.loadFailureClasses[issue.failureClass] = Number(current.loadFailureClasses[issue.failureClass] || 0) + 1;
+ current.clients.add(issue.client);
+ current.fixtures.add(issue.fixture);
+}
+const providerOutcomes = [...providerMap.values()].map((row) => ({
+ provider: row.provider,
+ observed: row.observed,
+ healthy: row.healthy,
+ failures: row.failures,
+ providerEligibleFailures: row.providerEligibleFailures,
+ clientRuntimeFailures: row.clientRuntimeFailures,
+ capabilityProbes: row.capabilityProbes,
+ capabilityHealthy: row.capabilityHealthy,
+ capabilityFailures: row.capabilityFailures,
+ loadFailures: row.loadFailures,
+ loadFailureClasses: row.loadFailureClasses,
+ failureClasses: row.failureClasses,
+ clients: [...row.clients].sort(),
+ fixtures: [...row.fixtures].sort(),
+ requestTypes: [...row.requestTypes].sort(),
+})).sort((a, b) => b.loadFailures - a.loadFailures || b.failures - a.failures || a.provider.localeCompare(b.provider));
+
+const capabilityProbes = capabilityRows.map((row) => ({
+ provider: String(row.provider || '').toLowerCase(), client: row.client, fixture: row.fixture,
+ requestType: row.requestType, index: row.index,
+ state: row.state, healthy: !isReaderFailure(row), failureClass: row.failureClass,
+ failureDomain: row.failureDomain, providerMutationEligible: false,
+ failureStage: row.failureStage, httpStatus: row.httpStatus,
+ durationSeconds: row.durationSeconds,
+ promotionEligibleFromReaderAlone: false,
+}));
+
+const payload = {
+ schemaVersion: 5,
+ generatedAt: new Date().toISOString(),
+ brainVersion: BRAIN_CONTROL_PLANE_VERSION,
+ evidenceComplete: evidence.complete,
+ evidenceProblems: evidence.problems,
+ evidenceStats: evidence.stats,
+ readerObserved: readerRows.length,
+ readerDeclaredObserved: declaredRows.length,
+ readerHealthy: declaredHealthy.length,
+ readerFailures: failures.length,
+ providerEligibleReaderFailures: providerEligibleFailures.length,
+ clientRuntimeReaderFailures: clientRuntimeFailures.length,
+ crossClientProviderFailureGroups: crossClientProviderFailures.length,
+ capabilityProbeObserved: capabilityRows.length,
+ capabilityProbeHealthy: capabilityHealthy.length,
+ capabilityProbeFailures: capabilityFailures.length,
+ providerLoadObservedFailures: providerLoadObservations.length,
+ providerLoadActionableFailures: providerLoadIssues.length,
+ readerLoadErrorEvidence: readerRows.filter((row) => row.loadDurationMs > 0 || row.loadBytes > 0 || row.httpStatus > 0).length,
+ observations,
+ providerLoadObservations,
+ providerLoadIssues,
+ providerOutcomes,
+ capabilityProbes,
+ plans,
+ priorities,
+ providerLoadPriorities,
+ policy: {
+ evidenceUsable: evidence.complete,
+ learningAllowed: providerLearningAllowed,
+ providerLearningAllowed,
+ repairPlanningAllowed: providerLearningAllowed,
+ providerMutationRequiresCrossClientConsensus: true,
+ clientRuntimeFailureLearningAllowed: false,
+ repositoryLearningAllowed: evidence.complete && providerLoadIssues.length > 0,
+ providerLoadJsMutationAllowed: false,
+ coreOrManifestLoadProposalAllowed: evidence.complete && providerLoadIssues.length > 0,
+ capabilityLearningAllowed: false,
+ capabilityPromotionRequiresIdentityProof: true,
+ productionWritesAllowed: false,
+ publicationAllowed: false,
+ requireFreshNativeReaderProofAfterRepair: true,
+ nonblockingSmoke,
+ },
+ privacy: 'No raw URLs, query tokens, cookie values, authorization values or response-header values are persisted.',
+};
+fs.mkdirSync(path.dirname(outputPath), { recursive: true });
+fs.writeFileSync(outputPath, JSON.stringify(payload, null, 2) + '\n');
+console.log(
+ `FIELD_NATIVE_READER_BRAIN evidence_complete=${payload.evidenceComplete} observed=${payload.readerObserved} ` +
+ `declared=${payload.readerDeclaredObserved} healthy=${payload.readerHealthy} failures=${payload.readerFailures} ` +
+ `provider_eligible_failures=${payload.providerEligibleReaderFailures} client_runtime_failures=${payload.clientRuntimeReaderFailures} ` +
+ `cross_client_provider_groups=${payload.crossClientProviderFailureGroups} ` +
+ `provider_load_failures=${payload.providerLoadActionableFailures} capability_probes=${payload.capabilityProbeObserved} ` +
+ `capability_probe_healthy=${payload.capabilityProbeHealthy} capability_probe_failures=${payload.capabilityProbeFailures} ` +
+ `priorities=${priorities.length} provider_load_priorities=${providerLoadPriorities.length} provider_outcomes=${providerOutcomes.length} ` +
+ `nonblocking_smoke=${nonblockingSmoke}`
+);
+if (!evidence.complete) {
+ for (const problem of evidence.problems.slice(0, 80)) console.log(`FIELD_NATIVE_READER_BRAIN_EVIDENCE_INCOMPLETE ${problem}`);
+}
+for (const priority of providerLoadPriorities.slice(0, 40)) console.log(`FIELD_NATIVE_READER_BRAIN_LOAD_PRIORITY ${JSON.stringify(priority)}`);
+for (const priority of priorities.slice(0, 40)) console.log(`FIELD_NATIVE_READER_BRAIN_PRIORITY ${JSON.stringify(priority)}`);
+if (!evidence.complete && !nonblockingSmoke) process.exitCode = 2;
diff --git a/engine_v2/scripts/learning-lab.mjs b/engine_v2/scripts/learning-lab.mjs
index c4413de33..10f3f1ecf 100644
--- a/engine_v2/scripts/learning-lab.mjs
+++ b/engine_v2/scripts/learning-lab.mjs
@@ -20,6 +20,8 @@ const repair = readJson(repairPath, {});
const diagnostics = readJson(path.join(root, 'diagnostics-report.json'), {});
const policy = readJson(path.join(root, 'engine_v2/config/brain-policy.json'), {});
const previous = previousPath ? readJson(previousPath, {}) : {};
+const previousReaderMemory = isRecord(previous.nativeReaderRepairMemory) ? previous.nativeReaderRepairMemory : null;
+const previousNativeFeedback = isRecord(previous.nativeFeedback) ? previous.nativeFeedback : {};
const historical = historicalPath ? readJson(historicalPath, {}) : {};
const nativeSummary = nativeSummaryPath ? readJson(nativeSummaryPath, {}) : {};
const portfolio = portfolioPath ? readJson(portfolioPath, {}) : {};
@@ -109,6 +111,57 @@ for (const row of (portfolio.providers || [])
});
}
+// Reader evidence has precedence over generic transport labels because it is the
+// closest observation to what a human sees after pressing Play on the real OS.
+const readerSignals = Array.isArray(nativeSummary.readerFailureSignals) ? nativeSummary.readerFailureSignals : [];
+for (const row of readerSignals.slice(0, 80)) {
+ const failureClass = String(row?.failureClass || 'unknown_failure');
+ const recipes = REPAIR_RECIPES[failureClass] ?? REPAIR_RECIPES.unknown_failure;
+ proposals.push({
+ type: 'native_reader_failure_class',
+ priority: Number(row?.occurrences || 0) >= 5 ? 'critical' : 'high',
+ failureClass,
+ evidenceCount: Number(row?.occurrences || 0),
+ providers: Array.isArray(row?.providers) ? row.providers.slice(0, 24) : [],
+ clients: Array.isArray(row?.clients) ? row.clients.slice(0, 8) : [],
+ proposedSkill: {
+ id: `native-reader-${failureClass}`,
+ compose: recipes.map((recipe) => recipe.id).slice(0, 3),
+ capabilities: [...new Set(recipes.flatMap((recipe) => recipe.capabilities ?? []))],
+ execution: 'targeted_native_reader_then_sandbox',
+ },
+ reason: `Official native readers observed ${Number(row?.occurrences || 0)} ${failureClass} failure(s). Prefer reader-causal repair before generic provider mutation.`,
+ });
+}
+
+const providerReaderFailures = Array.isArray(nativeSummary.providerReaderFailures) ? nativeSummary.providerReaderFailures : [];
+for (const row of providerReaderFailures.slice(0, 120)) {
+ const classes = isRecord(row?.failureClasses) ? row.failureClasses : {};
+ const dominant = Object.entries(classes).sort((a, b) => Number(b[1]) - Number(a[1]))[0]?.[0] || 'unknown_failure';
+ proposals.push({
+ type: 'native_reader_provider_target',
+ priority: dominant === 'short_media' || dominant === 'playback_http_access' ? 'critical' : 'high',
+ providerId: String(row?.provider || '').toLowerCase(),
+ failureClass: dominant,
+ evidenceCount: Number(row?.occurrences || 0),
+ readerFailureClasses: classes,
+ clients: Array.isArray(row?.clients) ? row.clients.slice(0, 8) : [],
+ fixtures: Array.isArray(row?.fixtures) ? row.fixtures.slice(0, 16) : [],
+ reason: `Native player failure for this provider is causally classified as ${dominant}; retest the same provider/fixture/reader before broad repair.`,
+ });
+}
+
+const repeatedReaderFailures = Array.isArray(nativeSummary.engineSignals?.repeatedReaderFailures)
+ ? nativeSummary.engineSignals.repeatedReaderFailures : [];
+for (const row of repeatedReaderFailures.slice(0, 80)) {
+ proposals.push({
+ type: 'native_reader_repeated_signature', priority: 'critical',
+ providerId: String(row?.provider || '').toLowerCase(), failureClass: String(row?.failureClass || 'unknown_failure'),
+ evidenceCount: Number(row?.occurrences || 0),
+ reason: 'The same native-reader causal failure repeated. Stop generic retries; use the matching Brain v4 recipe and require a fresh reader proof before acceptance.',
+ });
+}
+
const unknown = counts.get('unknown_failure') ?? 0;
if (unknown >= 3) proposals.push({
type: 'instrumentation_proposal', priority: 'high', target: 'evidence pipeline',
@@ -123,7 +176,7 @@ if (drift > 0) proposals.push({
});
const payload = {
- schemaVersion: 2,
+ schemaVersion: 3,
generatedAt: new Date().toISOString(),
brain: policy.identity ?? { name: 'NiakVIO Brain' },
mode: 'learning_lab',
@@ -135,21 +188,31 @@ const payload = {
unresolvedFailureCounts: Object.fromEntries(counts),
diagnosticsAvailable: Boolean(Object.keys(diagnostics).length),
experimentMemory,
+ nativeReaderRepairMemory: previousReaderMemory,
historicalTraining: { baseline: historical.baseline ?? null, stats: historical.stats ?? {}, targets: historicalTargets },
nativeFeedback: {
providersObserved: Number(nativeSummary.providersObserved || portfolio.observedProviders || 0),
executions: Number(nativeSummary.executions || 0),
contradictions: Number(nativeSummary.contradictions || 0),
transportFailures: Number(nativeSummary.transportFailures || 0),
+ nativeReaderObserved: Number(nativeSummary.nativeReaderObserved || 0),
+ nativeReaderFailures: Number(nativeSummary.nativeReaderFailures || 0),
+ readerFailureClasses: isRecord(nativeSummary.readerFailureClasses) ? nativeSummary.readerFailureClasses : {},
runtimeErrors: Number(nativeSummary.runtimeErrors || 0),
- repairPriorityProviders: nativeRepairTargets,
+ readerRepairAccepted: nonNegative(previousNativeFeedback.readerRepairAccepted),
+ readerRepairRejected: nonNegative(previousNativeFeedback.readerRepairRejected),
+ readerRepairInconclusive: nonNegative(previousNativeFeedback.readerRepairInconclusive),
+ repairPriorityProviders: [...new Set([
+ ...nativeRepairTargets,
+ ...providerReaderFailures.map((row) => String(row?.provider || '').toLowerCase()).filter(Boolean),
+ ])].slice(0, 240),
},
privacy: 'No raw URLs, tokens, header values, cookies, private notes or spreadsheet text are copied into persistent Brain learning state.',
};
fs.writeFileSync(path.join(outputDir, 'latest.json'), JSON.stringify(payload, null, 2) + '\n');
fs.writeFileSync(path.join(outputDir, 'latest.md'), renderMarkdown(payload));
-console.log(`FIELD_BRAIN_LEARNING proposals=${payload.proposals.length} skills=${payload.learnedSkillCount} memory=${payload.experimentMemory.entries.length} historical_high=${Number(payload.historicalTraining.stats?.unresolvedHighPriority || 0)} native_repair=${payload.nativeFeedback.repairPriorityProviders.length}`);
+console.log(`FIELD_BRAIN_LEARNING proposals=${payload.proposals.length} skills=${payload.learnedSkillCount} memory=${payload.experimentMemory.entries.length} historical_high=${Number(payload.historicalTraining.stats?.unresolvedHighPriority || 0)} native_repair=${payload.nativeFeedback.repairPriorityProviders.length} reader_failures=${payload.nativeFeedback.nativeReaderFailures}`);
function mergeExperimentMemory(previousMemory, report, planMap, limit) {
const map = new Map();
@@ -234,7 +297,8 @@ function renderMarkdown(data) {
`Learned skills observed: **${data.learnedSkillCount}**`,
`Negative-memory entries: **${data.experimentMemory.entries.length}**`,
`Historical high/critical unresolved: **${Number(data.historicalTraining.stats?.unresolvedHighPriority || 0)}**`,
- `Native repair-priority providers: **${data.nativeFeedback.repairPriorityProviders.length}**`, '',
+ `Native repair-priority providers: **${data.nativeFeedback.repairPriorityProviders.length}**`,
+ `Native reader failures: **${data.nativeFeedback.nativeReaderFailures}**`, '',
'## Highest-priority proposals', '',
];
const ordered = [...data.proposals].sort((a, b) => priorityScore(b.priority) - priorityScore(a.priority));
diff --git a/engine_v2/scripts/live-purstream-v2.mjs b/engine_v2/scripts/live-purstream-v2.mjs
index dfa2a9bae..28f927dbc 100644
--- a/engine_v2/scripts/live-purstream-v2.mjs
+++ b/engine_v2/scripts/live-purstream-v2.mjs
@@ -2,6 +2,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import { ResolverCore } from "../src/resolver-core.mjs";
+import { createTmdbMetadataResolver } from "../src/tmdb-metadata.mjs";
import { createPurstreamAdapter, derivePurstreamEndpoint } from "../providers/purstream.mjs";
const root = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..");
@@ -26,11 +27,14 @@ for (const terminalUrl of candidates) {
attempts.push({ terminalUrl, setupError: String(error?.message ?? error), fixtures: [] });
continue;
}
- const core = new ResolverCore({ mediaValidationOptions: { timeoutMs: 8000, maxCandidates: 3, probeHlsChild: true } });
+ const core = new ResolverCore({
+ metadataResolver: createTmdbMetadataResolver({ fetchImpl, timeoutMs: 6000 }),
+ mediaValidationOptions: { timeoutMs: 8000, maxCandidates: 3, probeHlsChild: true },
+ });
const fixtureResults = [];
for (const fixture of fixtures()) {
const result = await core.resolve({
- provider: { id: "purstream" },
+ provider: { id: "purstream", name: "Purstream" },
adapter,
request: { ...fixture.request, device: fixture.device },
fixtureId: fixture.id,
@@ -59,6 +63,7 @@ const report = {
generated_at: new Date().toISOString(),
mode: "diagnostic-not-publication-blocker",
provider: "purstream",
+ presentation: "core-shared-facts-plus-tmdb",
domainObservation: observation ? {
selected: observation.selected ?? null,
hubUrls: observation.hubUrls ?? [],
@@ -124,6 +129,20 @@ function summarizeFixture(result, fixture) {
failureClass: result.repair.failureClass,
repairHypotheses: result.repair.hypotheses.map((item) => item.id),
playableStreams: result.evidence.playableStreams,
+ streams: result.streams.map((stream) => ({
+ title: stream.title,
+ description: stream.description,
+ quality: stream.quality,
+ language: stream.language,
+ codec: stream.codec,
+ audio: stream.audio,
+ duration: stream.duration,
+ sourceType: stream.sourceType,
+ ageRating: stream.ageRating,
+ displayBadges: stream.displayBadges,
+ playable: stream.playable,
+ host: safeHost(stream.validation?.finalUrl ?? stream.url),
+ })),
streamHosts: [...new Set(result.streams.filter((stream) => stream.playable).map((stream) => safeHost(stream.validation?.finalUrl ?? stream.url)).filter(Boolean))],
stages: Object.fromEntries(Object.entries(result.evidence.stages).map(([name, stage]) => [name, compactStage(stage)])),
errors: result.evidence.errors,
@@ -146,4 +165,4 @@ async function readJson(file, fallback) {
function argValue(name) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : null;
-}
+}
\ No newline at end of file
diff --git a/engine_v2/scripts/watch-upstream-providers.mjs b/engine_v2/scripts/watch-upstream-providers.mjs
new file mode 100644
index 000000000..a8aacddcf
--- /dev/null
+++ b/engine_v2/scripts/watch-upstream-providers.mjs
@@ -0,0 +1,231 @@
+#!/usr/bin/env node
+import fs from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+
+const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
+
+export function providerKey(value) {
+ return String(value ?? "")
+ .normalize("NFKD")
+ .replace(/[\u0300-\u036f]/g, "")
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "")
+ .trim();
+}
+
+export function catalogIdentity(catalog = {}) {
+ const ids = new Set();
+ const names = new Set();
+ for (const row of catalog.providers ?? []) {
+ if (!row || typeof row !== "object") continue;
+ for (const value of [row.canonicalId, row.scraper?.id]) {
+ const key = providerKey(value);
+ if (key) ids.add(key);
+ }
+ const name = providerKey(row.scraper?.name);
+ if (name) names.add(name);
+ const filename = String(row.scraper?.filename ?? "");
+ const stem = path.basename(filename).split("--", 1)[0].replace(/\.js$/i, "");
+ const stemKey = providerKey(stem);
+ if (stemKey) ids.add(stemKey);
+ }
+ return { ids, names };
+}
+
+export function manifestRows(payload = {}) {
+ const candidates = [payload.scrapers, payload.providers, payload.items];
+ return candidates.find(Array.isArray) ?? [];
+}
+
+export function interestFor(row = {}) {
+ const reasons = [];
+ let score = 0;
+ const languages = list(row.contentLanguage ?? row.languages).map((value) => value.toLowerCase());
+ const formats = list(row.formats).map((value) => value.toLowerCase());
+ const types = list(row.supportedTypes).map((value) => value.toLowerCase());
+ const description = `${row.name ?? ""} ${row.description ?? ""}`.toLowerCase();
+
+ if (languages.some((value) => ["fr", "fra", "french", "vf", "vostfr"].includes(value))) {
+ score += 4;
+ reasons.push("French/VF metadata");
+ }
+ if (types.includes("movie") && (types.includes("tv") || types.includes("anime"))) {
+ score += 2;
+ reasons.push("movie + episodic coverage");
+ } else if (types.length) {
+ score += 1;
+ reasons.push(`covers ${types.join("/")}`);
+ }
+ const directFormats = formats.filter((value) => ["m3u8", "mp4", "mkv", "mpd", "webm"].includes(value));
+ if (directFormats.length) {
+ score += Math.min(3, directFormats.length);
+ reasons.push(`direct formats: ${directFormats.join(", ")}`);
+ }
+ if (/\b(?:4k|uhd|2160p)\b/.test(description)) {
+ score += 2;
+ reasons.push("4K/UHD signal");
+ }
+ if (/anime|vostfr|french|francais|français/.test(description)) {
+ score += 1;
+ reasons.push("catalogue niche/language signal");
+ }
+ if (row.enabled !== false) {
+ score += 1;
+ reasons.push("enabled upstream");
+ }
+ if (row.limited === true) {
+ score -= 1;
+ reasons.push("limited upstream");
+ }
+
+ return { score, interesting: score >= 4, reasons };
+}
+
+export function compareManifest({ upstream, manifest, catalog }) {
+ const known = catalogIdentity(catalog);
+ const unseen = [];
+ const existing = [];
+
+ for (const row of manifestRows(manifest)) {
+ if (!row || typeof row !== "object") continue;
+ const id = String(row.id ?? row.name ?? "").trim();
+ if (!id) continue;
+ const idKey = providerKey(id);
+ const nameKey = providerKey(row.name);
+ const isKnown = known.ids.has(idKey) || (nameKey && known.names.has(nameKey));
+ const summary = {
+ upstream: upstream.id,
+ repository: upstream.repository,
+ id,
+ name: row.name ?? id,
+ version: row.version ?? null,
+ filename: row.filename ?? null,
+ supportedTypes: list(row.supportedTypes),
+ contentLanguage: list(row.contentLanguage ?? row.languages),
+ formats: list(row.formats),
+ enabled: row.enabled !== false,
+ limited: row.limited === true,
+ };
+ if (isKnown) {
+ existing.push(summary);
+ continue;
+ }
+ const interest = interestFor(row);
+ unseen.push({ ...summary, ...interest });
+ }
+
+ unseen.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id));
+ return { unseen, existingCount: existing.length };
+}
+
+async function readJson(filename) {
+ return JSON.parse(await fs.readFile(filename, "utf8"));
+}
+
+async function fetchManifest(upstream) {
+ const repositories = [upstream.repository, upstream.fallback_repository].filter(Boolean);
+ const errors = [];
+ for (const repository of repositories) {
+ const url = `https://raw.githubusercontent.com/${repository}/${encodeURIComponent(upstream.branch)}/${upstream.manifest}`;
+ try {
+ const response = await fetch(url, {
+ headers: { "User-Agent": "NiakVIO-upstream-provider-watch/1" },
+ signal: AbortSignal.timeout(20_000),
+ });
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ const payload = await response.json();
+ return { payload, repository, url };
+ } catch (error) {
+ errors.push(`${repository}: ${error?.message ?? error}`);
+ }
+ }
+ throw new Error(`${upstream.id}: unable to fetch manifest (${errors.join("; ")})`);
+}
+
+function markdown(report) {
+ const lines = [
+ "# Weekly upstream provider watch",
+ "",
+ `Generated: ${report.generatedAt}`,
+ `New candidates: **${report.summary.newCandidates}** — interesting: **${report.summary.interestingCandidates}**`,
+ "",
+ ];
+ for (const source of report.sources) {
+ lines.push(`## ${source.id} — ${source.repository}`);
+ lines.push("");
+ if (!source.unseen.length) {
+ lines.push("No provider missing from the NiakVIO catalogue.", "");
+ continue;
+ }
+ lines.push("| Score | Provider | Types | Languages | Formats | Why |", "| ---: | --- | --- | --- | --- | --- |");
+ for (const row of source.unseen) {
+ lines.push(`| ${row.score} | ${escapeTable(row.name)} (${escapeTable(row.id)}) | ${escapeTable(row.supportedTypes.join(", ") || "-")} | ${escapeTable(row.contentLanguage.join(", ") || "-")} | ${escapeTable(row.formats.join(", ") || "-")} | ${escapeTable(row.reasons.join("; ") || "new upstream provider")} |`);
+ }
+ lines.push("");
+ }
+ lines.push("Candidates are observations only. This job never imports or publishes a provider automatically.", "");
+ return lines.join("\n");
+}
+
+function escapeTable(value) {
+ return String(value ?? "").replace(/\|/g, "\\|").replace(/\s+/g, " ").trim();
+}
+
+function list(value) {
+ return Array.isArray(value) ? value.map((item) => String(item).trim()).filter(Boolean) : value == null ? [] : [String(value).trim()].filter(Boolean);
+}
+
+async function main() {
+ const configPath = process.env.NIAKVIO_PROVIDER_UPSTREAMS ?? path.join(ROOT, "engine_v2/config/provider-upstreams.json");
+ const catalogPath = process.env.NIAKVIO_PROVIDER_CATALOG ?? path.join(ROOT, "provider_catalog.json");
+ const outputPath = process.env.NIAKVIO_UPSTREAM_REPORT ?? path.join(ROOT, "health-output/upstream-provider-watch.json");
+ const markdownPath = process.env.NIAKVIO_UPSTREAM_MARKDOWN ?? path.join(ROOT, "health-output/upstream-provider-watch.md");
+
+ const [config, catalog] = await Promise.all([readJson(configPath), readJson(catalogPath)]);
+ const sources = [];
+ for (const upstream of config.upstreams ?? []) {
+ const fetched = await fetchManifest(upstream);
+ const comparison = compareManifest({ upstream: { ...upstream, repository: fetched.repository }, manifest: fetched.payload, catalog });
+ sources.push({
+ id: upstream.id,
+ repository: fetched.repository,
+ branch: upstream.branch,
+ manifest: upstream.manifest,
+ fetchedFrom: fetched.url,
+ existingCount: comparison.existingCount,
+ unseen: comparison.unseen,
+ });
+ }
+
+ const all = sources.flatMap((source) => source.unseen);
+ const report = {
+ schemaVersion: 1,
+ generatedAt: new Date().toISOString(),
+ policy: {
+ importAutomatically: false,
+ compareAgainst: "provider_catalog.json",
+ sources: sources.map((source) => source.id),
+ },
+ summary: {
+ newCandidates: all.length,
+ interestingCandidates: all.filter((row) => row.interesting).length,
+ },
+ sources,
+ };
+
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
+ await fs.writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`);
+ await fs.writeFile(markdownPath, markdown(report));
+ console.log(`upstream provider watch: new=${report.summary.newCandidates} interesting=${report.summary.interestingCandidates}`);
+ for (const row of all.filter((candidate) => candidate.interesting).slice(0, 30)) {
+ console.log(`interesting: ${row.upstream}/${row.id} score=${row.score} ${row.reasons.join("; ")}`);
+ }
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
+ main().catch((error) => {
+ console.error(error?.stack ?? error);
+ process.exitCode = 1;
+ });
+}
diff --git a/engine_v2/src/contracts.mjs b/engine_v2/src/contracts.mjs
index a70f9a591..c468ed201 100644
--- a/engine_v2/src/contracts.mjs
+++ b/engine_v2/src/contracts.mjs
@@ -3,6 +3,8 @@ export const CANONICAL_MEDIA_TYPES = Object.freeze(["movie", "tv", "anime"]);
const SERIES_ALIASES = new Set(["series", "show", "other"]);
+// External/client-facing requests remain tolerant because official Nuvio clients
+// may surface aliases such as "series". NiakVIO normalizes those at the boundary.
export function normalizeMediaType(value) {
const raw = String(value ?? "").trim().toLowerCase();
if (!raw) throw new Error("mediaType is required");
@@ -11,6 +13,16 @@ export function normalizeMediaType(value) {
throw new Error(`unsupported mediaType: ${raw}`);
}
+// Provider manifests/specs are stricter than request inputs: one global vocabulary
+// prevents divergent publication contracts and ambiguous lab coverage.
+export function normalizeProviderMediaType(value) {
+ const raw = String(value ?? "").trim().toLowerCase();
+ if (!CANONICAL_MEDIA_TYPES.includes(raw)) {
+ throw new Error(`provider media type must be canonical (${CANONICAL_MEDIA_TYPES.join("|")}): ${raw || ""}`);
+ }
+ return raw;
+}
+
export function normalizeResolveRequest(input = {}) {
const mediaType = normalizeMediaType(input.mediaType ?? input.type ?? input.category);
const tmdbId = input.tmdbId == null ? null : String(input.tmdbId).trim();
@@ -40,8 +52,8 @@ export function normalizeResolveRequest(input = {}) {
export function adaptRequestForDevice(input, device) {
const request = normalizeResolveRequest({ ...input, device });
- // Nuvio Mobile/Desktop/TV all accept the canonical positional four-argument
- // invocation. Keep aliases out of provider code and normalize before calling.
+ // The provider runtime receives NiakVIO's canonical vocabulary. Official Nuvio
+ // aliases are accepted only before this boundary, never persisted in provider specs.
return {
device: request.device,
call: "getStreams",
@@ -57,11 +69,25 @@ export function adaptRequestForDevice(input, device) {
}
export function normalizeStreamCandidate(raw = {}, context = {}) {
- const nestedUrl = isPlainObject(raw.url) ? raw.url.url : null;
+ const nested = isPlainObject(raw.url) ? raw.url : null;
+ const nestedUrl = nested?.url ?? null;
+ // A signed provider URL is an opaque playback credential. Never rebuild it or
+ // move its query parameters: only discard accidental outer whitespace.
const url = String(nestedUrl ?? raw.url ?? "").trim();
if (!url) throw new Error("stream url is required");
- const headers = normalizeHeaders(raw.headers ?? raw.requestHeaders);
+ // Nuvio/Stremio providers use several equivalent header surfaces. The Core must
+ // preserve all of them because Referer/Origin/Cookie/Authorization may be required
+ // again for HLS child playlists, keys and segments. Later/more explicit sources win
+ // case-insensitively without producing duplicate header names.
+ const headers = mergeHeaders(
+ nested?.behaviorHints?.proxyHeaders?.request,
+ raw.behaviorHints?.proxyHeaders?.request,
+ nested?.requestHeaders,
+ nested?.headers,
+ raw.requestHeaders,
+ raw.headers,
+ );
const subtitles = Array.isArray(raw.subtitles)
? raw.subtitles.map(normalizeSubtitle).filter(Boolean)
: [];
@@ -69,10 +95,16 @@ export function normalizeStreamCandidate(raw = {}, context = {}) {
return {
title: textOrNull(raw.title) ?? textOrNull(raw.name) ?? context.providerName ?? "Unknown",
name: textOrNull(raw.name),
+ description: textOrNull(raw.description),
url,
- quality: textOrNull(raw.quality),
+ quality: normalizeQualityLabel(raw.quality ?? raw.resolution),
size: textOrNull(raw.size),
- language: textOrNull(raw.language),
+ language: textOrNull(raw.language ?? raw.lang ?? raw.audioLanguage),
+ codec: textOrNull(raw.codec ?? raw.videoCodec ?? raw.video_codec),
+ audio: textOrNull(raw.audio ?? raw.audioCodec ?? raw.audio_codec),
+ duration: normalizeDurationMinutes(raw.duration ?? raw.durationMinutes ?? raw.runtime),
+ sourceType: textOrNull(raw.sourceType ?? raw.source_type ?? raw.releaseType ?? raw.release_type),
+ ageRating: textOrNull(raw.ageRating ?? raw.age_rating ?? raw.certification),
provider: textOrNull(raw.provider) ?? context.providerId ?? null,
type: textOrNull(raw.type),
headers,
@@ -91,7 +123,7 @@ export function validateProviderSpec(spec = {}) {
const types = normalizeStringList(spec.supportedTypes);
if (!types.length) errors.push("supportedTypes is required");
for (const type of types) {
- try { normalizeMediaType(type); } catch { errors.push(`unsupported type: ${type}`); }
+ try { normalizeProviderMediaType(type); } catch { errors.push(`non-canonical provider type: ${type}`); }
}
if (!Array.isArray(spec.sources) || spec.sources.length === 0) errors.push("at least one provenance source is required");
if (!isPlainObject(spec.strategies)) errors.push("strategies object is required");
@@ -104,7 +136,7 @@ export function normalizeProviderSpec(spec = {}) {
return {
id: String(spec.id).trim().toLowerCase(),
name: String(spec.name).trim(),
- supportedTypes: [...new Set(spec.supportedTypes.map(normalizeMediaType))],
+ supportedTypes: [...new Set(spec.supportedTypes.map(normalizeProviderMediaType))],
languages: normalizeStringList(spec.languages),
sources: structuredClone(spec.sources),
hubs: normalizeStringList(spec.hubs),
@@ -118,13 +150,21 @@ export function normalizeProviderSpec(spec = {}) {
function normalizeSubtitle(value) {
if (!isPlainObject(value)) return null;
- const url = textOrNull(value.url);
+ const nested = isPlainObject(value.url) ? value.url : null;
+ const url = textOrNull(nested?.url ?? value.url);
if (!url) return null;
return {
url,
language: textOrNull(value.language) ?? "Unknown",
name: textOrNull(value.name),
- headers: normalizeHeaders(value.headers),
+ headers: mergeHeaders(
+ nested?.behaviorHints?.proxyHeaders?.request,
+ value.behaviorHints?.proxyHeaders?.request,
+ nested?.requestHeaders,
+ nested?.headers,
+ value.requestHeaders,
+ value.headers,
+ ),
};
}
@@ -139,6 +179,47 @@ function normalizeHeaders(value) {
return out;
}
+function mergeHeaders(...sources) {
+ const out = {};
+ const keys = new Map();
+ for (const source of sources) {
+ for (const [key, value] of Object.entries(normalizeHeaders(source))) {
+ const lower = key.toLowerCase();
+ const previous = keys.get(lower);
+ if (previous && previous !== key) delete out[previous];
+ out[key] = value;
+ keys.set(lower, key);
+ }
+ }
+ return out;
+}
+
+function normalizeQualityLabel(value) {
+ const text = textOrNull(value);
+ if (!text) return null;
+ if (/^(?:4k|uhd|2160p?)$/i.test(text)) return "2160p";
+ const resolution = text.match(/(?:^|\b)(2160|1440|1080|720|576|480)p?(?:\b|$)/i);
+ if (resolution) return `${resolution[1]}p`;
+ return text;
+}
+
+function normalizeDurationMinutes(value) {
+ if (value == null || value === "") return null;
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) {
+ return value > 600 ? Math.round(value / 60) : Math.round(value);
+ }
+ const text = textOrNull(value);
+ if (!text) return null;
+ const hm = text.match(/(?:(\d+)\s*h(?:ours?)?)?\s*(?:(\d+)\s*m(?:in(?:utes?)?)?)?/i);
+ if (hm && (hm[1] || hm[2])) {
+ const minutes = Number(hm[1] || 0) * 60 + Number(hm[2] || 0);
+ return minutes > 0 ? minutes : null;
+ }
+ const parsed = Number(text);
+ if (!Number.isFinite(parsed) || parsed <= 0) return null;
+ return parsed > 600 ? Math.round(parsed / 60) : Math.round(parsed);
+}
+
function normalizeStringList(value) {
const list = Array.isArray(value) ? value : value == null ? [] : [value];
return [...new Set(list.map(textOrNull).filter(Boolean).map((v) => v.toLowerCase()))];
diff --git a/engine_v2/src/media-validator.mjs b/engine_v2/src/media-validator.mjs
index 0f1c1b2fd..f99bb966e 100644
--- a/engine_v2/src/media-validator.mjs
+++ b/engine_v2/src/media-validator.mjs
@@ -2,8 +2,11 @@ export async function validateMediaCandidate(candidate, options = {}) {
const fetchImpl = options.fetchImpl ?? fetch;
const timeoutMs = clamp(Number(options.timeoutMs ?? 7000), 1000, 15000);
const maxBodyBytes = clamp(Number(options.maxBodyBytes ?? 262144), 4096, 1048576);
- const headers = { ...(candidate.headers ?? {}) };
- if (!hasHeader(headers, "Range")) headers.Range = "bytes=0-262143";
+ const maxHlsVariants = clamp(Number(options.maxHlsVariants ?? 4), 1, 8);
+ const maxHlsRenditions = clamp(Number(options.maxHlsRenditions ?? 3), 1, 6);
+ const maxHlsDepth = clamp(Number(options.maxHlsDepth ?? 2), 1, 4);
+ const baseHeaders = cloneHeaders(candidate.headers ?? {});
+ const inferred = inferFormat(candidate.url, candidate.format ?? candidate.type);
const result = {
url: candidate.url,
@@ -11,131 +14,521 @@ export async function validateMediaCandidate(candidate, options = {}) {
status: null,
finalUrl: null,
contentType: null,
- format: inferFormat(candidate.url, candidate.format),
+ format: inferred,
reason: null,
child: null,
+ chain: null,
+ effectiveHeight: inferHeight(candidate),
+ codecs: textOrNull(candidate.codecs ?? candidate.codec),
+ fallbackCount: 0,
+ warnings: [],
};
+
try {
+ const firstHeaders = headersForResource(baseHeaders, inferred === "mp4" || inferred === "mkv" || inferred === "webm");
const response = await fetchImpl(candidate.url, {
method: "GET",
redirect: "follow",
- signal: AbortSignal.timeout(timeoutMs),
- headers,
+ signal: timeoutSignal(timeoutMs),
+ headers: firstHeaders.headers,
});
result.status = response.status;
result.finalUrl = response.url || candidate.url;
result.contentType = response.headers?.get?.("content-type") ?? null;
- if (!(response.status >= 200 && response.status < 300) && response.status !== 206) {
+ if (!okStatus(response.status)) {
result.reason = `http-${response.status}`;
try { await response.body?.cancel?.(); } catch {}
return result;
}
- if (result.format === "hls") {
+ const contentType = String(result.contentType ?? "").toLowerCase();
+ const contentSaysHls = contentType.includes("mpegurl") || contentType.includes("vnd.apple.mpegurl");
+ if (inferred === "hls" || contentSaysHls) {
+ result.format = "hls";
const text = await readLimitedText(response, maxBodyBytes);
- if (!/^\s*#EXTM3U/m.test(text) || looksLikeHtml(text)) {
- result.reason = "invalid-hls-body";
- return result;
- }
- const childUrl = firstHlsChild(text, result.finalUrl);
- if (childUrl && options.probeHlsChild !== false) {
- result.child = await validateHlsChild(childUrl, headers, { fetchImpl, timeoutMs, maxBodyBytes });
- result.playable = result.child.playable;
- result.reason = result.playable ? null : `hls-child-${result.child.reason ?? "invalid"}`;
- return result;
- }
- result.playable = true;
+ const chain = await validateHlsDocument({
+ url: candidate.url,
+ finalUrl: result.finalUrl,
+ text,
+ headers: baseHeaders,
+ fetchImpl,
+ timeoutMs,
+ maxBodyBytes,
+ maxHlsVariants,
+ maxHlsRenditions,
+ maxHlsDepth,
+ depth: 0,
+ });
+ applyHlsChain(result, chain);
return result;
}
- const contentType = String(result.contentType ?? "").toLowerCase();
if (contentType.includes("text/html")) {
result.reason = "html-instead-of-media";
try { await response.body?.cancel?.(); } catch {}
return result;
}
- if (result.format === "mp4" || result.format === "mkv" || contentType.startsWith("video/") || contentType.includes("octet-stream")) {
- const sample = await readLimitedBytes(response, Math.min(maxBodyBytes, 32768));
- if (looksLikeHtmlBytes(sample)) {
- result.reason = "html-instead-of-media";
- return result;
- }
- result.playable = sample.length > 0 || Number(response.headers?.get?.("content-length") ?? 0) > 0;
- result.reason = result.playable ? null : "empty-media-body";
+ if (contentType.includes("application/json")) {
+ result.reason = "json-instead-of-media";
+ try { await response.body?.cancel?.(); } catch {}
return result;
}
const sample = await readLimitedBytes(response, Math.min(maxBodyBytes, 32768));
+ const sampleText = new TextDecoder("utf-8", { fatal: false }).decode(sample.slice(0, 4096));
+ if (/^\s*#EXTM3U/m.test(sampleText) && !looksLikeHtml(sampleText)) {
+ result.format = "hls";
+ const chain = await validateHlsDocument({
+ url: candidate.url,
+ finalUrl: result.finalUrl,
+ text: new TextDecoder("utf-8", { fatal: false }).decode(sample),
+ headers: baseHeaders,
+ fetchImpl,
+ timeoutMs,
+ maxBodyBytes,
+ maxHlsVariants,
+ maxHlsRenditions,
+ maxHlsDepth,
+ depth: 0,
+ });
+ applyHlsChain(result, chain);
+ return result;
+ }
if (looksLikeHtmlBytes(sample)) {
result.reason = "html-instead-of-media";
return result;
}
- result.playable = sample.length > 0;
- result.reason = result.playable ? null : "unknown-empty-media";
+ if (looksLikeJsonBytes(sample)) {
+ result.reason = "json-instead-of-media";
+ return result;
+ }
+
+ result.playable = sample.length > 0 || Number(response.headers?.get?.("content-length") ?? 0) > 0;
+ result.reason = result.playable ? null : (inferred === "unknown" ? "unknown-empty-media" : "empty-media-body");
return result;
} catch (error) {
- result.reason = String(error?.cause?.code ?? error?.name ?? error?.message ?? error);
+ result.reason = errorReason(error);
return result;
}
}
export async function validateMediaCandidates(candidates = [], options = {}) {
- const maxCandidates = clamp(Number(options.maxCandidates ?? 3), 1, 6);
+ const maxCandidates = clamp(Number(options.maxCandidates ?? 3), 1, 8);
const results = [];
for (const candidate of candidates.slice(0, maxCandidates)) {
const validation = await validateMediaCandidate(candidate, options);
- results.push({ candidate, validation });
+ results.push({ candidate, validation, rankScore: scoreMediaCandidate(candidate, validation) });
}
+ const rankedResults = results
+ .map((row, index) => ({ ...row, originalIndex: index }))
+ .sort((a, b) => b.rankScore - a.rankScore || a.originalIndex - b.originalIndex)
+ .map(({ originalIndex: _originalIndex, ...row }) => row);
return {
playable: results.some((row) => row.validation.playable),
playableCount: results.filter((row) => row.validation.playable).length,
results,
+ rankedResults,
+ best: rankedResults[0] ?? null,
};
}
-function inferFormat(url, explicit) {
- const hint = String(explicit ?? "").toLowerCase();
- if (hint.includes("m3u8") || hint === "hls") return "hls";
- if (hint.includes("mp4")) return "mp4";
- if (hint.includes("mkv")) return "mkv";
- const clean = String(url ?? "").split(/[?#]/)[0].toLowerCase();
- if (clean.endsWith(".m3u8")) return "hls";
- if (clean.endsWith(".mp4")) return "mp4";
- if (clean.endsWith(".mkv")) return "mkv";
- return "unknown";
+function applyHlsChain(result, chain) {
+ result.chain = chain;
+ result.child = chain.child ?? null;
+ result.playable = chain.playable === true;
+ result.reason = result.playable ? null : `hls-${chain.reason ?? "invalid"}`;
+ result.effectiveHeight = chain.effectiveHeight ?? result.effectiveHeight;
+ result.codecs = chain.codecs ?? result.codecs;
+ result.fallbackCount = Number(chain.fallbackCount ?? 0);
+ result.warnings = Array.isArray(chain.warnings) ? chain.warnings : [];
+}
+
+async function validateHlsDocument(ctx) {
+ const parsed = parseHlsPlaylist(ctx.text, ctx.finalUrl);
+ if (!parsed.valid) return hlsFailure(parsed.reason ?? "invalid-body");
+
+ if (parsed.kind === "media") {
+ const media = await validateHlsMediaResources(parsed, ctx);
+ return {
+ ...media,
+ kind: "media",
+ url: ctx.url,
+ finalUrl: ctx.finalUrl,
+ effectiveHeight: null,
+ codecs: null,
+ fallbackCount: 0,
+ warnings: media.warnings ?? [],
+ };
+ }
+
+ if (parsed.kind !== "master") return hlsFailure("playlist-without-media");
+ if (ctx.depth >= ctx.maxHlsDepth) return hlsFailure("depth-limit");
+
+ const variants = [...parsed.variants]
+ .sort((a, b) => (b.height ?? 0) - (a.height ?? 0) || (b.bandwidth ?? 0) - (a.bandwidth ?? 0))
+ .slice(0, ctx.maxHlsVariants);
+ if (!variants.length) return hlsFailure("master-without-variant");
+
+ const attempts = [];
+ let selected = null;
+ let selectedChild = null;
+ for (const variant of variants) {
+ const fetched = await fetchHlsPlaylist(variant.url, ctx);
+ if (!fetched.ok) {
+ attempts.push({ url: variant.url, playable: false, reason: fetched.reason, status: fetched.status ?? null });
+ continue;
+ }
+ const child = await validateHlsDocument({
+ ...ctx,
+ url: variant.url,
+ finalUrl: fetched.finalUrl,
+ text: fetched.text,
+ depth: ctx.depth + 1,
+ });
+ attempts.push({ url: variant.url, playable: child.playable, reason: child.reason, status: fetched.status, height: variant.height });
+ if (child.playable) {
+ selected = variant;
+ selectedChild = { ...child, status: fetched.status, finalUrl: fetched.finalUrl };
+ break;
+ }
+ }
+
+ if (!selected || !selectedChild) {
+ const reason = attempts.find((row) => row.reason)?.reason ?? "no-playable-variant";
+ return {
+ ...hlsFailure(`variant-${reason}`),
+ kind: "master",
+ variants: attempts,
+ fallbackCount: attempts.length,
+ };
+ }
+
+ const warnings = [...(selectedChild.warnings ?? [])];
+ const audio = await validateRequiredRendition("AUDIO", selected.audioGroup, parsed.renditions, ctx);
+ if (audio.required && !audio.playable) {
+ return {
+ ...hlsFailure(`audio-${audio.reason ?? "unplayable"}`),
+ kind: "master",
+ variants: attempts,
+ child: selectedChild,
+ audio,
+ effectiveHeight: selected.height ?? selectedChild.effectiveHeight ?? null,
+ codecs: selected.codecs ?? selectedChild.codecs ?? null,
+ fallbackCount: Math.max(0, attempts.length - 1) + Number(selectedChild.fallbackCount ?? 0),
+ warnings,
+ };
+ }
+
+ const subtitles = await validateOptionalRendition("SUBTITLES", selected.subtitleGroup, parsed.renditions, ctx);
+ if (subtitles.required && !subtitles.playable) warnings.push(`subtitle-${subtitles.reason ?? "unplayable"}`);
+
+ return {
+ playable: true,
+ reason: null,
+ kind: "master",
+ url: ctx.url,
+ finalUrl: ctx.finalUrl,
+ variants: attempts,
+ selectedVariant: selected,
+ child: selectedChild,
+ audio,
+ subtitles,
+ effectiveHeight: selected.height ?? selectedChild.effectiveHeight ?? null,
+ codecs: selected.codecs ?? selectedChild.codecs ?? null,
+ fallbackCount: Math.max(0, attempts.length - 1) + Number(selectedChild.fallbackCount ?? 0),
+ warnings,
+ };
+}
+
+async function validateHlsMediaResources(parsed, ctx) {
+ const warnings = [];
+ if (parsed.key) {
+ const key = await probeBinaryResource(parsed.key.url, ctx, "key", false);
+ if (!key.playable) return { ...hlsFailure(`key-${key.reason}`), key, warnings };
+ }
+ let map = null;
+ if (parsed.map) {
+ map = await probeBinaryResource(parsed.map.url, ctx, "map", true);
+ if (!map.playable) return { ...hlsFailure(`map-${map.reason}`), map, warnings };
+ }
+ const segmentUrl = parsed.segments[0] ?? parsed.parts[0] ?? null;
+ if (!segmentUrl) return { ...hlsFailure("media-without-segment"), map, warnings };
+ const segment = await probeBinaryResource(segmentUrl, ctx, "segment", true);
+ if (!segment.playable) return { ...hlsFailure(`segment-${segment.reason}`), map, segment, warnings };
+ return {
+ playable: true,
+ reason: null,
+ key: parsed.key ? { url: parsed.key.url, playable: true } : null,
+ map,
+ segment,
+ warnings,
+ };
+}
+
+async function validateRequiredRendition(type, groupId, renditions, ctx) {
+ if (!groupId) return { required: false, playable: true, reason: null, attempts: [] };
+ const matching = renditions.filter((row) => row.type === type && row.groupId === groupId);
+ const external = matching.filter((row) => row.url);
+ if (!external.length) return { required: false, playable: true, reason: null, attempts: [] };
+ const attempts = [];
+ for (const rendition of external.slice(0, ctx.maxHlsRenditions)) {
+ const probe = await probeHlsRendition(rendition.url, ctx);
+ attempts.push(probe);
+ if (probe.playable) return { required: true, playable: true, reason: null, selected: rendition, attempts };
+ }
+ return { required: true, playable: false, reason: attempts.find((row) => row.reason)?.reason ?? "no-playable-rendition", attempts };
+}
+
+async function validateOptionalRendition(type, groupId, renditions, ctx) {
+ return validateRequiredRendition(type, groupId, renditions, ctx);
}
-async function validateHlsChild(url, headers, options) {
+async function probeHlsRendition(url, ctx) {
+ const fetched = await fetchHlsPlaylist(url, ctx);
+ if (!fetched.ok) return { url, playable: false, status: fetched.status ?? null, reason: fetched.reason };
+ const parsed = parseHlsPlaylist(fetched.text, fetched.finalUrl);
+ if (!parsed.valid) return { url, playable: false, status: fetched.status, reason: parsed.reason };
+ if (parsed.kind === "master") {
+ const nested = await validateHlsDocument({ ...ctx, url, finalUrl: fetched.finalUrl, text: fetched.text, depth: ctx.depth + 1 });
+ return { url, playable: nested.playable, status: fetched.status, reason: nested.reason, child: nested };
+ }
+ if (parsed.kind !== "media") return { url, playable: false, status: fetched.status, reason: "playlist-without-media" };
+ const media = await validateHlsMediaResources(parsed, { ...ctx, url, finalUrl: fetched.finalUrl });
+ return { url, playable: media.playable, status: fetched.status, reason: media.reason, media };
+}
+
+async function fetchHlsPlaylist(url, ctx) {
try {
- const response = await options.fetchImpl(url, {
+ const response = await ctx.fetchImpl(url, {
method: "GET",
redirect: "follow",
- signal: AbortSignal.timeout(options.timeoutMs),
- headers,
+ signal: timeoutSignal(ctx.timeoutMs),
+ headers: headersForResource(ctx.headers, false).headers,
});
- const out = { url, status: response.status, finalUrl: response.url || url, playable: false, reason: null };
- if (!(response.status >= 200 && response.status < 300) && response.status !== 206) {
+ const out = { ok: false, url, finalUrl: response.url || url, status: response.status, reason: null, text: "" };
+ if (!okStatus(response.status)) {
out.reason = `http-${response.status}`;
try { await response.body?.cancel?.(); } catch {}
return out;
}
- const text = await readLimitedText(response, options.maxBodyBytes);
- if (/^\s*#EXTM3U/m.test(text) && !looksLikeHtml(text)) out.playable = true;
- else out.reason = "invalid-hls-child";
+ out.text = await readLimitedText(response, ctx.maxBodyBytes);
+ if (!/^\s*#EXTM3U/m.test(out.text) || looksLikeHtml(out.text)) {
+ out.reason = "invalid-playlist";
+ return out;
+ }
+ out.ok = true;
return out;
} catch (error) {
- return { url, playable: false, reason: String(error?.cause?.code ?? error?.name ?? error?.message ?? error) };
+ return { ok: false, url, status: null, finalUrl: url, reason: errorReason(error), text: "" };
+ }
+}
+
+async function probeBinaryResource(url, ctx, kind, allowSyntheticRange) {
+ const request = headersForResource(ctx.headers, allowSyntheticRange);
+ let response;
+ try {
+ response = await ctx.fetchImpl(url, {
+ method: "GET",
+ redirect: "follow",
+ signal: timeoutSignal(ctx.timeoutMs),
+ headers: request.headers,
+ });
+ if (response.status === 416 && request.syntheticRange) {
+ try { await response.body?.cancel?.(); } catch {}
+ response = await ctx.fetchImpl(url, {
+ method: "GET",
+ redirect: "follow",
+ signal: timeoutSignal(ctx.timeoutMs),
+ headers: headersForResource(ctx.headers, false).headers,
+ });
+ }
+ } catch (error) {
+ return { url, kind, playable: false, status: null, reason: errorReason(error) };
+ }
+ const out = {
+ url,
+ kind,
+ finalUrl: response.url || url,
+ playable: false,
+ status: response.status,
+ contentType: response.headers?.get?.("content-type") ?? null,
+ reason: null,
+ };
+ if (!okStatus(response.status)) {
+ out.reason = `http-${response.status}`;
+ try { await response.body?.cancel?.(); } catch {}
+ return out;
+ }
+ const sample = await readLimitedBytes(response, Math.min(ctx.maxBodyBytes, kind === "key" ? 4096 : 32768));
+ if (!sample.length && Number(response.headers?.get?.("content-length") ?? 0) <= 0) {
+ out.reason = "empty-body";
+ return out;
+ }
+ if (looksLikeHtmlBytes(sample)) {
+ out.reason = "html-body";
+ return out;
+ }
+ if (looksLikeJsonBytes(sample)) {
+ out.reason = "json-body";
+ return out;
+ }
+ out.playable = true;
+ return out;
+}
+
+function parseHlsPlaylist(text, baseUrl) {
+ const body = String(text ?? "").replace(/^\uFEFF/, "").trim();
+ if (!/^#EXTM3U(?:\s|$)/i.test(body) || looksLikeHtml(body)) return { valid: false, reason: "invalid-body" };
+ const lines = body.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
+ const variants = [];
+ const renditions = [];
+ const segments = [];
+ const parts = [];
+ let key = null;
+ let map = null;
+ let pendingVariant = null;
+ let hasMediaTag = false;
+
+ for (const line of lines) {
+ if (/^#EXT-X-STREAM-INF\s*:/i.test(line)) {
+ pendingVariant = parseAttributeList(line.slice(line.indexOf(":") + 1));
+ continue;
+ }
+ if (/^#EXT-X-MEDIA\s*:/i.test(line)) {
+ const attrs = parseAttributeList(line.slice(line.indexOf(":") + 1));
+ renditions.push({
+ type: String(attrs.TYPE ?? "").toUpperCase(),
+ groupId: attrs["GROUP-ID"] ?? null,
+ name: attrs.NAME ?? null,
+ language: attrs.LANGUAGE ?? null,
+ default: String(attrs.DEFAULT ?? "").toUpperCase() === "YES",
+ url: attrs.URI ? resolveHlsUri(attrs.URI, baseUrl) : null,
+ });
+ continue;
+ }
+ if (/^#EXT-X-KEY\s*:/i.test(line)) {
+ const attrs = parseAttributeList(line.slice(line.indexOf(":") + 1));
+ if (String(attrs.METHOD ?? "").toUpperCase() !== "NONE" && attrs.URI) {
+ key = { method: attrs.METHOD ?? null, url: resolveHlsUri(attrs.URI, baseUrl) };
+ }
+ continue;
+ }
+ if (/^#EXT-X-MAP\s*:/i.test(line)) {
+ const attrs = parseAttributeList(line.slice(line.indexOf(":") + 1));
+ if (attrs.URI) map = { url: resolveHlsUri(attrs.URI, baseUrl) };
+ hasMediaTag = true;
+ continue;
+ }
+ if (/^#EXT-X-PART\s*:/i.test(line)) {
+ const attrs = parseAttributeList(line.slice(line.indexOf(":") + 1));
+ if (attrs.URI) parts.push(resolveHlsUri(attrs.URI, baseUrl));
+ hasMediaTag = true;
+ continue;
+ }
+ if (/^#EXTINF\s*:/i.test(line)) {
+ hasMediaTag = true;
+ continue;
+ }
+ if (line.startsWith("#")) continue;
+
+ const url = resolveHlsUri(line, baseUrl);
+ if (pendingVariant) {
+ const resolution = String(pendingVariant.RESOLUTION ?? "").match(/^(\d+)x(\d+)$/i);
+ variants.push({
+ url,
+ width: resolution ? Number(resolution[1]) : null,
+ height: resolution ? Number(resolution[2]) : null,
+ bandwidth: Number(pendingVariant.BANDWIDTH ?? pendingVariant["AVERAGE-BANDWIDTH"] ?? 0) || null,
+ codecs: pendingVariant.CODECS ?? null,
+ audioGroup: pendingVariant.AUDIO ?? null,
+ subtitleGroup: pendingVariant.SUBTITLES ?? null,
+ });
+ pendingVariant = null;
+ } else {
+ segments.push(url);
+ }
+ }
+
+ if (variants.length) return { valid: true, kind: "master", variants, renditions, segments: [], parts: [], key: null, map: null };
+ if (hasMediaTag && (segments.length || parts.length || map)) return { valid: true, kind: "media", variants: [], renditions, segments, parts, key, map };
+ return { valid: true, kind: "unknown", variants: [], renditions, segments, parts, key, map };
+}
+
+function parseAttributeList(text) {
+ const out = {};
+ const re = /([A-Z0-9-]+)=("(?:[^"\\]|\\.)*"|[^,]*)/gi;
+ let match;
+ while ((match = re.exec(String(text ?? ""))) !== null) {
+ const key = match[1].toUpperCase();
+ let value = match[2].trim();
+ if (value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1);
+ out[key] = value;
}
+ return out;
+}
+
+function resolveHlsUri(raw, baseUrl) {
+ const value = String(raw ?? "").trim();
+ if (!value) return "";
+ // Absolute signed HLS child URLs are opaque credentials. Preserve their exact
+ // query spelling/order instead of round-tripping them through URL serialization.
+ if (/^https?:\/\//i.test(value)) return value;
+ try { return new URL(value, baseUrl).href; } catch { return value; }
+}
+
+function scoreMediaCandidate(candidate, validation) {
+ if (!validation?.playable) return -100000;
+ const height = Number(validation.effectiveHeight ?? inferHeight(candidate) ?? 0);
+ const fallbackCount = Math.max(0, Number(validation.fallbackCount ?? 0));
+ const codecText = [validation.codecs, candidate.codecs, candidate.codec, candidate.quality, candidate.name, candidate.title]
+ .filter(Boolean).join(" ").toLowerCase();
+ const broadCodecBonus = /\b(?:avc1|avc|h\.264|h264)\b/i.test(codecText) ? 300 : 0;
+ const stability = Number(candidate.stabilityScore);
+ const stabilityBonus = Number.isFinite(stability) ? clamp(stability, 0, 1) * 800 : 0;
+ // A failed higher variant is a real stability signal. This deliberately allows a
+ // clean AVC 1080p path to outrank a flaky 2160p/HEVC path, while a healthy 2160p
+ // stream still wins on quality.
+ return 10000 + Math.min(height, 2160) + broadCodecBonus + stabilityBonus - fallbackCount * 1800;
+}
+
+function inferHeight(candidate = {}) {
+ const text = [candidate.quality, candidate.resolution, candidate.name, candidate.title]
+ .filter(Boolean).join(" ");
+ if (/\b(?:4k|uhd)\b/i.test(text)) return 2160;
+ const match = text.match(/\b(2160|1440|1080|720|576|480)p?\b/i);
+ return match ? Number(match[1]) : null;
+}
+
+function inferFormat(url, explicit) {
+ const hint = String(explicit ?? "").toLowerCase();
+ if (hint.includes("m3u8") || hint === "hls" || hint.includes("mpegurl")) return "hls";
+ if (hint.includes("mp4")) return "mp4";
+ if (hint.includes("mkv") || hint.includes("matroska")) return "mkv";
+ if (hint.includes("webm")) return "webm";
+ const clean = String(url ?? "").split(/[?#]/)[0].toLowerCase();
+ if (clean.endsWith(".m3u8")) return "hls";
+ if (clean.endsWith(".mp4")) return "mp4";
+ if (clean.endsWith(".mkv")) return "mkv";
+ if (clean.endsWith(".webm")) return "webm";
+ return "unknown";
+}
+
+function headersForResource(baseHeaders, addRange) {
+ const headers = cloneHeaders(baseHeaders);
+ if (!addRange || hasHeader(headers, "Range")) return { headers, syntheticRange: false };
+ headers.Range = "bytes=0-32767";
+ return { headers, syntheticRange: true };
}
-function firstHlsChild(text, baseUrl) {
- const lines = text.split(/\r?\n/).map((line) => line.trim());
- for (let i = 0; i < lines.length; i++) {
- const line = lines[i];
- if (!line || line.startsWith("#")) continue;
- try { return new URL(line, baseUrl).href; } catch {}
+function cloneHeaders(value) {
+ const out = {};
+ if (!value || typeof value !== "object" || Array.isArray(value)) return out;
+ for (const [key, raw] of Object.entries(value)) {
+ if (raw == null) continue;
+ const text = String(raw).trim();
+ if (text) out[String(key)] = text;
}
- return null;
+ return out;
}
async function readLimitedText(response, limit) {
@@ -177,11 +570,38 @@ function looksLikeHtmlBytes(bytes) {
return looksLikeHtml(new TextDecoder("utf-8", { fatal: false }).decode(bytes.slice(0, 4096)));
}
+function looksLikeJsonBytes(bytes) {
+ const text = new TextDecoder("utf-8", { fatal: false }).decode(bytes.slice(0, 4096)).trim();
+ return /^(?:\{|\[)/.test(text) && /[}\]]/.test(text);
+}
+
function hasHeader(headers, name) {
const lower = name.toLowerCase();
return Object.keys(headers).some((key) => key.toLowerCase() === lower);
}
+function hlsFailure(reason) {
+ return { playable: false, reason, warnings: [] };
+}
+
+function okStatus(status) {
+ return (status >= 200 && status < 300) || status === 206;
+}
+
+function timeoutSignal(timeoutMs) {
+ try { return AbortSignal.timeout(timeoutMs); } catch { return undefined; }
+}
+
+function errorReason(error) {
+ return String(error?.cause?.code ?? error?.name ?? error?.message ?? error);
+}
+
+function textOrNull(value) {
+ if (value == null) return null;
+ const text = String(value).trim();
+ return text || null;
+}
+
function clamp(value, min, max) {
return Math.min(max, Math.max(min, Number(value)));
}
diff --git a/engine_v2/src/repair-brain.mjs b/engine_v2/src/repair-brain.mjs
index e49e22978..3a5151368 100644
--- a/engine_v2/src/repair-brain.mjs
+++ b/engine_v2/src/repair-brain.mjs
@@ -1,11 +1,16 @@
const HTTP_BLOCKED = new Set([401, 403, 407, 429, 451]);
const HTTP_GONE = new Set([404, 410]);
-export const BRAIN_CONTROL_PLANE_VERSION = 3;
+export const BRAIN_CONTROL_PLANE_VERSION = 4;
export const FAILURE_CLASSES = Object.freeze([
"healthy", "not_invoked", "dns_unreachable", "transport_blocked", "search_gap",
"identity_mismatch", "detail_gap", "episode_gap", "player_gap",
"media_extraction_gap", "playback_context_gap", "media_validation_gap",
+ "playback_http_access", "playback_http_gone", "playback_rate_limited",
+ "playback_http_upstream", "playback_http_response", "playback_timeout",
+ "playback_dns", "playback_tls", "playback_parser", "playback_decoder",
+ "playback_io", "playback_live_window", "playback_runtime_setup",
+ "playback_player_error", "playback_duration_unknown", "short_media",
"runtime_contract_drift", "unknown_failure",
]);
@@ -29,16 +34,12 @@ export const REPAIR_RECIPES = Object.freeze({
identity_mismatch: [
recipe("tighten-identity-match", ["identity"], ["require correct title/TMDB/year", "require correct season/episode when episodic", "reject near-title collisions"]),
],
- detail_gap: [
- recipe("repair-detail-resolution", ["detail", "parser"], ["inspect result href/id", "follow canonical detail URL", "use structured data before brittle selectors"]),
- ],
+ detail_gap: [recipe("repair-detail-resolution", ["detail", "parser"], ["inspect result href/id", "follow canonical detail URL", "use structured data before brittle selectors"])],
episode_gap: [
recipe("repair-series-navigation", ["series", "episode"], ["inspect season route/data", "map canonical season/episode", "verify selected episode identity"]),
recipe("repair-anime-episode-mapping", ["anime", "episode"], ["inspect absolute-vs-season episode numbering", "verify requested episode identity"]),
],
- player_gap: [
- recipe("discover-player-chain", ["player", "embed", "html"], ["traverse catalogue/detail/embed rather than requiring an API", "inspect iframe/player links", "inspect scripts and XHR", "follow the player chain to a terminal media candidate"]),
- ],
+ player_gap: [recipe("discover-player-chain", ["player", "embed", "html"], ["traverse catalogue/detail/embed rather than requiring an API", "inspect iframe/player links", "inspect scripts and XHR", "follow the player chain to a terminal media candidate"])],
media_extraction_gap: [
recipe("capture-media-network", ["media", "xhr", "json"], ["inspect player XHR/fetch", "extract HLS/DASH/direct candidates", "keep player provenance"]),
recipe("inspect-player-javascript", ["media", "javascript"], ["identify deterministic token/deobfuscation logic", "reproduce only the required logic"]),
@@ -47,15 +48,36 @@ export const REPAIR_RECIPES = Object.freeze({
recipe("preserve-playback-context", ["headers", "cookies", "referer", "origin"], ["carry Referer from player chain", "carry Origin when required", "carry scoped cookies", "preserve redirects and final URL"]),
recipe("refresh-media-token", ["token", "session"], ["re-enter player chain", "obtain fresh media token", "avoid persisting expired signed URLs"]),
],
- media_validation_gap: [
- recipe("validate-final-media", ["media", "identity"], ["probe final media response", "verify media identity and duration", "verify HLS/DASH/direct signatures", "reject HTML/error bodies and fake media"]),
- ],
- runtime_contract_drift: [
- recipe("reaudit-device-adapter", ["runtime-version", "contract"], ["diff changed Nuvio contract paths", "identify affected capabilities", "revalidate only impacted skills/providers"]),
- ],
- unknown_failure: [
- recipe("collect-missing-evidence", ["evidence"], ["find first unobserved pipeline stage", "probe that stage only", "reclassify before mutating provider code"]),
+ playback_http_access: [
+ recipe("replay-native-request-context", ["headers", "cookies", "referer", "origin", "redirects"], ["compare provider output context with native reader request", "preserve only required request headers", "preserve scoped cookies and redirect context", "retry through the same official client networking stack"]),
+ recipe("refresh-access-bound-media", ["token", "session", "player"], ["re-enter the player chain", "refresh signed or session-bound media", "reject stale terminal URLs"]),
],
+ playback_http_gone: [recipe("refresh-terminal-media-candidate", ["player", "media", "token"], ["re-enter current player page", "resolve a fresh terminal candidate", "do not retain dead media URLs"])],
+ playback_rate_limited: [recipe("respect-media-rate-limit", ["session", "timing"], ["avoid duplicate pre-consumption", "reuse established session", "back off before one targeted native-reader retry"])],
+ playback_http_upstream: [recipe("retry-or-rerank-upstream-media", ["media", "ranking"], ["confirm upstream server failure in native reader", "try next same-provider candidate", "deprioritize repeatedly failing host without cross-provider substitution"])],
+ playback_http_response: [recipe("inspect-unexpected-media-response", ["media", "headers"], ["record status and response header names", "verify redirects/content-disposition/content-type", "resolve a terminal media response the reader accepts"])],
+ playback_timeout: [recipe("diagnose-native-reader-timeout", ["timeout", "media", "range"], ["separate connect/read/segment timeout evidence", "verify range behavior", "try next same-provider stream when terminal host stalls"])],
+ playback_dns: [recipe("repair-media-host-resolution", ["dns", "media"], ["resolve terminal media host from OS environment", "refresh stale host from player chain", "avoid hardcoded dead CDN hosts"])],
+ playback_tls: [recipe("repair-media-tls-path", ["tls", "media"], ["capture TLS/handshake class from native reader", "verify official client networking policy", "refresh terminal host when certificate/SNI target is stale"])],
+ playback_parser: [recipe("resolve-real-media-not-wrapper", ["parser", "media", "html"], ["reject HTML/JSON/error wrappers presented as media", "follow embed/download chain further", "require reader-parseable HLS/DASH/container before promotion"])],
+ playback_decoder: [recipe("rerank-reader-compatible-encoding", ["decoder", "codec", "media"], ["record Media3 decoder error code", "prefer another same-provider rendition/container", "do not mutate transport context for a codec-only failure"])],
+ playback_io: [recipe("repair-reader-io-contract", ["range", "headers", "media"], ["inspect Range/Accept-Ranges/content-length behavior", "preserve reader-required headers", "distinguish server refusal from parser or decoder failure"])],
+ playback_live_window: [recipe("refresh-live-window", ["media", "playlist"], ["reload current manifest", "avoid stale media sequence", "retry only the refreshed live candidate"])],
+ playback_runtime_setup: [recipe("repair-native-reader-setup", ["runtime-version", "contract"], ["verify official client data-source factory can be constructed", "diff accepted client revision", "treat setup failure as lab/runtime defect before provider mutation"])],
+ playback_player_error: [recipe("inspect-native-player-error-chain", ["player", "evidence"], ["retain sanitized PlaybackException code/class chain", "classify the first causal layer", "do not mutate provider until the error is reclassified"])],
+ playback_duration_unknown: [recipe("prove-vod-duration-before-promotion", ["duration", "media", "identity"], ["wait for the native reader timeline to settle", "derive VOD duration from reader, manifest or container metadata", "reject promotion when long-form duration still cannot be proven"])],
+ short_media: [recipe("reject-short-or-preview-media", ["duration", "identity", "ranking"], ["compare reader duration with fixture expectation", "reject previews/trailers/20-second placeholders", "advance to the next same-provider candidate and validate again"])],
+ media_validation_gap: [recipe("validate-final-media", ["media", "identity"], ["probe final media response", "verify media identity and duration", "verify HLS/DASH/direct signatures", "reject HTML/error bodies and fake media"])],
+ runtime_contract_drift: [recipe("reaudit-device-adapter", ["runtime-version", "contract"], ["diff changed Nuvio contract paths", "identify affected capabilities", "revalidate only impacted skills/providers"])],
+ unknown_failure: [recipe("collect-missing-evidence", ["evidence"], ["find first unobserved pipeline stage", "probe that stage only", "reclassify before mutating provider code"])],
+});
+
+const READER_STAGE_TO_FAILURE = Object.freeze({
+ http_access: "playback_http_access", http_gone: "playback_http_gone", http_rate_limit: "playback_rate_limited",
+ http_upstream: "playback_http_upstream", http_response: "playback_http_response", timeout: "playback_timeout",
+ dns: "playback_dns", tls: "playback_tls", parser: "playback_parser", decoder: "playback_decoder",
+ io: "playback_io", live_window: "playback_live_window", player_setup: "playback_runtime_setup",
+ player: "playback_player_error", duration_identity: "short_media", duration_unknown: "playback_duration_unknown",
});
export function classifyFailure(evidence = {}) {
@@ -79,6 +101,26 @@ export function classifyFailure(evidence = {}) {
if (player?.attempted && player?.found === false) return "player_gap";
const media = evidence.stages?.media;
if (media?.attempted && media?.found === false) return "media_extraction_gap";
+
+ const reader = evidence.stages?.reader ?? evidence.reader;
+ if (reader?.attempted || reader?.observed || reader?.state) {
+ const explicit = String(reader.failureClass ?? reader.failure_class ?? "");
+ if (FAILURE_CLASSES.includes(explicit) && explicit !== "healthy") return explicit;
+ const state = String(reader.state ?? "").toLowerCase();
+ if (state === "ready" || state === "ended") return "healthy";
+ if (state === "short_media") return "short_media";
+ if (state === "duration_unknown") return "playback_duration_unknown";
+ const stage = String(reader.failureStage ?? reader.failure_stage ?? "").toLowerCase();
+ if (READER_STAGE_TO_FAILURE[stage]) return READER_STAGE_TO_FAILURE[stage];
+ const status = Number(reader.httpStatus ?? reader.http_status ?? 0);
+ if ([401, 403, 407, 451].includes(status)) return "playback_http_access";
+ if ([404, 410].includes(status)) return "playback_http_gone";
+ if (status === 429) return "playback_rate_limited";
+ if (status >= 500 && status <= 599) return "playback_http_upstream";
+ if (state === "timeout") return "playback_timeout";
+ if (state === "error") return "playback_player_error";
+ }
+
const validation = evidence.stages?.validation;
if (validation?.attempted) {
const statuses = new Set((validation.statuses ?? []).map(Number));
@@ -96,21 +138,13 @@ export function planRepair(evidence = {}, options = {}) {
const budget = normalizedBudget(options.budget ?? options);
const exitReason = budgetExitReason({ ...budget, signature });
if (failureClass === "healthy") return planResult(failureClass, [], "none", budget, null);
- if (failureClass === "identity_mismatch" && (evidence.suspicious || evidence.unsafe)) {
- return planResult(failureClass, [], "hold-or-quarantine-pending-proof", budget, null);
- }
+ if (failureClass === "identity_mismatch" && (evidence.suspicious || evidence.unsafe)) return planResult(failureClass, [], "hold-or-quarantine-pending-proof", budget, null);
if (exitReason) return planResult(failureClass, [], "deferred_retry", budget, exitReason);
- if (options.coreMutationRequested === true && options.learningLab !== true) {
- return planResult(failureClass, [], "deferred_retry", budget, "core_mutation_requires_learning_lab");
- }
+ if (options.coreMutationRequested === true && options.learningLab !== true) return planResult(failureClass, [], "deferred_retry", budget, "core_mutation_requires_learning_lab");
const learned = Array.isArray(options.learnedSkills) ? options.learnedSkills : [];
- const candidates = [
- ...learned.filter((skill) => !skill.failureClass || skill.failureClass === failureClass),
- ...(REPAIR_RECIPES[failureClass] ?? REPAIR_RECIPES.unknown_failure),
- ];
- const compatible = uniqueRecipes(candidates)
- .filter((candidate) => recipeIsCompatible(candidate, options.runtimeCompatibility));
+ const candidates = [...learned.filter((skill) => !skill.failureClass || skill.failureClass === failureClass), ...(REPAIR_RECIPES[failureClass] ?? REPAIR_RECIPES.unknown_failure)];
+ const compatible = uniqueRecipes(candidates).filter((candidate) => recipeIsCompatible(candidate, options.runtimeCompatibility));
const hypotheses = compatible.slice(0, budget.maxHypotheses);
const action = failureClass === "unknown_failure" ? "collect-more-evidence" : "probe-targeted-repair";
return planResult(failureClass, hypotheses, action, budget, null);
@@ -123,48 +157,16 @@ export function recipeIsCompatible(candidate, runtimeCompatibility = null) {
}
export function buildLearnedRecipe({ id, signature, actions, provenOn, runtime, capabilities = [], failureClass = null, scope = {} }) {
- if (!id || !signature || !Array.isArray(actions) || actions.length === 0) {
- throw new Error("learned recipe requires id, signature and actions");
- }
- return {
- id, signature, failureClass, actions: [...actions],
- capabilities: [...new Set(capabilities)],
- provenOn: Array.isArray(provenOn) ? [...provenOn] : [],
- scope: structuredClone(scope), runtime: runtime ?? {},
- learnedAt: new Date().toISOString(),
- };
+ if (!id || !signature || !Array.isArray(actions) || actions.length === 0) throw new Error("learned recipe requires id, signature and actions");
+ return { id, signature, failureClass, actions: [...actions], capabilities: [...new Set(capabilities)], provenOn: Array.isArray(provenOn) ? [...provenOn] : [], scope: structuredClone(scope), runtime: runtime ?? {}, learnedAt: new Date().toISOString() };
}
function planResult(failureClass, hypotheses, action, budget, exitReason) {
- return {
- brainVersion: BRAIN_CONTROL_PLANE_VERSION,
- failureClass, hypotheses, action, exitReason,
- budget: {
- maxHypotheses: budget.maxHypotheses,
- maxMutations: budget.maxMutations,
- maxRepeatedSignature: budget.maxRepeatedSignature,
- maxGeneratedBytes: budget.maxGeneratedBytes,
- maxElapsedMs: budget.maxElapsedMs,
- },
- fallbackPolicy: "lkg_only_after_repair_budget",
- coreMutationPolicy: "proposal_only",
- };
+ return { brainVersion: BRAIN_CONTROL_PLANE_VERSION, failureClass, hypotheses, action, exitReason, budget: { maxHypotheses: budget.maxHypotheses, maxMutations: budget.maxMutations, maxRepeatedSignature: budget.maxRepeatedSignature, maxGeneratedBytes: budget.maxGeneratedBytes, maxElapsedMs: budget.maxElapsedMs }, fallbackPolicy: "lkg_only_after_repair_budget", coreMutationPolicy: "proposal_only" };
}
-
function normalizedBudget(options = {}) {
- return {
- maxHypotheses: clamp(options.maxHypotheses ?? 3, 1, 3),
- maxMutations: clamp(options.maxMutations ?? 2, 0, 8),
- mutationCount: Math.max(0, Number(options.mutationCount ?? 0)),
- maxRepeatedSignature: clamp(options.maxRepeatedSignature ?? 2, 1, 5),
- repeatedSignatureCount: Math.max(0, Number(options.repeatedSignatureCount ?? 0)),
- maxGeneratedBytes: Math.max(0, Number(options.maxGeneratedBytes ?? 180000)),
- generatedBytes: Math.max(0, Number(options.generatedBytes ?? 0)),
- maxElapsedMs: Math.max(1000, Number(options.maxElapsedMs ?? 45000)),
- elapsedMs: Math.max(0, Number(options.elapsedMs ?? 0)),
- };
+ return { maxHypotheses: clamp(options.maxHypotheses ?? 3, 1, 3), maxMutations: clamp(options.maxMutations ?? 2, 0, 8), mutationCount: Math.max(0, Number(options.mutationCount ?? 0)), maxRepeatedSignature: clamp(options.maxRepeatedSignature ?? 2, 1, 5), repeatedSignatureCount: Math.max(0, Number(options.repeatedSignatureCount ?? 0)), maxGeneratedBytes: Math.max(0, Number(options.maxGeneratedBytes ?? 180000)), generatedBytes: Math.max(0, Number(options.generatedBytes ?? 0)), maxElapsedMs: Math.max(1000, Number(options.maxElapsedMs ?? 45000)), elapsedMs: Math.max(0, Number(options.elapsedMs ?? 0)) };
}
-
function budgetExitReason(budget) {
if (budget.mutationCount >= budget.maxMutations) return "mutation_budget_exhausted";
if (budget.repeatedSignatureCount >= budget.maxRepeatedSignature) return "repair_loop_detected";
@@ -172,14 +174,8 @@ function budgetExitReason(budget) {
if (budget.elapsedMs >= budget.maxElapsedMs) return "time_budget_exhausted";
return null;
}
-
-function recipe(id, capabilities, actions) {
- return Object.freeze({ id, capabilities: Object.freeze(capabilities), actions: Object.freeze(actions) });
-}
-function uniqueRecipes(rows) {
- const seen = new Set();
- return rows.filter((row) => row?.id && !seen.has(row.id) && seen.add(row.id));
-}
+function recipe(id, capabilities, actions) { return Object.freeze({ id, capabilities: Object.freeze(capabilities), actions: Object.freeze(actions) }); }
+function uniqueRecipes(rows) { const seen = new Set(); return rows.filter((row) => row?.id && !seen.has(row.id) && seen.add(row.id)); }
function isSuccess(status) { const code = Number(status); return code >= 200 && code < 300; }
function isBlocked(status) { return HTTP_BLOCKED.has(Number(status)); }
function clamp(value, min, max) { return Math.min(max, Math.max(min, Number(value))); }
diff --git a/engine_v2/src/resolver-core.mjs b/engine_v2/src/resolver-core.mjs
index 5ce21f837..640aa4bb1 100644
--- a/engine_v2/src/resolver-core.mjs
+++ b/engine_v2/src/resolver-core.mjs
@@ -2,6 +2,7 @@ import { normalizeResolveRequest, normalizeStreamCandidate } from "./contracts.m
import { addEvidenceError, newEvidenceRecord, recordStage, summarizeEvidence } from "./evidence.mjs";
import { validateMediaCandidates } from "./media-validator.mjs";
import { planRepair } from "./repair-brain.mjs";
+import { presentStreamCandidates } from "./stream-presentation.mjs";
const PIPELINE = Object.freeze(["homepage", "search", "identity", "detail", "episode", "player", "media", "validation"]);
@@ -10,6 +11,10 @@ export class ResolverCore {
this.maxRepairHypotheses = Math.max(1, Math.min(3, Number(options.maxRepairHypotheses ?? 3)));
this.mediaValidator = options.mediaValidator ?? validateMediaCandidates;
this.mediaValidationOptions = options.mediaValidationOptions ?? {};
+ // Optional shared metadata resolver (normally TMDB-backed). Metadata enriches
+ // presentation only: a metadata outage must never turn a playable provider into
+ // a provider failure or fabricate media facts.
+ this.metadataResolver = typeof options.metadataResolver === "function" ? options.metadataResolver : null;
}
async resolve({ provider, adapter, request, fixtureId = "adhoc", clientRef = "unknown", runtimeCompatibility = null }) {
@@ -36,6 +41,15 @@ export class ResolverCore {
streams: [],
};
+ if (this.metadataResolver) {
+ try {
+ const metadata = await this.metadataResolver(canonical);
+ if (metadata && typeof metadata === "object") context.state.coreMetadata = metadata;
+ } catch (error) {
+ context.state.coreMetadataError = String(error?.message ?? error);
+ }
+ }
+
try {
if (typeof adapter.discover === "function") {
const discovery = await adapter.discover(context);
@@ -71,6 +85,7 @@ export class ResolverCore {
if (stage === "media") {
context.streams = normalizeStreams(result, provider.id);
+ context.streams = presentStreamCandidates(context.streams, presentationMetadata(context), provider);
evidence.playableStreams = 0;
recordStage(evidence, stage, stageEvidence(stage, { ...asObject(result), streams: context.streams }));
} else if (stage === "validation") {
@@ -192,17 +207,50 @@ function normalizeStreams(result, providerId) {
return out;
}
+function presentationMetadata(context) {
+ const request = context.request ?? {};
+ const shared = context.state.coreMetadata && typeof context.state.coreMetadata === "object"
+ ? context.state.coreMetadata
+ : {};
+ const providerMetadata = context.state.metadata && typeof context.state.metadata === "object"
+ ? context.state.metadata
+ : {};
+ return {
+ title: request.title ?? null,
+ year: request.year ?? null,
+ ...shared,
+ ...providerMetadata,
+ };
+}
+
function applyValidationResult(context, result = {}) {
const rows = Array.isArray(result.results) ? result.results : [];
const byUrl = new Map(rows.map((row) => [row?.candidate?.url, row?.validation]));
- context.streams = context.streams.map((stream) => {
+ const rankedRows = Array.isArray(result.rankedResults) ? result.rankedResults : [];
+ const rankByUrl = new Map();
+ for (const row of rankedRows) {
+ const url = row?.candidate?.url;
+ if (url && !rankByUrl.has(url)) rankByUrl.set(url, rankByUrl.size);
+ }
+
+ const annotated = context.streams.map((stream, originalIndex) => {
const validation = byUrl.get(stream.url) ?? null;
return {
- ...stream,
- playable: validation?.playable === true,
- validation,
+ originalIndex,
+ rank: rankByUrl.has(stream.url) ? rankByUrl.get(stream.url) : Number.POSITIVE_INFINITY,
+ stream: {
+ ...stream,
+ playable: validation?.playable === true,
+ validation,
+ },
};
});
+
+ // Validation/ranking is part of the canonical Core contract, not a device hack.
+ // Proven playable rows lead; the validator can therefore prefer a stable 1080p
+ // fallback over a flaky 2160p route while leaving untested rows in stable order.
+ annotated.sort((a, b) => a.rank - b.rank || a.originalIndex - b.originalIndex);
+ context.streams = annotated.map((row) => row.stream);
context.evidence.playableStreams = context.streams.filter((stream) => stream.playable === true).length;
}
diff --git a/engine_v2/src/stream-presentation.mjs b/engine_v2/src/stream-presentation.mjs
new file mode 100644
index 000000000..a5f6474ed
--- /dev/null
+++ b/engine_v2/src/stream-presentation.mjs
@@ -0,0 +1,181 @@
+const UNKNOWN = /^(?:unknown|n\/a|na|none|null|undefined|-)$/i;
+
+export function presentStreamCandidates(streams, metadata = {}, provider = {}) {
+ return (Array.isArray(streams) ? streams : []).map((stream) => presentStreamCandidate(stream, metadata, provider));
+}
+
+export function presentStreamCandidate(stream = {}, metadata = {}, provider = {}) {
+ const facts = collectFacts(stream, metadata);
+ const providerName = clean(stream.name) ?? clean(provider.name) ?? clean(provider.id) ?? clean(stream.provider) ?? "Source";
+ const originalDescription = useful(stream.description);
+ const badges = buildBadges(facts);
+ const descriptionParts = [];
+
+ if (badges.length) descriptionParts.push(badges.join(" • "));
+ if (originalDescription && !descriptionParts.some((part) => part.includes(originalDescription))) {
+ descriptionParts.push(originalDescription);
+ }
+
+ // TMDB (or another shared factual metadata resolver) fills sparse/Unknown provider
+ // descriptions. It supplements facts only; it never invents stream provenance.
+ const tmdbTitle = useful(metadata.title ?? metadata.name ?? metadata.originalTitle ?? metadata.original_name);
+ const tmdbYear = positiveInt(metadata.year ?? yearFromDate(metadata.releaseDate ?? metadata.release_date ?? metadata.firstAirDate ?? metadata.first_air_date));
+ if (!originalDescription && (tmdbTitle || tmdbYear)) {
+ descriptionParts.push([tmdbTitle, tmdbYear].filter(Boolean).join(" • "));
+ }
+
+ return {
+ ...stream,
+ title: providerName,
+ description: descriptionParts.join("\n") || null,
+ quality: facts.quality,
+ language: facts.language,
+ codec: facts.codec,
+ audio: facts.audio,
+ duration: facts.duration,
+ sourceType: facts.sourceType,
+ ageRating: facts.ageRating,
+ displayBadges: badges,
+ };
+}
+
+export function collectFacts(stream = {}, metadata = {}) {
+ return {
+ quality: normalizeQuality(stream.quality ?? stream.resolution),
+ language: useful(stream.language ?? stream.lang ?? stream.audioLanguage),
+ codec: normalizeCodec(stream.codec ?? stream.videoCodec ?? stream.video_codec),
+ audio: normalizeAudio(stream.audio ?? stream.audioCodec ?? stream.audio_codec),
+ duration: normalizeDuration(
+ stream.duration ?? stream.durationMinutes ?? stream.runtime ??
+ metadata.duration ?? metadata.durationMinutes ?? metadata.runtime,
+ ),
+ sourceType: normalizeSourceType(stream.sourceType ?? stream.source_type ?? stream.releaseType ?? stream.release_type),
+ ageRating: normalizeAgeRating(
+ stream.ageRating ?? stream.age_rating ?? stream.certification ??
+ metadata.ageRating ?? metadata.age_rating ?? metadata.certification ?? metadata.contentRating ?? metadata.content_rating,
+ ),
+ };
+}
+
+export function buildBadges(facts = {}) {
+ const out = [];
+ const quality = normalizeQuality(facts.quality);
+ const sourceType = normalizeSourceType(facts.sourceType);
+ const language = useful(facts.language);
+ const codec = normalizeCodec(facts.codec);
+ const audio = normalizeAudio(facts.audio);
+ const duration = normalizeDuration(facts.duration);
+ const ageRating = normalizeAgeRating(facts.ageRating);
+
+ if (quality) out.push(quality === "2160p" ? "【4K】" : `【${quality.toUpperCase()}】`);
+ if (sourceType) out.push(`【${sourceType}】`);
+ if (language) out.push(`🌐 ${language.toUpperCase()}`);
+ if (codec) out.push(`🎞 ${codec}`);
+ if (audio) out.push(`🔊 ${audio}`);
+ if (duration) out.push(`⏱ ${formatDuration(duration)}`);
+ if (ageRating) out.push(`🔞 ${ageRating}`);
+ return out;
+}
+
+export function normalizeQuality(value) {
+ const text = useful(value);
+ if (!text) return null;
+ if (/^(?:4k|uhd|2160p?)$/i.test(text)) return "2160p";
+ const match = text.match(/(?:^|\b)(2160|1440|1080|720|576|480)p?(?:\b|$)/i);
+ return match ? `${match[1]}p` : text;
+}
+
+export function normalizeSourceType(value) {
+ const text = useful(value);
+ if (!text) return null;
+ const compact = text.toUpperCase().replace(/[._\s]+/g, "-");
+ if (/BLU-?RAY|BDRIP|BRRIP/.test(compact)) return "BLU-RAY";
+ if (/WEB-?DL/.test(compact)) return "WEB-DL";
+ if (/WEB-?RIP/.test(compact)) return "WEBRIP";
+ if (/HDTV/.test(compact)) return "HDTV";
+ if (/DVDRIP|DVD/.test(compact)) return "DVD";
+ if (/(?:^|-)CAM(?:-|$)|TELESYNC/.test(compact)) return "CAM";
+ // Unknown strings are not provenance. In particular, a quality such as 1080p
+ // must never be converted into a source badge or used to imply Blu-ray/Web-DL.
+ return null;
+}
+
+export function normalizeCodec(value) {
+ const text = useful(value);
+ if (!text) return null;
+ const upper = text.toUpperCase();
+ if (/H\.?265|X265|HEVC/.test(upper)) return "HEVC";
+ if (/H\.?264|X264|AVC/.test(upper)) return "H.264";
+ if (/AV1/.test(upper)) return "AV1";
+ if (/VP9/.test(upper)) return "VP9";
+ return text;
+}
+
+export function normalizeAudio(value) {
+ const text = useful(value);
+ if (!text) return null;
+ return text
+ .replace(/\bDDP\b/ig, "E-AC3")
+ .replace(/\bDD\b/ig, "AC3")
+ .replace(/\s+/g, " ")
+ .trim();
+}
+
+export function normalizeDuration(value) {
+ if (value == null || value === "") return null;
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) {
+ return value > 600 ? Math.round(value / 60) : Math.round(value);
+ }
+ const text = useful(value);
+ if (!text) return null;
+ const hm = text.match(/(?:(\d+)\s*h(?:ours?)?)?\s*(?:(\d+)\s*m(?:in(?:utes?)?)?)?/i);
+ if (hm && (hm[1] || hm[2])) {
+ const minutes = Number(hm[1] || 0) * 60 + Number(hm[2] || 0);
+ return minutes > 0 ? minutes : null;
+ }
+ const number = Number(text);
+ if (Number.isFinite(number) && number > 0) return number > 600 ? Math.round(number / 60) : Math.round(number);
+ return null;
+}
+
+export function normalizeAgeRating(value) {
+ const text = useful(value);
+ if (!text) return null;
+ const upper = text.toUpperCase();
+ const france = upper.match(/(?:-|INTERDIT\s+MOINS\s+DE\s+)(10|12|16|18)\b/);
+ if (france) return `-${france[1]}`;
+ const plus = upper.match(/(?:^|\b)(7|10|12|13|14|15|16|17|18)\+(?:$|\s)/);
+ if (plus) return `${plus[1]}+`;
+ if (/^(?:U|G|PG|PG-13|R|NC-17|TV-Y|TV-Y7|TV-G|TV-PG|TV-14|TV-MA)$/i.test(text)) return upper;
+ return text;
+}
+
+function formatDuration(minutes) {
+ const total = Math.max(1, Math.round(Number(minutes)));
+ const hours = Math.floor(total / 60);
+ const rest = total % 60;
+ if (!hours) return `${rest} min`;
+ return rest ? `${hours}h${String(rest).padStart(2, "0")}` : `${hours}h`;
+}
+
+function yearFromDate(value) {
+ const text = useful(value);
+ const match = text?.match(/(?:19|20)\d{2}/);
+ return match ? Number(match[0]) : null;
+}
+
+function positiveInt(value) {
+ const number = Number(value);
+ return Number.isInteger(number) && number > 0 ? number : null;
+}
+
+function useful(value) {
+ const text = clean(value);
+ return text && !UNKNOWN.test(text) ? text : null;
+}
+
+function clean(value) {
+ if (value == null) return null;
+ const text = String(value).trim();
+ return text || null;
+}
diff --git a/engine_v2/src/tmdb-metadata.mjs b/engine_v2/src/tmdb-metadata.mjs
new file mode 100644
index 000000000..6e2a133a5
--- /dev/null
+++ b/engine_v2/src/tmdb-metadata.mjs
@@ -0,0 +1,142 @@
+// Shared factual metadata resolver for Core presentation/catalogue matching.
+// The default key is the same public TMDB v3 key already embedded by upstream
+// provider bundles in this repository; callers can override it with TMDB_API_KEY.
+const DEFAULT_TMDB_API_KEY = "1865f43a0549ca50d341dd9ab8b29f49";
+const TMDB_BASE = "https://api.themoviedb.org/3";
+
+export function createTmdbMetadataResolver(options = {}) {
+ const fetchImpl = options.fetchImpl ?? fetch;
+ const apiKey = String(options.apiKey ?? process.env.TMDB_API_KEY ?? DEFAULT_TMDB_API_KEY).trim();
+ const language = String(options.language ?? "fr-FR").trim() || "fr-FR";
+ const timeoutMs = Math.max(1000, Math.min(15000, Number(options.timeoutMs ?? 6000)));
+ if (!apiKey) throw new Error("TMDB API key is required");
+
+ return async function resolveTmdbMetadata(request = {}) {
+ const tmdbId = String(request.tmdbId ?? "").trim();
+ if (!/^\d+$/.test(tmdbId)) return requestFallback(request);
+ const mediaType = normalizeMediaType(request.mediaType ?? request.type);
+ const kind = mediaType === "movie" ? "movie" : "tv";
+ const append = kind === "movie" ? "release_dates,alternative_titles" : "content_ratings,alternative_titles";
+ const url = new URL(`${TMDB_BASE}/${kind}/${tmdbId}`);
+ url.searchParams.set("api_key", apiKey);
+ url.searchParams.set("language", language);
+ url.searchParams.set("append_to_response", append);
+
+ const response = await fetchImpl(url, {
+ headers: { Accept: "application/json" },
+ signal: AbortSignal.timeout(timeoutMs),
+ });
+ if (!response.ok) throw new Error(`TMDB HTTP ${response.status}`);
+ const payload = await response.json();
+ return normalizeTmdbPayload(payload, { mediaType, request });
+ };
+}
+
+export function normalizeTmdbPayload(payload = {}, context = {}) {
+ const mediaType = normalizeMediaType(context.mediaType ?? context.request?.mediaType);
+ const movie = mediaType === "movie";
+ const request = context.request ?? {};
+ const releaseDate = clean(payload.release_date ?? payload.first_air_date);
+ const runtime = movie
+ ? positiveNumber(payload.runtime)
+ : firstPositive(payload.episode_run_time) ?? positiveNumber(payload.runtime);
+ const aliases = movie
+ ? collectAlternativeTitles(payload.alternative_titles?.titles, "title")
+ : collectAlternativeTitles(payload.alternative_titles?.results, "title");
+ const title = clean(movie ? payload.title : payload.name) ?? clean(request.title);
+ const originalTitle = clean(movie ? payload.original_title : payload.original_name);
+ const certification = movie
+ ? movieCertification(payload.release_dates?.results)
+ : tvCertification(payload.content_ratings?.results);
+
+ return {
+ tmdbId: String(payload.id ?? request.tmdbId ?? "").trim() || null,
+ title,
+ originalTitle,
+ aliases: unique([title, originalTitle, ...aliases, clean(request.title)].filter(Boolean)),
+ year: yearFromDate(releaseDate) ?? positiveInt(request.year),
+ releaseDate,
+ runtime,
+ durationMinutes: runtime,
+ certification,
+ ageRating: certification,
+ source: "tmdb",
+ };
+}
+
+function requestFallback(request = {}) {
+ return {
+ tmdbId: clean(request.tmdbId),
+ title: clean(request.title),
+ originalTitle: null,
+ aliases: unique([clean(request.title)].filter(Boolean)),
+ year: positiveInt(request.year),
+ releaseDate: null,
+ runtime: null,
+ durationMinutes: null,
+ certification: null,
+ ageRating: null,
+ source: "request",
+ };
+}
+
+function movieCertification(results) {
+ const rows = Array.isArray(results) ? results : [];
+ const preferred = rows.find((row) => String(row?.iso_3166_1 ?? "").toUpperCase() === "FR")
+ ?? rows.find((row) => String(row?.iso_3166_1 ?? "").toUpperCase() === "US")
+ ?? rows[0];
+ const dates = Array.isArray(preferred?.release_dates) ? preferred.release_dates : [];
+ return clean(dates.map((row) => row?.certification).find((value) => clean(value)));
+}
+
+function tvCertification(results) {
+ const rows = Array.isArray(results) ? results : [];
+ const preferred = rows.find((row) => String(row?.iso_3166_1 ?? "").toUpperCase() === "FR")
+ ?? rows.find((row) => String(row?.iso_3166_1 ?? "").toUpperCase() === "US")
+ ?? rows[0];
+ return clean(preferred?.rating);
+}
+
+function collectAlternativeTitles(rows, key) {
+ if (!Array.isArray(rows)) return [];
+ return rows.map((row) => clean(row?.[key])).filter(Boolean).slice(0, 20);
+}
+
+function normalizeMediaType(value) {
+ const raw = String(value ?? "movie").trim().toLowerCase();
+ return raw === "movie" ? "movie" : "tv";
+}
+
+function firstPositive(value) {
+ if (!Array.isArray(value)) return null;
+ for (const item of value) {
+ const number = positiveNumber(item);
+ if (number) return number;
+ }
+ return null;
+}
+
+function positiveNumber(value) {
+ const number = Number(value);
+ return Number.isFinite(number) && number > 0 ? Math.round(number) : null;
+}
+
+function positiveInt(value) {
+ const number = Number(value);
+ return Number.isInteger(number) && number > 0 ? number : null;
+}
+
+function yearFromDate(value) {
+ const match = String(value ?? "").match(/(?:19|20)\d{2}/);
+ return match ? Number(match[0]) : null;
+}
+
+function clean(value) {
+ if (value == null) return null;
+ const text = String(value).trim();
+ return text || null;
+}
+
+function unique(values) {
+ return [...new Set(values)];
+}
diff --git a/engine_v2/tests/contracts.test.mjs b/engine_v2/tests/contracts.test.mjs
index 7a22b4763..7ecd963be 100644
--- a/engine_v2/tests/contracts.test.mjs
+++ b/engine_v2/tests/contracts.test.mjs
@@ -2,16 +2,26 @@ import assert from "node:assert/strict";
import {
adaptRequestForDevice,
normalizeMediaType,
+ normalizeProviderMediaType,
normalizeResolveRequest,
normalizeStreamCandidate,
normalizeProviderSpec,
} from "../src/contracts.mjs";
+// External/client aliases are accepted only at the request boundary.
assert.equal(normalizeMediaType("series"), "tv");
assert.equal(normalizeMediaType("show"), "tv");
assert.equal(normalizeMediaType("other"), "tv");
assert.equal(normalizeMediaType("anime"), "anime");
+// Provider/manifests use one global vocabulary only.
+assert.equal(normalizeProviderMediaType("movie"), "movie");
+assert.equal(normalizeProviderMediaType("tv"), "tv");
+assert.equal(normalizeProviderMediaType("anime"), "anime");
+for (const alias of ["series", "serie", "show", "other"]) {
+ assert.throws(() => normalizeProviderMediaType(alias), /provider media type must be canonical/);
+}
+
const breakingBad = normalizeResolveRequest({
tmdbId: "1396",
mediaType: "series",
@@ -31,6 +41,15 @@ for (const device of ["mobile", "desktop", "tv"]) {
assert.equal(adapted.canonical.device, device);
}
+const anime = adaptRequestForDevice({
+ tmdbId: "95479",
+ mediaType: "anime",
+ title: "Jujutsu Kaisen",
+ season: 1,
+ episode: 1,
+}, "mobile");
+assert.deepEqual(anime.args, ["95479", "anime", 1, 1]);
+
const interstellar = adaptRequestForDevice({
tmdbId: "157336",
mediaType: "movie",
@@ -39,27 +58,81 @@ const interstellar = adaptRequestForDevice({
assert.deepEqual(interstellar.args, ["157336", "movie", undefined, undefined]);
const stream = normalizeStreamCandidate({
- title: "VF 1080p",
- url: "https://media.example/stream.m3u8",
+ title: "VF 4K",
+ url: "https://media.example/stream.m3u8?token=A%2FB&expires=1785885619&sig=x%2By%3D",
+ quality: "4K",
language: "fr",
- headers: { Referer: "https://player.example/", Origin: "https://player.example" },
- subtitles: [{ url: "https://sub.example/fr.vtt", language: "fr" }],
+ behaviorHints: { proxyHeaders: { request: {
+ Referer: "https://proxy-player.example/",
+ Cookie: "session=proxy",
+ Authorization: "Bearer proxy",
+ } } },
+ requestHeaders: {
+ origin: "https://request.example",
+ cookie: "session=request",
+ },
+ headers: {
+ referer: "https://player.example/",
+ Origin: "https://player.example",
+ COOKIE: "session=explicit",
+ },
+ subtitles: [{
+ url: "https://sub.example/fr.vtt?token=sub%2F1",
+ language: "fr",
+ behaviorHints: { proxyHeaders: { request: { Referer: "https://subtitle.example/" } } },
+ }],
}, { providerId: "example" });
assert.equal(stream.provider, "example");
-assert.equal(stream.headers.Referer, "https://player.example/");
+assert.equal(stream.url, "https://media.example/stream.m3u8?token=A%2FB&expires=1785885619&sig=x%2By%3D");
+assert.equal(stream.quality, "2160p");
+assert.equal(stream.headers.referer, "https://player.example/");
+assert.equal(stream.headers.Origin, "https://player.example");
+assert.equal(stream.headers.COOKIE, "session=explicit");
+assert.equal(stream.headers.Authorization, "Bearer proxy");
+assert.equal(Object.keys(stream.headers).filter((key) => key.toLowerCase() === "referer").length, 1);
+assert.equal(Object.keys(stream.headers).filter((key) => key.toLowerCase() === "cookie").length, 1);
assert.equal(stream.subtitles.length, 1);
+assert.equal(stream.subtitles[0].url, "https://sub.example/fr.vtt?token=sub%2F1");
+assert.equal(stream.subtitles[0].headers.Referer, "https://subtitle.example/");
+
+const nested = normalizeStreamCandidate({
+ url: {
+ url: "https://signed.example/media?X-Amz-Signature=a%2Bb%2Fc&X-Amz-Expires=600",
+ requestHeaders: { Referer: "https://nested.example/" },
+ headers: { Cookie: "nested=1" },
+ },
+ requestHeaders: { Origin: "https://outer.example" },
+}, { providerId: "nested" });
+assert.equal(nested.url, "https://signed.example/media?X-Amz-Signature=a%2Bb%2Fc&X-Amz-Expires=600");
+assert.deepEqual(nested.headers, {
+ Referer: "https://nested.example/",
+ Cookie: "nested=1",
+ Origin: "https://outer.example",
+});
+
+// 1080p is a resolution only. Do not invent Blu-ray provenance from it.
+const plain1080 = normalizeStreamCandidate({ url: "https://media.example/a.mp4", quality: "1080p" });
+assert.equal(plain1080.quality, "1080p");
+assert.doesNotMatch(JSON.stringify(plain1080), /blu[- ]?ray/i);
const spec = normalizeProviderSpec({
id: "Example",
name: "Example",
- supportedTypes: ["movie", "series"],
+ supportedTypes: ["movie", "tv", "anime"],
languages: ["fr", "FR"],
sources: [{ upstream: "gowaru", path: "providers/example.js" }],
strategies: { search: { kind: "html" }, media: { kind: "hls" } },
});
-assert.deepEqual(spec.supportedTypes, ["movie", "tv"]);
+assert.deepEqual(spec.supportedTypes, ["movie", "tv", "anime"]);
assert.deepEqual(spec.languages, ["fr"]);
+assert.throws(() => normalizeProviderSpec({
+ id: "LegacyAlias",
+ name: "Legacy Alias",
+ supportedTypes: ["movie", "series"],
+ sources: [{ upstream: "test", path: "providers/test.js" }],
+ strategies: {},
+}), /non-canonical provider type: series/);
assert.throws(() => normalizeResolveRequest({ mediaType: "tv", title: "Breaking Bad" }), /season and episode/);
assert.throws(() => normalizeMediaType("documentary"), /unsupported mediaType/);
diff --git a/engine_v2/tests/learning-lab-memory.test.mjs b/engine_v2/tests/learning-lab-memory.test.mjs
index 5994c41df..ec86eb074 100644
--- a/engine_v2/tests/learning-lab-memory.test.mjs
+++ b/engine_v2/tests/learning-lab-memory.test.mjs
@@ -22,11 +22,46 @@ write(repair, {
rejected: [{ parent_key: 'published:foo', repair_key: 'r1', profile: 'adaptive_runtime_recovery', reason: 'no improvement' }],
}],
});
+const readerMemory = {
+ schemaVersion: 1,
+ updatedAt: '2026-08-18T00:00:00Z',
+ importedOutcomesThisRun: 1,
+ entries: [{
+ providerId: 'moviesdrive', fixture: 'sinners-2025', failureClass: 'playback_http_access',
+ skill: 'global_media_enrichment_v1', attempts: 2, successes: 1, failures: 1, inconclusive: 0,
+ consecutiveFailures: 0, lastOutcome: 'accepted', lastReason: 'fresh_native_reader_proof_green', lastSeenAt: '2026-08-18T00:00:00Z',
+ }],
+ skillStats: [{
+ skill: 'global_media_enrichment_v1', attempts: 2, successes: 1, failures: 1, inconclusive: 0,
+ provenProviderCount: 1, failedProviderCount: 0, provenProviders: ['moviesdrive'], failedProviders: [], maturity: 'promising',
+ }],
+ readerBacklog: {
+ schemaVersion: 1,
+ updatedAt: '2026-08-18T00:00:00Z',
+ lastRunId: '12345',
+ importedRunIds: ['12345'],
+ importedEvidenceIds: ['abc123'],
+ openCount: 1,
+ resolvedCount: 0,
+ externalCandidateOpenCount: 1,
+ entries: [{
+ id: 'reader-bug-1', client: 'tv', providerId: 'moviesdrive', fixture: 'sinners-2025',
+ requestType: 'movie', failureClass: 'playback_http_access', layer: 'playback_transport',
+ scope: 'external_or_context', status: 'open', externalCandidate: true, providerJsMutationAllowed: false,
+ occurrences: 2, consecutiveFailures: 2, healthyRetests: 0,
+ firstSeenAt: '2026-08-18T00:00:00Z', lastSeenAt: '2026-08-18T00:00:00Z', resolvedAt: null,
+ lastRunId: '12345', lastOutcome: 'failure', lastReason: 'playback_http_access',
+ hypotheses: ['replay-native-request-context'],
+ }],
+ },
+};
write(previous, {
experimentMemory: { entries: [{
providerId: 'foo', providerVersion: '*', signature: 'sig-foo', failureClass: 'search_gap', profile: 'adaptive_runtime_recovery',
attempts: 1, successes: 0, failures: 1, consecutiveFailures: 1, lastOutcome: 'rejected', lastReason: 'no improvement', lastSeenAt: '2026-08-16T00:00:00Z',
}] },
+ nativeReaderRepairMemory: readerMemory,
+ nativeFeedback: { readerRepairAccepted: 3, readerRepairRejected: 2, readerRepairInconclusive: 1 },
});
write(historical, {
baseline: { id: 'excel-provider-audit-5.20.63', release: '5.20.63' },
@@ -56,9 +91,14 @@ assert.equal(entry.consecutiveFailures, 2);
assert.ok(latest.proposals.some((row) => row.type === 'avoid_failed_profile' && row.providerId === 'foo'));
assert.ok(latest.proposals.some((row) => row.type === 'historical_provider_repair_target' && row.providerId === 'foo'));
assert.ok(latest.proposals.some((row) => row.type === 'native_cross_device_repair_target' && row.providerId === 'foo'));
+assert.deepEqual(latest.nativeReaderRepairMemory, readerMemory, 'daily Brain learning must preserve repair memory and reader backlog');
+assert.equal(latest.nativeReaderRepairMemory.readerBacklog.openCount, 1);
+assert.equal(latest.nativeFeedback.readerRepairAccepted, 3);
+assert.equal(latest.nativeFeedback.readerRepairRejected, 2);
+assert.equal(latest.nativeFeedback.readerRepairInconclusive, 1);
assert.equal(latest.productionWritesAllowed, false);
assert.equal(latest.publicationAllowed, false);
-console.log('learning lab negative-memory tests passed');
+console.log('learning lab negative-memory, native-reader-memory and backlog preservation tests passed');
function write(file, value) {
fs.writeFileSync(file, JSON.stringify(value, null, 2) + '\n');
diff --git a/engine_v2/tests/media-validator.test.mjs b/engine_v2/tests/media-validator.test.mjs
index 573e8acde..bde845f18 100644
--- a/engine_v2/tests/media-validator.test.mjs
+++ b/engine_v2/tests/media-validator.test.mjs
@@ -1,68 +1,219 @@
import assert from "node:assert/strict";
import { validateMediaCandidate, validateMediaCandidates } from "../src/media-validator.mjs";
-const calls = [];
-const fetchImpl = async (url, options = {}) => {
- calls.push({ url: String(url), headers: options.headers ?? {} });
- if (String(url) === "https://cdn.example/master.m3u8") {
- return new Response("#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1000000\nvariant.m3u8\n", {
- status: 200,
- headers: { "content-type": "application/vnd.apple.mpegurl" },
- });
- }
- if (String(url) === "https://cdn.example/variant.m3u8") {
- return new Response("#EXTM3U\n#EXT-X-TARGETDURATION:6\n#EXTINF:6,\nsegment0.ts\n", {
- status: 200,
- headers: { "content-type": "application/vnd.apple.mpegurl" },
- });
- }
- if (String(url) === "https://cdn.example/fake.m3u8") {
- return new Response("blocked", {
- status: 200,
- headers: { "content-type": "text/html" },
- });
- }
- if (String(url) === "https://cdn.example/movie.mp4") {
- return new Response(new Uint8Array([0, 0, 0, 24, 102, 116, 121, 112, 105, 115, 111, 109]), {
- status: 206,
- headers: { "content-type": "video/mp4", "content-length": "12" },
- });
- }
- if (String(url) === "https://cdn.example/blocked.m3u8") {
- return new Response("Forbidden", { status: 403, headers: { "content-type": "text/plain" } });
+const requiredHeaders = {
+ Referer: "https://player.example/watch",
+ Origin: "https://player.example",
+ Cookie: "session=a%2Fb",
+ Authorization: "Bearer signed-token",
+};
+
+function response(body, status = 200, contentType = "application/vnd.apple.mpegurl") {
+ return new Response(body, { status, headers: { "content-type": contentType } });
+}
+
+function binary(bytes = [0x47, 0x40, 0x00, 0x10]) {
+ return response(new Uint8Array(bytes), 200, "application/octet-stream");
+}
+
+function assertPlaybackHeaders(headers, label) {
+ const lower = Object.fromEntries(Object.entries(headers || {}).map(([key, value]) => [key.toLowerCase(), value]));
+ for (const [key, value] of Object.entries(requiredHeaders)) {
+ assert.equal(lower[key.toLowerCase()], value, `${label}: missing ${key}`);
}
- throw new Error(`unexpected url ${url}`);
+}
+
+const masterUrl = "https://cdn.example/master.m3u8?token=master%2F1&sig=a%2Bb%3D";
+const variant2160 = "https://cdn.example/v2160.m3u8?token=hi%2F1&sig=x%2By";
+const variant1080 = "https://cdn.example/v1080.m3u8?token=ok%2F1&sig=q%2Br";
+const keyUrl = "https://keys.example/aes.key?token=k%2F1&sig=k%2Bk";
+const mapUrl = "https://cdn.example/init.mp4?token=map%2F1";
+const segmentUrl = "https://segments.example/seg-001.m4s?token=s%2F1&sig=s%2Bs";
+const audioUrl = "https://cdn.example/audio-fr.m3u8?token=audio%2F1";
+const audioSegment = "https://cdn.example/audio-001.aac?token=aseg%2F1";
+const subtitleUrl = "https://cdn.example/sub-fr.m3u8?token=sub%2F1";
+
+const master = `#EXTM3U
+#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aud",LANGUAGE="fr",NAME="Français",DEFAULT=YES,URI="${audioUrl}"
+#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",LANGUAGE="fr",NAME="Français",URI="${subtitleUrl}"
+#EXT-X-STREAM-INF:BANDWIDTH=16000000,RESOLUTION=3840x2160,CODECS="hvc1.1.6.L120",AUDIO="aud",SUBTITLES="subs"
+${variant2160}
+#EXT-X-STREAM-INF:BANDWIDTH=6500000,RESOLUTION=1920x1080,CODECS="avc1.640028",AUDIO="aud",SUBTITLES="subs"
+${variant1080}
+`;
+const broken2160 = `#EXTM3U
+#EXT-X-TARGETDURATION:6
+#EXTINF:6,
+https://segments.example/2160.ts?token=broken%2F1
+`;
+const good1080 = `#EXTM3U
+#EXT-X-TARGETDURATION:6
+#EXT-X-KEY:METHOD=AES-128,URI="${keyUrl}"
+#EXT-X-MAP:URI="init.mp4?token=map%2F1"
+#EXTINF:6,
+${segmentUrl}
+#EXT-X-ENDLIST
+`;
+const audio = `#EXTM3U
+#EXT-X-TARGETDURATION:6
+#EXTINF:6,
+${audioSegment}
+#EXT-X-ENDLIST
+`;
+const subtitle = `#EXTM3U
+#EXT-X-TARGETDURATION:6
+#EXTINF:6,
+sub-001.vtt?token=vtt%2F1
+#EXT-X-ENDLIST
+`;
+
+const trace = [];
+const chainFetch = async (input, init = {}) => {
+ const url = String(input);
+ trace.push({ url, headers: { ...(init.headers || {}) } });
+ assertPlaybackHeaders(init.headers, url);
+ if (url === masterUrl) return response(master);
+ if (url === variant2160) return response(broken2160);
+ if (url === "https://segments.example/2160.ts?token=broken%2F1") return response("denied", 403, "text/plain");
+ if (url === variant1080) return response(good1080);
+ if (url === keyUrl) return binary(new Array(16).fill(7));
+ if (url === mapUrl) return binary([0, 0, 0, 24, 102, 116, 121, 112]);
+ if (url === segmentUrl) return binary();
+ if (url === audioUrl) return response(audio);
+ if (url === audioSegment) return binary([0xff, 0xf1, 0x50, 0x80]);
+ if (url === subtitleUrl) return response(subtitle);
+ if (url === "https://cdn.example/sub-001.vtt?token=vtt%2F1") return response("WEBVTT\n", 200, "text/vtt");
+ throw new Error(`unexpected URL ${url}`);
+};
+
+const recoveredFallback = await validateMediaCandidate({
+ url: masterUrl,
+ type: "hls",
+ quality: "4K",
+ headers: requiredHeaders,
+}, { fetchImpl: chainFetch, timeoutMs: 1500, maxHlsVariants: 4 });
+assert.equal(recoveredFallback.playable, true, recoveredFallback.reason);
+assert.equal(recoveredFallback.effectiveHeight, 1080);
+assert.match(String(recoveredFallback.codecs), /avc1/i);
+assert.equal(recoveredFallback.fallbackCount, 1);
+assert.equal(recoveredFallback.chain.segment, undefined);
+assert.equal(recoveredFallback.child.segment.url, segmentUrl);
+assert.equal(recoveredFallback.child.key.url, keyUrl);
+assert.equal(recoveredFallback.child.map.url, mapUrl);
+assert.equal(recoveredFallback.chain.audio.playable, true);
+assert.equal(recoveredFallback.chain.subtitles.playable, true);
+for (const exact of [masterUrl, variant2160, variant1080, keyUrl, mapUrl, segmentUrl, audioUrl, audioSegment, subtitleUrl]) {
+ assert(trace.some((row) => row.url === exact), `signed/explicit URL changed or was not fetched: ${exact}`);
+}
+
+async function validateSingleResourceFailure(tag, body, failingUrl) {
+ const playlistUrl = `https://failure.example/${tag}.m3u8?token=${tag}%2F1`;
+ const seen = [];
+ const fetchImpl = async (input, init = {}) => {
+ const url = String(input);
+ seen.push(url);
+ assertPlaybackHeaders(init.headers, `${tag}:${url}`);
+ if (url === playlistUrl) return response(body);
+ if (url === failingUrl) return response("forbidden", 403, "text/plain");
+ if (/\.key(?:\?|$)/.test(url)) return binary(new Array(16).fill(1));
+ if (/init\.mp4/.test(url)) return binary([0, 0, 0, 20]);
+ return binary();
+ };
+ const value = await validateMediaCandidate({ url: playlistUrl, type: "hls", headers: requiredHeaders }, { fetchImpl, timeoutMs: 1500 });
+ assert.equal(value.playable, false, `${tag} unexpectedly playable`);
+ assert.equal(value.reason, `hls-${tag}-http-403`);
+ assert(seen.includes(failingUrl));
+}
+
+await validateSingleResourceFailure(
+ "key",
+ `#EXTM3U\n#EXT-X-KEY:METHOD=AES-128,URI="https://failure.example/denied.key?token=k%2F2"\n#EXTINF:6,\nhttps://failure.example/seg.ts\n`,
+ "https://failure.example/denied.key?token=k%2F2",
+);
+await validateSingleResourceFailure(
+ "map",
+ `#EXTM3U\n#EXT-X-MAP:URI="https://failure.example/init.mp4?token=m%2F2"\n#EXTINF:6,\nhttps://failure.example/seg.m4s\n`,
+ "https://failure.example/init.mp4?token=m%2F2",
+);
+await validateSingleResourceFailure(
+ "segment",
+ `#EXTM3U\n#EXTINF:6,\nhttps://failure.example/denied.ts?token=s%2F2\n`,
+ "https://failure.example/denied.ts?token=s%2F2",
+);
+
+const opaque = await validateMediaCandidate({ url: "https://opaque.example/play?id=1", headers: requiredHeaders }, {
+ timeoutMs: 1500,
+ fetchImpl: async (input, init = {}) => {
+ assertPlaybackHeaders(init.headers, "opaque");
+ const url = String(input);
+ if (url.includes("play?id=1")) return response("#EXTM3U\n#EXTINF:6,\nseg.ts\n");
+ if (url === "https://opaque.example/seg.ts") return binary();
+ throw new Error(url);
+ },
+});
+assert.equal(opaque.playable, true);
+assert.equal(opaque.format, "hls");
+
+const html = await validateMediaCandidate({ url: "https://opaque.example/fake.m3u8", headers: requiredHeaders }, {
+ timeoutMs: 1500,
+ fetchImpl: async (_input, init = {}) => {
+ assertPlaybackHeaders(init.headers, "html");
+ return response("blocked", 200, "text/html");
+ },
+});
+assert.equal(html.playable, false);
+assert.equal(html.reason, "hls-invalid-body");
+
+const rankingFetch = async (input) => {
+ const url = String(input);
+ if (url === "https://rank.example/flaky.m3u8") return response(`#EXTM3U\n#EXT-X-STREAM-INF:RESOLUTION=3840x2160,CODECS="hvc1.1.6.L120"\nhi.m3u8\n#EXT-X-STREAM-INF:RESOLUTION=3840x2160,CODECS="hvc1.1.6.L120"\nbackup.m3u8\n`);
+ if (url === "https://rank.example/hi.m3u8") return response("no", 403, "text/plain");
+ if (url === "https://rank.example/backup.m3u8") return response("#EXTM3U\n#EXTINF:6,\nbackup.ts\n");
+ if (url === "https://rank.example/backup.ts") return binary();
+ if (url === "https://rank.example/stable.m3u8") return response(`#EXTM3U\n#EXT-X-STREAM-INF:RESOLUTION=1920x1080,CODECS="avc1.640028"\nstable-child.m3u8\n`);
+ if (url === "https://rank.example/stable-child.m3u8") return response("#EXTM3U\n#EXTINF:6,\nstable.ts\n");
+ if (url === "https://rank.example/stable.ts") return binary();
+ if (url === "https://rank.example/healthy4k.m3u8") return response(`#EXTM3U\n#EXT-X-STREAM-INF:RESOLUTION=3840x2160,CODECS="hvc1.1.6.L120"\nhealthy4k-child.m3u8\n`);
+ if (url === "https://rank.example/healthy4k-child.m3u8") return response("#EXTM3U\n#EXTINF:6,\nhealthy4k.ts\n");
+ if (url === "https://rank.example/healthy4k.ts") return binary();
+ throw new Error(url);
};
+const ranked = await validateMediaCandidates([
+ { url: "https://rank.example/flaky.m3u8", type: "hls", quality: "2160p" },
+ { url: "https://rank.example/stable.m3u8", type: "hls", quality: "1080p" },
+], { fetchImpl: rankingFetch, timeoutMs: 1500, maxCandidates: 3 });
+assert.equal(ranked.rankedResults[0].candidate.url, "https://rank.example/stable.m3u8");
+assert(ranked.rankedResults[0].rankScore > ranked.rankedResults[1].rankScore);
+
+const healthy4k = await validateMediaCandidates([
+ { url: "https://rank.example/stable.m3u8", type: "hls", quality: "1080p" },
+ { url: "https://rank.example/healthy4k.m3u8", type: "hls", quality: "2160p" },
+], { fetchImpl: rankingFetch, timeoutMs: 1500 });
+assert.equal(healthy4k.rankedResults[0].candidate.url, "https://rank.example/healthy4k.m3u8");
+
+console.log("engine v2 deep media validator tests passed");
-const hls = await validateMediaCandidate({
- url: "https://cdn.example/master.m3u8",
- headers: { Referer: "https://player.example/", Origin: "https://player.example" },
-}, { fetchImpl });
-assert.equal(hls.playable, true);
-assert.equal(hls.status, 200);
-assert.equal(hls.child.playable, true);
-assert.equal(calls[0].headers.Referer, "https://player.example/");
-assert.equal(calls[0].headers.Origin, "https://player.example");
-assert.equal(calls[0].headers.Range, "bytes=0-262143");
-
-const fake = await validateMediaCandidate({ url: "https://cdn.example/fake.m3u8" }, { fetchImpl });
-assert.equal(fake.playable, false);
-assert.equal(fake.reason, "invalid-hls-body");
-
-const mp4 = await validateMediaCandidate({ url: "https://cdn.example/movie.mp4" }, { fetchImpl });
-assert.equal(mp4.playable, true);
-assert.equal(mp4.status, 206);
-
-const blocked = await validateMediaCandidate({ url: "https://cdn.example/blocked.m3u8" }, { fetchImpl });
-assert.equal(blocked.playable, false);
-assert.equal(blocked.reason, "http-403");
-
-const batch = await validateMediaCandidates([
- { url: "https://cdn.example/blocked.m3u8" },
- { url: "https://cdn.example/movie.mp4" },
-], { fetchImpl, maxCandidates: 2 });
-assert.equal(batch.playable, true);
-assert.equal(batch.playableCount, 1);
-assert.equal(batch.results.length, 2);
-
-console.log("engine v2 media validator tests passed");
+const { ResolverCore } = await import("../src/resolver-core.mjs");
+const resolver = new ResolverCore({
+ mediaValidator: async (candidates) => ({
+ playable: true,
+ playableCount: 2,
+ results: candidates.map((candidate) => ({ candidate, validation: { playable: true, status: 200 } })),
+ rankedResults: [
+ { candidate: candidates[1], validation: { playable: true, status: 200 }, rankScore: 11380 },
+ { candidate: candidates[0], validation: { playable: true, status: 200 }, rankScore: 10360 },
+ ],
+ }),
+});
+const resolved = await resolver.resolve({
+ provider: { id: "rank-test" },
+ request: { title: "Ranking", mediaType: "movie" },
+ adapter: {
+ pipeline: ["media", "validation"],
+ media: async () => [
+ { url: "https://rank.example/flaky.m3u8", quality: "2160p" },
+ { url: "https://rank.example/stable.m3u8", quality: "1080p" },
+ ],
+ },
+});
+assert.equal(resolved.streams[0].url, "https://rank.example/stable.m3u8");
diff --git a/engine_v2/tests/native-reader-brain-v4.test.mjs b/engine_v2/tests/native-reader-brain-v4.test.mjs
new file mode 100644
index 000000000..ffd59458e
--- /dev/null
+++ b/engine_v2/tests/native-reader-brain-v4.test.mjs
@@ -0,0 +1,32 @@
+import assert from 'node:assert/strict';
+import { BRAIN_CONTROL_PLANE_VERSION, classifyFailure, planRepair } from '../src/repair-brain.mjs';
+
+assert.equal(BRAIN_CONTROL_PLANE_VERSION, 4);
+
+const cases = [
+ [{ stages: { reader: { attempted: true, state: 'error', httpStatus: 403, failureStage: 'http_access' } } }, 'playback_http_access', 'replay-native-request-context'],
+ [{ stages: { reader: { attempted: true, state: 'error', httpStatus: 404, failureStage: 'http_gone' } } }, 'playback_http_gone', 'refresh-terminal-media-candidate'],
+ [{ stages: { reader: { attempted: true, state: 'error', httpStatus: 429, failureStage: 'http_rate_limit' } } }, 'playback_rate_limited', 'respect-media-rate-limit'],
+ [{ stages: { reader: { attempted: true, state: 'timeout', failureStage: 'timeout' } } }, 'playback_timeout', 'diagnose-native-reader-timeout'],
+ [{ stages: { reader: { attempted: true, state: 'error', failureStage: 'dns' } } }, 'playback_dns', 'repair-media-host-resolution'],
+ [{ stages: { reader: { attempted: true, state: 'error', failureStage: 'tls' } } }, 'playback_tls', 'repair-media-tls-path'],
+ [{ stages: { reader: { attempted: true, state: 'error', failureStage: 'parser' } } }, 'playback_parser', 'resolve-real-media-not-wrapper'],
+ [{ stages: { reader: { attempted: true, state: 'error', failureStage: 'decoder' } } }, 'playback_decoder', 'rerank-reader-compatible-encoding'],
+ [{ stages: { reader: { attempted: true, state: 'error', failureStage: 'io' } } }, 'playback_io', 'repair-reader-io-contract'],
+ [{ stages: { reader: { attempted: true, state: 'short_media', failureStage: 'duration_identity' } } }, 'short_media', 'reject-short-or-preview-media'],
+];
+
+for (const [evidence, failureClass, recipe] of cases) {
+ assert.equal(classifyFailure(evidence), failureClass, failureClass);
+ const plan = planRepair(evidence, { maxHypotheses: 3 });
+ assert.equal(plan.failureClass, failureClass);
+ assert.equal(plan.hypotheses[0]?.id, recipe, `${failureClass} recipe`);
+}
+
+assert.equal(classifyFailure({ stages: { reader: { attempted: true, state: 'ready' } } }), 'healthy');
+assert.equal(classifyFailure({ stages: { reader: { observed: true, failureClass: 'playback_parser' } } }), 'playback_parser');
+
+const legacy = { invoked: true, stages: { media: { attempted: true, found: true }, validation: { attempted: true, playable: false, playableCount: 0, statuses: [403] } } };
+assert.equal(classifyFailure(legacy), 'playback_context_gap');
+
+console.log('Brain v4 native reader causality tests passed');
diff --git a/engine_v2/tests/native-reader-diagnosis.test.mjs b/engine_v2/tests/native-reader-diagnosis.test.mjs
new file mode 100644
index 000000000..98e96a21d
--- /dev/null
+++ b/engine_v2/tests/native-reader-diagnosis.test.mjs
@@ -0,0 +1,228 @@
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { spawnSync } from 'node:child_process';
+import { fileURLToPath } from 'node:url';
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
+const script = path.join(root, 'engine_v2/scripts/diagnose-native-reader.mjs');
+const b64 = (value) => Buffer.from(String(value)).toString('base64url');
+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'niakvio-reader-brain-'));
+try {
+ const log = path.join(tmp, 'tv-native-corpus-sinners-2025.log');
+ const output = path.join(tmp, 'brain.json');
+ const common = [
+ 'FIELD_NATIVE_EVIDENCE_INSTRUMENTED client=tv',
+ 'FIELD_NATIVE_FRONTEND_CAPTURE client=tv phase=ui-launched screenshot=a.png bytes=100',
+ 'FIELD_NATIVE_FRONTEND_CAPTURE client=tv phase=repository-load screenshot=repo-a.png bytes=100',
+ 'FIELD_NATIVE_FRONTEND_CAPTURE client=tv phase=repository-http-request screenshot=repo-http-a.png bytes=100',
+ 'FIELD_NATIVE_FRONTEND_CAPTURE client=tv phase=repository-http-response screenshot=repo-http-b.png bytes=100',
+ 'FIELD_NATIVE_FRONTEND_CAPTURE client=tv phase=repository-loaded screenshot=repo-b.png bytes=100',
+ 'FIELD_NATIVE_FRONTEND_CAPTURE client=tv phase=provider-load-state screenshot=repo-c.png bytes=100',
+ 'FIELD_NATIVE_FRONTEND_CAPTURE client=tv phase=corpus-begin screenshot=b.png bytes=100',
+ 'FIELD_NATIVE_FRONTEND_CAPTURE client=tv phase=provider-loading screenshot=c.png bytes=100',
+ 'FIELD_NATIVE_FRONTEND_CAPTURE client=tv phase=provider-result screenshot=d.png bytes=100',
+ 'FIELD_NATIVE_FRONTEND_CAPTURE client=tv phase=player-start screenshot=e.png bytes=100',
+ 'FIELD_NATIVE_FRONTEND_CAPTURE client=tv phase=player-result screenshot=f.png bytes=100',
+ 'FIELD_NATIVE_FRONTEND_CAPTURE client=tv phase=corpus-end screenshot=g.png bytes=100',
+ 'FIELD_NATIVE_REPOSITORY_LOAD_BEGIN client=tv fixture=sinners-2025 expected=2 manifest_host=raw.githubusercontent.com',
+ 'FIELD_NATIVE_REPOSITORY_HTTP_REQUEST client=tv kind=manifest method=GET endpoint=https://raw.githubusercontent.com/niakw/NiakVIO/sha/manifest.json request_header_names=accept',
+ 'FIELD_NATIVE_REPOSITORY_HTTP_RESPONSE client=tv kind=manifest method=GET endpoint=https://raw.githubusercontent.com/niakw/NiakVIO/sha/manifest.json status=200 duration_ms=20 response_header_names=content-type source=network',
+ 'FIELD_NATIVE_REPOSITORY_LOAD_RESULT client=tv fixture=sinners-2025 expected=2 loaded=2',
+ `FIELD_NATIVE_PROVIDER_LOAD_RESULT client=tv fixture=sinners-2025 provider64=${b64('MOVIESDRIVE')} manifest_enabled=true runtime_enabled=true metadata_match=true`,
+ `FIELD_NATIVE_PROVIDER_LOAD_RESULT client=tv fixture=sinners-2025 provider64=${b64('PURSTREAM')} manifest_enabled=true runtime_enabled=true metadata_match=true`,
+ 'FIELD_NATIVE_CORPUS_BEGIN client=tv fixture=sinners-2025 providers=2',
+ `FIELD_NATIVE_PROVIDER_BEGIN client=tv fixture=sinners-2025 provider64=${b64('MOVIESDRIVE')} request_type=movie`,
+ `FIELD_NATIVE_RESULT client=tv fixture=sinners-2025 provider64=${b64('MOVIESDRIVE')} request_type=movie enabled=true duration_ms=1 count=2`,
+ `FIELD_NATIVE_PLAYER_BEGIN client=tv fixture=sinners-2025 provider64=${b64('MOVIESDRIVE')} request_type=movie index=0`,
+ `FIELD_NATIVE_PLAYER client=tv fixture=sinners-2025 provider64=${b64('MOVIESDRIVE')} request_type=movie index=0 state=error engine=media3 http_status=403 failure_stage=http_access duration_seconds=0 host64=${b64('zip.example')} error_class64=${b64('PlaybackException')} error_code64=${b64('ERROR_CODE_IO_BAD_HTTP_STATUS')} exception_chain64=${b64('InvalidResponseCodeException')} response_header_names64=${b64('content-type,date')}`,
+ `FIELD_NATIVE_PLAYER_BEGIN client=tv fixture=sinners-2025 provider64=${b64('MOVIESDRIVE')} request_type=movie index=1`,
+ `FIELD_NATIVE_PLAYER client=tv fixture=sinners-2025 provider64=${b64('MOVIESDRIVE')} request_type=movie index=1 state=short_media engine=media3 http_status=0 failure_stage=duration_identity duration_seconds=20 host64=${b64('media.example')} error_class64=${b64('')} error_code64=${b64('')} exception_chain64=${b64('')} response_header_names64=${b64('')}`,
+ `FIELD_NATIVE_PROVIDER_BEGIN client=tv fixture=sinners-2025 provider64=${b64('PURSTREAM')} request_type=movie`,
+ `FIELD_NATIVE_RESULT client=tv fixture=sinners-2025 provider64=${b64('PURSTREAM')} request_type=movie enabled=true duration_ms=1 count=1`,
+ `FIELD_NATIVE_PLAYER_BEGIN client=tv fixture=sinners-2025 provider64=${b64('PURSTREAM')} request_type=movie index=0`,
+ `FIELD_NATIVE_PLAYER client=tv fixture=sinners-2025 provider64=${b64('PURSTREAM')} request_type=movie index=0 state=ready engine=media3 http_status=0 failure_stage=none duration_seconds=8220 host64=${b64('media.example')} error_class64=${b64('')} error_code64=${b64('')} exception_chain64=${b64('')} response_header_names64=${b64('')}`,
+ 'FIELD_NATIVE_CORPUS_END client=tv fixture=sinners-2025 errors=0',
+ ];
+ fs.writeFileSync(log, common.join('\n') + '\n');
+ const run = spawnSync(process.execPath, [script, '--output', output, log], { cwd: root, encoding: 'utf8' });
+ assert.equal(run.status, 0, run.stderr + run.stdout);
+ const data = JSON.parse(fs.readFileSync(output, 'utf8'));
+ assert.equal(data.brainVersion, 4);
+ assert.equal(data.schemaVersion, 5);
+ assert.equal(data.evidenceComplete, true);
+ assert.equal(data.policy.evidenceUsable, true);
+ assert.equal(data.policy.learningAllowed, false);
+ assert.equal(data.policy.repairPlanningAllowed, false);
+ assert.equal(data.policy.providerMutationRequiresCrossClientConsensus, true);
+ assert.equal(data.crossClientProviderFailureGroups, 0);
+ assert.equal(data.policy.providerLoadJsMutationAllowed, false);
+ assert.equal(data.readerObserved, 3);
+ assert.equal(data.readerHealthy, 1);
+ assert.equal(data.readerFailures, 2);
+ assert.equal(data.providerEligibleReaderFailures, 2);
+ assert.equal(data.clientRuntimeReaderFailures, 0);
+ assert.equal(data.evidenceStats.providerLoads, 2);
+ assert.equal(data.evidenceStats.repositoryHttpRequests, 1);
+ assert.equal(data.providerLoadIssues.length, 0);
+ // Single-client failures remain useful diagnostic observations, but the policy
+ // above forbids learning/repair until an independent client corroborates them.
+ const access = data.plans.find((row) => row.failureClass === 'playback_http_access');
+ assert.ok(access);
+ assert.equal(access.requestType, 'movie');
+ assert.match(access.signature, /^movie:/);
+ assert.equal(access.httpStatus, 403);
+ assert.equal(access.hypotheses[0].id, 'replay-native-request-context');
+ const short = data.plans.find((row) => row.failureClass === 'short_media');
+ assert.ok(short);
+ assert.equal(short.durationSeconds, 20);
+ assert.equal(short.hypotheses[0].id, 'reject-short-or-preview-media');
+ assert.equal(data.policy.requireFreshNativeReaderProofAfterRepair, true);
+ assert.match(data.privacy, /No raw URLs/);
+ assert.match(run.stdout, /FIELD_NATIVE_READER_BRAIN evidence_complete=true/);
+
+ // A provider can fail after repository installation but before any execution/
+ // reader route. Complete loading/network/traversal evidence makes this actionable
+ // repository/Core evidence, not an incomplete lab.
+ const loadFailure = path.join(tmp, 'provider-load-failure.log');
+ const loadFailureOutput = path.join(tmp, 'provider-load-failure.json');
+ fs.writeFileSync(loadFailure, [
+ 'FIELD_NATIVE_EVIDENCE_INSTRUMENTED client=tv',
+ 'FIELD_NATIVE_FRONTEND_CAPTURE client=tv phase=ui-launched screenshot=a.png bytes=100',
+ 'FIELD_NATIVE_FRONTEND_CAPTURE client=tv phase=repository-load screenshot=b.png bytes=100',
+ 'FIELD_NATIVE_FRONTEND_CAPTURE client=tv phase=repository-http-request screenshot=bh.png bytes=100',
+ 'FIELD_NATIVE_FRONTEND_CAPTURE client=tv phase=repository-http-response screenshot=bi.png bytes=100',
+ 'FIELD_NATIVE_FRONTEND_CAPTURE client=tv phase=repository-loaded screenshot=c.png bytes=100',
+ 'FIELD_NATIVE_FRONTEND_CAPTURE client=tv phase=provider-load-state screenshot=d.png bytes=100',
+ 'FIELD_NATIVE_FRONTEND_CAPTURE client=tv phase=corpus-begin screenshot=e.png bytes=100',
+ 'FIELD_NATIVE_FRONTEND_CAPTURE client=tv phase=corpus-end screenshot=f.png bytes=100',
+ 'FIELD_NATIVE_REPOSITORY_LOAD_BEGIN client=tv fixture=sinners-2025 expected=1 manifest_host=raw.githubusercontent.com',
+ 'FIELD_NATIVE_REPOSITORY_HTTP_REQUEST client=tv kind=manifest method=GET endpoint=https://raw.githubusercontent.com/niakw/NiakVIO/sha/manifest.json request_header_names=accept',
+ 'FIELD_NATIVE_REPOSITORY_HTTP_RESPONSE client=tv kind=manifest method=GET endpoint=https://raw.githubusercontent.com/niakw/NiakVIO/sha/manifest.json status=200 duration_ms=20 response_header_names=content-type source=network',
+ 'FIELD_NATIVE_REPOSITORY_LOAD_RESULT client=tv fixture=sinners-2025 expected=1 loaded=0',
+ `FIELD_NATIVE_PROVIDER_LOAD_ERROR client=tv fixture=sinners-2025 provider64=${b64('MOVIESDRIVE')} reason=missing_after_repository_install`,
+ 'FIELD_NATIVE_CORPUS_BEGIN client=tv fixture=sinners-2025 providers=1',
+ `FIELD_NATIVE_PROVIDER_SKIPPED client=tv fixture=sinners-2025 provider64=${b64('MOVIESDRIVE')} enabled=false requested_type=movie reason=load_failure`,
+ 'FIELD_NATIVE_CORPUS_END client=tv fixture=sinners-2025 errors=0',
+ ].join('\n') + '\n');
+ const loadFailureRun = spawnSync(process.execPath, [script, '--output', loadFailureOutput, loadFailure], { cwd: root, encoding: 'utf8' });
+ assert.equal(loadFailureRun.status, 0, loadFailureRun.stderr + loadFailureRun.stdout);
+ const loadFailureData = JSON.parse(fs.readFileSync(loadFailureOutput, 'utf8'));
+ assert.equal(loadFailureData.evidenceComplete, true);
+ assert.equal(loadFailureData.readerObserved, 0);
+ assert.equal(loadFailureData.providerLoadActionableFailures, 1);
+ assert.deepEqual(loadFailureData.plans, []);
+ assert.equal(loadFailureData.providerLoadIssues[0].failureClass, 'provider_repository_load_missing');
+ assert.equal(loadFailureData.providerLoadIssues[0].providerJsMutationAllowed, false);
+ assert.equal(loadFailureData.providerLoadIssues[0].coreOrManifestProposalAllowed, true);
+ assert.equal(loadFailureData.providerLoadPriorities[0].layer, 'repository');
+ assert.equal(loadFailureData.providerOutcomes[0].loadFailures, 1);
+ assert.equal(loadFailureData.policy.repositoryLearningAllowed, true);
+ assert.equal(loadFailureData.policy.providerLoadJsMutationAllowed, false);
+ assert.match(loadFailureRun.stdout, /provider_load_failures=1/);
+
+ // A whole repository can fail before any provider or player runs. The HTTP 404,
+ // terminal repository error and per-provider fallout are still a complete proof.
+ // The Brain may propose Core/repository work but must never generate provider JS.
+ const installFailure = path.join(tmp, 'repository-install-failure.log');
+ const installFailureOutput = path.join(tmp, 'repository-install-failure.json');
+ fs.writeFileSync(installFailure, [
+ 'FIELD_NATIVE_EVIDENCE_INSTRUMENTED client=tv',
+ 'FIELD_NATIVE_FRONTEND_CAPTURE client=tv phase=ui-launched screenshot=a.png bytes=100',
+ 'FIELD_NATIVE_FRONTEND_CAPTURE client=tv phase=repository-load screenshot=b.png bytes=100',
+ 'FIELD_NATIVE_FRONTEND_CAPTURE client=tv phase=repository-http-request screenshot=c.png bytes=100',
+ 'FIELD_NATIVE_FRONTEND_CAPTURE client=tv phase=repository-http-response screenshot=d.png bytes=100',
+ 'FIELD_NATIVE_FRONTEND_CAPTURE client=tv phase=repository-load-error screenshot=e.png bytes=100',
+ 'FIELD_NATIVE_FRONTEND_CAPTURE client=tv phase=provider-load-state screenshot=f.png bytes=100',
+ 'FIELD_NATIVE_FRONTEND_CAPTURE client=tv phase=corpus-begin screenshot=g.png bytes=100',
+ 'FIELD_NATIVE_FRONTEND_CAPTURE client=tv phase=corpus-end screenshot=h.png bytes=100',
+ 'FIELD_NATIVE_REPOSITORY_LOAD_BEGIN client=tv fixture=sinners-2025 expected=1 manifest_host=raw.githubusercontent.com',
+ 'FIELD_NATIVE_REPOSITORY_HTTP_REQUEST client=tv kind=manifest method=GET endpoint=https://raw.githubusercontent.com/niakw/NiakVIO/sha/manifest.json request_header_names=accept',
+ 'FIELD_NATIVE_REPOSITORY_HTTP_RESPONSE client=tv kind=manifest method=GET endpoint=https://raw.githubusercontent.com/niakw/NiakVIO/sha/manifest.json status=404 duration_ms=12 response_header_names=content-type source=network',
+ `FIELD_NATIVE_REPOSITORY_LOAD_ERROR client=tv fixture=sinners-2025 reason=install_failed error64=${b64('HTTP 404')}`,
+ `FIELD_NATIVE_PROVIDER_LOAD_ERROR client=tv fixture=sinners-2025 provider64=${b64('MOVIESDRIVE')} reason=repository_install_failed`,
+ 'FIELD_NATIVE_CORPUS_BEGIN client=tv fixture=sinners-2025 providers=1',
+ `FIELD_NATIVE_PROVIDER_SKIPPED client=tv fixture=sinners-2025 provider64=${b64('MOVIESDRIVE')} enabled=false requested_type=movie reason=load_failure`,
+ 'FIELD_NATIVE_CORPUS_END client=tv fixture=sinners-2025 errors=0',
+ ].join('\n') + '\n');
+ const installFailureRun = spawnSync(process.execPath, [script, '--output', installFailureOutput, installFailure], { cwd: root, encoding: 'utf8' });
+ assert.equal(installFailureRun.status, 0, installFailureRun.stderr + installFailureRun.stdout);
+ const installFailureData = JSON.parse(fs.readFileSync(installFailureOutput, 'utf8'));
+ assert.equal(installFailureData.evidenceComplete, true);
+ assert.equal(installFailureData.evidenceStats.repositoryLoadFailures, 1);
+ assert.equal(installFailureData.evidenceStats.repositoryHttpRequests, 1);
+ assert.equal(installFailureData.readerObserved, 0);
+ assert.equal(installFailureData.providerLoadActionableFailures, 1);
+ assert.equal(installFailureData.providerLoadIssues[0].failureClass, 'provider_repository_load_error');
+ assert.equal(installFailureData.providerLoadIssues[0].providerJsMutationAllowed, false);
+ assert.equal(installFailureData.providerLoadIssues[0].coreOrManifestProposalAllowed, true);
+ assert.deepEqual(installFailureData.plans, []);
+
+ // Metadata drift at load time belongs to manifest/Core semantics, never JS repair.
+ const metadataFailure = path.join(tmp, 'provider-metadata-failure.log');
+ const metadataFailureOutput = path.join(tmp, 'provider-metadata-failure.json');
+ fs.writeFileSync(metadataFailure, fs.readFileSync(loadFailure, 'utf8')
+ .replace('missing_after_repository_install', 'metadata_or_code_mismatch'));
+ const metadataRun = spawnSync(process.execPath, [script, '--output', metadataFailureOutput, metadataFailure], { cwd: root, encoding: 'utf8' });
+ assert.equal(metadataRun.status, 0, metadataRun.stderr + metadataRun.stdout);
+ const metadataData = JSON.parse(fs.readFileSync(metadataFailureOutput, 'utf8'));
+ assert.equal(metadataData.providerLoadIssues[0].failureClass, 'provider_repository_metadata_mismatch');
+ assert.equal(metadataData.providerLoadIssues[0].layer, 'manifest_contract');
+ assert.deepEqual(metadataData.plans, []);
+
+ // A log can look structurally complete yet still be causally unusable if the
+ // Nuvio media route was not recorded. The Brain must refuse to learn from it.
+ const routeMissing = path.join(tmp, 'route-missing.log');
+ const routeMissingOutput = path.join(tmp, 'route-missing.json');
+ fs.writeFileSync(routeMissing, common.map((line) => line.replace(/ request_type=movie/g, '')).join('\n') + '\n');
+ const routeRefused = spawnSync(process.execPath, [script, '--output', routeMissingOutput, routeMissing], { cwd: root, encoding: 'utf8' });
+ assert.equal(routeRefused.status, 2, routeRefused.stderr + routeRefused.stdout);
+ const routeRefusedData = JSON.parse(fs.readFileSync(routeMissingOutput, 'utf8'));
+ assert.equal(routeRefusedData.evidenceComplete, false);
+ assert.equal(routeRefusedData.policy.learningAllowed, false);
+ assert.equal(routeRefusedData.policy.repairPlanningAllowed, false);
+ assert.deepEqual(routeRefusedData.plans, []);
+ assert.ok(routeRefusedData.evidenceProblems.some((problem) => problem.startsWith('invalid_request_type:')));
+
+ // Likewise, direct PluginRuntime evidence without proof that Nuvio actually
+ // installed/reconstructed the providers is no longer sufficient.
+ const loadMissing = path.join(tmp, 'load-missing.log');
+ const loadMissingOutput = path.join(tmp, 'load-missing.json');
+ const withoutLoading = common.filter((line) =>
+ !line.includes('repository-load') &&
+ !line.includes('repository-loaded') &&
+ !line.includes('repository-http') &&
+ !line.includes('provider-load-state') &&
+ !line.startsWith('FIELD_NATIVE_REPOSITORY_LOAD_') &&
+ !line.startsWith('FIELD_NATIVE_REPOSITORY_HTTP_') &&
+ !line.startsWith('FIELD_NATIVE_PROVIDER_LOAD_')
+ );
+ fs.writeFileSync(loadMissing, withoutLoading.join('\n') + '\n');
+ const loadRefused = spawnSync(process.execPath, [script, '--output', loadMissingOutput, loadMissing], { cwd: root, encoding: 'utf8' });
+ assert.equal(loadRefused.status, 2, loadRefused.stderr + loadRefused.stdout);
+ const loadRefusedData = JSON.parse(fs.readFileSync(loadMissingOutput, 'utf8'));
+ assert.equal(loadRefusedData.evidenceComplete, false);
+ assert.equal(loadRefusedData.policy.learningAllowed, false);
+ assert.deepEqual(loadRefusedData.plans, []);
+ assert.ok(loadRefusedData.evidenceProblems.some((problem) => problem.startsWith('missing_repository_load:')));
+
+ const incomplete = path.join(tmp, 'incomplete.log');
+ const incompleteOutput = path.join(tmp, 'incomplete.json');
+ fs.writeFileSync(incomplete, [
+ 'FIELD_NATIVE_CORPUS_BEGIN client=tv fixture=sinners-2025 providers=1',
+ `FIELD_NATIVE_PLAYER client=tv fixture=sinners-2025 provider64=${b64('MOVIESDRIVE')} request_type=movie index=0 state=error engine=media3 http_status=403 failure_stage=http_access`,
+ ].join('\n') + '\n');
+ const refused = spawnSync(process.execPath, [script, '--output', incompleteOutput, incomplete], { cwd: root, encoding: 'utf8' });
+ assert.equal(refused.status, 2, refused.stderr + refused.stdout);
+ const refusedData = JSON.parse(fs.readFileSync(incompleteOutput, 'utf8'));
+ assert.equal(refusedData.evidenceComplete, false);
+ assert.equal(refusedData.policy.learningAllowed, false);
+ assert.equal(refusedData.policy.repairPlanningAllowed, false);
+ assert.deepEqual(refusedData.plans, []);
+ assert.ok(refusedData.evidenceProblems.length > 0);
+ assert.match(refused.stdout, /FIELD_NATIVE_READER_BRAIN_EVIDENCE_INCOMPLETE/);
+} finally {
+ fs.rmSync(tmp, { recursive: true, force: true });
+}
+console.log('targeted native reader Brain diagnosis tests passed');
\ No newline at end of file
diff --git a/engine_v2/tests/repair-brain.test.mjs b/engine_v2/tests/repair-brain.test.mjs
index de5f79c76..16c201422 100644
--- a/engine_v2/tests/repair-brain.test.mjs
+++ b/engine_v2/tests/repair-brain.test.mjs
@@ -4,7 +4,7 @@ import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { BRAIN_CONTROL_PLANE_VERSION, classifyFailure, planRepair, recipeIsCompatible } from "../src/repair-brain.mjs";
-assert.equal(BRAIN_CONTROL_PLANE_VERSION, 3);
+assert.equal(BRAIN_CONTROL_PLANE_VERSION, 4);
assert.equal(classifyFailure({ invoked: false }), "not_invoked");
assert.equal(classifyFailure({ invoked: true, dns: { ok: false } }), "dns_unreachable");
assert.equal(classifyFailure({ invoked: true, dns: { ok: true }, stages: { homepage: { status: 403 } } }), "transport_blocked");
@@ -17,7 +17,7 @@ assert.equal(classifyFailure({ contractDrift: true }), "runtime_contract_drift")
const blockedEvidence = { invoked: true, stages: { player: { attempted: true, found: true }, media: { attempted: true, found: true }, validation: { attempted: true, playable: false, playableCount: 0, statuses: [403] } } };
const plan = planRepair(blockedEvidence, { maxHypotheses: 3 });
-assert.equal(plan.brainVersion, 3);
+assert.equal(plan.brainVersion, 4);
assert.equal(plan.failureClass, "playback_context_gap");
assert.equal(plan.action, "probe-targeted-repair");
assert.equal(plan.hypotheses[0].id, "preserve-playback-context");
@@ -79,7 +79,7 @@ const dirtyPayload = {
const plannerRun = spawnSync(process.execPath, [planner], { input: JSON.stringify(dirtyPayload), encoding: "utf8" });
assert.equal(plannerRun.status, 0, plannerRun.stderr);
const plannerOutput = JSON.parse(plannerRun.stdout);
-assert.equal(plannerOutput.brainVersion, 3);
+assert.equal(plannerOutput.brainVersion, 4);
assert.equal(plannerOutput.plannerErrors, 0);
assert.ok(plannerOutput.plans["published:dirty-provider"]);
assert.equal(plannerOutput.plans["published:healthy-provider"].action, "none");
@@ -181,4 +181,4 @@ const deepRepairSource = readFileSync(deepRepairPath, "utf8");
assert.match(deepRepairSource, /parent_key = str\(\(candidate\.get\("runtime_repair"\) or \{\}\)\.get\("parent_key"\) or ""\)/);
assert.match(deepRepairSource, /brain\.PLANS\.get\(parent_key or key\)/);
-console.log("engine v2 repair brain tests passed");
+console.log("engine v2 repair brain tests passed");
\ No newline at end of file
diff --git a/engine_v2/tests/stream-presentation.test.mjs b/engine_v2/tests/stream-presentation.test.mjs
new file mode 100644
index 000000000..63ca92f4f
--- /dev/null
+++ b/engine_v2/tests/stream-presentation.test.mjs
@@ -0,0 +1,131 @@
+import assert from "node:assert/strict";
+import { normalizeStreamCandidate } from "../src/contracts.mjs";
+import {
+ buildBadges,
+ presentStreamCandidate,
+ normalizeSourceType,
+} from "../src/stream-presentation.mjs";
+import { createTmdbMetadataResolver, normalizeTmdbPayload } from "../src/tmdb-metadata.mjs";
+
+const facts = normalizeStreamCandidate({
+ name: "Purstream",
+ url: "https://media.example/master.m3u8",
+ quality: "4K",
+ language: "VFF",
+ codec: "x265",
+ audio: "DDP 5.1",
+ duration: 169,
+ sourceType: "BluRay",
+ ageRating: "-12",
+}, { providerId: "purstream" });
+
+assert.equal(facts.quality, "2160p");
+assert.equal(facts.language, "VFF");
+assert.equal(facts.codec, "x265");
+assert.equal(facts.audio, "DDP 5.1");
+assert.equal(facts.duration, 169);
+assert.equal(facts.sourceType, "BluRay");
+assert.equal(facts.ageRating, "-12");
+
+const presented = presentStreamCandidate(facts, {
+ title: "Interstellar",
+ year: 2014,
+ runtime: 169,
+ certification: "-12",
+}, { id: "purstream", name: "Purstream" });
+
+assert.equal(presented.title, "Purstream");
+assert.equal(presented.quality, "2160p");
+assert.equal(presented.codec, "HEVC");
+assert.equal(presented.audio, "E-AC3 5.1");
+assert.equal(presented.duration, 169);
+assert.equal(presented.sourceType, "BLU-RAY");
+assert.match(presented.description, /【4K】/);
+assert.match(presented.description, /【BLU-RAY】/);
+assert.match(presented.description, /🌐 VFF/);
+assert.match(presented.description, /🎞 HEVC/);
+assert.match(presented.description, /🔊 E-AC3 5\.1/);
+assert.match(presented.description, /⏱ 2h49/);
+assert.match(presented.description, /🔞 -12/);
+assert.match(presented.description, /Interstellar • 2014/);
+
+const tmdbFallback = presentStreamCandidate({
+ name: "Cineby",
+ url: "https://media.example/unknown.mp4",
+ description: "Unknown",
+}, {
+ title: "Sinners",
+ year: 2025,
+ runtime: 137,
+ certification: "16+",
+}, { name: "Cineby" });
+assert.doesNotMatch(tmdbFallback.description ?? "", /Unknown/i);
+assert.match(tmdbFallback.description, /⏱ 2h17/);
+assert.match(tmdbFallback.description, /🔞 16\+/);
+assert.match(tmdbFallback.description, /Sinners • 2025/);
+
+const noInventedBluray = presentStreamCandidate({
+ name: "FrenchStream",
+ url: "https://media.example/1080.mp4",
+ quality: "1080p",
+}, {}, { name: "FrenchStream" });
+assert.match(noInventedBluray.description, /【1080P】/);
+assert.doesNotMatch(noInventedBluray.description, /BLU-RAY/i);
+assert.equal(normalizeSourceType("1080p"), null);
+assert.equal(normalizeSourceType("some provider label"), null);
+
+const badges = buildBadges({ quality: "4K", language: "VFQ", codec: "H.264" });
+assert.deepEqual(badges, ["【4K】", "🌐 VFQ", "🎞 H.264"]);
+
+const normalizedMovie = normalizeTmdbPayload({
+ id: 157336,
+ title: "Interstellar",
+ original_title: "Interstellar",
+ release_date: "2014-11-05",
+ runtime: 169,
+ alternative_titles: { titles: [{ title: "Interstellar" }] },
+ release_dates: {
+ results: [
+ { iso_3166_1: "US", release_dates: [{ certification: "PG-13" }] },
+ { iso_3166_1: "FR", release_dates: [{ certification: "U" }] },
+ ],
+ },
+}, { mediaType: "movie", request: { tmdbId: "157336" } });
+assert.equal(normalizedMovie.runtime, 169);
+assert.equal(normalizedMovie.certification, "U");
+assert.equal(normalizedMovie.year, 2014);
+
+let requestedUrl = "";
+const tmdbResolver = createTmdbMetadataResolver({
+ apiKey: "test-key",
+ fetchImpl: async (url) => {
+ requestedUrl = String(url);
+ return {
+ ok: true,
+ async json() {
+ return {
+ id: 95396,
+ name: "Severance",
+ original_name: "Severance",
+ first_air_date: "2022-02-18",
+ episode_run_time: [50],
+ alternative_titles: { results: [] },
+ content_ratings: { results: [{ iso_3166_1: "FR", rating: "12" }] },
+ };
+ },
+ };
+ },
+});
+const tvMetadata = await tmdbResolver({ tmdbId: "95396", mediaType: "tv", title: "Severance" });
+assert.match(requestedUrl, /\/tv\/95396/);
+assert.match(requestedUrl, /language=fr-FR/);
+assert.equal(tvMetadata.runtime, 50);
+assert.equal(tvMetadata.certification, "12");
+assert.equal(tvMetadata.source, "tmdb");
+
+const fallbackWithoutId = await tmdbResolver({ mediaType: "movie", title: "Sinners", year: 2025 });
+assert.equal(fallbackWithoutId.title, "Sinners");
+assert.equal(fallbackWithoutId.year, 2025);
+assert.equal(fallbackWithoutId.source, "request");
+
+console.log("engine v2 stream presentation and shared TMDB metadata tests passed");
\ No newline at end of file
diff --git a/engine_v2/tests/upstream-provider-watch.test.mjs b/engine_v2/tests/upstream-provider-watch.test.mjs
new file mode 100644
index 000000000..c482d6fe1
--- /dev/null
+++ b/engine_v2/tests/upstream-provider-watch.test.mjs
@@ -0,0 +1,73 @@
+import assert from "node:assert/strict";
+import {
+ catalogIdentity,
+ compareManifest,
+ interestFor,
+ providerKey,
+} from "../scripts/watch-upstream-providers.mjs";
+
+assert.equal(providerKey("🐍 Anime-Sama"), "animesama");
+assert.equal(providerKey("ANIME_SAMA"), "animesama");
+
+const catalog = {
+ providers: [
+ {
+ canonicalId: "cineby",
+ scraper: { id: "CINEBY", name: "Cineby", filename: "providers/cineby--nuvio--abc.js" },
+ },
+ {
+ canonicalId: "anime-sama",
+ scraper: { id: "ANIME-SAMA", name: "🐍 Anime-Sama", filename: "providers/anime-sama--nuvio--def.js" },
+ },
+ ],
+};
+const identity = catalogIdentity(catalog);
+assert(identity.ids.has("cineby"));
+assert(identity.ids.has("animesama"));
+
+const upstream = { id: "gowaru", repository: "Gowaru/gowaru-nuvio-providers" };
+const manifest = {
+ scrapers: [
+ { id: "cineby", name: "Cineby", supportedTypes: ["movie", "tv"], formats: ["m3u8"] },
+ {
+ id: "new-vf-provider",
+ name: "New VF Provider 4K",
+ description: "Films et séries français 4K",
+ supportedTypes: ["movie", "tv"],
+ contentLanguage: ["fr"],
+ formats: ["m3u8", "mp4"],
+ enabled: true,
+ },
+ {
+ id: "new-limited",
+ name: "New Limited",
+ supportedTypes: ["movie"],
+ contentLanguage: ["en"],
+ limited: true,
+ enabled: false,
+ },
+ ],
+};
+
+const compared = compareManifest({ upstream, manifest, catalog });
+assert.equal(compared.existingCount, 1);
+assert.equal(compared.unseen.length, 2);
+assert.equal(compared.unseen[0].id, "new-vf-provider");
+assert.equal(compared.unseen[0].interesting, true);
+assert(compared.unseen[0].reasons.includes("French/VF metadata"));
+assert(compared.unseen[0].reasons.some((reason) => reason.startsWith("direct formats:")));
+assert.equal(compared.unseen.at(-1).id, "new-limited");
+assert.equal(compared.unseen.at(-1).interesting, false);
+
+const noAutoMagic = interestFor({
+ id: "plain",
+ name: "Plain",
+ supportedTypes: ["movie"],
+ formats: [],
+ contentLanguage: ["en"],
+ enabled: false,
+});
+assert.equal(noAutoMagic.interesting, false);
+assert(noAutoMagic.score < 4);
+
+console.log("engine v2 upstream provider watch tests passed");
diff --git a/manifest-playback-hotfix.json b/manifest-playback-hotfix.json
deleted file mode 100644
index 0e1eff6cb..000000000
--- a/manifest-playback-hotfix.json
+++ /dev/null
@@ -1,67 +0,0 @@
-{
- "name": "NiakVIO Playback Hotfix",
- "version": "5.20.71",
- "description": "Immediate native-first playback hotfix for TV verification while the canonical publication pipeline is being repaired.",
- "scrapers": [
- {
- "id": "4khdhub",
- "name": "4KHDHub",
- "description": "4KHDHub direct links - native-first hotfix",
- "version": "1.0.30",
- "author": "Nuvio Team",
- "supportedTypes": ["movie", "tv"],
- "filename": "providers/4khdhub--emergency-native-first--7085d8f0adcfe55c.js",
- "enabled": true,
- "hasSettings": true,
- "formats": ["mp4", "mkv", "m3u8"],
- "logo": "https://i.postimg.cc/DZpW6Xfb/4khdhub.png",
- "contentLanguage": ["en"],
- "disabledPlatforms": []
- },
- {
- "id": "MOVIESDRIVE",
- "name": "MoviesDrive",
- "description": "MoviesDrive - native-first hotfix",
- "version": "3.0.22",
- "author": "PirateZoro9",
- "supportedTypes": ["movie", "tv"],
- "filename": "providers/moviesdrive--emergency-native-first--bf1b8ea425de3e7a.js",
- "enabled": true,
- "hasSettings": true,
- "formats": ["mp4", "mkv", "m3u8"],
- "logo": "https://i.postimg.cc/bwDbhq9q/moviesdrive.png",
- "contentLanguage": ["en", "hi"],
- "disabledPlatforms": []
- },
- {
- "id": "MOVIESMOD",
- "name": "MoviesMod",
- "description": "MoviesMod - native-first hotfix",
- "version": "1.0.22",
- "author": "Nuvio Team",
- "supportedTypes": ["movie", "tv"],
- "filename": "providers/moviesmod--emergency-native-first--352a91f5ecbf2005.js",
- "enabled": true,
- "hasSettings": true,
- "formats": ["mp4", "mkv", "m3u8"],
- "logo": "https://www.google.com/s2/favicons?domain=moviesmod.farm&sz=128",
- "contentLanguage": ["en"],
- "disabledPlatforms": []
- },
- {
- "id": "movieshunt",
- "name": "🎬 MoviesHunt",
- "description": "MoviesHunt - native-first hotfix",
- "version": "1.0.22",
- "author": "piratezoro9",
- "supportedTypes": ["movie"],
- "filename": "providers/movieshunt--emergency-native-first--5c7d2e240f1876c0.js",
- "enabled": true,
- "hasSettings": false,
- "formats": ["m3u8", "mp4", "mkv"],
- "logo": "https://i.postimg.cc/3Rc34LJj/movieshunt.png",
- "contentLanguage": ["hi", "en"],
- "disabledPlatforms": []
- }
- ]
-}
diff --git a/package.json b/package.json
index fc734cd88..4e9fa58a7 100644
--- a/package.json
+++ b/package.json
@@ -16,9 +16,9 @@
"node": ">=24"
},
"scripts": {
- "pretest": "python3 scripts/build_provider_runtime_profiles.py && python3 scripts/reapply_published_overrides.py && node tests/nuvio_client_lab.test.cjs && python3 tests/native_corpus_device_lab_test.py && python3 tests/native_corpus_suite_summary_test.py && python3 tests/native_corpus_transport_capability_test.py && python3 tests/streamzo_native_platform_contract_test.py",
- "test": "node tests/network_guard.test.cjs && node tests/dns_preflight.test.mjs && python3 tests/global_stream_output_guard_test.py && python3 tests/global_local_repair_pipeline_test.py && python3 tests/adaptive_domain_recovery_test.py && python3 tests/reapply_adaptive_domain_revision_test.py && python3 tests/generic_recovery_hardening_test.py && python3 tests/provider_hub_registry_test.py && python3 tests/upstream_lkg_test.py && python3 tests/provider_specific_adapters_test.py && python3 tests/movix_api_route_discovery_test.py && python3 tests/short_hls_preview_guard_test.py && python3 tests/stream_output_sanitizer_reconfiguration_test.py && python3 tests/stream_output_sanitizer_fail_closed_test.py && python3 tests/stream_output_sanitizer_direct_normalization_test.py && python3 tests/vf_movie_policy_test.py && node tests/vf_movie_recovery.test.cjs && python3 tests/overrides_test.py && python3 tests/override_net_noop_regression_test.py && python3 tests/runtime_repair_test.py && python3 tests/adaptive_runtime_repair_test.py && python3 tests/repairable_observation_statuses_test.py && python3 tests/published_overrides_test.py && python3 tests/override_transaction_order_test.py && python3 scripts/reapply_published_overrides.py --check && python3 tests/prune_unreferenced_providers_test.py && python3 scripts/validate_policy.py && python3 tests/ci_preservation_policy_test.py && node scripts/validate_provider_semantics.cjs && python3 scripts/test_route_overrides.py && python3 tests/provider_diagnostics_test.py && python3 tests/deep_health_integrity_test.py && python3 tests/deep_repair_rollback_test.py && node tests/provider_worker_security.test.cjs && node tests/provider_worker_invocation.test.cjs && node scripts/provider_worker.cjs --self-test-response-wrapper && node tests/provider_signature_fallback.test.cjs && python3 tests/category_regression_policy_test.py && python3 tests/removed_provider_regression_test.py && python3 tests/lkg_regression_policy_test.py && node tests/patched_provider_runtime_smoke.test.cjs && node scripts/provider_worker.cjs --self-test-fixture-fallback && python3 tests/provider_versioning_test.py && python3 tests/reapply_provider_versioning_test.py && python3 tests/release_auto_bump_test.py && python3 tests/release_version_sync_test.py && python3 tests/language_projection_test.py && python3 tests/language_domain_homepage_fallback_test.py && python3 tests/release_hash_scope_test.py && python3 tests/desktop_runtime_compat_test.py && python3 tests/platform_filter_semantics_test.py && python3 tests/nuvio_client_upstream_drift_guard_test.py && python3 scripts/validate_platform_runtime_policy.py && python3 scripts/validate_mobile_runtime_policy.py && python3 scripts/validate_nuvio_tv_runtime_policy.py && python3 scripts/generate_release_hashes.py && python3 scripts/validate_release_integrity.py && python3 tests/general_manifest_runtime_profiles_test.py && python3 tests/new_provider_same_deep_profile_test.py && python3 tests/hls_playback_integrity_test.py && python3 tests/vf_catalogue_identity_hardening_test.py && python3 tests/language_manifest_metadata_consistency_test.py && python3 tests/global_playback_integrity_policy_test.py && python3 tests/global_media_enrichment_direct_safety_test.py && python3 tests/scoped_playback_context_regression_test.py && python3 tests/runtime_media_safety_guard_test.py && python3 tests/playback_context_compat_test.py && python3 tests/nuvio_tv_target_media_playback_context_test.py && python3 tests/runtime_capability_media_safety_v4_test.py && node tests/direct_media_probe.test.cjs && python3 tests/sync_atomic_publication_test.py && python3 tests/vidzy_hostname_decoder_test.py && python3 tests/streamzo_target_media_profile_test.py && python3 tests/vf_recovery_profiles_test.py && python3 tests/catalogue_audit_coverage_test.py && python3 tests/sync_catalogue_audit_gate_test.py && python3 tests/tv_provider_hardening_test.py && python3 tests/deep_stream_sampling_test.py && python3 tests/type_scoped_activation_test.py && python3 tests/direct_media_dimensions_test.py && python3 tests/tv_catalogue_fixture_coverage_test.py && python3 tests/media_duration_identity_test.py && python3 tests/matroska_metadata_test.py && python3 tests/global_provider_policy_test.py && python3 tests/global_catalogue_alias_recovery_test.py && python3 tests/catalogue_audit_transient_hls_test.py && python tests/adaptive_binary_probe_test.py && python tests/adaptive_extension_media_proof_test.py && python tests/reapply_adaptive_runtime_revision_test.py && python tests/animezey_stream_host_v1_test.py && python tests/french_manga_player_capture_v1_test.py && python tests/french_manga_target_media_profile_test.py && python3 tests/strict_native_identity_guard_test.py && python3 tests/provider_quarantine_test.py && python3 tests/provider_portfolio_ranking_test.py && python3 tests/provider_coverage_preservation_test.py && python3 tests/provider_catalogue_breadth_test.py",
+ "pretest": "python3 scripts/build_provider_runtime_profiles.py && python3 scripts/reapply_published_overrides.py && python3 tests/full_provider_portfolio_audit_test.py && node tests/nuvio_client_lab.test.cjs && python3 tests/native_pr_timeout_contract_test.py && python3 tests/native_reader_runtime_bootstrap_test.py && python3 tests/native_corpus_device_lab_test.py && python3 tests/native_corpus_suite_summary_test.py && python3 tests/native_corpus_transport_capability_test.py && python3 tests/streamzo_native_platform_contract_test.py && python3 tests/native_player_diagnostics_codegen_test.py && node tests/native_player_diagnostics.test.cjs && python3 tests/native_reader_gate_test.py && python3 tests/targeted_manifest_staging_test.py && python3 tests/native_reader_exhaustive_acceptance_test.py && python3 tests/native_reader_brain_repair_test.py && python3 tests/native_reader_learning_memory_test.py && python3 tests/native_reader_backlog_test.py && node engine_v2/tests/native-reader-brain-v4.test.mjs && node engine_v2/tests/native-reader-diagnosis.test.mjs && node engine_v2/tests/repair-brain.test.mjs",
+ "test": "node tests/network_guard.test.cjs && node tests/dns_preflight.test.mjs && python3 tests/global_stream_output_guard_test.py && python3 tests/global_local_repair_pipeline_test.py && python3 tests/adaptive_domain_recovery_test.py && python3 tests/reapply_adaptive_domain_revision_test.py && python3 tests/generic_recovery_hardening_test.py && python3 tests/provider_hub_registry_test.py && python3 tests/upstream_lkg_test.py && python3 tests/provider_specific_adapters_test.py && python3 tests/movix_api_route_discovery_test.py && python3 tests/short_hls_preview_guard_test.py && python3 tests/stream_output_sanitizer_reconfiguration_test.py && python3 tests/stream_output_sanitizer_fail_closed_test.py && python3 tests/stream_output_sanitizer_direct_normalization_test.py && python3 tests/vf_movie_policy_test.py && node tests/vf_movie_recovery.test.cjs && python3 tests/overrides_test.py && python3 tests/override_net_noop_regression_test.py && python3 tests/runtime_repair_test.py && python3 tests/adaptive_runtime_repair_test.py && python3 tests/repairable_observation_statuses_test.py && python3 tests/published_overrides_test.py && python3 tests/override_transaction_order_test.py && python3 scripts/reapply_published_overrides.py --check && python3 tests/prune_unreferenced_providers_test.py && python3 scripts/validate_policy.py && python3 tests/ci_preservation_policy_test.py && node scripts/validate_provider_semantics.cjs && python3 scripts/test_route_overrides.py && python3 tests/provider_diagnostics_test.py && python3 tests/deep_health_integrity_test.py && python3 tests/deep_repair_rollback_test.py && node tests/provider_worker_security.test.cjs && node tests/provider_worker_invocation.test.cjs && node scripts/provider_worker.cjs --self-test-response-wrapper && node tests/provider_signature_fallback.test.cjs && python3 tests/category_regression_policy_test.py && python3 tests/removed_provider_regression_test.py && python3 tests/lkg_regression_policy_test.py && node tests/patched_provider_runtime_smoke.test.cjs && node scripts/provider_worker.cjs --self-test-fixture-fallback && python3 tests/provider_versioning_test.py && python3 tests/reapply_provider_versioning_test.py && python3 tests/release_auto_bump_test.py && python3 tests/release_version_sync_test.py && python3 tests/language_projection_test.py && python3 tests/language_domain_homepage_fallback_test.py && python3 tests/release_hash_scope_test.py && python3 tests/desktop_runtime_compat_test.py && python3 tests/platform_filter_semantics_test.py && python3 tests/nuvio_client_upstream_drift_guard_test.py && python3 scripts/validate_platform_runtime_policy.py && python3 scripts/validate_mobile_runtime_policy.py && python3 scripts/validate_nuvio_tv_runtime_policy.py && python3 scripts/generate_release_hashes.py && python3 scripts/validate_release_integrity.py && python3 tests/general_manifest_runtime_profiles_test.py && python3 tests/new_provider_same_deep_profile_test.py && python3 tests/hls_playback_integrity_test.py && python3 tests/vf_catalogue_identity_hardening_test.py && python3 tests/language_manifest_metadata_consistency_test.py && python3 tests/global_playback_integrity_policy_test.py && python3 tests/global_stream_presentation_test.py && python3 tests/global_media_enrichment_direct_safety_test.py && python3 tests/scoped_playback_context_regression_test.py && python3 tests/opaque_native_media_enrichment_test.py && python3 tests/runtime_media_safety_guard_test.py && python3 tests/playback_context_compat_test.py && python3 tests/nuvio_tv_target_media_playback_context_test.py && python3 tests/runtime_capability_media_safety_v4_test.py && node tests/direct_media_probe.test.cjs && python3 tests/sync_atomic_publication_test.py && python3 tests/vidzy_hostname_decoder_test.py && python3 tests/streamzo_target_media_profile_test.py && python3 tests/vf_recovery_profiles_test.py && python3 tests/catalogue_audit_coverage_test.py && python3 tests/sync_catalogue_audit_gate_test.py && python3 tests/tv_provider_hardening_test.py && python3 tests/deep_stream_sampling_test.py && python3 tests/type_scoped_activation_test.py && python3 tests/direct_media_dimensions_test.py && python3 tests/tv_catalogue_fixture_coverage_test.py && python3 tests/media_duration_identity_test.py && python3 tests/matroska_metadata_test.py && python3 tests/global_provider_policy_test.py && python3 tests/global_catalogue_alias_recovery_test.py && python3 tests/catalogue_audit_transient_hls_test.py && python tests/adaptive_binary_probe_test.py && python tests/adaptive_extension_media_proof_test.py && python tests/reapply_adaptive_runtime_revision_test.py && python tests/animezey_stream_host_v1_test.py && python tests/french_manga_player_capture_v1_test.py && python tests/french_manga_target_media_profile_test.py && python3 tests/strict_native_identity_guard_test.py && python3 tests/provider_quarantine_test.py && python3 tests/provider_portfolio_ranking_test.py && python3 tests/provider_coverage_preservation_test.py && python3 tests/provider_catalogue_breadth_test.py",
"posttest": "python3 scripts/validate_release_integrity.py",
"diagnostics": "python3 scripts/generate_diagnostics.py"
}
-}
+}
\ No newline at end of file
diff --git a/playback-hotfix/manifest.json b/playback-hotfix/manifest.json
deleted file mode 100644
index e1986a518..000000000
--- a/playback-hotfix/manifest.json
+++ /dev/null
@@ -1,67 +0,0 @@
-{
- "name": "NiakVIO Playback Hotfix",
- "version": "5.20.72",
- "description": "Immediate native-first playback hotfix for TV verification while the canonical publication pipeline is being repaired.",
- "scrapers": [
- {
- "id": "4khdhub",
- "name": "4KHDHub",
- "description": "4KHDHub direct links - native-first hotfix",
- "version": "1.0.31",
- "author": "Nuvio Team",
- "supportedTypes": ["movie", "tv"],
- "filename": "https://raw.githubusercontent.com/niakw/NiakVIO/main/providers/4khdhub--emergency-native-first--7085d8f0adcfe55c.js",
- "enabled": true,
- "hasSettings": true,
- "formats": ["mp4", "mkv", "m3u8"],
- "logo": "https://i.postimg.cc/DZpW6Xfb/4khdhub.png",
- "contentLanguage": ["en"],
- "disabledPlatforms": []
- },
- {
- "id": "MOVIESDRIVE",
- "name": "MoviesDrive",
- "description": "MoviesDrive - native-first hotfix",
- "version": "3.0.23",
- "author": "PirateZoro9",
- "supportedTypes": ["movie", "tv"],
- "filename": "https://raw.githubusercontent.com/niakw/NiakVIO/main/providers/moviesdrive--emergency-native-first--bf1b8ea425de3e7a.js",
- "enabled": true,
- "hasSettings": true,
- "formats": ["mp4", "mkv", "m3u8"],
- "logo": "https://i.postimg.cc/bwDbhq9q/moviesdrive.png",
- "contentLanguage": ["en", "hi"],
- "disabledPlatforms": []
- },
- {
- "id": "MOVIESMOD",
- "name": "MoviesMod",
- "description": "MoviesMod - native-first hotfix",
- "version": "1.0.23",
- "author": "Nuvio Team",
- "supportedTypes": ["movie", "tv"],
- "filename": "https://raw.githubusercontent.com/niakw/NiakVIO/main/providers/moviesmod--emergency-native-first--352a91f5ecbf2005.js",
- "enabled": true,
- "hasSettings": true,
- "formats": ["mp4", "mkv", "m3u8"],
- "logo": "https://www.google.com/s2/favicons?domain=moviesmod.farm&sz=128",
- "contentLanguage": ["en"],
- "disabledPlatforms": []
- },
- {
- "id": "movieshunt",
- "name": "🎬 MoviesHunt",
- "description": "MoviesHunt - native-first hotfix",
- "version": "1.0.23",
- "author": "piratezoro9",
- "supportedTypes": ["movie"],
- "filename": "https://raw.githubusercontent.com/niakw/NiakVIO/main/providers/movieshunt--emergency-native-first--5c7d2e240f1876c0.js",
- "enabled": true,
- "hasSettings": false,
- "formats": ["m3u8", "mp4", "mkv"],
- "logo": "https://i.postimg.cc/3Rc34LJj/movieshunt.png",
- "contentLanguage": ["hi", "en"],
- "disabledPlatforms": []
- }
- ]
-}
diff --git a/providers/4khdhub--emergency-native-first--7085d8f0adcfe55c.js b/providers/4khdhub--emergency-native-first--7085d8f0adcfe55c.js
deleted file mode 100644
index 30b1e2332..000000000
--- a/providers/4khdhub--emergency-native-first--7085d8f0adcfe55c.js
+++ /dev/null
@@ -1,28 +0,0 @@
-"use strict";
-/* NUVIO_EMERGENCY_NATIVE_FIRST_V1 */
-const __NUVIO_SOURCE__="https://raw.githubusercontent.com/yoruix/nuvio-providers/refs/heads/main/providers/4khdhub.js";
-let __nuvioLoaded=null;
-let __nuvioTrace=[];
-function __s(v){return String(v==null?"":v).replace(/\\\//g,"/").trim()}
-function __urlOf(row){let v=row&&row.url;if(v&&typeof v==="object")v=v.url||v.href||v.src;return __s(v||(row&&(row.streamUrl||row.stream||row.link||row.file)))}
-function __key(o,n){n=String(n).toLowerCase();return Object.keys(o||{}).find(k=>String(k).toLowerCase()===n)||""}
-function __headers(row){const out={};const merge=src=>{if(src&&typeof src==="object")Object.keys(src).forEach(k=>{if(String(k).toLowerCase()!=="range"&&__s(src[k]))out[k]=__s(src[k])})};try{merge(row&&row.url&&typeof row.url==="object"&&row.url.headers)}catch(_){}try{merge(row&&row.headers)}catch(_){}try{merge(row&&row.requestHeaders)}catch(_){}try{merge(row&&row.behaviorHints&&row.behaviorHints.proxyHeaders&&row.behaviorHints.proxyHeaders.request)}catch(_){}return out}
-function __norm(row){const u=__urlOf(row);if(!u)return row;return Object.assign({},row,{url:u,headers:__headers(row)})}
-function __host(u){try{return new URL(u).hostname.toLowerCase()}catch(_){return""}}
-const __BAD=/(?:^|\.)(?:youtube\.com|youtu\.be|twitter\.com|x\.com|twimg\.com|facebook\.com|instagram\.com|googletagmanager\.com|google-analytics\.com|doubleclick\.net)$/i;
-const __ASSET=/\.(?:css|js|mjs|map|png|jpe?g|gif|svg|ico|woff2?|ttf|otf|eot|json|xml|vtt|srt)(?:[?#]|$)/i;
-function __bad(u){const h=__host(u);return !/^https?:\/\//i.test(u)||!h||__BAD.test(h)||__ASSET.test(u)||/(?:trailer|bande-annonce|sample[-_]?video|big[_-]?buck[_-]?bunny)/i.test(u)}
-function __direct(row,u){const t=__s(row&&(row.type||row.format||row.mimeType||row.contentType||row.name)).toLowerCase();return !!(row&&row.isDirect===true)||/\.(?:m3u8|mpd|mp4|mkv|webm)(?:[?#]|$)|\/hls2?\//i.test(u)||/(?:hls|mpegurl|m3u8|dash|mpd|mp4|matroska|mkv|webm|video|worker|fsl|direct)/i.test(t)}
-function __slot(v){if(Array.isArray(v))return{key:null,list:v};if(v&&typeof v==="object")for(const k of ["streams","results","data"])if(Array.isArray(v[k]))return{key:k,list:v[k]};return null}
-function __rebuild(v,x,list){if(x.key===null)return list;const o=Object.assign({},v);o[x.key]=list;return o}
-function __abs(v,b){try{return new URL(__s(v),b).toString()}catch(_){return""}}
-function __candidates(text,base){const out=[],seen={};const add=v=>{const u=__abs(v,base);if(!u||__bad(u)||seen[u])return;seen[u]=1;out.push(u)};const body=__s(text);const ps=[/(?:src|href|data-src|data-url|data-embed|data-player|data-file)=["']([^"']+)["']/gi,/(?:file|source|src|url|playlist|embedUrl|embed_url|contentUrl)\s*[:=]\s*["'](https?:\/\/[^"']+)["']/gi,/(https?:\/\/[^"'<>\s\\]+(?:m3u8|mpd|mp4|mkv|webm|embed|player|download|\/e\/|\/hls2?\/)[^"'<>\s\\]*)/gi];for(const p of ps){p.lastIndex=0;let m;while((m=p.exec(body))!==null&&out.length<16)add(m[1])}return out}
-async function __resolve(url,row,referer,depth,seen){if(depth>2||__bad(url)||seen[url])return[];seen[url]=1;try{const h=__headers(row);if(referer){h.Referer=referer;try{h.Origin=new URL(referer).origin}catch(_){}}delete h.Range;const r=await globalThis.fetch(url,{headers:h,redirect:"follow"});if(!r||!r.ok)return[];const final=__s(r.url||url),ct=r.headers&&r.headers.get?__s(r.headers.get("content-type")):"";if(/^video\//i.test(ct))return[{url:final,type:ct.includes("webm")?"webm":ct.includes("matroska")?"mkv":"mp4",headers:h}];let text="";if(typeof r.text==="function")text=String(await r.text()||"").slice(0,350000);const trim=text.trimStart();if(trim.startsWith("#EXTM3U"))return[{url:final,type:"hls",headers:h}];if(/]/i.test(trim.slice(0,8192))||/application\/dash\+xml/i.test(ct))return[{url:final,type:"dash",headers:h}];const next=__candidates(text,final),out=[];for(const u of next){if(out.length>=8)break;if(/\.(?:m3u8|mpd|mp4|mkv|webm)(?:[?#]|$)|\/hls2?\//i.test(u)){out.push({url:u,type:/\.m3u8/i.test(u)?"hls":/\.mpd/i.test(u)?"dash":/\.mkv/i.test(u)?"mkv":"mp4",headers:Object.assign({},h,{Referer:final,Origin:(()=>{try{return new URL(final).origin}catch(_){return""}})()})});continue}const more=await __resolve(u,row,final,depth+1,seen);for(const x of more)if(!out.some(y=>y.url===x.url))out.push(x)}return out}catch(_){return[]}}
-async function __tracedFetch(input,init){const raw=(()=>{try{return typeof input==="string"?input:input&&input.url?input.url:String(input)}catch(_){return""}})();const h=Object.assign({},init&&init.headers||{});const r=await globalThis.fetch(input,init);try{const final=__s(r&&r.url||raw);if(/^https?:\/\//i.test(raw)&&!/(?:themoviedb\.org|raw\.githubusercontent\.com|githubusercontent\.com)/i.test(__host(raw)))__nuvioTrace.push({url:raw,final,headers:h})}catch(_){}return r}
-async function __load(){if(__nuvioLoaded)return __nuvioLoaded;const r=await globalThis.fetch(__NUVIO_SOURCE__,{headers:{Accept:"text/javascript,*/*"}});if(!r||!r.ok)throw new Error("NiakVIO upstream load failed: "+(r&&r.status));const code=await r.text();const m={exports:{}};const req=typeof require==="function"?require:undefined;const fn=new Function("module","exports","require","fetch","globalThis",code+"\n;return module.exports;");const ex=fn(m,m.exports,req,__tracedFetch,globalThis)||m.exports;__nuvioLoaded=ex;return ex}
-async function __post(v){const x=__slot(v);if(!x||!x.list.length)return v;const out=[],seen={};const add=r=>{r=__norm(r);const u=__urlOf(r);if(!u||__bad(u)||seen[u])return;seen[u]=1;out.push(r)};for(const row0 of x.list){if(!row0||typeof row0!=="object")continue;const row=__norm(row0),u=__urlOf(row);if(!u||__bad(u))continue;if(__direct(row,u)){add(row);continue}const found=await __resolve(u,row,u,0,{});for(const media of found)add(Object.assign({},row,{url:media.url,headers:media.headers||row.headers||{},isDirect:true,type:media.type}))}
-if(!out.length){const trace=[...__nuvioTrace].reverse();for(const t of trace){if(out.length>=6)break;const u=__s(t.final||t.url);if(!u||__bad(u))continue;const fake={url:u,headers:t.headers||{}};const found=await __resolve(u,fake,t.url,0,{});for(const media of found)add({name:"Recovered media",title:"Recovered media",url:media.url,headers:media.headers||{},isDirect:true,type:media.type})}}
-return __rebuild(v,x,out)}
-async function __getStreams(){__nuvioTrace=[];const p=await __load();const fn=p&&typeof p.getStreams==="function"?p.getStreams:p&&typeof p.streams==="function"?p.streams:null;if(!fn)throw new Error("NiakVIO upstream provider has no getStreams");return __post(await fn.apply(p,arguments))}
-module.exports={getStreams:__getStreams};
-try{globalThis.getStreams=__getStreams}catch(_){}
diff --git a/providers/moviesdrive--emergency-native-first--bf1b8ea425de3e7a.js b/providers/moviesdrive--emergency-native-first--bf1b8ea425de3e7a.js
deleted file mode 100644
index b6a2e603b..000000000
--- a/providers/moviesdrive--emergency-native-first--bf1b8ea425de3e7a.js
+++ /dev/null
@@ -1,28 +0,0 @@
-"use strict";
-/* NUVIO_EMERGENCY_NATIVE_FIRST_V1 */
-const __NUVIO_SOURCE__="https://raw.githubusercontent.com/yoruix/nuvio-providers/refs/heads/main/providers/moviesdrive.js";
-let __nuvioLoaded=null;
-let __nuvioTrace=[];
-function __s(v){return String(v==null?"":v).replace(/\\\//g,"/").trim()}
-function __urlOf(row){let v=row&&row.url;if(v&&typeof v==="object")v=v.url||v.href||v.src;return __s(v||(row&&(row.streamUrl||row.stream||row.link||row.file)))}
-function __key(o,n){n=String(n).toLowerCase();return Object.keys(o||{}).find(k=>String(k).toLowerCase()===n)||""}
-function __headers(row){const out={};const merge=src=>{if(src&&typeof src==="object")Object.keys(src).forEach(k=>{if(String(k).toLowerCase()!=="range"&&__s(src[k]))out[k]=__s(src[k])})};try{merge(row&&row.url&&typeof row.url==="object"&&row.url.headers)}catch(_){}try{merge(row&&row.headers)}catch(_){}try{merge(row&&row.requestHeaders)}catch(_){}try{merge(row&&row.behaviorHints&&row.behaviorHints.proxyHeaders&&row.behaviorHints.proxyHeaders.request)}catch(_){}return out}
-function __norm(row){const u=__urlOf(row);if(!u)return row;return Object.assign({},row,{url:u,headers:__headers(row)})}
-function __host(u){try{return new URL(u).hostname.toLowerCase()}catch(_){return""}}
-const __BAD=/(?:^|\.)(?:youtube\.com|youtu\.be|twitter\.com|x\.com|twimg\.com|facebook\.com|instagram\.com|googletagmanager\.com|google-analytics\.com|doubleclick\.net)$/i;
-const __ASSET=/\.(?:css|js|mjs|map|png|jpe?g|gif|svg|ico|woff2?|ttf|otf|eot|json|xml|vtt|srt)(?:[?#]|$)/i;
-function __bad(u){const h=__host(u);return !/^https?:\/\//i.test(u)||!h||__BAD.test(h)||__ASSET.test(u)||/(?:trailer|bande-annonce|sample[-_]?video|big[_-]?buck[_-]?bunny)/i.test(u)}
-function __direct(row,u){const t=__s(row&&(row.type||row.format||row.mimeType||row.contentType||row.name)).toLowerCase();return !!(row&&row.isDirect===true)||/\.(?:m3u8|mpd|mp4|mkv|webm)(?:[?#]|$)|\/hls2?\//i.test(u)||/(?:hls|mpegurl|m3u8|dash|mpd|mp4|matroska|mkv|webm|video|worker|fsl|direct)/i.test(t)}
-function __slot(v){if(Array.isArray(v))return{key:null,list:v};if(v&&typeof v==="object")for(const k of ["streams","results","data"])if(Array.isArray(v[k]))return{key:k,list:v[k]};return null}
-function __rebuild(v,x,list){if(x.key===null)return list;const o=Object.assign({},v);o[x.key]=list;return o}
-function __abs(v,b){try{return new URL(__s(v),b).toString()}catch(_){return""}}
-function __candidates(text,base){const out=[],seen={};const add=v=>{const u=__abs(v,base);if(!u||__bad(u)||seen[u])return;seen[u]=1;out.push(u)};const body=__s(text);const ps=[/(?:src|href|data-src|data-url|data-embed|data-player|data-file)=["']([^"']+)["']/gi,/(?:file|source|src|url|playlist|embedUrl|embed_url|contentUrl)\s*[:=]\s*["'](https?:\/\/[^"']+)["']/gi,/(https?:\/\/[^"'<>\s\\]+(?:m3u8|mpd|mp4|mkv|webm|embed|player|download|\/e\/|\/hls2?\/)[^"'<>\s\\]*)/gi];for(const p of ps){p.lastIndex=0;let m;while((m=p.exec(body))!==null&&out.length<16)add(m[1])}return out}
-async function __resolve(url,row,referer,depth,seen){if(depth>2||__bad(url)||seen[url])return[];seen[url]=1;try{const h=__headers(row);if(referer){h.Referer=referer;try{h.Origin=new URL(referer).origin}catch(_){}}delete h.Range;const r=await globalThis.fetch(url,{headers:h,redirect:"follow"});if(!r||!r.ok)return[];const final=__s(r.url||url),ct=r.headers&&r.headers.get?__s(r.headers.get("content-type")):"";if(/^video\//i.test(ct))return[{url:final,type:ct.includes("webm")?"webm":ct.includes("matroska")?"mkv":"mp4",headers:h}];let text="";if(typeof r.text==="function")text=String(await r.text()||"").slice(0,350000);const trim=text.trimStart();if(trim.startsWith("#EXTM3U"))return[{url:final,type:"hls",headers:h}];if(/]/i.test(trim.slice(0,8192))||/application\/dash\+xml/i.test(ct))return[{url:final,type:"dash",headers:h}];const next=__candidates(text,final),out=[];for(const u of next){if(out.length>=8)break;if(/\.(?:m3u8|mpd|mp4|mkv|webm)(?:[?#]|$)|\/hls2?\//i.test(u)){out.push({url:u,type:/\.m3u8/i.test(u)?"hls":/\.mpd/i.test(u)?"dash":/\.mkv/i.test(u)?"mkv":"mp4",headers:Object.assign({},h,{Referer:final,Origin:(()=>{try{return new URL(final).origin}catch(_){return""}})()})});continue}const more=await __resolve(u,row,final,depth+1,seen);for(const x of more)if(!out.some(y=>y.url===x.url))out.push(x)}return out}catch(_){return[]}}
-async function __tracedFetch(input,init){const raw=(()=>{try{return typeof input==="string"?input:input&&input.url?input.url:String(input)}catch(_){return""}})();const h=Object.assign({},init&&init.headers||{});const r=await globalThis.fetch(input,init);try{const final=__s(r&&r.url||raw);if(/^https?:\/\//i.test(raw)&&!/(?:themoviedb\.org|raw\.githubusercontent\.com|githubusercontent\.com)/i.test(__host(raw)))__nuvioTrace.push({url:raw,final,headers:h})}catch(_){}return r}
-async function __load(){if(__nuvioLoaded)return __nuvioLoaded;const r=await globalThis.fetch(__NUVIO_SOURCE__,{headers:{Accept:"text/javascript,*/*"}});if(!r||!r.ok)throw new Error("NiakVIO upstream load failed: "+(r&&r.status));const code=await r.text();const m={exports:{}};const req=typeof require==="function"?require:undefined;const fn=new Function("module","exports","require","fetch","globalThis",code+"\n;return module.exports;");const ex=fn(m,m.exports,req,__tracedFetch,globalThis)||m.exports;__nuvioLoaded=ex;return ex}
-async function __post(v){const x=__slot(v);if(!x||!x.list.length)return v;const out=[],seen={};const add=r=>{r=__norm(r);const u=__urlOf(r);if(!u||__bad(u)||seen[u])return;seen[u]=1;out.push(r)};for(const row0 of x.list){if(!row0||typeof row0!=="object")continue;const row=__norm(row0),u=__urlOf(row);if(!u||__bad(u))continue;if(__direct(row,u)){add(row);continue}const found=await __resolve(u,row,u,0,{});for(const media of found)add(Object.assign({},row,{url:media.url,headers:media.headers||row.headers||{},isDirect:true,type:media.type}))}
-if(!out.length){const trace=[...__nuvioTrace].reverse();for(const t of trace){if(out.length>=6)break;const u=__s(t.final||t.url);if(!u||__bad(u))continue;const fake={url:u,headers:t.headers||{}};const found=await __resolve(u,fake,t.url,0,{});for(const media of found)add({name:"Recovered media",title:"Recovered media",url:media.url,headers:media.headers||{},isDirect:true,type:media.type})}}
-return __rebuild(v,x,out)}
-async function __getStreams(){__nuvioTrace=[];const p=await __load();const fn=p&&typeof p.getStreams==="function"?p.getStreams:p&&typeof p.streams==="function"?p.streams:null;if(!fn)throw new Error("NiakVIO upstream provider has no getStreams");return __post(await fn.apply(p,arguments))}
-module.exports={getStreams:__getStreams};
-try{globalThis.getStreams=__getStreams}catch(_){}
diff --git a/providers/movieshunt--emergency-native-first--5c7d2e240f1876c0.js b/providers/movieshunt--emergency-native-first--5c7d2e240f1876c0.js
deleted file mode 100644
index 6eb66d5a9..000000000
--- a/providers/movieshunt--emergency-native-first--5c7d2e240f1876c0.js
+++ /dev/null
@@ -1,28 +0,0 @@
-"use strict";
-/* NUVIO_EMERGENCY_NATIVE_FIRST_V1 */
-const __NUVIO_SOURCE__="https://raw.githubusercontent.com/NuvioPlugin/All-in-One-Nuvio/refs/heads/main/providers/movieshunt.js";
-let __nuvioLoaded=null;
-let __nuvioTrace=[];
-function __s(v){return String(v==null?"":v).replace(/\\\//g,"/").trim()}
-function __urlOf(row){let v=row&&row.url;if(v&&typeof v==="object")v=v.url||v.href||v.src;return __s(v||(row&&(row.streamUrl||row.stream||row.link||row.file)))}
-function __key(o,n){n=String(n).toLowerCase();return Object.keys(o||{}).find(k=>String(k).toLowerCase()===n)||""}
-function __headers(row){const out={};const merge=src=>{if(src&&typeof src==="object")Object.keys(src).forEach(k=>{if(String(k).toLowerCase()!=="range"&&__s(src[k]))out[k]=__s(src[k])})};try{merge(row&&row.url&&typeof row.url==="object"&&row.url.headers)}catch(_){}try{merge(row&&row.headers)}catch(_){}try{merge(row&&row.requestHeaders)}catch(_){}try{merge(row&&row.behaviorHints&&row.behaviorHints.proxyHeaders&&row.behaviorHints.proxyHeaders.request)}catch(_){}return out}
-function __norm(row){const u=__urlOf(row);if(!u)return row;return Object.assign({},row,{url:u,headers:__headers(row)})}
-function __host(u){try{return new URL(u).hostname.toLowerCase()}catch(_){return""}}
-const __BAD=/(?:^|\.)(?:youtube\.com|youtu\.be|twitter\.com|x\.com|twimg\.com|facebook\.com|instagram\.com|googletagmanager\.com|google-analytics\.com|doubleclick\.net)$/i;
-const __ASSET=/\.(?:css|js|mjs|map|png|jpe?g|gif|svg|ico|woff2?|ttf|otf|eot|json|xml|vtt|srt)(?:[?#]|$)/i;
-function __bad(u){const h=__host(u);return !/^https?:\/\//i.test(u)||!h||__BAD.test(h)||__ASSET.test(u)||/(?:trailer|bande-annonce|sample[-_]?video|big[_-]?buck[_-]?bunny)/i.test(u)}
-function __direct(row,u){const t=__s(row&&(row.type||row.format||row.mimeType||row.contentType||row.name)).toLowerCase();return !!(row&&row.isDirect===true)||/\.(?:m3u8|mpd|mp4|mkv|webm)(?:[?#]|$)|\/hls2?\//i.test(u)||/(?:hls|mpegurl|m3u8|dash|mpd|mp4|matroska|mkv|webm|video|worker|fsl|direct)/i.test(t)}
-function __slot(v){if(Array.isArray(v))return{key:null,list:v};if(v&&typeof v==="object")for(const k of ["streams","results","data"])if(Array.isArray(v[k]))return{key:k,list:v[k]};return null}
-function __rebuild(v,x,list){if(x.key===null)return list;const o=Object.assign({},v);o[x.key]=list;return o}
-function __abs(v,b){try{return new URL(__s(v),b).toString()}catch(_){return""}}
-function __candidates(text,base){const out=[],seen={};const add=v=>{const u=__abs(v,base);if(!u||__bad(u)||seen[u])return;seen[u]=1;out.push(u)};const body=__s(text);const ps=[/(?:src|href|data-src|data-url|data-embed|data-player|data-file)=["']([^"']+)["']/gi,/(?:file|source|src|url|playlist|embedUrl|embed_url|contentUrl)\s*[:=]\s*["'](https?:\/\/[^"']+)["']/gi,/(https?:\/\/[^"'<>\s\\]+(?:m3u8|mpd|mp4|mkv|webm|embed|player|download|\/e\/|\/hls2?\/)[^"'<>\s\\]*)/gi];for(const p of ps){p.lastIndex=0;let m;while((m=p.exec(body))!==null&&out.length<16)add(m[1])}return out}
-async function __resolve(url,row,referer,depth,seen){if(depth>2||__bad(url)||seen[url])return[];seen[url]=1;try{const h=__headers(row);if(referer){h.Referer=referer;try{h.Origin=new URL(referer).origin}catch(_){}}delete h.Range;const r=await globalThis.fetch(url,{headers:h,redirect:"follow"});if(!r||!r.ok)return[];const final=__s(r.url||url),ct=r.headers&&r.headers.get?__s(r.headers.get("content-type")):"";if(/^video\//i.test(ct))return[{url:final,type:ct.includes("webm")?"webm":ct.includes("matroska")?"mkv":"mp4",headers:h}];let text="";if(typeof r.text==="function")text=String(await r.text()||"").slice(0,350000);const trim=text.trimStart();if(trim.startsWith("#EXTM3U"))return[{url:final,type:"hls",headers:h}];if(/]/i.test(trim.slice(0,8192))||/application\/dash\+xml/i.test(ct))return[{url:final,type:"dash",headers:h}];const next=__candidates(text,final),out=[];for(const u of next){if(out.length>=8)break;if(/\.(?:m3u8|mpd|mp4|mkv|webm)(?:[?#]|$)|\/hls2?\//i.test(u)){out.push({url:u,type:/\.m3u8/i.test(u)?"hls":/\.mpd/i.test(u)?"dash":/\.mkv/i.test(u)?"mkv":"mp4",headers:Object.assign({},h,{Referer:final,Origin:(()=>{try{return new URL(final).origin}catch(_){return""}})()})});continue}const more=await __resolve(u,row,final,depth+1,seen);for(const x of more)if(!out.some(y=>y.url===x.url))out.push(x)}return out}catch(_){return[]}}
-async function __tracedFetch(input,init){const raw=(()=>{try{return typeof input==="string"?input:input&&input.url?input.url:String(input)}catch(_){return""}})();const h=Object.assign({},init&&init.headers||{});const r=await globalThis.fetch(input,init);try{const final=__s(r&&r.url||raw);if(/^https?:\/\//i.test(raw)&&!/(?:themoviedb\.org|raw\.githubusercontent\.com|githubusercontent\.com)/i.test(__host(raw)))__nuvioTrace.push({url:raw,final,headers:h})}catch(_){}return r}
-async function __load(){if(__nuvioLoaded)return __nuvioLoaded;const r=await globalThis.fetch(__NUVIO_SOURCE__,{headers:{Accept:"text/javascript,*/*"}});if(!r||!r.ok)throw new Error("NiakVIO upstream load failed: "+(r&&r.status));const code=await r.text();const m={exports:{}};const req=typeof require==="function"?require:undefined;const fn=new Function("module","exports","require","fetch","globalThis",code+"\n;return module.exports;");const ex=fn(m,m.exports,req,__tracedFetch,globalThis)||m.exports;__nuvioLoaded=ex;return ex}
-async function __post(v){const x=__slot(v);if(!x||!x.list.length)return v;const out=[],seen={};const add=r=>{r=__norm(r);const u=__urlOf(r);if(!u||__bad(u)||seen[u])return;seen[u]=1;out.push(r)};for(const row0 of x.list){if(!row0||typeof row0!=="object")continue;const row=__norm(row0),u=__urlOf(row);if(!u||__bad(u))continue;if(__direct(row,u)){add(row);continue}const found=await __resolve(u,row,u,0,{});for(const media of found)add(Object.assign({},row,{url:media.url,headers:media.headers||row.headers||{},isDirect:true,type:media.type}))}
-if(!out.length){const trace=[...__nuvioTrace].reverse();for(const t of trace){if(out.length>=6)break;const u=__s(t.final||t.url);if(!u||__bad(u))continue;const fake={url:u,headers:t.headers||{}};const found=await __resolve(u,fake,t.url,0,{});for(const media of found)add({name:"Recovered media",title:"Recovered media",url:media.url,headers:media.headers||{},isDirect:true,type:media.type})}}
-return __rebuild(v,x,out)}
-async function __getStreams(){__nuvioTrace=[];const p=await __load();const fn=p&&typeof p.getStreams==="function"?p.getStreams:p&&typeof p.streams==="function"?p.streams:null;if(!fn)throw new Error("NiakVIO upstream provider has no getStreams");return __post(await fn.apply(p,arguments))}
-module.exports={getStreams:__getStreams};
-try{globalThis.getStreams=__getStreams}catch(_){}
diff --git a/providers/moviesmod--emergency-native-first--352a91f5ecbf2005.js b/providers/moviesmod--emergency-native-first--352a91f5ecbf2005.js
deleted file mode 100644
index 4c84a4bab..000000000
--- a/providers/moviesmod--emergency-native-first--352a91f5ecbf2005.js
+++ /dev/null
@@ -1,28 +0,0 @@
-"use strict";
-/* NUVIO_EMERGENCY_NATIVE_FIRST_V1 */
-const __NUVIO_SOURCE__="https://raw.githubusercontent.com/yoruix/nuvio-providers/refs/heads/main/providers/moviesmod.js";
-let __nuvioLoaded=null;
-let __nuvioTrace=[];
-function __s(v){return String(v==null?"":v).replace(/\\\//g,"/").trim()}
-function __urlOf(row){let v=row&&row.url;if(v&&typeof v==="object")v=v.url||v.href||v.src;return __s(v||(row&&(row.streamUrl||row.stream||row.link||row.file)))}
-function __key(o,n){n=String(n).toLowerCase();return Object.keys(o||{}).find(k=>String(k).toLowerCase()===n)||""}
-function __headers(row){const out={};const merge=src=>{if(src&&typeof src==="object")Object.keys(src).forEach(k=>{if(String(k).toLowerCase()!=="range"&&__s(src[k]))out[k]=__s(src[k])})};try{merge(row&&row.url&&typeof row.url==="object"&&row.url.headers)}catch(_){}try{merge(row&&row.headers)}catch(_){}try{merge(row&&row.requestHeaders)}catch(_){}try{merge(row&&row.behaviorHints&&row.behaviorHints.proxyHeaders&&row.behaviorHints.proxyHeaders.request)}catch(_){}return out}
-function __norm(row){const u=__urlOf(row);if(!u)return row;return Object.assign({},row,{url:u,headers:__headers(row)})}
-function __host(u){try{return new URL(u).hostname.toLowerCase()}catch(_){return""}}
-const __BAD=/(?:^|\.)(?:youtube\.com|youtu\.be|twitter\.com|x\.com|twimg\.com|facebook\.com|instagram\.com|googletagmanager\.com|google-analytics\.com|doubleclick\.net)$/i;
-const __ASSET=/\.(?:css|js|mjs|map|png|jpe?g|gif|svg|ico|woff2?|ttf|otf|eot|json|xml|vtt|srt)(?:[?#]|$)/i;
-function __bad(u){const h=__host(u);return !/^https?:\/\//i.test(u)||!h||__BAD.test(h)||__ASSET.test(u)||/(?:trailer|bande-annonce|sample[-_]?video|big[_-]?buck[_-]?bunny)/i.test(u)}
-function __direct(row,u){const t=__s(row&&(row.type||row.format||row.mimeType||row.contentType||row.name)).toLowerCase();return !!(row&&row.isDirect===true)||/\.(?:m3u8|mpd|mp4|mkv|webm)(?:[?#]|$)|\/hls2?\//i.test(u)||/(?:hls|mpegurl|m3u8|dash|mpd|mp4|matroska|mkv|webm|video|worker|fsl|direct)/i.test(t)}
-function __slot(v){if(Array.isArray(v))return{key:null,list:v};if(v&&typeof v==="object")for(const k of ["streams","results","data"])if(Array.isArray(v[k]))return{key:k,list:v[k]};return null}
-function __rebuild(v,x,list){if(x.key===null)return list;const o=Object.assign({},v);o[x.key]=list;return o}
-function __abs(v,b){try{return new URL(__s(v),b).toString()}catch(_){return""}}
-function __candidates(text,base){const out=[],seen={};const add=v=>{const u=__abs(v,base);if(!u||__bad(u)||seen[u])return;seen[u]=1;out.push(u)};const body=__s(text);const ps=[/(?:src|href|data-src|data-url|data-embed|data-player|data-file)=["']([^"']+)["']/gi,/(?:file|source|src|url|playlist|embedUrl|embed_url|contentUrl)\s*[:=]\s*["'](https?:\/\/[^"']+)["']/gi,/(https?:\/\/[^"'<>\s\\]+(?:m3u8|mpd|mp4|mkv|webm|embed|player|download|\/e\/|\/hls2?\/)[^"'<>\s\\]*)/gi];for(const p of ps){p.lastIndex=0;let m;while((m=p.exec(body))!==null&&out.length<16)add(m[1])}return out}
-async function __resolve(url,row,referer,depth,seen){if(depth>2||__bad(url)||seen[url])return[];seen[url]=1;try{const h=__headers(row);if(referer){h.Referer=referer;try{h.Origin=new URL(referer).origin}catch(_){}}delete h.Range;const r=await globalThis.fetch(url,{headers:h,redirect:"follow"});if(!r||!r.ok)return[];const final=__s(r.url||url),ct=r.headers&&r.headers.get?__s(r.headers.get("content-type")):"";if(/^video\//i.test(ct))return[{url:final,type:ct.includes("webm")?"webm":ct.includes("matroska")?"mkv":"mp4",headers:h}];let text="";if(typeof r.text==="function")text=String(await r.text()||"").slice(0,350000);const trim=text.trimStart();if(trim.startsWith("#EXTM3U"))return[{url:final,type:"hls",headers:h}];if(/]/i.test(trim.slice(0,8192))||/application\/dash\+xml/i.test(ct))return[{url:final,type:"dash",headers:h}];const next=__candidates(text,final),out=[];for(const u of next){if(out.length>=8)break;if(/\.(?:m3u8|mpd|mp4|mkv|webm)(?:[?#]|$)|\/hls2?\//i.test(u)){out.push({url:u,type:/\.m3u8/i.test(u)?"hls":/\.mpd/i.test(u)?"dash":/\.mkv/i.test(u)?"mkv":"mp4",headers:Object.assign({},h,{Referer:final,Origin:(()=>{try{return new URL(final).origin}catch(_){return""}})()})});continue}const more=await __resolve(u,row,final,depth+1,seen);for(const x of more)if(!out.some(y=>y.url===x.url))out.push(x)}return out}catch(_){return[]}}
-async function __tracedFetch(input,init){const raw=(()=>{try{return typeof input==="string"?input:input&&input.url?input.url:String(input)}catch(_){return""}})();const h=Object.assign({},init&&init.headers||{});const r=await globalThis.fetch(input,init);try{const final=__s(r&&r.url||raw);if(/^https?:\/\//i.test(raw)&&!/(?:themoviedb\.org|raw\.githubusercontent\.com|githubusercontent\.com)/i.test(__host(raw)))__nuvioTrace.push({url:raw,final,headers:h})}catch(_){}return r}
-async function __load(){if(__nuvioLoaded)return __nuvioLoaded;const r=await globalThis.fetch(__NUVIO_SOURCE__,{headers:{Accept:"text/javascript,*/*"}});if(!r||!r.ok)throw new Error("NiakVIO upstream load failed: "+(r&&r.status));const code=await r.text();const m={exports:{}};const req=typeof require==="function"?require:undefined;const fn=new Function("module","exports","require","fetch","globalThis",code+"\n;return module.exports;");const ex=fn(m,m.exports,req,__tracedFetch,globalThis)||m.exports;__nuvioLoaded=ex;return ex}
-async function __post(v){const x=__slot(v);if(!x||!x.list.length)return v;const out=[],seen={};const add=r=>{r=__norm(r);const u=__urlOf(r);if(!u||__bad(u)||seen[u])return;seen[u]=1;out.push(r)};for(const row0 of x.list){if(!row0||typeof row0!=="object")continue;const row=__norm(row0),u=__urlOf(row);if(!u||__bad(u))continue;if(__direct(row,u)){add(row);continue}const found=await __resolve(u,row,u,0,{});for(const media of found)add(Object.assign({},row,{url:media.url,headers:media.headers||row.headers||{},isDirect:true,type:media.type}))}
-if(!out.length){const trace=[...__nuvioTrace].reverse();for(const t of trace){if(out.length>=6)break;const u=__s(t.final||t.url);if(!u||__bad(u))continue;const fake={url:u,headers:t.headers||{}};const found=await __resolve(u,fake,t.url,0,{});for(const media of found)add({name:"Recovered media",title:"Recovered media",url:media.url,headers:media.headers||{},isDirect:true,type:media.type})}}
-return __rebuild(v,x,out)}
-async function __getStreams(){__nuvioTrace=[];const p=await __load();const fn=p&&typeof p.getStreams==="function"?p.getStreams:p&&typeof p.streams==="function"?p.streams:null;if(!fn)throw new Error("NiakVIO upstream provider has no getStreams");return __post(await fn.apply(p,arguments))}
-module.exports={getStreams:__getStreams};
-try{globalThis.getStreams=__getStreams}catch(_){}
diff --git a/scripts/analyze_native_corpus_collection.cjs b/scripts/analyze_native_corpus_collection.cjs
index 6909dca32..fba113730 100644
--- a/scripts/analyze_native_corpus_collection.cjs
+++ b/scripts/analyze_native_corpus_collection.cjs
@@ -19,11 +19,16 @@ if (child.stderr) process.stderr.write(child.stderr);
const starts = new Map();
const ends = new Set();
const observed = new Map();
+const executed = new Map();
+const mediaVerified = new Map();
+const clients = new Set();
let readableLogs = 0;
+let hasCapabilityProbe = false;
for (const log of logs) {
if (!fs.existsSync(log)) continue;
readableLogs += 1;
const text = fs.readFileSync(log, 'utf8');
+ if (/route_mode=capability_probe(?:\s|$)/.test(text)) hasCapabilityProbe = true;
for (const raw of text.split(/\r?\n/)) {
const marker = raw.indexOf('FIELD_NATIVE_');
if (marker < 0) continue;
@@ -31,15 +36,32 @@ for (const log of logs) {
const f = fields(line);
const client = f.client || '';
const rowFixture = f.fixture || '';
+ if (client) clients.add(client);
if (rowFixture && rowFixture !== fixture) continue;
const key = `${client}\u0000${fixture}`;
if (line.startsWith('FIELD_NATIVE_CORPUS_BEGIN ')) {
starts.set(key, Number(f.providers || 0));
} else if (line.startsWith('FIELD_NATIVE_CORPUS_END ')) {
ends.add(key);
- } else if (line.startsWith('FIELD_NATIVE_RESULT ') || line.startsWith('FIELD_NATIVE_ERROR ')) {
+ } else if (
+ line.startsWith('FIELD_NATIVE_RESULT ') ||
+ line.startsWith('FIELD_NATIVE_ERROR ') ||
+ line.startsWith('FIELD_NATIVE_PROVIDER_SKIPPED ')
+ ) {
if (!observed.has(key)) observed.set(key, new Set());
if (f.provider64) observed.get(key).add(f.provider64);
+ if (line.startsWith('FIELD_NATIVE_RESULT ') || line.startsWith('FIELD_NATIVE_ERROR ')) {
+ if (!executed.has(key)) executed.set(key, new Set());
+ const provider = f.provider64 || f.provider || '';
+ const requestType = String(f.request_type || 'unknown').toLowerCase();
+ executed.get(key).add(`${provider}\u0000${requestType}`);
+ }
+ } else if (line.startsWith('FIELD_NATIVE_PLAYER ')) {
+ if (!mediaVerified.has(key)) mediaVerified.set(key, new Set());
+ const provider = f.provider64 || f.provider || '';
+ const requestType = String(f.request_type || 'unknown').toLowerCase();
+ const index = Number(f.index || 0);
+ mediaVerified.get(key).add(`${provider}\u0000${requestType}\u0000${index}`);
}
}
}
@@ -47,24 +69,55 @@ for (const log of logs) {
const problems = [];
if (readableLogs === 0) problems.push('no_readable_log');
if (starts.size === 0) problems.push('missing_begin_marker');
+let providersObservedCount = 0;
+let executionCount = 0;
+let mediaVerifiedCount = 0;
for (const [key, expected] of starts) {
if (!ends.has(key)) problems.push(`missing_end:${safeKey(key)}`);
const count = observed.get(key)?.size || 0;
+ const scopeExecutions = executed.get(key) || new Set();
+ const scopeProviders = new Set([...scopeExecutions].map((value) => value.split('\u0000')[0]).filter(Boolean));
+ const scopeMediaVerified = mediaVerified.get(key)?.size || 0;
+ providersObservedCount += scopeProviders.size;
+ executionCount += scopeExecutions.size;
+ mediaVerifiedCount += scopeMediaVerified;
if (expected <= 0) problems.push(`invalid_expected_provider_count:${safeKey(key)}`);
else if (count < expected) problems.push(`incomplete_provider_traversal:${safeKey(key)}:${count}/${expected}`);
+ if (scopeProviders.size === 0) problems.push(`zero_providers_observed:${safeKey(key)}`);
+ if (scopeExecutions.size === 0) problems.push(`zero_provider_executions:${safeKey(key)}`);
+ if (scopeMediaVerified === 0) problems.push(`zero_media_verified:${safeKey(key)}`);
+}
+
+// Media-type discovery is Brain evidence, not a provider repair. Run it only when
+// the generated corpus actually exercised an undeclared tv/anime capability.
+// The dedicated diagnostic is itself fail-closed on backend/frontend completeness.
+if (hasCapabilityProbe && problems.length === 0) {
+ const client = clients.size === 1 ? [...clients][0] : 'cross-client';
+ const evidenceRoot = path.join(path.dirname(path.resolve(logs[0])), 'native-evidence', client, fixture);
+ fs.mkdirSync(evidenceRoot, { recursive: true });
+ const output = path.join(evidenceRoot, 'native-media-capabilities-brain.json');
+ const capabilityBrain = path.join(__dirname, '..', 'engine_v2', 'scripts', 'diagnose-native-media-capabilities.mjs');
+ const capability = spawnSync(process.execPath, [capabilityBrain, '--output', output, ...logs], { encoding: 'utf8' });
+ if (capability.stdout) process.stdout.write(capability.stdout);
+ if (capability.stderr) process.stderr.write(capability.stderr);
+ const status = Number.isInteger(capability.status) ? capability.status : 2;
+ if (status !== 0) problems.push(`capability_evidence_incomplete:${client}:${status}`);
+ else console.log(`FIELD_NATIVE_MEDIA_CAPABILITY_ARTIFACT client=${client} fixture=${fixture} path=${output}`);
}
const anomalyStatus = Number.isInteger(child.status) ? child.status : 1;
const complete = problems.length === 0;
console.log(
`FIELD_NATIVE_CORPUS_COLLECTION_GATE fixture=${fixture} complete=${complete} ` +
- `analyzer_status=${anomalyStatus} problems=${problems.length}`
+ `analyzer_status=${anomalyStatus} capability_probe=${hasCapabilityProbe} providers_observed=${providersObservedCount} ` +
+ `executions=${executionCount} media_verified=${mediaVerifiedCount} problems=${problems.length}`
);
for (const problem of problems) console.log(`FIELD_NATIVE_CORPUS_INFRA_ERROR ${problem}`);
-// The underlying analyzer deliberately reports provider anomalies with exit 1.
-// Those are Brain evidence, not lab infrastructure failure. Only an incomplete
-// corpus collection fails this gate.
+// Provider/runtime anomalies and intentionally skipped incompatible routes are
+// evidence, not lab infrastructure failure. Only an incomplete corpus/evidence
+// chain fails this gate. A corpus with zero provider execution or zero native
+// player terminal is not evidence and must never reach the Brain as complete.
process.exitCode = complete ? 0 : 2;
function fields(line) {
diff --git a/scripts/analyze_native_corpus_results.cjs b/scripts/analyze_native_corpus_results.cjs
index 049e262e2..7be2bf760 100644
--- a/scripts/analyze_native_corpus_results.cjs
+++ b/scripts/analyze_native_corpus_results.cjs
@@ -4,6 +4,7 @@
const fs = require('node:fs');
const path = require('node:path');
const { streamIdentity } = require('./nuvio_client_lab.cjs');
+const { readerFailureClass, readerSignature, isReaderFailure } = require('./native_player_diagnostics.cjs');
function usage() {
process.stderr.write('usage: node scripts/analyze_native_corpus_results.cjs [log ...]\n');
@@ -23,11 +24,7 @@ const fixture = fixtureRow.fixture;
const minimumDurationRatio = Number(config.policy?.minimum_duration_ratio || 0.55);
const maximumDurationRatio = Number(config.policy?.maximum_duration_ratio || 1.8);
const expectedDurationSeconds = Number(fixture.expectedDurationMinutes || 0) * 60 || null;
-const manifestMap = new Map(
- (manifest.scrapers || [])
- .filter((row) => row && typeof row === 'object')
- .map((row) => [String(row.id || '').toLowerCase(), row]),
-);
+const manifestMap = new Map((manifest.scrapers || []).filter(Boolean).map((row) => [String(row.id || '').toLowerCase(), row]));
const providerPatches = overrides.provider_patches || {};
const configuredCapabilities = overrides.provider_capabilities || {};
@@ -37,7 +34,6 @@ function decode(value) {
while (text.length % 4) text += '=';
try { return Buffer.from(text, 'base64').toString('utf8'); } catch { return ''; }
}
-
function fields(line) {
const out = {};
const re = /([A-Za-z0-9_]+)=([^\s]+)/g;
@@ -45,45 +41,36 @@ function fields(line) {
while ((match = re.exec(line)) !== null) out[match[1]] = match[2];
return out;
}
-
function safeSyntheticUrl(host, mediaHint) {
if (!host || !mediaHint) return '';
return `https://${host}/${encodeURIComponent(mediaHint)}`;
}
-
function providerPolicy(provider) {
const id = String(provider || '').toLowerCase();
const manifestRow = manifestMap.get(id) || {};
const patch = providerPatches[id] || {};
const configured = configuredCapabilities[id] || {};
- const strategy = String(
- patch.capability ||
- (typeof configured === 'object' ? configured.strategy : configured) ||
- manifestRow.capability ||
- 'unknown'
- );
- // HTML is a legitimate terminal output only for recovery models whose public
- // contract is explicitly an external/embed player. A normal html_scraper still
- // has to traverse its detail/player chain to a usable output (StreamZo is the
- // canonical sentinel), so generated allow_html_url on a generic scraper is not
- // enough to waive transport validation.
- const allowEmbed = strategy === 'iframe_player' || (
- strategy === 'mixed_embed_resolver' && (
- manifestRow.supportsExternalPlayer === true || patch.preserve_embed_urls === true
- )
- );
+ const strategy = String(patch.capability || (typeof configured === 'object' ? configured.strategy : configured) || manifestRow.capability || 'unknown');
+ const allowEmbed = strategy === 'iframe_player' || (strategy === 'mixed_embed_resolver' && (manifestRow.supportsExternalPlayer === true || patch.preserve_embed_urls === true));
return { strategy, allowEmbed };
}
-
function withPolicy(row) {
const policy = providerPolicy(row.provider);
return { ...row, capability: policy.strategy, allowEmbed: policy.allowEmbed };
}
+function routeType(f) { return String(f.request_type || 'unknown').toLowerCase(); }
+function routeMode(f) { return String(f.route_mode || 'declared').toLowerCase(); }
+function isProbe(row) { return row.routeMode === 'capability_probe'; }
+function streamKey(client, provider, requestType, index = 0) {
+ return `${client}\u0000${provider}\u0000${requestType}\u0000${index}`;
+}
const results = new Map();
const rows = [];
const transports = new Map();
+const players = new Map();
const runtimeErrors = [];
+const skippedProviders = [];
for (const input of logPaths) {
if (!fs.existsSync(input)) continue;
const text = fs.readFileSync(input, 'utf8');
@@ -92,57 +79,62 @@ for (const input of logPaths) {
if (marker < 0) continue;
const line = raw.slice(marker).trim();
const f = fields(line);
- if (line.startsWith('FIELD_NATIVE_RESULT ')) {
+ if (line.startsWith('FIELD_NATIVE_PROVIDER_SKIPPED ')) {
+ skippedProviders.push(withPolicy({
+ client: f.client || 'unknown', provider: decode(f.provider64), requestedType: f.requested_type || 'unknown',
+ enabled: f.enabled === 'true', reason: f.reason || 'unknown', declaredTypes: decode(f.declared_types64),
+ }));
+ } else if (line.startsWith('FIELD_NATIVE_RESULT ')) {
const provider = decode(f.provider64);
const client = f.client || 'unknown';
- results.set(`${client}\u0000${provider}`, withPolicy({
- client,
- provider,
- enabled: f.enabled === 'true',
- durationMs: Number(f.duration_ms || 0),
- count: Number(f.count || 0),
+ const requestType = routeType(f);
+ results.set(`${client}\u0000${provider}\u0000${requestType}`, withPolicy({
+ client, provider, requestType, routeMode: routeMode(f), enabled: f.enabled === 'true',
+ durationMs: Number(f.duration_ms || 0), count: Number(f.count || 0),
}));
} else if (line.startsWith('FIELD_NATIVE_ROW ')) {
const host = decode(f.host64);
const mediaHint = decode(f.media_hint64);
rows.push(withPolicy({
- client: f.client || 'unknown',
- provider: decode(f.provider64),
- index: Number(f.index || 0),
- host,
- mediaHint,
- stream: {
- title: decode(f.title64),
- name: decode(f.name64),
- quality: decode(f.quality64),
- language: decode(f.language64),
- type: decode(f.type64),
- // Only a synthetic URL is built. Native logs never expose the real URL,
- // query string, path token or header value.
- url: safeSyntheticUrl(host, mediaHint),
- },
+ client: f.client || 'unknown', provider: decode(f.provider64), requestType: routeType(f), routeMode: routeMode(f),
+ index: Number(f.index || 0), host, mediaHint,
+ stream: { title: decode(f.title64), name: decode(f.name64), quality: decode(f.quality64), language: decode(f.language64), type: decode(f.type64), url: safeSyntheticUrl(host, mediaHint) },
}));
} else if (line.startsWith('FIELD_NATIVE_TRANSPORT ')) {
const provider = decode(f.provider64);
const client = f.client || 'unknown';
- transports.set(`${client}\u0000${provider}`, withPolicy({
- client,
- provider,
- state: f.state || 'unknown',
- kind: f.kind || 'unknown',
- status: Number(f.status || 0),
- contentType: decode(f.content_type64),
- extm3u: f.extm3u === 'true',
- durationSeconds: Number(f.duration_seconds || 0) || null,
- host: decode(f.host64),
- mediaHint: decode(f.media_hint64),
+ const requestType = routeType(f);
+ const index = Number(f.index || 0);
+ transports.set(streamKey(client, provider, requestType, index), withPolicy({
+ client, provider, requestType, routeMode: routeMode(f), index,
+ state: f.state || 'unknown', kind: f.kind || 'unknown', status: Number(f.status || 0),
+ contentType: decode(f.content_type64), extm3u: f.extm3u === 'true', durationSeconds: Number(f.duration_seconds || 0) || null,
+ host: decode(f.host64), mediaHint: decode(f.media_hint64),
}));
+ } else if (line.startsWith('FIELD_NATIVE_PLAYER ')) {
+ const provider = decode(f.provider64);
+ const client = f.client || 'unknown';
+ const requestType = routeType(f);
+ const index = Number(f.index || 0);
+ const player = withPolicy({
+ client, provider, requestType, routeMode: routeMode(f), index,
+ state: f.state || 'unknown', engine: f.engine || 'unknown',
+ httpStatus: Number(f.http_status || 0), failureStage: f.failure_stage || 'unknown',
+ durationSeconds: Number(f.duration_seconds || 0) || null, host: decode(f.host64),
+ errorClass: decode(f.error_class64), errorCode: decode(f.error_code64),
+ exceptionChain: decode(f.exception_chain64), responseHeaderNames: decode(f.response_header_names64),
+ loadBytes: Math.max(0, Number(f.load_bytes || 0) || 0),
+ loadDurationMs: Math.max(0, Number(f.load_duration_ms || 0) || 0),
+ mediaDataType: Number.isFinite(Number(f.media_data_type)) ? Number(f.media_data_type) : -1,
+ trackType: Number.isFinite(Number(f.track_type)) ? Number(f.track_type) : -1,
+ });
+ player.failureClass = readerFailureClass(player);
+ player.signature = readerSignature(player);
+ players.set(streamKey(client, provider, requestType, index), player);
} else if (line.startsWith('FIELD_NATIVE_ERROR ')) {
runtimeErrors.push(withPolicy({
- client: f.client || 'unknown',
- provider: decode(f.provider64),
- durationMs: Number(f.duration_ms || 0),
- error: decode(f.error64),
+ client: f.client || 'unknown', provider: decode(f.provider64), requestType: routeType(f), routeMode: routeMode(f),
+ durationMs: Number(f.duration_ms || 0), error: decode(f.error64),
}));
}
}
@@ -153,93 +145,101 @@ const matches = [];
const unknown = [];
for (const row of rows) {
let identity = streamIdentity(row.stream, fixture);
- const transport = transports.get(`${row.client}\u0000${row.provider}`);
- const durationRatio = expectedDurationSeconds && transport?.durationSeconds
- ? transport.durationSeconds / expectedDurationSeconds
- : null;
- if (row.index === 0 && durationRatio != null && (durationRatio < minimumDurationRatio || durationRatio > maximumDurationRatio)) {
+ const transport = transports.get(streamKey(row.client, row.provider, row.requestType, row.index));
+ const player = players.get(streamKey(row.client, row.provider, row.requestType, row.index));
+ const measuredDuration = player?.durationSeconds || transport?.durationSeconds || null;
+ const durationRatio = expectedDurationSeconds && measuredDuration ? measuredDuration / expectedDurationSeconds : null;
+ if (durationRatio != null && (durationRatio < minimumDurationRatio || durationRatio > maximumDurationRatio)) {
identity = { status: 'contradiction', reason: 'fixture_duration_mismatch' };
- } else if (row.index === 0 && identity.status === 'unknown' && durationRatio != null) {
+ } else if (identity.status === 'unknown' && durationRatio != null) {
identity = { status: 'match', reason: 'fixture_duration_match' };
}
const record = {
- client: row.client,
- provider: row.provider,
- capability: row.capability,
- index: row.index,
- status: identity.status,
- reason: identity.reason,
- title: row.stream.title,
- name: row.stream.name,
- host: row.host || null,
- mediaHint: row.mediaHint || null,
- durationRatio,
+ client: row.client, provider: row.provider, requestType: row.requestType, routeMode: row.routeMode,
+ capability: row.capability, index: row.index, status: identity.status, reason: identity.reason,
+ title: row.stream.title, name: row.stream.name, host: row.host || null, mediaHint: row.mediaHint || null, durationRatio,
};
if (identity.status === 'contradiction') contradictions.push(record);
else if (identity.status === 'match') matches.push(record);
else unknown.push(record);
}
-const expectedEmbeds = [...transports.values()]
- .filter((row) => row.state === 'dead' && row.kind === 'html' && row.allowEmbed)
- .map((row) => ({
- client: row.client,
- provider: row.provider,
- capability: row.capability,
- state: 'embed',
- kind: row.kind,
- status: row.status,
- contentType: row.contentType,
- host: row.host || null,
- mediaHint: row.mediaHint || null,
- }));
-const transportFailures = [...transports.values()]
- .filter((row) => (row.state === 'dead' || row.state === 'error') && !(row.state === 'dead' && row.kind === 'html' && row.allowEmbed))
- .map((row) => ({
- client: row.client,
- provider: row.provider,
- capability: row.capability,
- state: row.state,
- kind: row.kind,
- status: row.status,
- contentType: row.contentType,
- host: row.host || null,
- mediaHint: row.mediaHint || null,
- }));
-const transportUnknown = [...transports.values()].filter((row) => row.state === 'unknown');
-const transportOk = [...transports.values()].filter((row) => row.state === 'ok');
-const slow = [...results.values()].filter((row) => row.durationMs >= 30000).sort((a, b) => b.durationMs - a.durationMs);
-const nonEmpty = [...results.values()].filter((row) => row.count > 0);
-const empty = [...results.values()].filter((row) => row.count === 0);
-const clients = [...new Set([...results.values()].map((row) => row.client))].sort();
-const providers = [...new Set([...results.values()].map((row) => row.provider))].sort();
+const expectedEmbeds = [...transports.values()].filter((row) => row.state === 'dead' && row.kind === 'html' && row.allowEmbed).map((row) => ({ ...row, state: 'embed' }));
+const allTransportFailures = [...transports.values()].filter((row) => (row.state === 'dead' || row.state === 'error') && !(row.state === 'dead' && row.kind === 'html' && row.allowEmbed));
+const transportFailures = allTransportFailures.filter((row) => !isProbe(row));
+const capabilityTransportFailures = allTransportFailures.filter(isProbe);
+const transportUnknown = [...transports.values()].filter((row) => row.state === 'unknown' && !isProbe(row));
+const transportOk = [...transports.values()].filter((row) => row.state === 'ok' && !isProbe(row));
+const allReaderFailures = [...players.values()].filter(isReaderFailure);
+const readerFailures = allReaderFailures.filter((row) => !isProbe(row));
+const capabilityReaderFailures = allReaderFailures.filter(isProbe);
+const readerHealthy = [...players.values()].filter((row) => !isProbe(row) && !isReaderFailure(row));
+const capabilityReaderHealthy = [...players.values()].filter((row) => isProbe(row) && !isReaderFailure(row));
+const declaredRuntimeErrors = runtimeErrors.filter((row) => !isProbe(row));
+const capabilityRuntimeErrors = runtimeErrors.filter(isProbe);
+const declaredContradictions = contradictions.filter((row) => !isProbe(row));
+const capabilityContradictions = contradictions.filter(isProbe);
+const capabilityMatches = matches.filter(isProbe);
+const capabilityUnknown = unknown.filter(isProbe);
+const slow = [...results.values()].filter((row) => !isProbe(row) && row.durationMs >= 30000).sort((a, b) => b.durationMs - a.durationMs);
+const nonEmpty = [...results.values()].filter((row) => !isProbe(row) && row.count > 0);
+const empty = [...results.values()].filter((row) => !isProbe(row) && row.count === 0);
+const capabilityExecutions = [...results.values()].filter(isProbe);
+const allObservedRows = [...results.values(), ...runtimeErrors, ...skippedProviders];
+const clients = [...new Set(allObservedRows.map((row) => row.client))].sort();
+const providers = [...new Set(allObservedRows.map((row) => row.provider).filter(Boolean))].sort();
+const readerFailureClasses = Object.fromEntries([...new Set(readerFailures.map((row) => row.failureClass))].sort().map((cls) => [cls, readerFailures.filter((row) => row.failureClass === cls).length]));
const summary = {
- fixture: fixtureSlug,
- title: fixture.title,
- tmdbId: fixture.tmdbId,
- clients,
+ fixture: fixtureSlug, title: fixture.title, tmdbId: fixture.tmdbId, clients,
providerCount: providers.length,
- executions: results.size,
- nonEmpty: nonEmpty.length,
- empty: empty.length,
- runtimeErrors: runtimeErrors.length,
- identityMatches: matches.length,
- identityUnknown: unknown.length,
- identityContradictions: contradictions.length,
+ executions: [...results.values()].filter((row) => !isProbe(row)).length,
+ capabilityExecutions: capabilityExecutions.length,
+ skippedUnsupported: skippedProviders.length,
+ nonEmpty: nonEmpty.length, empty: empty.length,
+ runtimeErrors: declaredRuntimeErrors.length,
+ capabilityRuntimeErrors: capabilityRuntimeErrors.length,
+ identityMatches: matches.filter((row) => !isProbe(row)).length,
+ identityUnknown: unknown.filter((row) => !isProbe(row)).length,
+ identityContradictions: declaredContradictions.length,
+ capabilityIdentityMatches: capabilityMatches.length,
+ capabilityIdentityUnknown: capabilityUnknown.length,
+ capabilityIdentityContradictions: capabilityContradictions.length,
transportOk: transportOk.length,
- transportExpectedEmbeds: expectedEmbeds.length,
+ transportExpectedEmbeds: expectedEmbeds.filter((row) => !isProbe(row)).length,
transportUnknown: transportUnknown.length,
transportFailures: transportFailures.length,
- durationEvidence: [...transports.values()].filter((row) => row.durationSeconds != null).length,
+ capabilityTransportFailures: capabilityTransportFailures.length,
+ nativeReaderObserved: [...players.values()].filter((row) => !isProbe(row)).length,
+ nativeReaderHealthy: readerHealthy.length,
+ nativeReaderFailures: readerFailures.length,
+ capabilityReaderObserved: [...players.values()].filter(isProbe).length,
+ capabilityReaderHealthy: capabilityReaderHealthy.length,
+ capabilityReaderFailures: capabilityReaderFailures.length,
+ readerFailureClasses,
+ durationEvidence: rows.filter((row) => {
+ if (isProbe(row)) return false;
+ const key = streamKey(row.client, row.provider, row.requestType, row.index);
+ return Boolean(players.get(key)?.durationSeconds || transports.get(key)?.durationSeconds);
+ }).length,
+ readerLoadErrorEvidence: [...players.values()].filter((row) => !isProbe(row) && (row.loadDurationMs > 0 || row.loadBytes > 0 || row.httpStatus > 0)).length,
slowProviders: slow.length,
};
console.log(`FIELD_NATIVE_CORPUS_ANALYSIS ${JSON.stringify(summary)}`);
-for (const row of contradictions.slice(0, 80)) console.log(`FIELD_NATIVE_CONTRADICTION ${JSON.stringify(row)}`);
-for (const row of expectedEmbeds.slice(0, 80)) console.log(`FIELD_NATIVE_EXPECTED_EMBED ${JSON.stringify(row)}`);
+for (const row of declaredContradictions.slice(0, 80)) console.log(`FIELD_NATIVE_CONTRADICTION ${JSON.stringify(row)}`);
+for (const row of expectedEmbeds.filter((entry) => !isProbe(entry)).slice(0, 80)) console.log(`FIELD_NATIVE_EXPECTED_EMBED ${JSON.stringify(row)}`);
for (const row of transportFailures.slice(0, 80)) console.log(`FIELD_NATIVE_TRANSPORT_FAILURE ${JSON.stringify(row)}`);
-for (const row of runtimeErrors.slice(0, 80)) console.log(`FIELD_NATIVE_RUNTIME_ERROR ${JSON.stringify(row)}`);
+for (const row of readerFailures.slice(0, 120)) console.log(`FIELD_NATIVE_PLAYER_FAILURE ${JSON.stringify(row)}`);
+for (const row of declaredRuntimeErrors.slice(0, 80)) console.log(`FIELD_NATIVE_RUNTIME_ERROR ${JSON.stringify(row)}`);
for (const row of slow.slice(0, 80)) console.log(`FIELD_NATIVE_SLOW ${JSON.stringify(row)}`);
-if (runtimeErrors.length || contradictions.length || transportFailures.length) process.exitCode = 1;
+for (const row of capabilityMatches.slice(0, 160)) console.log(`FIELD_NATIVE_CAPABILITY_PROBE_IDENTITY_MATCH ${JSON.stringify(row)}`);
+for (const row of capabilityContradictions.slice(0, 160)) console.log(`FIELD_NATIVE_CAPABILITY_PROBE_CONTRADICTION ${JSON.stringify(row)}`);
+for (const row of capabilityReaderFailures.slice(0, 160)) console.log(`FIELD_NATIVE_CAPABILITY_PROBE_PLAYER_FAILURE ${JSON.stringify(row)}`);
+for (const row of capabilityRuntimeErrors.slice(0, 160)) console.log(`FIELD_NATIVE_CAPABILITY_PROBE_RUNTIME_ERROR ${JSON.stringify(row)}`);
+
+// Capability probes are discovery evidence. Their failures never turn a healthy
+// declared provider contract into a regression. Only published-route anomalies
+// affect this analyzer's status.
+if (declaredRuntimeErrors.length || declaredContradictions.length || transportFailures.length || readerFailures.length) process.exitCode = 1;
diff --git a/scripts/analyze_native_media_type_capabilities.cjs b/scripts/analyze_native_media_type_capabilities.cjs
new file mode 100644
index 000000000..4457fad92
--- /dev/null
+++ b/scripts/analyze_native_media_type_capabilities.cjs
@@ -0,0 +1,246 @@
+#!/usr/bin/env node
+'use strict';
+
+const fs = require('node:fs');
+const path = require('node:path');
+const { streamIdentity } = require('./nuvio_client_lab.cjs');
+const { isReaderFailure, readerFailureClass } = require('./native_player_diagnostics.cjs');
+const { assessNativeEvidence } = require('./native_evidence_completeness.cjs');
+
+const root = path.resolve(__dirname, '..');
+
+function decode(value) {
+ if (!value) return '';
+ let text = String(value).replace(/-/g, '+').replace(/_/g, '/');
+ while (text.length % 4) text += '=';
+ try { return Buffer.from(text, 'base64').toString('utf8'); } catch { return ''; }
+}
+function fields(line) {
+ const out = {};
+ const re = /([A-Za-z0-9_]+)=([^\s]+)/g;
+ let match;
+ while ((match = re.exec(line)) !== null) out[match[1]] = match[2];
+ return out;
+}
+function safeSyntheticUrl(host, mediaHint) {
+ if (!host || !mediaHint) return '';
+ return `https://${host}/${encodeURIComponent(mediaHint)}`;
+}
+function routeKey(client, provider, requestType) {
+ return `${client}\u0000${provider.toLowerCase()}\u0000${requestType.toLowerCase()}`;
+}
+function streamKey(route, index) { return `${route}\u0000${Number(index || 0)}`; }
+function routeFields(f) {
+ return {
+ client: f.client || 'unknown',
+ provider: decode(f.provider64).toLowerCase(),
+ requestType: String(f.request_type || 'unknown').toLowerCase(),
+ routeMode: String(f.route_mode || 'declared').toLowerCase(),
+ };
+}
+
+function analyzeMediaTypeCapabilities(fixtureSlug, inputLogPaths) {
+ const logPaths = inputLogPaths.map((file) => path.resolve(file));
+ const config = JSON.parse(fs.readFileSync(path.join(root, '.github/triggers/nuvio-client-lab.json'), 'utf8'));
+ const manifest = JSON.parse(fs.readFileSync(path.join(root, 'manifest.json'), 'utf8'));
+ const fixtureRow = (config.fixtures || []).find((row) => row && row.slug === fixtureSlug);
+ if (!fixtureRow || !fixtureRow.fixture) throw new Error(`unknown corpus fixture: ${fixtureSlug}`);
+ const fixture = fixtureRow.fixture;
+ const expectedDurationSeconds = Number(fixture.expectedDurationMinutes || 0) * 60 || null;
+ const minimumDurationRatio = Number(config.policy?.minimum_duration_ratio || 0.55);
+ const maximumDurationRatio = Number(config.policy?.maximum_duration_ratio || 1.8);
+ const manifestTypes = new Map((manifest.scrapers || []).filter(Boolean).map((row) => [
+ String(row.id || '').toLowerCase(),
+ new Set((row.supportedTypes || []).map((value) => String(value).toLowerCase())),
+ ]));
+
+ const evidence = assessNativeEvidence(logPaths);
+ const routes = new Map();
+ const rows = new Map();
+ const players = new Map();
+ const runtimeErrors = new Map();
+
+ for (const input of logPaths) {
+ if (!fs.existsSync(input)) continue;
+ for (const raw of fs.readFileSync(input, 'utf8').split(/\r?\n/)) {
+ const marker = raw.indexOf('FIELD_NATIVE_');
+ if (marker < 0) continue;
+ const line = raw.slice(marker).trim();
+ const f = fields(line);
+ if (f.fixture && f.fixture !== fixtureSlug) continue;
+ if (line.startsWith('FIELD_NATIVE_RESULT ')) {
+ const meta = routeFields(f);
+ if (meta.routeMode !== 'capability_probe') continue;
+ routes.set(routeKey(meta.client, meta.provider, meta.requestType), {
+ ...meta,
+ returned: Math.max(0, Number(f.count || 0) || 0),
+ });
+ } else if (line.startsWith('FIELD_NATIVE_ROW ')) {
+ const meta = routeFields(f);
+ if (meta.routeMode !== 'capability_probe') continue;
+ const host = decode(f.host64);
+ const mediaHint = decode(f.media_hint64);
+ rows.set(streamKey(routeKey(meta.client, meta.provider, meta.requestType), f.index), {
+ ...meta,
+ index: Number(f.index || 0),
+ stream: {
+ title: decode(f.title64), name: decode(f.name64), quality: decode(f.quality64),
+ language: decode(f.language64), type: decode(f.type64), url: safeSyntheticUrl(host, mediaHint),
+ },
+ });
+ } else if (line.startsWith('FIELD_NATIVE_PLAYER ')) {
+ const meta = routeFields(f);
+ if (meta.routeMode !== 'capability_probe') continue;
+ const player = {
+ ...meta,
+ index: Number(f.index || 0), state: f.state || 'unknown', engine: f.engine || 'unknown',
+ httpStatus: Number(f.http_status || 0), failureStage: f.failure_stage || 'unknown',
+ durationSeconds: Number(f.duration_seconds || 0) || null,
+ errorCode: decode(f.error_code64), errorClass: decode(f.error_class64),
+ };
+ player.failureClass = readerFailureClass(player);
+ players.set(streamKey(routeKey(meta.client, meta.provider, meta.requestType), f.index), player);
+ } else if (line.startsWith('FIELD_NATIVE_ERROR ')) {
+ const meta = routeFields(f);
+ if (meta.routeMode !== 'capability_probe') continue;
+ const key = routeKey(meta.client, meta.provider, meta.requestType);
+ runtimeErrors.set(key, (runtimeErrors.get(key) || 0) + 1);
+ }
+ }
+ }
+
+ function identityFor(row, player) {
+ let identity = streamIdentity(row.stream, fixture);
+ const duration = player?.durationSeconds || null;
+ const ratio = expectedDurationSeconds && duration ? duration / expectedDurationSeconds : null;
+ if (ratio != null && (ratio < minimumDurationRatio || ratio > maximumDurationRatio)) {
+ identity = { status: 'contradiction', reason: 'fixture_duration_mismatch' };
+ } else if (identity.status === 'unknown' && ratio != null) {
+ identity = { status: 'match', reason: 'fixture_duration_match' };
+ }
+ return { ...identity, durationRatio: ratio };
+ }
+
+ const outcomes = [];
+ const proposals = [];
+ for (const [key, route] of routes) {
+ const declared = manifestTypes.get(route.provider) || new Set();
+ const declaredAlready = declared.has(route.requestType);
+ const routeRows = [];
+ const reasons = [];
+ if (declaredAlready) reasons.push('probe_type_already_declared');
+ if (route.returned <= 0) reasons.push('no_streams_returned');
+ if ((runtimeErrors.get(key) || 0) > 0) reasons.push('runtime_error');
+
+ let healthyPlayers = 0;
+ let identityMatches = 0;
+ let identityUnknown = 0;
+ let identityContradictions = 0;
+ for (let index = 0; index < route.returned; index += 1) {
+ const row = rows.get(streamKey(key, index));
+ const player = players.get(streamKey(key, index));
+ if (!row) reasons.push(`missing_row:${index}`);
+ if (!player) reasons.push(`missing_player:${index}`);
+ if (!row || !player) continue;
+ if (isReaderFailure(player)) {
+ reasons.push(`reader_failure:${index}:${player.failureClass}`);
+ } else {
+ healthyPlayers += 1;
+ }
+ const identity = identityFor(row, player);
+ if (identity.status === 'match') identityMatches += 1;
+ else if (identity.status === 'contradiction') {
+ identityContradictions += 1;
+ reasons.push(`identity_contradiction:${index}:${identity.reason}`);
+ } else {
+ identityUnknown += 1;
+ reasons.push(`identity_unproven:${index}:${identity.reason}`);
+ }
+ routeRows.push({ index, playerState: player.state, failureClass: player.failureClass, identity });
+ }
+
+ const proven = evidence.complete && !declaredAlready && route.returned > 0 &&
+ healthyPlayers === route.returned && identityMatches === route.returned &&
+ identityUnknown === 0 && identityContradictions === 0 &&
+ (runtimeErrors.get(key) || 0) === 0 && reasons.length === 0;
+ const outcome = {
+ client: route.client,
+ fixture: fixtureSlug,
+ provider: route.provider,
+ addType: route.requestType,
+ declaredTypes: [...declared].sort(),
+ returned: route.returned,
+ healthyPlayers,
+ identityMatches,
+ identityUnknown,
+ identityContradictions,
+ proven,
+ reasons: [...new Set(reasons)],
+ streams: routeRows,
+ };
+ outcomes.push(outcome);
+ if (proven) {
+ proposals.push({
+ provider: route.provider,
+ addType: route.requestType,
+ client: route.client,
+ fixture: fixtureSlug,
+ streamCount: route.returned,
+ proof: 'all_returned_streams_reader_healthy_and_identity_matched',
+ requiresCrossDeviceConfirmation: true,
+ productionWritesAllowed: false,
+ });
+ }
+ }
+
+ return {
+ schemaVersion: 1,
+ generatedAt: new Date().toISOString(),
+ fixture: fixtureSlug,
+ evidenceComplete: evidence.complete,
+ evidenceProblems: evidence.problems,
+ capabilityRoutes: outcomes.length,
+ provenCapabilities: proposals.length,
+ outcomes: outcomes.sort((a, b) => a.provider.localeCompare(b.provider) || a.addType.localeCompare(b.addType) || a.client.localeCompare(b.client)),
+ proposals: proposals.sort((a, b) => a.provider.localeCompare(b.provider) || a.addType.localeCompare(b.addType) || a.client.localeCompare(b.client)),
+ policy: {
+ productionWritesAllowed: false,
+ manifestMutationAllowed: false,
+ requireCompleteNativeEvidence: true,
+ requireEveryReturnedStreamHealthy: true,
+ requireEveryReturnedStreamIdentityMatch: true,
+ requireCrossDeviceConfirmationBeforeManifestMutation: true,
+ },
+ };
+}
+
+function main() {
+ const argv = process.argv.slice(2);
+ const outputIndex = argv.indexOf('--output');
+ let outputPath = '';
+ if (outputIndex >= 0) {
+ outputPath = argv[outputIndex + 1] || '';
+ argv.splice(outputIndex, 2);
+ }
+ const fixtureSlug = argv.shift();
+ const logPaths = argv;
+ if (!fixtureSlug || !logPaths.length) {
+ console.error('usage: node scripts/analyze_native_media_type_capabilities.cjs [--output report.json] [log ...]');
+ process.exit(64);
+ }
+ const payload = analyzeMediaTypeCapabilities(fixtureSlug, logPaths);
+ if (outputPath) {
+ const resolved = path.resolve(outputPath);
+ fs.mkdirSync(path.dirname(resolved), { recursive: true });
+ fs.writeFileSync(resolved, JSON.stringify(payload, null, 2) + '\n');
+ }
+ console.log(`FIELD_NATIVE_MEDIA_CAPABILITY evidence_complete=${payload.evidenceComplete} routes=${payload.capabilityRoutes} proven=${payload.provenCapabilities}`);
+ for (const proposal of payload.proposals.slice(0, 80)) console.log(`FIELD_NATIVE_MEDIA_CAPABILITY_PROVEN ${JSON.stringify(proposal)}`);
+ for (const outcome of payload.outcomes.filter((row) => !row.proven).slice(0, 80)) {
+ console.log(`FIELD_NATIVE_MEDIA_CAPABILITY_NOT_PROVEN ${JSON.stringify({ provider: outcome.provider, addType: outcome.addType, client: outcome.client, reasons: outcome.reasons })}`);
+ }
+ if (!payload.evidenceComplete) process.exitCode = 2;
+}
+
+if (require.main === module) main();
+module.exports = { analyzeMediaTypeCapabilities };
diff --git a/scripts/audit_native_client_checkout.py b/scripts/audit_native_client_checkout.py
new file mode 100644
index 000000000..4bdd02b71
--- /dev/null
+++ b/scripts/audit_native_client_checkout.py
@@ -0,0 +1,215 @@
+#!/usr/bin/env python3
+"""Fail closed when a native human-UX lab mutates official Nuvio runtime code.
+
+The machine-readable source of truth is automation/native-human-ux-policy.json.
+The same file also persists validated lab profiles and known job blockers so later
+runs reuse proven assumptions instead of re-opening the harness after every failure.
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import subprocess
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+POLICY_PATH = ROOT / "automation/native-human-ux-policy.json"
+
+REQUIRED_PROFILE_IDS = ("common", "tv", "mobile", "desktop")
+REQUIRED_BLOCKER_IDS = {
+ "stale-repository-http-instrumentation-contract",
+ "stale-tv-bootstrap-wrapper-contract",
+ "stale-tv-bootstrap-alias-contract",
+ "reader-run-cancellation-churn",
+ "repository-http-evidence-gap-after-instrumentation-disable",
+ "actions-log-blob-not-ready",
+}
+
+
+def load_policy() -> dict:
+ if not POLICY_PATH.is_file():
+ raise SystemExit(f"native human-UX policy missing: {POLICY_PATH}")
+ try:
+ policy = json.loads(POLICY_PATH.read_text(encoding="utf-8"))
+ except Exception as error:
+ raise SystemExit(f"native human-UX policy is invalid JSON: {error}") from error
+ if int(policy.get("version") or 0) < 4:
+ raise SystemExit("native human-UX policy version must include persistent profile/blocker memory")
+ if policy.get("mode") != "human-ux-observation-only":
+ raise SystemExit("native human-UX policy mode must remain human-ux-observation-only")
+
+ change_control = policy.get("change_control") or {}
+ for flag in (
+ "policy_file_is_source_of_truth",
+ "audit_must_read_this_file",
+ "persistent_profiles_are_source_of_truth",
+ "job_blocker_memory_is_source_of_truth",
+ ):
+ if change_control.get(flag) is not True:
+ raise SystemExit(f"native human-UX policy must keep {flag}=true")
+ if change_control.get("default_on_ambiguity") != "fail-closed":
+ raise SystemExit("native human-UX policy must remain fail-closed")
+
+ harness = policy.get("harness_change_control") or {}
+ if harness.get("default") != "locked-do-not-modify":
+ raise SystemExit("native harness must remain locked by default")
+ if harness.get("change_policy") != "only-when-faithful-observation-is-blocked":
+ raise SystemExit("native harness change policy must remain observation-blocker-only")
+ required_before_change = set(harness.get("required_before_change") or [])
+ if "consult persistent_profiles and job_blocker_memory" not in required_before_change:
+ raise SystemExit("native harness changes must consult persistent profile/blocker memory first")
+
+ profiles = policy.get("persistent_profiles") or {}
+ if profiles.get("schema_version") != 1 or profiles.get("reuse_rule") != "reuse-before-rediagnosis":
+ raise SystemExit("native persistent profile memory contract is missing or invalid")
+ for profile_id in REQUIRED_PROFILE_IDS:
+ profile = profiles.get(profile_id) or {}
+ if profile.get("status") != "validated-and-reusable":
+ raise SystemExit(f"native persistent profile is not reusable: {profile_id}")
+ if profiles["common"].get("human_ux_acceptance_requires_real_ui_path") is not True:
+ raise SystemExit("common native profile must require the real UI path")
+ if profiles["common"].get("component_probes_are_diagnostic_only") is not True:
+ raise SystemExit("common native profile must keep component probes diagnostic-only")
+ if profiles["common"].get("production_player_first") is not True:
+ raise SystemExit("common native profile must keep production-player-first ordering")
+
+ blocker_memory = policy.get("job_blocker_memory") or {}
+ if blocker_memory.get("schema_version") != 1:
+ raise SystemExit("native job blocker memory schema is missing")
+ if blocker_memory.get("consult_before_harness_change") is not True:
+ raise SystemExit("native job blocker memory must be consulted before harness changes")
+ entries = blocker_memory.get("entries") or []
+ blocker_ids = [str(row.get("id") or "") for row in entries if isinstance(row, dict)]
+ if len(blocker_ids) != len(set(blocker_ids)):
+ raise SystemExit("native job blocker memory contains duplicate ids")
+ missing_blockers = sorted(REQUIRED_BLOCKER_IDS - set(blocker_ids))
+ if missing_blockers:
+ raise SystemExit("native job blocker memory lost required entries: " + ", ".join(missing_blockers))
+ for row in entries:
+ if not isinstance(row, dict):
+ raise SystemExit("native job blocker memory contains a non-object entry")
+ for key in ("id", "status", "signature", "cause"):
+ if not str(row.get(key) or "").strip():
+ raise SystemExit(f"native job blocker entry missing {key}: {row.get('id') or ''}")
+ if row.get("status") == "resolved" and not str(row.get("never_repeat") or "").strip():
+ raise SystemExit(f"resolved native blocker lacks never_repeat guard: {row.get('id')}")
+
+ for key in ("allowed_checkout_changes", "allowed_gradle_additions", "forbidden_checkout_tokens"):
+ if not policy.get(key):
+ raise SystemExit(f"native human-UX policy missing non-empty {key}")
+ return policy
+
+
+POLICY = load_policy()
+ALLOWED_PREFIXES = {
+ str(client): tuple(str(value) for value in values)
+ for client, values in POLICY["allowed_checkout_changes"].items()
+}
+ALLOWED_GRADLE_ADDITIONS = {
+ str(client): tuple(str(value) for value in values)
+ for client, values in POLICY["allowed_gradle_additions"].items()
+}
+FORBIDDEN_DIFF_TOKENS = tuple(str(value) for value in POLICY["forbidden_checkout_tokens"])
+
+
+def _run(repo: Path, *args: str) -> str:
+ proc = subprocess.run(
+ ["git", "-C", str(repo), *args],
+ check=True,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ )
+ return proc.stdout
+
+
+def _status_paths(repo: Path) -> list[str]:
+ paths: list[str] = []
+ for line in _run(repo, "status", "--porcelain=v1", "--untracked-files=all").splitlines():
+ if not line.strip():
+ continue
+ raw = line[3:]
+ if " -> " in raw:
+ raw = raw.split(" -> ", 1)[1]
+ paths.append(raw.strip())
+ return paths
+
+
+def _path_allowed(client: str, path: str) -> bool:
+ return any(path == prefix or path.startswith(prefix) for prefix in ALLOWED_PREFIXES[client])
+
+
+def _audit_gradle_diff(client: str, diff: str) -> None:
+ allowed = set(ALLOWED_GRADLE_ADDITIONS.get(client, ()))
+ for line in diff.splitlines():
+ if not line.startswith("+") or line.startswith("+++"):
+ continue
+ value = line[1:].strip()
+ if not value or value in {"{", "}"}:
+ continue
+ if value not in allowed:
+ raise SystemExit(f"native lab forbidden Gradle mutation ({client}): {value}")
+
+
+def _read_changed_file(repo: Path, relative: str) -> str:
+ path = repo / relative
+ if not path.is_file():
+ return ""
+ if path.suffix.lower() not in {".kt", ".java", ".kts", ".xml", ".gradle", ".properties", ".txt"}:
+ return ""
+ try:
+ return path.read_text(encoding="utf-8", errors="replace")
+ except Exception:
+ return ""
+
+
+def audit_checkout(repo: Path, client: str) -> None:
+ repo = Path(repo).resolve()
+ client = str(client).strip().lower()
+ if client not in ALLOWED_PREFIXES:
+ raise SystemExit(f"unsupported native client audit target: {client}")
+ if not (repo / ".git").exists():
+ raise SystemExit(f"native client checkout is not a git repository: {repo}")
+
+ paths = _status_paths(repo)
+ forbidden_paths = [path for path in paths if not _path_allowed(client, path)]
+ if forbidden_paths:
+ raise SystemExit(
+ "native human-UX lab mutated runtime-owned path(s): " + ", ".join(forbidden_paths[:20])
+ )
+
+ tracked_diff = _run(repo, "diff", "--no-ext-diff", "--unified=0", "--")
+ changed_text = tracked_diff + "\n" + "\n".join(_read_changed_file(repo, path) for path in paths)
+ for token in FORBIDDEN_DIFF_TOKENS:
+ if token in changed_text:
+ raise SystemExit(f"native human-UX lab introduced forbidden runtime mutation: {token}")
+
+ gradle_path = {
+ "mobile": "composeApp/build.gradle.kts",
+ "tv": "app/build.gradle.kts",
+ }.get(client)
+ if gradle_path and gradle_path in paths:
+ _audit_gradle_diff(
+ client,
+ _run(repo, "diff", "--no-ext-diff", "--unified=0", "--", gradle_path),
+ )
+
+ blocker_count = len(POLICY["job_blocker_memory"]["entries"])
+ print(
+ f"FIELD_NATIVE_CHECKOUT_AUDIT client={client} changed_paths={len(paths)} "
+ f"policy_version={POLICY.get('version')} persistent_profile=true "
+ f"known_blockers={blocker_count} runtime_mutation=false status=ok"
+ )
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("client", choices=tuple(ALLOWED_PREFIXES))
+ parser.add_argument("repo")
+ args = parser.parse_args()
+ audit_checkout(Path(args.repo), args.client)
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/augment_native_corpus_request_contract.py b/scripts/augment_native_corpus_request_contract.py
new file mode 100644
index 000000000..dc9177228
--- /dev/null
+++ b/scripts/augment_native_corpus_request_contract.py
@@ -0,0 +1,178 @@
+#!/usr/bin/env python3
+"""Augment generated native-corpus tests with the real Nuvio media-type contract.
+
+The manifest vocabulary is strictly movie|tv|anime. Catalogue/user aliases are a
+client input concern. The lab still traverses every staged provider (including
+manifest-disabled rows), but executes only meaningful media routes.
+
+For episodic anime, Nuvio catalogues may surface the same title through a series/tv
+route or an anime route. A provider that already declares either tv or anime is
+therefore exercised on BOTH routes. The declared route is normal validation; the
+other route is explicitly tagged ``capability_probe``. A failed capability probe is
+never a provider failure or Brain repair signal. A successful end-to-end probe can
+justify a later supportedTypes expansion.
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import re
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+CORPUS = ROOT / ".github/triggers/nuvio-client-lab.json"
+CANONICAL = {"movie", "tv", "anime"}
+ALIASES = {"series": "tv", "show": "tv", "other": "tv"}
+
+
+def canonical_type(value: object) -> str:
+ raw = str(value or "").strip().lower()
+ raw = ALIASES.get(raw, raw)
+ if raw not in CANONICAL:
+ raise SystemExit(f"unsupported native corpus media type: {raw!r}")
+ return raw
+
+
+def fixture(slug: str) -> dict:
+ data = json.loads(CORPUS.read_text(encoding="utf-8"))
+ for row in data.get("fixtures", []):
+ if isinstance(row, dict) and str(row.get("slug") or "") == slug and isinstance(row.get("fixture"), dict):
+ return row["fixture"]
+ raise SystemExit(f"unknown native corpus fixture: {slug}")
+
+
+def manifest_types(path: Path) -> dict[str, list[str]]:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ out: dict[str, list[str]] = {}
+ seen: set[str] = set()
+ for row in data.get("scrapers", []):
+ if not isinstance(row, dict):
+ continue
+ provider_id = str(row.get("id") or "").strip()
+ if not provider_id:
+ continue
+ key = provider_id.casefold()
+ if key in seen:
+ raise SystemExit(f"duplicate provider id in canonical manifest: {provider_id}")
+ seen.add(key)
+ raw_types = row.get("supportedTypes")
+ if not isinstance(raw_types, list) or not raw_types:
+ raise SystemExit(
+ f"provider {provider_id}: supportedTypes must be a non-empty list of canonical types"
+ )
+ types: list[str] = []
+ for raw in raw_types:
+ typ = str(raw or "").strip().lower()
+ if typ not in CANONICAL:
+ raise SystemExit(f"provider {provider_id}: non-canonical supportedType {typ!r}")
+ if typ not in types:
+ types.append(typ)
+ out[key] = types
+ if not out:
+ raise SystemExit(f"manifest contains no providers: {path}")
+ return out
+
+
+def kotlin_map(values: dict[str, list[str]]) -> str:
+ rows = []
+ for provider_id, types in values.items():
+ items = ", ".join(json.dumps(value) for value in types)
+ rows.append(
+ f" Pair>({json.dumps(provider_id)}, setOf({items}))"
+ )
+ # NuvioTV's androidTest Kotlin compiler has failed to infer the generic T of
+ # the infix `to` helper in this generated nested map. Keep the complete Pair
+ # type explicit so TV and Mobile consume one deterministic request contract.
+ return "mapOf>(\n" + ",\n".join(rows) + "\n )"
+
+
+def replace_once(text: str, old: str, new: str, label: str) -> str:
+ count = text.count(old)
+ if count != 1:
+ raise SystemExit(f"request-contract anchor {label!r} count={count}")
+ return text.replace(old, new, 1)
+
+
+def augment(path: Path, client: str, slug: str, manifest: Path) -> None:
+ text = path.read_text(encoding="utf-8")
+ if "FIELD_NATIVE_PROVIDER_SKIPPED" in text:
+ print(f"FIELD_NATIVE_REQUEST_CONTRACT already=true client={client} fixture={slug} path={path}")
+ return
+
+ f = fixture(slug)
+ fixture_media_type = canonical_type(f.get("mediaType") or "movie")
+ fixture_category = str(f.get("category") or fixture_media_type).strip().lower()
+ is_anime = fixture_category == "anime" or fixture_media_type == "anime"
+ types = manifest_types(manifest)
+
+ provider_list = re.search(r"( private val providers = listOf\(\n.*?\n \)\n)", text, flags=re.S)
+ if not provider_list:
+ raise SystemExit("request-contract provider list anchor missing")
+ helpers = f'''\n data class ProviderRequestRoute(val mediaType: String, val declared: Boolean)\n\n private val declaredTypesByProvider: Map> = {kotlin_map(types)}\n\n private fun requestRoutesFor(providerId: String, fixtureMediaType: String): List {{\n val declared: Set = declaredTypesByProvider[providerId.lowercase()] ?: emptySet()\n return if ({str(is_anime).lower()}) {{\n // Episodic anime can enter Nuvio through either anime or series/tv.\n // If a provider participates in either ecosystem, test both. The\n // undeclared side is discovery evidence only and cannot fail the provider.\n if ("anime" !in declared && "tv" !in declared) emptyList()\n else listOf("anime", "tv").map {{ type: String -> ProviderRequestRoute(type, type in declared) }}\n }} else {{\n listOf(fixtureMediaType).filter {{ it in declared }}\n .map {{ type -> ProviderRequestRoute(type, true) }}\n }}\n }}\n'''
+ text = text[: provider_list.end()] + helpers + text[provider_list.end() :]
+
+ if client in {"tv", "mobile"}:
+ test_anchor = " @Test\n"
+ if client == "mobile":
+ # composeApp device tests run under a test package, while the real
+ # official debug application is produced by androidApp and overrides
+ # its applicationId to com.nuviodebug.com. Prefer that real launcher;
+ # retain the instrumentation-derived package only as a compatibility
+ # fallback for upstream changes.
+ launch_helper = ''' private fun launchClientUi() {\n val instrumentation = InstrumentationRegistry.getInstrumentation()\n val context = instrumentation.targetContext\n var packageName: String = "com.nuviodebug.com"\n var intent: android.content.Intent? = context.packageManager.getLaunchIntentForPackage(packageName)\n if (intent == null) {\n val instrumentationPackage: String = context.packageName\n packageName = if (instrumentationPackage.endsWith(".test")) instrumentationPackage.removeSuffix(".test") else instrumentationPackage\n intent = context.packageManager.getLaunchIntentForPackage(packageName)\n }\n val launchIntent: android.content.Intent? = intent\n if (launchIntent == null) {\n emit("FIELD_NATIVE_UI_LAUNCH_ERROR client=mobile package64=${b64(packageName)} reason=no_launch_intent")\n return\n }\n launchIntent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK or android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP)\n context.startActivity(launchIntent)\n Thread.sleep(1200L)\n emit("FIELD_NATIVE_UI_LAUNCHED client=mobile package64=${b64(packageName)}")\n }\n\n'''
+ else:
+ launch_helper = ''' private fun launchClientUi() {\n val instrumentation = InstrumentationRegistry.getInstrumentation()\n val context = instrumentation.targetContext\n val instrumentationPackage: String = context.packageName\n val packageName: String = if (instrumentationPackage.endsWith(".test")) instrumentationPackage.removeSuffix(".test") else instrumentationPackage\n val intent: android.content.Intent? = context.packageManager.getLaunchIntentForPackage(packageName)\n if (intent == null) {\n emit("FIELD_NATIVE_UI_LAUNCH_ERROR client=tv package64=${b64(packageName)} reason=no_launch_intent")\n return\n }\n intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK or android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP)\n context.startActivity(intent)\n Thread.sleep(1200L)\n emit("FIELD_NATIVE_UI_LAUNCHED client=tv package64=${b64(packageName)}")\n }\n\n'''
+ text = replace_once(text, test_anchor, launch_helper + test_anchor, "android test")
+ begin = f' emit("FIELD_NATIVE_CORPUS_BEGIN client={client} fixture=$fixtureSlug title64=${{b64(title)}} providers=${{providers.size}}")'
+ text = replace_once(text, begin, f" launchClientUi()\n{begin}", "ui launch")
+
+ loop = " for (provider in providers) {\n val started = System.currentTimeMillis()\n try {"
+ replacement = f''' for (provider in providers) {{\n val requestRoutes = requestRoutesFor(provider.id, mediaType)\n if (requestRoutes.isEmpty()) {{\n emit("FIELD_NATIVE_PROVIDER_SKIPPED client={client} fixture=$fixtureSlug provider64=${{b64(provider.id)}} enabled=${{provider.enabled}} requested_type=$mediaType declared_types64=${{b64(declaredTypesByProvider[provider.id.lowercase()].orEmpty().sorted().joinToString(","))}} reason=unsupported_type")\n continue\n }}\n for (requestRoute in requestRoutes) {{\n val requestMediaType = requestRoute.mediaType\n val routeMode = if (requestRoute.declared) "declared" else "capability_probe"\n val started = System.currentTimeMillis()\n emit("FIELD_NATIVE_PROVIDER_BEGIN client={client} fixture=$fixtureSlug provider64=${{b64(provider.id)}} enabled=${{provider.enabled}} request_type=$requestMediaType route_mode=$routeMode declared_types64=${{b64(declaredTypesByProvider[provider.id.lowercase()].orEmpty().sorted().joinToString(","))}}")\n try {{'''
+ text = replace_once(text, loop, replacement, "provider loop")
+ text = replace_once(text, " mediaType = mediaType,", " mediaType = requestMediaType,", "runtime media type")
+
+ markers = [
+ "FIELD_NATIVE_RESULT",
+ "FIELD_NATIVE_ROW",
+ "FIELD_NATIVE_TRANSPORT",
+ "FIELD_NATIVE_PLAYER",
+ "FIELD_NATIVE_ERROR",
+ ]
+ for marker in markers:
+ needle = f"{marker} client={client} fixture=$fixtureSlug provider64=${{b64(provider.id)}}"
+ if needle in text:
+ text = text.replace(needle, needle + " request_type=$requestMediaType route_mode=$routeMode")
+
+ reader_needle = " val reader = probeNativePlayer(row.url, row.headers,"
+ if reader_needle in text:
+ text = text.replace(
+ reader_needle,
+ f' emit("FIELD_NATIVE_PLAYER_BEGIN client={client} fixture=$fixtureSlug provider64=${{b64(provider.id)}} request_type=$requestMediaType route_mode=$routeMode index=$index")\n' + reader_needle,
+ )
+
+ end_anchor = ' }\n emit("FIELD_NATIVE_CORPUS_END client=' + client
+ text = replace_once(text, end_anchor, ' }\n }\n emit("FIELD_NATIVE_CORPUS_END client=' + client, "nested request loop close")
+
+ path.write_text(text, encoding="utf-8")
+ print(
+ f"FIELD_NATIVE_REQUEST_CONTRACT client={client} fixture={slug} media_type={fixture_media_type} "
+ f"anime_dual_route={str(is_anime).lower()} providers={len(types)} path={path}"
+ )
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("client", choices=("tv", "mobile", "desktop"))
+ parser.add_argument("--fixture", required=True)
+ parser.add_argument("--manifest", required=True)
+ parser.add_argument("--source", required=True)
+ args = parser.parse_args()
+ manifest = Path(args.manifest)
+ if not manifest.is_absolute():
+ manifest = (ROOT / manifest).resolve()
+ augment(Path(args.source).resolve(), args.client, args.fixture, manifest)
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/augment_native_desktop_player.py b/scripts/augment_native_desktop_player.py
new file mode 100644
index 000000000..106f94da2
--- /dev/null
+++ b/scripts/augment_native_desktop_player.py
@@ -0,0 +1,279 @@
+#!/usr/bin/env python3
+"""Augment a generated NuvioDesktop corpus test with the production player surface.
+
+Human-UX invariant: provider output is handed to NuvioDesktop's real
+PlatformPlayerSurface. The lab does not construct NativePlayerController directly,
+does not sanitize/rewrite the stream itself and does not choose decoder/network
+settings. Whatever Nuvio's production player does with the source is the evidence.
+"""
+from __future__ import annotations
+
+import argparse
+import os
+import re
+from pathlib import Path
+
+IMPORT_ANCHOR = "import com.nuvio.app.features.plugins.runtime.PluginRuntime\n"
+TEST_ANCHOR = " @Test\n"
+DEFAULT_PR_STREAM_LIMIT = 2
+
+IMPORTS = """import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.awt.ComposePanel
+import com.nuvio.app.core.ui.NuvioTheme
+import com.nuvio.app.features.player.PlatformPlayerSurface
+import com.nuvio.app.features.player.PlayerControlsState
+import com.nuvio.app.features.player.PlayerPlaybackSnapshot
+import com.nuvio.app.features.player.PlayerResizeMode
+import java.awt.BorderLayout
+import java.awt.Rectangle
+import java.awt.Robot
+import java.awt.Toolkit
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicReference
+import javax.imageio.ImageIO
+import javax.swing.JFrame
+import javax.swing.SwingUtilities
+"""
+
+HELPERS = r'''
+ data class DesktopNativePlayerProbe(
+ val state: String,
+ val errorClass: String,
+ val errorCode: String,
+ val httpStatus: Int,
+ val failureStage: String,
+ val durationSeconds: Double?,
+ val positionSeconds: Double?,
+ val exceptionChain: String = "",
+ )
+
+ private fun desktopThrowableChain(error: Throwable): Pair {
+ var current = error
+ val parts = mutableListOf()
+ repeat(8) {
+ val name = current::class.qualifiedName.orEmpty().ifBlank { current.javaClass.name }
+ val message = current.message.orEmpty()
+ .replace(Regex("https?://\\S+", RegexOption.IGNORE_CASE), "")
+ .replace(Regex("(?i)(authorization|cookie|token|secret)\\s*[:=]\\s*\\S+"), "$1=")
+ .replace(Regex("\\s+"), " ")
+ .take(180)
+ parts += if (message.isBlank()) name else "$name:$message"
+ val next = when (current) {
+ is java.lang.reflect.InvocationTargetException -> current.targetException ?: current.cause
+ else -> current.cause
+ }
+ if (next == null || next === current) {
+ return current to parts.joinToString(" -> ").take(420)
+ }
+ current = next
+ }
+ return current to parts.joinToString(" -> ").take(420)
+ }
+
+ private fun captureDesktopPhase(phase: String, fixtureSlug: String) {
+ val safe = phase.replace(Regex("[^A-Za-z0-9_.-]+"), "-").trim('-').ifBlank { "phase" }
+ val dir = File(workspace, "native-evidence/desktop/${System.getProperty("os.name").lowercase().replace(Regex("[^a-z0-9]+"), "-")}/$fixtureSlug")
+ dir.mkdirs()
+ val target = File(dir, "$safe.png")
+ try {
+ val size = Toolkit.getDefaultToolkit().screenSize
+ val image = Robot().createScreenCapture(Rectangle(size))
+ ImageIO.write(image, "png", target)
+ emit("FIELD_NATIVE_FRONTEND_CAPTURE client=desktop phase=$safe screenshot64=${b64(target.absolutePath)} bytes=${target.length()}")
+ } catch (error: Throwable) {
+ emit("FIELD_NATIVE_FRONTEND_ERROR client=desktop phase=$safe error_class64=${b64(error::class.qualifiedName.orEmpty())}")
+ }
+ }
+
+ private fun probeDesktopProductionPlayer(
+ url: String,
+ headers: Map?,
+ streamType: String?,
+ expectedDurationMinutes: Int,
+ ): DesktopNativePlayerProbe {
+ val frameRef = AtomicReference(null)
+ val latestSnapshot = AtomicReference(null)
+ val errorRef = AtomicReference(null)
+ val terminal = CountDownLatch(1)
+ try {
+ SwingUtilities.invokeAndWait {
+ val frame = JFrame("Nuvio Desktop native reader lab")
+ val panel = ComposePanel()
+ frame.layout = BorderLayout()
+ frame.add(panel, BorderLayout.CENTER)
+ frame.setSize(960, 540)
+ frame.setLocationRelativeTo(null)
+ frame.defaultCloseOperation = JFrame.DISPOSE_ON_CLOSE
+ frameRef.set(frame)
+ panel.setContent {
+ // PlatformPlayerSurface assumes the same production composition
+ // locals as a normal Nuvio app screen. In particular the current
+ // Desktop implementation reads LocalNuvioPlatformDensity, whose
+ // deliberate default throws outside NuvioTheme. Keep the real
+ // theme around the real surface instead of inventing test values.
+ NuvioTheme {
+ PlatformPlayerSurface(
+ sourceUrl = url,
+ sourceHeaders = headers.orEmpty(),
+ sourceResponseHeaders = emptyMap(),
+ externalSubtitles = emptyList(),
+ streamType = streamType,
+ useYoutubeChunkedPlayback = false,
+ modifier = Modifier.fillMaxSize(),
+ playWhenReady = true,
+ initialPositionMs = 0L,
+ initialPositionRequestKey = "niakvio-native-reader",
+ resizeMode = PlayerResizeMode.Fit,
+ useNativeController = true,
+ playerControlsState = PlayerControlsState(),
+ onControllerReady = { _ -> },
+ onSnapshot = { snapshot ->
+ latestSnapshot.set(snapshot)
+ if (snapshot.isEnded || (!snapshot.isLoading && (snapshot.isPlaying || snapshot.positionMs > 0L || snapshot.durationMs > 0L))) {
+ terminal.countDown()
+ }
+ },
+ onError = { message ->
+ if (!message.isNullOrBlank()) {
+ errorRef.compareAndSet(null, message)
+ terminal.countDown()
+ }
+ },
+ )
+ }
+ }
+ frame.isVisible = true
+ }
+
+ terminal.await(__READER_TIMEOUT_MS__L, TimeUnit.MILLISECONDS)
+ val error = errorRef.get()
+ val snapshot = latestSnapshot.get()
+ if (!error.isNullOrBlank()) {
+ return DesktopNativePlayerProbe(
+ "error", "NuvioDesktopProductionPlayer",
+ error.replace(Regex("\\s+"), "_").take(160),
+ 0, "player", null, null,
+ )
+ }
+ val durationSeconds = snapshot?.durationMs?.takeIf { it > 0L }?.div(1000.0)
+ val positionSeconds = snapshot?.positionMs?.takeIf { it >= 0L }?.div(1000.0)
+ val expected = expectedDurationMinutes.takeIf { it > 0 }?.times(60.0)
+ val shortMedia = durationSeconds != null && (
+ durationSeconds < 60.0 || (expected != null && durationSeconds / expected < 0.55)
+ )
+ if (shortMedia) {
+ return DesktopNativePlayerProbe("short_media", "", "", 0, "duration_identity", durationSeconds, positionSeconds)
+ }
+ if (snapshot?.isEnded == true) {
+ return DesktopNativePlayerProbe("ended", "", "", 0, "none", durationSeconds, positionSeconds)
+ }
+ if (snapshot != null && !snapshot.isLoading && (snapshot.isPlaying || snapshot.positionMs > 0L || snapshot.durationMs > 0L)) {
+ return DesktopNativePlayerProbe("ready", "", "", 0, "none", durationSeconds, positionSeconds)
+ }
+ return DesktopNativePlayerProbe("timeout", "NuvioDesktopProductionPlayer", "READER_TIMEOUT", 0, "timeout", durationSeconds, positionSeconds)
+ } catch (error: Throwable) {
+ val (root, chain) = desktopThrowableChain(error)
+ val rootMessage = root.message.orEmpty().ifBlank { "PLAYER_SETUP" }
+ return DesktopNativePlayerProbe(
+ "error", root::class.qualifiedName.orEmpty().ifBlank { root.javaClass.name },
+ rootMessage.replace(Regex("\\s+"), "_").take(160),
+ 0, "player_setup", null, null, chain,
+ )
+ } finally {
+ runCatching { SwingUtilities.invokeAndWait { frameRef.getAndSet(null)?.dispose() } }
+ }
+ }
+'''
+
+
+def replace_once(text: str, old: str, new: str, label: str) -> str:
+ count = text.count(old)
+ if count != 1:
+ raise SystemExit(f"desktop reader anchor {label!r} count={count}")
+ return text.replace(old, new, 1)
+
+
+def augment(path: Path, expected_minutes: int, stream_scope: str) -> None:
+ text = path.read_text(encoding="utf-8")
+ if "probeDesktopProductionPlayer" in text:
+ return
+ pr_bounded = os.environ.get("GITHUB_EVENT_NAME", "").strip().lower() == "pull_request"
+ if pr_bounded and stream_scope == "all":
+ configured = os.environ.get("NIAKVIO_PR_STREAM_LIMIT", str(DEFAULT_PR_STREAM_LIMIT)).strip()
+ try:
+ stream_scope = str(max(1, min(int(configured), 4)))
+ except ValueError:
+ stream_scope = str(DEFAULT_PR_STREAM_LIMIT)
+ reader_timeout_ms = 12_000 if pr_bounded else 25_000
+
+ text = replace_once(text, IMPORT_ANCHOR, IMPORT_ANCHOR + IMPORTS, "imports")
+ helpers = HELPERS.replace("__READER_TIMEOUT_MS__", str(reader_timeout_ms))
+ text = replace_once(text, TEST_ANCHOR, helpers + "\n" + TEST_ANCHOR, "test")
+
+ if stream_scope == "all":
+ text = re.sub(r"rows\.take\(\d+\)\.forEachIndexed", "rows.forEachIndexed", text)
+ reader_iter = "rows.forEachIndexed"
+ else:
+ count = max(1, min(int(stream_scope), 4))
+ reader_iter = f"rows.take({count}).forEachIndexed"
+
+ transport_pattern = re.compile(
+ r''' rows\.firstOrNull\(\)\?\.let \{ row ->\n'''
+ r''' val probe = probeTransport\(row\.url, row\.headers\)\n'''
+ r''' emit\("FIELD_NATIVE_TRANSPORT client=desktop fixture=\$fixtureSlug provider64=\$\{b64\(provider\.id\)\} request_type=\$requestMediaType route_mode=\$routeMode state=\$\{probe\.state\} kind=\$\{probe\.kind\} status=\$\{probe\.status\} content_type64=\$\{b64\(probe\.contentType\)\} extm3u=\$\{probe\.extm3u\} duration_seconds=\$\{probe\.durationSeconds \?: 0\.0\} host64=\$\{b64\(probe\.host\)\} media_hint64=\$\{b64\(probe\.mediaHint\)\}"\)\n'''
+ r''' \}\n'''
+ )
+ replacement = f''' {reader_iter} {{ index, row ->
+ emit("FIELD_NATIVE_PLAYER_BEGIN client=desktop fixture=$fixtureSlug provider64=${{b64(provider.id)}} request_type=$requestMediaType route_mode=$routeMode index=$index entry=PlatformPlayerSurface")
+ captureDesktopPhase("player-start", fixtureSlug)
+ val reader = probeDesktopProductionPlayer(row.url, row.headers, row.type, {expected_minutes})
+ emit("FIELD_NATIVE_PLAYER client=desktop fixture=$fixtureSlug provider64=${{b64(provider.id)}} request_type=$requestMediaType route_mode=$routeMode index=$index state=${{reader.state}} engine=nuvio-production-desktop http_status=${{reader.httpStatus}} failure_stage=${{reader.failureStage}} duration_seconds=${{reader.durationSeconds ?: 0.0}} host64=${{b64(hostOnly(row.url))}} error_class64=${{b64(reader.errorClass)}} error_code64=${{b64(reader.errorCode)}} exception_chain64=${{b64(reader.exceptionChain)}} response_header_names64=${{b64("")}} load_bytes=0 load_duration_ms=0 media_data_type=-1 track_type=-1")
+ captureDesktopPhase("player-result", fixtureSlug)
+ // Independent transport diagnostics run only after the production
+ // player has reached a terminal observation for this source.
+ val transport = probeTransport(row.url, row.headers)
+ emit("FIELD_NATIVE_TRANSPORT client=desktop fixture=$fixtureSlug provider64=${{b64(provider.id)}} request_type=$requestMediaType route_mode=$routeMode index=$index state=${{transport.state}} kind=${{transport.kind}} status=${{transport.status}} content_type64=${{b64(transport.contentType)}} extm3u=${{transport.extm3u}} duration_seconds=${{transport.durationSeconds ?: 0.0}} host64=${{b64(transport.host)}} media_hint64=${{b64(transport.mediaHint)}}")
+ }}
+'''
+ text, changed = transport_pattern.subn(replacement, text, count=1)
+ if changed != 1:
+ raise SystemExit(f"desktop reader transport replacement count={changed}")
+
+ begin = ' emit("FIELD_NATIVE_CORPUS_BEGIN client=desktop fixture=$fixtureSlug title64=${b64(title)} providers=${providers.size}")'
+ text = replace_once(text, begin, begin + '\n captureDesktopPhase("corpus-begin", fixtureSlug)', "corpus begin")
+ provider_begin = ' emit("FIELD_NATIVE_PROVIDER_BEGIN client=desktop fixture=$fixtureSlug provider64=${b64(provider.id)} enabled=${provider.enabled} request_type=$requestMediaType route_mode=$routeMode declared_types64=${b64(declaredTypesByProvider[provider.id.lowercase()].orEmpty().sorted().joinToString(","))}")'
+ text = replace_once(text, provider_begin, provider_begin + '\n captureDesktopPhase("provider-loading", fixtureSlug)', "provider begin")
+ result_marker = 'emit("FIELD_NATIVE_RESULT client=desktop fixture=$fixtureSlug provider64=${b64(provider.id)} request_type=$requestMediaType route_mode=$routeMode'
+ text = text.replace(result_marker, 'captureDesktopPhase("provider-result", fixtureSlug)\n ' + result_marker)
+ end = ' emit("FIELD_NATIVE_CORPUS_END client=desktop fixture=$fixtureSlug errors=${errors.size}")'
+ text = replace_once(text, end, ' captureDesktopPhase("corpus-end", fixtureSlug)\n' + end, "corpus end")
+ path.write_text(text, encoding="utf-8")
+ print(
+ f"FIELD_NATIVE_DESKTOP_READER_AUGMENTED source={path} streams={stream_scope} "
+ f"expected_minutes={expected_minutes} timeout_ms={reader_timeout_ms} entry=PlatformPlayerSurface "
+ f"ci_mode={'pr-bounded' if pr_bounded else 'deep'} theme=NuvioTheme"
+ )
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--source", required=True)
+ parser.add_argument("--expected-minutes", type=int, default=0)
+ parser.add_argument("--streams", default="all")
+ args = parser.parse_args()
+ raw = str(args.streams).strip().lower()
+ if raw != "all":
+ try:
+ value = int(raw)
+ except ValueError as error:
+ raise SystemExit("--streams must be all or 1-4") from error
+ if value < 1 or value > 4:
+ raise SystemExit("--streams must be all or 1-4")
+ augment(Path(args.source).resolve(), max(0, args.expected_minutes), raw)
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/augment_native_provider_loading.py b/scripts/augment_native_provider_loading.py
new file mode 100644
index 000000000..2e776ed2d
--- /dev/null
+++ b/scripts/augment_native_provider_loading.py
@@ -0,0 +1,415 @@
+#!/usr/bin/env python3
+"""Route native-corpus execution through the official Nuvio repository layer.
+
+The generated corpus still owns fixture traversal and reader evidence, but provider
+code must first travel through the same repository/manager path used by the client:
+
+* NuvioTV: PluginManager repository state -> ScraperInfo -> executeScraper
+* Mobile/Desktop: PluginRepository profile state -> PluginScraper -> executeScraper
+
+Manifest-disabled providers are still loaded and executed individually. Providers
+that the official client rejects for the current platform are recorded as platform
+skips. The request-contract postprocessor runs first and owns declared/capability
+probe routes; this postprocessor preserves those routes unchanged.
+
+Normal evidence accepts only an exact 40-hex raw.githubusercontent.com revision.
+Repair sandboxes may explicitly opt into a loopback-only HTTP repository so the
+real client can install a locally generated candidate without publishing it first.
+
+The lab deliberately preserves the Nuvio app/profile state between fixtures. It
+reuses an already-installed repository and provider-code cache whenever the exact
+manifest URL is already present, instead of clearing PluginRepository state. This
+keeps repeated launches fast and mirrors a real user session more closely.
+
+Repository installation failures are terminal observations, not harness crashes:
+every selected provider receives a structured load failure and the corpus continues
+to its normal end marker so the Brain can classify Core/manifest/repository faults
+without ever turning them into provider-JS mutations.
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import re
+from pathlib import Path
+from urllib.parse import urlparse
+
+ROOT = Path(__file__).resolve().parents[1]
+CANONICAL_TYPES = {"movie", "tv", "anime"}
+LOCAL_LAB_HOSTS = {"127.0.0.1", "localhost", "10.0.2.2"}
+
+
+def kotlin_string(value: str) -> str:
+ return json.dumps(str(value), ensure_ascii=False)
+
+
+def validate_manifest_url(manifest_url: str, allow_local_lab_url: bool):
+ parsed = urlparse(manifest_url)
+ if parsed.username or parsed.password or parsed.fragment:
+ raise SystemExit("native provider loading manifest URL must not contain credentials or fragments")
+ if parsed.scheme == "https" and parsed.hostname == "raw.githubusercontent.com":
+ parts = [part for part in parsed.path.split("/") if part]
+ if len(parts) < 4 or re.fullmatch(r"[0-9a-fA-F]{40}", parts[2]) is None:
+ raise SystemExit("raw GitHub native provider manifest must be pinned to an exact 40-hex commit SHA")
+ return parsed, "pinned_github"
+ if allow_local_lab_url and parsed.scheme == "http" and parsed.hostname in LOCAL_LAB_HOSTS:
+ if parsed.port is None or parsed.port < 1 or parsed.port > 65535:
+ raise SystemExit("local native provider lab manifest URL requires an explicit TCP port")
+ return parsed, "local_candidate"
+ raise SystemExit(
+ "native provider loading manifest URL must be pinned raw.githubusercontent.com HTTPS; "
+ "loopback HTTP is allowed only with --allow-local-lab-url"
+ )
+
+
+def load_manifest(path: Path) -> list[dict]:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ rows: list[dict] = []
+ seen: set[str] = set()
+ for raw in data.get("scrapers", []):
+ if not isinstance(raw, dict):
+ continue
+ provider_id = str(raw.get("id") or "").strip()
+ if not provider_id:
+ raise SystemExit(f"{path}: provider without id")
+ key = provider_id.casefold()
+ if key in seen:
+ raise SystemExit(f"{path}: duplicate provider id {provider_id}")
+ seen.add(key)
+ types = [str(value or "").strip().lower() for value in raw.get("supportedTypes", [])]
+ if not types or any(value not in CANONICAL_TYPES for value in types):
+ raise SystemExit(f"{path}: provider {provider_id} has invalid supportedTypes={types}")
+ rows.append(raw)
+ if not rows:
+ raise SystemExit(f"{path}: no providers")
+ return rows
+
+
+def platform_tags(client: str, host_platform: str) -> set[str]:
+ if client == "mobile":
+ return {"android"}
+ if client == "desktop":
+ host = host_platform.strip().lower()
+ if host not in {"macos", "windows"}:
+ raise SystemExit(f"desktop native provider loading requires macos/windows, got {host_platform!r}")
+ return {"desktop", "jvm", host}
+ # Accepted NuvioTV parses platform metadata but its JS download path currently
+ # does not filter on it. Reproduce the client instead of inventing a TV tag.
+ return set()
+
+
+def excluded_ids(rows: list[dict], client: str, host_platform: str) -> list[str]:
+ tags = platform_tags(client, host_platform)
+ if not tags:
+ return []
+ excluded: list[str] = []
+ for row in rows:
+ supported = {str(value).lower() for value in (row.get("supportedPlatforms") or [])}
+ disabled = {str(value).lower() for value in (row.get("disabledPlatforms") or [])}
+ if (supported and not (tags & supported)) or (tags & disabled):
+ excluded.append(str(row["id"]).casefold())
+ return excluded
+
+
+def insert_imports(text: str, client: str) -> str:
+ if client != "tv":
+ return text
+ anchor = "import androidx.test.platform.app.InstrumentationRegistry\n"
+ shared_imports = (
+ "import dagger.hilt.EntryPoint\n",
+ "import dagger.hilt.InstallIn\n",
+ "import dagger.hilt.android.EntryPointAccessors\n",
+ "import dagger.hilt.components.SingletonComponent\n",
+ "import kotlinx.coroutines.flow.first\n",
+ )
+ missing = [line for line in shared_imports if line not in text]
+ if not missing:
+ return text
+ if text.count(anchor) != 1:
+ raise SystemExit(f"provider-loading import anchor client=tv count={text.count(anchor)}")
+ return text.replace(anchor, anchor + "".join(missing), 1)
+
+
+def platform_set_literal(ids: list[str]) -> str:
+ # Generated Kotlin is compiled by several different client/compiler stacks.
+ # Empty generic constructors therefore carry their type explicitly instead of
+ # relying on local inference (a real TV route run has failed here before).
+ if not ids:
+ return "emptySet()"
+ return "setOf(" + ", ".join(kotlin_string(value) for value in ids) + ")"
+
+
+def tv_helpers(manifest_url: str, blocked: list[str]) -> str:
+ return f'''
+ private val repositoryManifestUrl = {kotlin_string(manifest_url)}
+ private val platformExcludedProviders: Set = {platform_set_literal(blocked)}
+
+ @EntryPoint
+ @InstallIn(SingletonComponent::class)
+ interface NiakvioPluginManagerEntryPoint {{
+ fun pluginManager(): PluginManager
+ }}
+
+ private suspend fun loadProvidersThroughNuvio(): Pair> {{
+ val app = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
+ val manager = EntryPointAccessors.fromApplication(
+ app,
+ NiakvioPluginManagerEntryPoint::class.java,
+ ).pluginManager()
+ emit("FIELD_NATIVE_REPOSITORY_LOAD_BEGIN client=tv fixture=$fixtureSlugForLoad manifest_host=${{hostOnly(repositoryManifestUrl)}} expected=${{providers.size}}")
+ val canonicalTarget = repositoryManifestUrl.substringBefore("?").trimEnd('/')
+ val existing = manager.repositories.first().firstOrNull {{ repo ->
+ repo.url.substringBefore("?").trimEnd('/').equals(canonicalTarget, ignoreCase = true)
+ }}
+ val repo = if (existing != null) {{
+ emit("FIELD_NATIVE_REPOSITORY_CACHE_HIT client=tv fixture=$fixtureSlugForLoad repository64=${{b64(existing.name)}}")
+ existing
+ }} else {{
+ val installed = manager.addRepository(repositoryManifestUrl)
+ if (installed.isFailure) {{
+ val message = installed.exceptionOrNull()?.message ?: "repository install failed"
+ emit("FIELD_NATIVE_REPOSITORY_LOAD_ERROR client=tv fixture=$fixtureSlugForLoad reason=install_failed error64=${{b64(message)}}")
+ providers.forEach {{ provider ->
+ val key = provider.id.lowercase()
+ if (key in platformExcludedProviders) {{
+ emit("FIELD_NATIVE_PROVIDER_LOAD_SKIPPED client=tv fixture=$fixtureSlugForLoad provider64=${{b64(provider.id)}} reason=disabled_platform")
+ }} else {{
+ emit("FIELD_NATIVE_PROVIDER_LOAD_ERROR client=tv fixture=$fixtureSlugForLoad provider64=${{b64(provider.id)}} reason=repository_install_failed")
+ }}
+ }}
+ return manager to emptyMap()
+ }}
+ installed.getOrThrow()
+ }}
+ val loaded = manager.scrapers.first().filter {{ it.repositoryId == repo.id }}
+ val byId = loaded.associateBy {{ it.id.substringAfterLast(':').lowercase() }}
+ val selectedKeys = providers.map {{ it.id.lowercase() }}.toSet()
+ val selectedLoaded = byId.keys.count {{ it in selectedKeys }}
+ val expectedLoaded = providers.count {{ it.id.lowercase() !in platformExcludedProviders }}
+ emit("FIELD_NATIVE_REPOSITORY_LOAD_RESULT client=tv fixture=$fixtureSlugForLoad repository64=${{b64(repo.name)}} expected=$expectedLoaded loaded=$selectedLoaded")
+ providers.forEach {{ provider ->
+ val key = provider.id.lowercase()
+ if (key in platformExcludedProviders) {{
+ emit("FIELD_NATIVE_PROVIDER_LOAD_SKIPPED client=tv fixture=$fixtureSlugForLoad provider64=${{b64(provider.id)}} reason=disabled_platform")
+ }} else {{
+ val scraper = byId[key]
+ if (scraper == null) {{
+ emit("FIELD_NATIVE_PROVIDER_LOAD_ERROR client=tv fixture=$fixtureSlugForLoad provider64=${{b64(provider.id)}} reason=missing_after_repository_install")
+ }} else {{
+ val loadedTypes = scraper.supportedTypes.map {{ it.lowercase() }}.distinct().sorted()
+ val declaredTypes = declaredTypesByProvider[key].orEmpty().sorted()
+ val metadataMatch = scraper.manifestEnabled == provider.enabled && loadedTypes == declaredTypes
+ emit("FIELD_NATIVE_PROVIDER_LOAD_RESULT client=tv fixture=$fixtureSlugForLoad provider64=${{b64(provider.id)}} manifest_enabled=${{scraper.manifestEnabled}} runtime_enabled=${{scraper.enabled}} supported_types64=${{b64(loadedTypes.joinToString(\",\"))}} metadata_match=$metadataMatch")
+ if (!metadataMatch) {{
+ emit("FIELD_NATIVE_PROVIDER_LOAD_ERROR client=tv fixture=$fixtureSlugForLoad provider64=${{b64(provider.id)}} reason=metadata_mismatch")
+ }}
+ }}
+ }}
+ }}
+ return manager to byId
+ }}
+'''
+
+
+def repository_helpers(client: str, manifest_url: str, blocked: list[str]) -> str:
+ desktop_before = ' captureDesktopPhase("repository-http-request", fixtureSlugForLoad)\n' if client == "desktop" else ""
+ desktop_after = ' captureDesktopPhase("repository-http-response", fixtureSlugForLoad)\n' if client == "desktop" else ""
+ return f'''
+ private val repositoryManifestUrl = {kotlin_string(manifest_url)}
+ private val platformExcludedProviders: Set = {platform_set_literal(blocked)}
+
+ private suspend fun loadProvidersThroughNuvio(): Map {{
+ // Preserve Nuvio's active profile/settings and previously downloaded plugin
+ // cache. Reusing the exact repository makes subsequent fixture launches much
+ // faster and mirrors a real user's persistent installation.
+ PluginRepository.initialize()
+ emit("FIELD_NATIVE_REPOSITORY_LOAD_BEGIN client={client} fixture=$fixtureSlugForLoad manifest_host=${{hostOnly(repositoryManifestUrl)}} expected=${{providers.size}}")
+ val existing = PluginRepository.uiState.value.repositories.firstOrNull {{ repo ->
+ repo.manifestUrl == repositoryManifestUrl
+ }}
+ val repositoryUrl = if (existing != null) {{
+ emit("FIELD_NATIVE_REPOSITORY_CACHE_HIT client={client} fixture=$fixtureSlugForLoad repository64=${{b64(existing.name)}}")
+ existing.manifestUrl
+ }} else {{
+{desktop_before} when (val installed = PluginRepository.addRepository(repositoryManifestUrl)) {{
+ is AddPluginRepositoryResult.Success -> {{
+{desktop_after} installed.repository.manifestUrl
+ }}
+ is AddPluginRepositoryResult.Error -> {{
+{desktop_after} emit("FIELD_NATIVE_REPOSITORY_LOAD_ERROR client={client} fixture=$fixtureSlugForLoad reason=install_failed error64=${{b64(installed.message)}}")
+ providers.forEach {{ provider ->
+ val key = provider.id.lowercase()
+ if (key in platformExcludedProviders) {{
+ emit("FIELD_NATIVE_PROVIDER_LOAD_SKIPPED client={client} fixture=$fixtureSlugForLoad provider64=${{b64(provider.id)}} reason=disabled_platform")
+ }} else {{
+ emit("FIELD_NATIVE_PROVIDER_LOAD_ERROR client={client} fixture=$fixtureSlugForLoad provider64=${{b64(provider.id)}} reason=repository_install_failed")
+ }}
+ }}
+ return emptyMap()
+ }}
+ }}
+ }}
+ val loaded = PluginRepository.uiState.value.scrapers.filter {{ it.repositoryUrl == repositoryUrl }}
+ val byId = loaded.associateBy {{ it.id.substringAfterLast(':').lowercase() }}
+ val selectedKeys = providers.map {{ it.id.lowercase() }}.toSet()
+ val selectedLoaded = byId.keys.count {{ it in selectedKeys }}
+ val expectedLoaded = providers.count {{ it.id.lowercase() !in platformExcludedProviders }}
+ emit("FIELD_NATIVE_REPOSITORY_LOAD_RESULT client={client} fixture=$fixtureSlugForLoad expected=$expectedLoaded loaded=$selectedLoaded")
+ providers.forEach {{ provider ->
+ val key = provider.id.lowercase()
+ if (key in platformExcludedProviders) {{
+ emit("FIELD_NATIVE_PROVIDER_LOAD_SKIPPED client={client} fixture=$fixtureSlugForLoad provider64=${{b64(provider.id)}} reason=disabled_platform")
+ }} else {{
+ val scraper = byId[key]
+ if (scraper == null) {{
+ emit("FIELD_NATIVE_PROVIDER_LOAD_ERROR client={client} fixture=$fixtureSlugForLoad provider64=${{b64(provider.id)}} reason=missing_after_repository_install")
+ }} else {{
+ val loadedTypes = scraper.supportedTypes.map {{ it.lowercase() }}.distinct().sorted()
+ val declaredTypes = declaredTypesByProvider[key].orEmpty().sorted()
+ val metadataMatch = scraper.manifestEnabled == provider.enabled && loadedTypes == declaredTypes && scraper.code.isNotBlank()
+ emit("FIELD_NATIVE_PROVIDER_LOAD_RESULT client={client} fixture=$fixtureSlugForLoad provider64=${{b64(provider.id)}} manifest_enabled=${{scraper.manifestEnabled}} runtime_enabled=${{scraper.enabled}} code_bytes=${{scraper.code.toByteArray(Charsets.UTF_8).size}} supported_types64=${{b64(loadedTypes.joinToString(\",\"))}} metadata_match=$metadataMatch")
+ if (!metadataMatch) {{
+ emit("FIELD_NATIVE_PROVIDER_LOAD_ERROR client={client} fixture=$fixtureSlugForLoad provider64=${{b64(provider.id)}} reason=metadata_or_code_mismatch")
+ }}
+ }}
+ }}
+ }}
+ return byId
+ }}
+'''
+
+
+def replace_official_execution(text: str, client: str) -> str:
+ if client == "tv":
+ pattern = re.compile(
+ r"val rows = runtime\.executePlugin\(\s*"
+ r"code = code\(provider\.asset\),\s*"
+ r"tmdbId = tmdbId,\s*"
+ r"mediaType = requestMediaType,\s*"
+ r"season = season,\s*"
+ r"episode = episode,\s*"
+ r"scraperId = provider\.id,\s*"
+ r"\)",
+ flags=re.S,
+ )
+ replacement = "val rows = officialPluginManager.executeScraper(loadedScraper, tmdbId, requestMediaType, season, episode)"
+ else:
+ code_expr = r"File\(root, provider\.asset\)\.readText\(\)" if client == "desktop" else r"code\(provider\.asset\)"
+ pattern = re.compile(
+ r"val rows = PluginRuntime\.executePlugin\(\s*"
+ rf"code = {code_expr},\s*"
+ r"tmdbId = tmdbId,\s*"
+ r"mediaType = requestMediaType,\s*"
+ r"season = season,\s*"
+ r"episode = episode,\s*"
+ r"scraperId = provider\.id,\s*"
+ r"\)",
+ flags=re.S,
+ )
+ replacement = "val rows = PluginRepository.executeScraper(loadedScraper, tmdbId, requestMediaType, season, episode).getOrThrow()"
+ text, count = pattern.subn(replacement, text, count=1)
+ if count != 1:
+ raise SystemExit(f"provider-loading official execution rewrite client={client} count={count}")
+ return text
+
+
+def augment(
+ source: Path,
+ client: str,
+ manifest: Path,
+ manifest_url: str,
+ host_platform: str,
+ allow_local_lab_url: bool = False,
+) -> None:
+ parsed, manifest_transport = validate_manifest_url(manifest_url, allow_local_lab_url)
+ rows = load_manifest(manifest)
+ blocked = excluded_ids(rows, client, host_platform)
+ text = source.read_text(encoding="utf-8")
+ if "FIELD_NATIVE_REPOSITORY_LOAD_BEGIN" in text:
+ print(f"FIELD_NATIVE_PROVIDER_LOADING already=true client={client} source={source}")
+ return
+ text = insert_imports(text, client)
+ if "private val declaredTypesByProvider" not in text or "requestRoutesFor(" not in text:
+ raise SystemExit("provider loading requires the current request-contract augmentation first")
+
+ slug_match = re.search(r"val fixtureSlug = (\"(?:[^\"\\]|\\.)*\")", text)
+ if not slug_match:
+ raise SystemExit("provider-loading fixture slug anchor missing")
+ fixture_literal = slug_match.group(1)
+
+ provider_list = re.search(r"( private val providers = listOf\(\n.*?\n \)\n)", text, flags=re.S)
+ if not provider_list:
+ raise SystemExit("provider-loading provider list anchor missing")
+ helper = f"\n private val fixtureSlugForLoad = {fixture_literal}\n"
+ helper += tv_helpers(manifest_url, blocked) if client == "tv" else repository_helpers(client, manifest_url, blocked)
+ text = text[: provider_list.end()] + helper + text[provider_list.end() :]
+
+ begin_anchor = f' emit("FIELD_NATIVE_CORPUS_BEGIN client={client} fixture=$fixtureSlug'
+ begin_at = text.find(begin_anchor)
+ if begin_at < 0:
+ raise SystemExit("provider-loading corpus begin anchor missing")
+ line_start = text.rfind("\n", 0, begin_at) + 1
+ load_line = (
+ " val (officialPluginManager, loadedProviders) = loadProvidersThroughNuvio()\n"
+ if client == "tv"
+ else " val loadedProviders = loadProvidersThroughNuvio()\n"
+ )
+ text = text[:line_start] + load_line + text[line_start:]
+
+ # The request-contract owns requestRoutes/requestMediaType/routeMode. Insert
+ # official loading checks before route construction, then leave both declared
+ # and capability_probe routes intact.
+ loop_anchor = " for (provider in providers) {\n val requestRoutes = requestRoutesFor(provider.id, mediaType)"
+ loop_replacement = f''' for (provider in providers) {{
+ val providerKey = provider.id.lowercase()
+ if (providerKey in platformExcludedProviders) {{
+ emit("FIELD_NATIVE_PROVIDER_SKIPPED client={client} fixture=$fixtureSlug provider64=${{b64(provider.id)}} enabled=${{provider.enabled}} requested_type=$mediaType declared_types64=${{b64(declaredTypesByProvider[providerKey].orEmpty().sorted().joinToString(\",\"))}} reason=disabled_platform")
+ continue
+ }}
+ val loadedScraper = loadedProviders[providerKey]
+ if (loadedScraper == null) {{
+ emit("FIELD_NATIVE_PROVIDER_SKIPPED client={client} fixture=$fixtureSlug provider64=${{b64(provider.id)}} enabled=${{provider.enabled}} requested_type=$mediaType declared_types64=${{b64(declaredTypesByProvider[providerKey].orEmpty().sorted().joinToString(\",\"))}} reason=load_failure")
+ continue
+ }}
+ val requestRoutes = requestRoutesFor(provider.id, mediaType)'''
+ if text.count(loop_anchor) != 1:
+ raise SystemExit(f"provider-loading route loop anchor count={text.count(loop_anchor)}")
+ text = text.replace(loop_anchor, loop_replacement, 1)
+ text = replace_official_execution(text, client)
+
+ source.write_text(text, encoding="utf-8")
+ print(
+ f"FIELD_NATIVE_PROVIDER_LOADING client={client} providers={len(rows)} platform_blocked={len(blocked)} "
+ f"manifest={manifest} manifest_host={parsed.hostname} manifest_transport={manifest_transport} source={source}"
+ )
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("client", choices=("tv", "mobile", "desktop"))
+ parser.add_argument("--manifest", required=True)
+ parser.add_argument("--manifest-url", required=True)
+ parser.add_argument("--source", required=True)
+ parser.add_argument("--platform", default="", help="desktop host: macos or windows")
+ parser.add_argument(
+ "--allow-local-lab-url",
+ action="store_true",
+ help="allow only localhost/127.0.0.1/10.0.2.2 HTTP repository URLs for an isolated repair lab",
+ )
+ args = parser.parse_args()
+ manifest = Path(args.manifest)
+ if not manifest.is_absolute():
+ manifest = (ROOT / manifest).resolve()
+ augment(
+ Path(args.source).resolve(),
+ args.client,
+ manifest,
+ args.manifest_url,
+ args.platform,
+ allow_local_lab_url=args.allow_local_lab_url,
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/build_native_reader_brain_repair.py b/scripts/build_native_reader_brain_repair.py
new file mode 100644
index 000000000..2a84a9a02
--- /dev/null
+++ b/scripts/build_native_reader_brain_repair.py
@@ -0,0 +1,418 @@
+#!/usr/bin/env python3
+"""Materialize bounded provider-agnostic repair candidates from native-reader Brain evidence.
+
+Native readers are compatibility observers, not authorities that may condemn a
+provider globally. A provider mutation is eligible only after the same declared
+route fails on at least two distinct Nuvio client families and no peer client has a
+healthy observation for that provider/route/fixture. Single-client/OS failures remain
+compatibility evidence for future Nuvio updates and Core analysis; they are never
+turned into provider JS mutations here.
+
+This remains a Learning Lab mutation step, never publication. Candidate bundles still
+require fresh native proof before they can be considered successful.
+"""
+from __future__ import annotations
+
+import argparse
+import copy
+import hashlib
+import importlib.util
+import json
+import re
+from pathlib import Path
+from typing import Any
+
+ROOT = Path(__file__).resolve().parents[1]
+PATCH_PATH = ROOT / "scripts/provider_patches/global_media_enrichment_v1.py"
+MIN_DISTINCT_CLIENTS_FOR_GLOBAL_PROVIDER_REPAIR = 2
+
+HYPOTHESIS_SKILLS: dict[str, tuple[str, ...]] = {
+ "replay-native-request-context": ("global_media_enrichment_v1",),
+ "refresh-access-bound-media": ("global_media_enrichment_v1",),
+ "refresh-terminal-media-candidate": ("global_media_enrichment_v1",),
+ "inspect-unexpected-media-response": ("global_media_enrichment_v1",),
+ "resolve-real-media-not-wrapper": ("global_media_enrichment_v1",),
+ "repair-reader-io-contract": ("global_media_enrichment_v1",),
+ "reject-short-or-preview-media": ("global_media_enrichment_v1",),
+}
+
+
+def load_json(path: Path) -> dict[str, Any]:
+ value = json.loads(path.read_text(encoding="utf-8"))
+ if not isinstance(value, dict):
+ raise ValueError(f"{path}: expected JSON object")
+ return value
+
+
+def load_optional(path: Path | None) -> dict[str, Any]:
+ if path is None or not path.is_file():
+ return {}
+ try:
+ return load_json(path)
+ except (OSError, json.JSONDecodeError, ValueError):
+ return {}
+
+
+def write_json(path: Path, value: Any) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
+
+
+def safe_fragment(value: str) -> str:
+ return re.sub(r"[^A-Za-z0-9._-]+", "-", value).strip(".-")[:100] or "provider"
+
+
+def sha256(data: bytes) -> str:
+ return hashlib.sha256(data).hexdigest()
+
+
+def load_patch_module():
+ spec = importlib.util.spec_from_file_location("native_reader_global_media_enrichment", PATCH_PATH)
+ if spec is None or spec.loader is None:
+ raise RuntimeError(f"cannot load {PATCH_PATH}")
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+def local_provider_path(filename: str) -> Path:
+ raw = str(filename or "").strip()
+ if not raw or raw.startswith(("http://", "https://")):
+ raise ValueError(f"native reader repair requires an in-repository provider bundle: {raw!r}")
+ path = (ROOT / raw).resolve()
+ path.relative_to(ROOT.resolve())
+ if not path.is_file():
+ raise FileNotFoundError(path)
+ return path
+
+
+def _key(row: dict[str, Any]) -> tuple[str, str, str]:
+ return (
+ str(row.get("provider") or "").casefold().strip(),
+ str(row.get("requestType") or row.get("request_type") or "unknown").casefold().strip(),
+ str(row.get("fixture") or "").strip(),
+ )
+
+
+def diagnosis_targets(
+ diagnosis: dict[str, Any],
+ max_providers: int,
+ fixture: str | None = None,
+) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
+ """Return globally repairable targets plus compatibility-only skips.
+
+ A native failure is globally mutable only when:
+ * evidence contains the same provider/request/fixture failure on >=2 distinct
+ Nuvio client families; and
+ * no client has a healthy declared-route observation for that same key.
+
+ This deliberately treats Windows/macOS as the same `desktop` family unless the
+ evidence schema later exposes distinct production player families. OS-specific
+ failures therefore cannot accidentally satisfy cross-client consensus.
+ """
+ wanted_fixture = str(fixture or "").strip()
+ healthy_clients: dict[tuple[str, str, str], set[str]] = {}
+ for raw in diagnosis.get("observations") or []:
+ if not isinstance(raw, dict):
+ continue
+ if str(raw.get("routeMode") or raw.get("route_mode") or "declared").casefold() == "capability_probe":
+ continue
+ if str(raw.get("failureClass") or "").casefold() != "healthy":
+ continue
+ key = _key(raw)
+ if wanted_fixture and key[2] != wanted_fixture:
+ continue
+ client = str(raw.get("client") or "unknown").casefold().strip() or "unknown"
+ healthy_clients.setdefault(key, set()).add(client)
+
+ grouped: dict[tuple[str, str, str], dict[str, Any]] = {}
+ for raw in diagnosis.get("plans") or []:
+ if not isinstance(raw, dict):
+ continue
+ if wanted_fixture and str(raw.get("fixture") or "") != wanted_fixture:
+ continue
+ if str(raw.get("routeMode") or "declared").casefold() == "capability_probe":
+ continue
+ provider = str(raw.get("provider") or "").casefold().strip()
+ if not provider or str(raw.get("action") or "") != "probe-targeted-repair":
+ continue
+ key = _key(raw)
+ target = grouped.setdefault(key, {
+ "provider": provider,
+ "requestType": key[1],
+ "fixture": key[2],
+ "failureClasses": [],
+ "hypotheses": [],
+ "occurrences": 0,
+ "fixtures": [],
+ "failingClients": [],
+ "healthyClients": [],
+ })
+ target["occurrences"] += 1
+ raw_fixture = str(raw.get("fixture") or "")
+ if raw_fixture and raw_fixture not in target["fixtures"]:
+ target["fixtures"].append(raw_fixture)
+ client = str(raw.get("client") or "unknown").casefold().strip() or "unknown"
+ if client not in target["failingClients"]:
+ target["failingClients"].append(client)
+ failure = str(raw.get("failureClass") or "unknown_failure")
+ if failure not in target["failureClasses"]:
+ target["failureClasses"].append(failure)
+ for hypothesis in raw.get("hypotheses") or []:
+ if not isinstance(hypothesis, dict):
+ continue
+ hid = str(hypothesis.get("id") or "")
+ if hid and hid not in target["hypotheses"]:
+ target["hypotheses"].append(hid)
+
+ eligible: list[dict[str, Any]] = []
+ compatibility_only: list[dict[str, Any]] = []
+ for key, target in grouped.items():
+ target["failingClients"] = sorted(set(target["failingClients"]))
+ target["healthyClients"] = sorted(healthy_clients.get(key, set()))
+ target["crossClientConfirmed"] = (
+ len(target["failingClients"]) >= MIN_DISTINCT_CLIENTS_FOR_GLOBAL_PROVIDER_REPAIR
+ and not target["healthyClients"]
+ )
+ if target["healthyClients"]:
+ compatibility_only.append({
+ **target,
+ "reason": "client_specific_failure_has_healthy_peer",
+ "providerMutationAllowed": False,
+ })
+ elif len(target["failingClients"]) < MIN_DISTINCT_CLIENTS_FOR_GLOBAL_PROVIDER_REPAIR:
+ compatibility_only.append({
+ **target,
+ "reason": "insufficient_cross_client_confirmation",
+ "providerMutationAllowed": False,
+ })
+ else:
+ eligible.append(target)
+
+ eligible.sort(key=lambda row: (-int(row["occurrences"]), row["provider"], row["requestType"]))
+ compatibility_only.sort(key=lambda row: (-int(row["occurrences"]), row["provider"], row["requestType"]))
+
+ # Keep at most max_providers distinct providers, while retaining multiple
+ # corroborated request/fixture rows for those providers.
+ selected_providers: list[str] = []
+ selected: list[dict[str, Any]] = []
+ for row in eligible:
+ provider = row["provider"]
+ if provider not in selected_providers:
+ if len(selected_providers) >= max_providers:
+ continue
+ selected_providers.append(provider)
+ selected.append(row)
+ return selected, compatibility_only
+
+
+def skill_memory(learning: dict[str, Any], target: dict[str, Any], skill: str) -> dict[str, int]:
+ memory = learning.get("nativeReaderRepairMemory") if isinstance(learning.get("nativeReaderRepairMemory"), dict) else {}
+ entries = memory.get("entries") if isinstance(memory.get("entries"), list) else []
+ provider = str(target.get("provider") or "").casefold()
+ failures = {str(value) for value in target.get("failureClasses") or []}
+ fixtures = {str(value) for value in target.get("fixtures") or []}
+ matched = [
+ row for row in entries
+ if isinstance(row, dict)
+ and str(row.get("providerId") or "").casefold() == provider
+ and str(row.get("skill") or "") == skill
+ and (not failures or str(row.get("failureClass") or "") in failures)
+ and (not fixtures or str(row.get("fixture") or "") in fixtures)
+ ]
+ return {
+ "attempts": sum(max(0, int(row.get("attempts") or 0)) for row in matched),
+ "successes": sum(max(0, int(row.get("successes") or 0)) for row in matched),
+ "failures": sum(max(0, int(row.get("failures") or 0)) for row in matched),
+ "consecutiveFailures": max([max(0, int(row.get("consecutiveFailures") or 0)) for row in matched] or [0]),
+ }
+
+
+def skills_for(target: dict[str, Any], learning: dict[str, Any], avoid_threshold: int = 2) -> tuple[list[str], list[str]]:
+ candidates: list[str] = []
+ for hypothesis in target.get("hypotheses") or []:
+ for skill in HYPOTHESIS_SKILLS.get(str(hypothesis), ()):
+ if skill not in candidates:
+ candidates.append(skill)
+ ranked: list[tuple[int, int, str]] = []
+ suppressed: list[str] = []
+ for skill in candidates:
+ memory = skill_memory(learning, target, skill)
+ if memory["successes"] == 0 and memory["consecutiveFailures"] >= avoid_threshold:
+ suppressed.append(skill)
+ continue
+ ranked.append((-memory["successes"], memory["failures"], skill))
+ return [skill for _success, _failures, skill in sorted(ranked)], suppressed
+
+
+def apply_skill(source: str, skill: str, patch_module) -> str:
+ if skill != "global_media_enrichment_v1":
+ raise ValueError(f"unsupported reader repair skill: {skill}")
+ return patch_module.apply(source, options={
+ "max_rows": 12,
+ "max_depth": 3,
+ "max_candidates": 20,
+ "timeout_ms": 9000,
+ "default_user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
+ })
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--diagnosis", type=Path, required=True)
+ parser.add_argument("--manifest", type=Path, default=ROOT / "manifest.json")
+ parser.add_argument("--output-dir", type=Path, default=ROOT / "native-reader-repair")
+ parser.add_argument("--learning-state", type=Path, help="optional prior sanitized Brain learning state")
+ parser.add_argument("--fixture", default="", help="optional fixture slug; only its reader failures are considered")
+ parser.add_argument("--max-providers", type=int, default=12)
+ parser.add_argument("--avoid-threshold", type=int, default=2)
+ args = parser.parse_args()
+
+ diagnosis = load_json(args.diagnosis.resolve())
+ manifest = load_json(args.manifest.resolve())
+ learning = load_optional(args.learning_state.resolve() if args.learning_state else None)
+ output_dir = args.output_dir.resolve()
+ output_dir.mkdir(parents=True, exist_ok=True)
+ providers_dir = output_dir / "providers"
+ providers_dir.mkdir(parents=True, exist_ok=True)
+
+ target_rows, compatibility_only = diagnosis_targets(
+ diagnosis,
+ max(1, min(int(args.max_providers), 24)),
+ args.fixture.strip() or None,
+ )
+ by_id = {
+ str(row.get("id") or "").casefold(): row
+ for row in manifest.get("scrapers") or []
+ if isinstance(row, dict) and row.get("id")
+ }
+ proposed_manifest = copy.deepcopy(manifest)
+ proposed_by_id = {
+ str(row.get("id") or "").casefold(): row
+ for row in proposed_manifest.get("scrapers") or []
+ if isinstance(row, dict) and row.get("id")
+ }
+ patch_module = load_patch_module()
+ proposals: list[dict[str, Any]] = []
+ skipped: list[dict[str, Any]] = [dict(row) for row in compatibility_only]
+
+ # Collapse multiple corroborated request rows into one provider mutation proposal.
+ provider_targets: dict[str, dict[str, Any]] = {}
+ for target in target_rows:
+ provider = target["provider"]
+ merged = provider_targets.setdefault(provider, {
+ "provider": provider,
+ "failureClasses": [],
+ "hypotheses": [],
+ "occurrences": 0,
+ "fixtures": [],
+ "requestTypes": [],
+ "failingClients": [],
+ })
+ merged["occurrences"] += int(target.get("occurrences") or 0)
+ for field, source in (
+ ("failureClasses", target.get("failureClasses") or []),
+ ("hypotheses", target.get("hypotheses") or []),
+ ("fixtures", target.get("fixtures") or []),
+ ("requestTypes", [target.get("requestType")]),
+ ("failingClients", target.get("failingClients") or []),
+ ):
+ for value in source:
+ if value and value not in merged[field]:
+ merged[field].append(value)
+
+ for target in sorted(provider_targets.values(), key=lambda row: (-int(row["occurrences"]), row["provider"])):
+ provider = target["provider"]
+ row = by_id.get(provider)
+ proposed_row = proposed_by_id.get(provider)
+ skills, suppressed = skills_for(target, learning, max(1, int(args.avoid_threshold)))
+ if row is None or proposed_row is None:
+ skipped.append({"provider": provider, "reason": "provider_not_in_manifest"})
+ continue
+ if not skills:
+ skipped.append({
+ "provider": provider,
+ "reason": "all_compatible_reader_skills_suppressed_by_negative_memory" if suppressed else "no_allowlisted_reader_repair_skill",
+ "failureClasses": target["failureClasses"],
+ "hypotheses": target["hypotheses"],
+ "failingClients": target["failingClients"],
+ "providerMutationAllowed": False,
+ "suppressedSkills": suppressed,
+ })
+ continue
+ try:
+ source_path = local_provider_path(str(row.get("filename") or ""))
+ original = source_path.read_text(encoding="utf-8")
+ repaired = original
+ for skill in skills:
+ repaired = apply_skill(repaired, skill, patch_module)
+ except Exception as error:
+ skipped.append({"provider": provider, "reason": f"mutation_error:{type(error).__name__}"})
+ continue
+ original_bytes = original.encode("utf-8")
+ repaired_bytes = repaired.encode("utf-8")
+ if repaired_bytes == original_bytes:
+ skipped.append({"provider": provider, "reason": "mutation_made_no_change", "skills": skills})
+ continue
+
+ digest = sha256(repaired_bytes)
+ target_file = providers_dir / f"{safe_fragment(provider)}--brain-reader--{digest[:16]}.js"
+ target_file.write_bytes(repaired_bytes)
+ relative = target_file.relative_to(ROOT).as_posix()
+ proposed_row["filename"] = relative
+ proposals.append({
+ "provider": provider,
+ "fixtures": target["fixtures"],
+ "requestTypes": target["requestTypes"],
+ "failureClasses": target["failureClasses"],
+ "hypotheses": target["hypotheses"],
+ "skills": skills,
+ "suppressedSkills": suppressed,
+ "occurrences": target["occurrences"],
+ "failingClients": sorted(target["failingClients"]),
+ "crossClientConfirmed": True,
+ "sourceSha256": sha256(original_bytes),
+ "candidateSha256": digest,
+ "candidateFile": relative,
+ "requiresFreshNativeReaderProof": True,
+ })
+
+ candidate_manifest = output_dir / "manifest.json"
+ write_json(candidate_manifest, proposed_manifest)
+ report = {
+ "schemaVersion": 3,
+ "brainVersion": diagnosis.get("brainVersion"),
+ "mode": "native_reader_repair_sandbox",
+ "fixtureScope": args.fixture.strip() or "all",
+ "diagnosedReaderFailures": int(diagnosis.get("readerFailures") or 0),
+ "proposalCount": len(proposals),
+ "providers": [row["provider"] for row in proposals],
+ "proposals": proposals,
+ "skipped": skipped,
+ "compatibilityOnlyCount": len(compatibility_only),
+ "learningApplied": bool(learning.get("nativeReaderRepairMemory")),
+ "policy": {
+ "productionWritesAllowed": False,
+ "publicationAllowed": False,
+ "candidateManifestOnly": True,
+ "requireFreshNativeReaderProof": True,
+ "minimumDistinctClientFamiliesForProviderMutation": MIN_DISTINCT_CLIENTS_FOR_GLOBAL_PROVIDER_REPAIR,
+ "healthyPeerVetoesProviderMutation": True,
+ "singleClientFailureIsCompatibilityEvidenceOnly": True,
+ "maxMutationProviders": max(1, min(int(args.max_providers), 24)),
+ "avoidRepeatedFailedSkillThreshold": max(1, int(args.avoid_threshold)),
+ },
+ "privacy": "No raw media URLs, query tokens, cookie values, authorization values or response-header values are written to this repair report.",
+ }
+ write_json(output_dir / "repair-report.json", report)
+ print(
+ f"FIELD_NATIVE_READER_REPAIR proposals={len(proposals)} skipped={len(skipped)} "
+ f"compatibility_only={len(compatibility_only)} reader_failures={report['diagnosedReaderFailures']} "
+ f"fixture={report['fixtureScope']} learning={str(report['learningApplied']).lower()} "
+ f"manifest={candidate_manifest.relative_to(ROOT)}"
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/capture_native_device_frontend.sh b/scripts/capture_native_device_frontend.sh
new file mode 100644
index 000000000..7996241a8
--- /dev/null
+++ b/scripts/capture_native_device_frontend.sh
@@ -0,0 +1,25 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+CLIENT="${1:?client required}"
+PHASE="${2:?phase required}"
+OUT_DIR="${3:?output directory required}"
+mkdir -p "$OUT_DIR"
+
+safe_phase="$(printf '%s' "$PHASE" | tr -cs 'A-Za-z0-9._-' '_' | cut -c1-120)"
+stamp="$(date -u +%Y%m%dT%H%M%SZ)"
+prefix="$OUT_DIR/${CLIENT}-${stamp}-${safe_phase}"
+
+# The framebuffer is the canonical front-end proof. Keep this synchronous path
+# intentionally tiny: uiautomator/dumpsys waits used to block logcat consumption,
+# build a multi-minute backlog, and assign otherwise genuine screenshots to the
+# wrong corpus fixture. The structured runtime markers already carry the exact
+# backend phase; the screenshot proves what the official client displayed then.
+adb exec-out screencap -p > "${prefix}.png"
+if [[ ! -s "${prefix}.png" ]]; then
+ echo "FIELD_NATIVE_FRONTEND_ERROR client=$CLIENT phase=$safe_phase reason=empty_screenshot" >&2
+ exit 2
+fi
+
+bytes="$(wc -c < "${prefix}.png" | tr -d ' ')"
+echo "FIELD_NATIVE_FRONTEND_CAPTURE client=$CLIENT phase=$safe_phase screenshot=$(basename "${prefix}.png") bytes=$bytes"
diff --git a/scripts/check_nuvio_client_upstreams.py b/scripts/check_nuvio_client_upstreams.py
index e90c1bde6..2ed8efcde 100644
--- a/scripts/check_nuvio_client_upstreams.py
+++ b/scripts/check_nuvio_client_upstreams.py
@@ -7,6 +7,7 @@
import subprocess
import sys
import tempfile
+from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
@@ -205,7 +206,17 @@ def current_head(repository: str, branch: str) -> str:
return sha
-def compare(repository: str, base: str, head: str) -> dict[str, Any]:
+def compare(
+ repository: str,
+ base: str,
+ head: str,
+ patch_rules: list[str] | None = None,
+) -> dict[str, Any]:
+ """Compare refs while materializing patches only for semantic-review paths.
+
+ Hard contract changes need only their filenames. Diffing every changed file was
+ the dominant cost of the native Lab resolver and provided no extra evidence.
+ """
with tempfile.TemporaryDirectory(prefix="niakvio-client-drift-") as tmp:
work = Path(tmp)
run_git(["init", "--quiet"], cwd=work)
@@ -244,8 +255,12 @@ def compare(repository: str, base: str, head: str) -> dict[str, Any]:
timeout=30,
)
files = [name.strip() for name in names.splitlines() if name.strip()]
+ if patch_rules:
+ patch_files = [name for name in files if path_matches(name, patch_rules)][:250]
+ else:
+ patch_files = files[:250]
patches: dict[str, str] = {}
- for filename in files[:250]:
+ for filename in patch_files:
patch = run_git(
[
"diff",
@@ -315,7 +330,7 @@ def inspect_client(key: str, row: dict[str, Any], sources: dict[str, Any] | None
if head == accepted_ref:
return result
- comparison = compare(repository, accepted_ref, head)
+ comparison = compare(repository, accepted_ref, head, semantic_rules)
status = str(comparison.get("status") or "unknown")
patches = comparison.get("patches") or {}
files = [
@@ -389,6 +404,8 @@ def apply_safe_state(
advanced: list[str] = []
for key, row in (config.get("clients") or {}).items():
+ if key not in results:
+ continue
result = results[key]
contract_ref = str(row.get("verified_ref") or "")
state = clients.get(key)
@@ -431,6 +448,11 @@ def main() -> int:
"--output",
default=str(ROOT / "health-output" / "nuvio-client-upstream-status.json"),
)
+ parser.add_argument(
+ "--clients",
+ nargs="+",
+ help="Inspect only these configured client ids. Omit for the full registry.",
+ )
parser.add_argument(
"--no-fail",
action="store_true",
@@ -460,12 +482,20 @@ def main() -> int:
"Nuvio client upstream configuration invalid:\n- " + "\n- ".join(config_errors)
)
+ all_clients = config.get("clients") or {}
+ requested = args.clients or list(all_clients)
+ unknown = [key for key in requested if key not in all_clients]
+ if unknown:
+ raise SystemExit("unknown Nuvio client ids: " + ", ".join(unknown))
+ selected_items = [(key, all_clients[key]) for key in requested]
+
now = datetime.now(timezone.utc).isoformat()
report: dict[str, Any] = {
- "schema_version": 3,
+ "schema_version": 4,
"generated_at": now,
- "transport": "git-ls-remote-plus-partial-tree-diff",
+ "transport": "parallel-git-ls-remote-plus-targeted-partial-tree-diff",
"policy": config.get("policy") or {},
+ "selected_clients": requested,
"clients": {},
"review_required": [],
"safe_advance_available": [],
@@ -474,12 +504,11 @@ def main() -> int:
"inconclusive": [],
}
- failures: list[str] = []
- for key, row in (config.get("clients") or {}).items():
+ def inspect_one(key: str, row: dict[str, Any]) -> dict[str, Any]:
try:
- result = resilient_inspect_client(str(key), row, sources)
+ return resilient_inspect_client(str(key), row, sources)
except Exception as error:
- result = {
+ return {
"id": key,
"repository": row.get("repository"),
"branch": row.get("branch"),
@@ -488,6 +517,15 @@ def main() -> int:
"review_required": True,
"error": f"{type(error).__name__}: {error}",
}
+
+ worker_count = max(1, min(3, len(selected_items)))
+ with ThreadPoolExecutor(max_workers=worker_count, thread_name_prefix="nuvio-drift") as pool:
+ futures = {key: pool.submit(inspect_one, key, row) for key, row in selected_items}
+ inspected = {key: futures[key].result() for key, _row in selected_items}
+
+ failures: list[str] = []
+ for key, _row in selected_items:
+ result = inspected[key]
report["clients"][key] = result
status = result.get("status")
if status == "verified":
@@ -529,7 +567,8 @@ def main() -> int:
if args.apply_safe_advance and not failures and not report["inconclusive"]:
advanced = apply_safe_state(sources, config, report["clients"], now)
- if advanced or "nuvio_client_compatibility" not in load(sources_path):
+ previous_sources = load(sources_path) if sources_path.is_file() else {}
+ if advanced or "nuvio_client_compatibility" not in previous_sources:
dump(sources_path, sources)
report["auto_advanced"] = advanced
for key in advanced:
@@ -555,6 +594,7 @@ def main() -> int:
print(
"Nuvio client upstream drift check: "
+ f"selected={','.join(requested) or '-'}; "
f"verified={','.join(report['verified']) or '-'}; "
f"safe={','.join(report['safe_advance_available']) or '-'}; "
f"auto_advanced={','.join(report['auto_advanced']) or '-'}; "
diff --git a/scripts/compare_native_reader_brain_repair.py b/scripts/compare_native_reader_brain_repair.py
new file mode 100644
index 000000000..393ade3d4
--- /dev/null
+++ b/scripts/compare_native_reader_brain_repair.py
@@ -0,0 +1,126 @@
+#!/usr/bin/env python3
+"""Compare native-reader outcomes before/after a Brain sandbox mutation."""
+# Kept in the native-reader workflow path set so reader-proof changes trigger a fresh exact-run comparison.
+from __future__ import annotations
+
+import argparse
+import json
+from pathlib import Path
+from typing import Any
+
+
+def load_json(path: Path) -> dict[str, Any]:
+ value = json.loads(path.read_text(encoding="utf-8"))
+ if not isinstance(value, dict):
+ raise ValueError(f"{path}: expected object")
+ return value
+
+
+def safe_runtime_fingerprint(value: Any) -> str:
+ text = str(value or "").strip()
+ lowered = text.casefold()
+ if not text:
+ return ""
+ if len(text) > 700 or "://" in text or any(token in lowered for token in ("authorization=", "cookie=", "token=", "secret=")):
+ raise ValueError("unsafe runtime fingerprint")
+ return text
+
+
+def aggregate(diagnosis: dict[str, Any], fixture: str) -> dict[str, dict[str, Any]]:
+ output: dict[str, dict[str, Any]] = {}
+ for raw in diagnosis.get("observations") or []:
+ if not isinstance(raw, dict) or str(raw.get("fixture") or "") != fixture:
+ continue
+ provider = str(raw.get("provider") or "").casefold().strip()
+ if not provider:
+ continue
+ row = output.setdefault(provider, {"observed": 0, "healthy": 0, "failures": 0, "failureClasses": {}})
+ row["observed"] += 1
+ failure = str(raw.get("failureClass") or "unknown_failure")
+ if failure == "healthy":
+ row["healthy"] += 1
+ else:
+ row["failures"] += 1
+ row["failureClasses"][failure] = int(row["failureClasses"].get(failure) or 0) + 1
+ return output
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--before", type=Path, required=True)
+ parser.add_argument("--after", type=Path, required=True)
+ parser.add_argument("--repair-report", type=Path, required=True)
+ parser.add_argument("--fixture", required=True)
+ parser.add_argument("--runtime-fingerprint", default="")
+ parser.add_argument("--output", type=Path, required=True)
+ args = parser.parse_args()
+
+ before = aggregate(load_json(args.before), args.fixture)
+ after = aggregate(load_json(args.after), args.fixture)
+ repair = load_json(args.repair_report)
+ runtime_fingerprint = safe_runtime_fingerprint(args.runtime_fingerprint or repair.get("runtimeFingerprint"))
+ accepted: list[dict[str, Any]] = []
+ rejected: list[dict[str, Any]] = []
+ inconclusive: list[dict[str, Any]] = []
+
+ for proposal in repair.get("proposals") or []:
+ if not isinstance(proposal, dict):
+ continue
+ provider = str(proposal.get("provider") or "").casefold().strip()
+ pre = before.get(provider, {"observed": 0, "healthy": 0, "failures": 0, "failureClasses": {}})
+ post = after.get(provider, {"observed": 0, "healthy": 0, "failures": 0, "failureClasses": {}})
+ row = {
+ "provider": provider,
+ "fixture": args.fixture,
+ "candidateFile": proposal.get("candidateFile"),
+ "failureClasses": [str(value) for value in proposal.get("failureClasses") or [] if str(value)],
+ "hypotheses": [str(value) for value in proposal.get("hypotheses") or [] if str(value)],
+ "skills": [str(value) for value in proposal.get("skills") or [] if str(value)],
+ "before": pre,
+ "after": post,
+ }
+ if int(pre["failures"]) <= 0:
+ row["reason"] = "no_baseline_reader_failure_for_fixture"
+ inconclusive.append(row)
+ elif int(post["observed"]) <= 0:
+ row["reason"] = "no_fresh_reader_evidence_after_mutation"
+ inconclusive.append(row)
+ elif int(post["failures"]) == 0 and int(post["healthy"]) == int(post["observed"]):
+ row["reason"] = "fresh_native_reader_proof_green"
+ accepted.append(row)
+ else:
+ row["reason"] = "reader_failure_persisted_or_changed"
+ rejected.append(row)
+
+ payload = {
+ "schemaVersion": 3,
+ "fixture": args.fixture,
+ "runtimeFingerprint": runtime_fingerprint or None,
+ "acceptedCount": len(accepted),
+ "rejectedCount": len(rejected),
+ "inconclusiveCount": len(inconclusive),
+ "acceptedProviders": [row["provider"] for row in accepted],
+ "rejectedProviders": [row["provider"] for row in rejected],
+ "accepted": accepted,
+ "rejected": rejected,
+ "inconclusive": inconclusive,
+ "policy": {
+ "productionWritesAllowed": False,
+ "publicationAllowed": False,
+ "acceptedRequiresAllPlayedStreamsHealthy": True,
+ "freshNativeReaderProofRequired": True,
+ "readerLearningReuseRequiresExactRuntimeFingerprint": True,
+ },
+ "privacy": "Only provider ids, fixture ids, failure classes, generic hypothesis/skill ids, aggregate reader outcomes and official client revision fingerprints are persisted; no raw media URLs, tokens, cookies or header values.",
+ }
+ args.output.parent.mkdir(parents=True, exist_ok=True)
+ args.output.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
+ print(
+ f"FIELD_NATIVE_READER_REPAIR_COMPARE fixture={args.fixture} accepted={len(accepted)} "
+ f"rejected={len(rejected)} inconclusive={len(inconclusive)} runtime_scoped={str(bool(runtime_fingerprint)).lower()}"
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/complete_native_desktop_frontend_phases.py b/scripts/complete_native_desktop_frontend_phases.py
new file mode 100644
index 000000000..472dc530b
--- /dev/null
+++ b/scripts/complete_native_desktop_frontend_phases.py
@@ -0,0 +1,114 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import argparse
+from pathlib import Path
+
+
+def replace_once(text: str, old: str, new: str, label: str) -> str:
+ count = text.count(old)
+ if count != 1:
+ raise SystemExit(f"desktop frontend phase anchor {label!r} count={count}")
+ return text.replace(old, new, 1)
+
+
+def canonicalize_http_terminal_phases(text: str) -> str:
+ # The native evidence contract defines a terminal HTTP phase as either a
+ # response or an error. Older desktop augmentation emitted a misleading
+ # *-http-response phase even when FIELD_NATIVE_*_HTTP_ERROR was terminal.
+ # Keep the repository migration explicit because provider-loading generates
+ # this exact fixtureSlugForLoad call; then retain generic fallbacks for already
+ # generated labs that may use another fixture expression.
+ text = text.replace(
+ 'captureDesktopPhase("repository-http-response", fixtureSlugForLoad)',
+ 'captureDesktopPhase("repository-http-terminal", fixtureSlugForLoad)',
+ )
+ return (
+ text.replace(
+ 'captureDesktopPhase("repository-http-response",',
+ 'captureDesktopPhase("repository-http-terminal",',
+ )
+ .replace(
+ 'captureDesktopPhase("provider-http-response",',
+ 'captureDesktopPhase("provider-http-terminal",',
+ )
+ )
+
+
+def strip_branch_specific_repository_http_phases(text: str) -> str:
+ # Provider loading used to add repository HTTP frontend checkpoints only in
+ # the addRepository branch. On later fixtures PluginRepository.initialize()
+ # can refresh an already-installed repository before the cache-hit branch is
+ # selected, producing real HTTP evidence without matching frontend phases.
+ # The actual FIELD_NATIVE_REPOSITORY_HTTP_* recorder remains the source of
+ # truth; these checkpoints only bracket the official repository operation.
+ for line in (
+ ' captureDesktopPhase("repository-http-request", fixtureSlugForLoad)\n',
+ ' captureDesktopPhase("repository-http-terminal", fixtureSlugForLoad)\n',
+ ):
+ text = text.replace(line, "")
+ return text
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("source")
+ args = parser.parse_args()
+ path = Path(args.source).resolve()
+ original = path.read_text(encoding="utf-8")
+ text = canonicalize_http_terminal_phases(original)
+ text = strip_branch_specific_repository_http_phases(text)
+ if 'captureDesktopPhase("ui-launched", fixtureSlug)' in text:
+ if text != original:
+ path.write_text(text, encoding="utf-8")
+ print(f"FIELD_NATIVE_DESKTOP_FRONTEND_PHASES source={path} canonicalized=true")
+ return 0
+
+ # The repository call itself owns the outcome. The HTTP frontend checkpoints
+ # bracket the whole official load operation, so they also exist on persistent
+ # cache/refresh paths. They never substitute for FIELD_NATIVE_REPOSITORY_HTTP_*
+ # transport evidence and therefore cannot manufacture an HTTP success.
+ text = replace_once(
+ text,
+ ' val loadedProviders = loadProvidersThroughNuvio()\n',
+ ' captureDesktopPhase("ui-launched", fixtureSlug)\n'
+ ' captureDesktopPhase("repository-load", fixtureSlug)\n'
+ ' captureDesktopPhase("repository-http-request", fixtureSlug)\n'
+ ' val loadedProviders = loadProvidersThroughNuvio()\n'
+ ' captureDesktopPhase("repository-http-terminal", fixtureSlug)\n'
+ ' captureDesktopPhase("provider-load-state", fixtureSlug)\n',
+ "repository loading",
+ )
+ repository_result = ' emit("FIELD_NATIVE_REPOSITORY_LOAD_RESULT client=desktop fixture=$fixtureSlugForLoad expected=$expectedLoaded loaded=$selectedLoaded")'
+ text = replace_once(
+ text,
+ repository_result,
+ ' captureDesktopPhase("repository-loaded", fixtureSlugForLoad)\n' + repository_result,
+ "repository success",
+ )
+ repository_error = ' emit("FIELD_NATIVE_REPOSITORY_LOAD_ERROR client=desktop fixture=$fixtureSlugForLoad reason=install_failed error64=${b64(installed.message)}")'
+ text = replace_once(
+ text,
+ repository_error,
+ ' captureDesktopPhase("repository-load-error", fixtureSlugForLoad)\n' + repository_error,
+ "repository error",
+ )
+ text = replace_once(
+ text,
+ ' captureDesktopPhase("provider-loading", fixtureSlug)',
+ ' captureDesktopPhase("provider-loading", fixtureSlug)\n captureDesktopPhase("provider-http-request", fixtureSlug)',
+ "provider request",
+ )
+ text = replace_once(
+ text,
+ ' captureDesktopPhase("provider-result", fixtureSlug)',
+ ' captureDesktopPhase("provider-http-terminal", fixtureSlug)\n captureDesktopPhase("provider-result", fixtureSlug)',
+ "provider terminal",
+ )
+ path.write_text(text, encoding="utf-8")
+ print(f"FIELD_NATIVE_DESKTOP_FRONTEND_PHASES source={path} canonicalized=true")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/configure_native_android_lab_transport.py b/scripts/configure_native_android_lab_transport.py
new file mode 100644
index 000000000..7d5b8c084
--- /dev/null
+++ b/scripts/configure_native_android_lab_transport.py
@@ -0,0 +1,79 @@
+#!/usr/bin/env python3
+"""Enforce production-equivalent Android transport conditions for native UX labs.
+
+The lab is observational. It must never make playback or repository traffic easier
+than the accepted Nuvio application would make it on the same device. In particular
+this helper must not add INTERNET permission, cleartext exceptions, network-security
+configuration, proxy/DNS overrides or any other test-only transport capability.
+
+It is intentionally a validator rather than a configurator. Existing test manifests
+are accepted only when they do not opt into cleartext transport. Production manifests
+are never modified.
+"""
+from __future__ import annotations
+
+import argparse
+import xml.etree.ElementTree as ET
+from pathlib import Path
+
+ANDROID_NS = "http://schemas.android.com/apk/res/android"
+ANDROID = f"{{{ANDROID_NS}}}"
+
+
+def _assert_test_manifest(path: Path) -> None:
+ normalized = path.as_posix().lower()
+ if "/src/main/" in normalized or normalized.endswith("/src/main/androidmanifest.xml"):
+ raise ValueError(f"refusing to inspect production manifest as a lab manifest: {path}")
+ if "/src/androidtest/" not in normalized and "/src/androiddevicetest/" not in normalized:
+ raise ValueError(
+ "native UX transport validation only accepts androidTest/androidDeviceTest manifests: "
+ f"{path}"
+ )
+
+
+def validate_manifest(path: Path) -> None:
+ path = path.resolve()
+ _assert_test_manifest(path)
+ if not path.exists() or not path.read_text(encoding="utf-8").strip():
+ # A missing/empty test overlay is the cleanest case: the target application
+ # keeps its own production network policy unchanged.
+ return
+
+ root = ET.parse(path).getroot()
+ if root.tag != "manifest":
+ raise ValueError(f"unexpected Android manifest root {root.tag!r}: {path}")
+
+ applications = [child for child in root if child.tag == "application"]
+ if len(applications) > 1:
+ raise ValueError(f"multiple elements in test manifest: {path}")
+ application = applications[0] if applications else None
+ if application is not None:
+ cleartext = application.get(f"{ANDROID}usesCleartextTraffic")
+ if cleartext is not None and cleartext.strip().lower() == "true":
+ raise RuntimeError(
+ "native UX lab refuses test-only android:usesCleartextTraffic=true; "
+ "the accepted Nuvio production policy must decide whether the request is allowed"
+ )
+ network_security = application.get(f"{ANDROID}networkSecurityConfig")
+ if network_security:
+ raise RuntimeError(
+ "native UX lab refuses a test-only networkSecurityConfig; use the accepted "
+ "Nuvio production network policy unchanged"
+ )
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("manifests", nargs="+", type=Path)
+ args = parser.parse_args()
+ for manifest in args.manifests:
+ validate_manifest(manifest)
+ print(
+ f"FIELD_NATIVE_ANDROID_LAB_TRANSPORT manifest={manifest.resolve()} "
+ "mode=production-policy observational=true modified=false"
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/enrich_native_corpus_summary_with_player.cjs b/scripts/enrich_native_corpus_summary_with_player.cjs
new file mode 100644
index 000000000..d0f738fb4
--- /dev/null
+++ b/scripts/enrich_native_corpus_summary_with_player.cjs
@@ -0,0 +1,156 @@
+#!/usr/bin/env node
+'use strict';
+
+const fs = require('node:fs');
+const path = require('node:path');
+const { readerFailureClass, readerSignature, isReaderFailure } = require('./native_player_diagnostics.cjs');
+
+const args = process.argv.slice(2);
+const dirIndex = args.indexOf('--dir');
+const summaryIndex = args.indexOf('--summary');
+if (dirIndex < 0 || !args[dirIndex + 1] || summaryIndex < 0 || !args[summaryIndex + 1]) {
+ process.stderr.write('usage: node scripts/enrich_native_corpus_summary_with_player.cjs --dir --summary \n');
+ process.exit(64);
+}
+const inputDir = path.resolve(args[dirIndex + 1]);
+const summaryPath = path.resolve(args[summaryIndex + 1]);
+const summary = fs.existsSync(summaryPath) ? JSON.parse(fs.readFileSync(summaryPath, 'utf8')) : {};
+
+function listFiles(directory) {
+ if (!fs.existsSync(directory)) return [];
+ const out = [];
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
+ const full = path.join(directory, entry.name);
+ if (entry.isDirectory()) out.push(...listFiles(full));
+ else if (entry.isFile() && /(?:desktop|mobile|tv)-native-corpus-.*\.log$/i.test(entry.name)) out.push(full);
+ }
+ return out.sort();
+}
+function decode(value) {
+ if (!value) return '';
+ let text = String(value).replace(/-/g, '+').replace(/_/g, '/');
+ while (text.length % 4) text += '=';
+ try { return Buffer.from(text, 'base64').toString('utf8'); } catch { return ''; }
+}
+function fields(line) {
+ const out = {};
+ const re = /([A-Za-z0-9_]+)=([^\s]+)/g;
+ let match;
+ while ((match = re.exec(line)) !== null) out[match[1]] = match[2];
+ return out;
+}
+function groupBy(items, fn) {
+ const out = new Map();
+ for (const item of items) {
+ const key = fn(item);
+ if (!out.has(key)) out.set(key, []);
+ out.get(key).push(item);
+ }
+ return out;
+}
+
+const players = [];
+const routeTerminals = new Map();
+for (const file of listFiles(inputDir)) {
+ for (const raw of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
+ const marker = raw.indexOf('FIELD_NATIVE_');
+ if (marker < 0) continue;
+ const line = raw.slice(marker).trim();
+ const f = fields(line);
+
+ // A provider execution is terminal whether extraction returned streams or
+ // raised a structured runtime error. Provider failures are valid evidence;
+ // treating only FIELD_NATIVE_RESULT as execution used to erase exactly the
+ // failures the Brain is supposed to learn from.
+ if (line.startsWith('FIELD_NATIVE_RESULT ') || line.startsWith('FIELD_NATIVE_ERROR ')) {
+ const provider = decode(f.provider64) || String(f.provider || '');
+ const client = f.client || 'unknown';
+ const fixture = f.fixture || 'unknown';
+ const requestType = String(f.request_type || 'unknown').toLowerCase();
+ const routeMode = String(f.route_mode || 'declared').toLowerCase();
+ const key = `${client}\u0000${fixture}\u0000${provider.toLowerCase()}\u0000${requestType}\u0000${routeMode}`;
+ routeTerminals.set(key, {
+ client, fixture, provider, requestType, routeMode,
+ terminal: line.startsWith('FIELD_NATIVE_ERROR ') ? 'error' : 'result',
+ });
+ continue;
+ }
+
+ if (!line.startsWith('FIELD_NATIVE_PLAYER ')) continue;
+ const row = {
+ client: f.client || 'unknown', fixture: f.fixture || 'unknown', provider: decode(f.provider64),
+ requestType: String(f.request_type || 'unknown').toLowerCase(),
+ routeMode: String(f.route_mode || 'declared').toLowerCase(),
+ index: Number(f.index || 0), state: f.state || 'unknown', engine: f.engine || 'unknown',
+ httpStatus: Number(f.http_status || 0), failureStage: f.failure_stage || 'unknown',
+ durationSeconds: Number(f.duration_seconds || 0) || null, host: decode(f.host64),
+ errorClass: decode(f.error_class64), errorCode: decode(f.error_code64),
+ exceptionChain: decode(f.exception_chain64), responseHeaderNames: decode(f.response_header_names64),
+ };
+ row.failureClass = readerFailureClass(row);
+ row.signature = readerSignature(row);
+ players.push(row);
+ }
+}
+
+const failures = players.filter(isReaderFailure);
+const healthy = players.filter((row) => !isReaderFailure(row));
+const byClass = [...groupBy(failures, (row) => row.failureClass).entries()]
+ .map(([failureClass, values]) => ({
+ failureClass, occurrences: values.length,
+ providers: [...new Set(values.map((row) => row.provider))].sort(),
+ clients: [...new Set(values.map((row) => row.client))].sort(),
+ contexts: values.slice(0, 24),
+ }))
+ .sort((a, b) => b.occurrences - a.occurrences || a.failureClass.localeCompare(b.failureClass));
+const byProvider = [...groupBy(failures, (row) => String(row.provider || '').toLowerCase()).entries()]
+ .filter(([provider]) => provider)
+ .map(([provider, values]) => ({
+ provider, occurrences: values.length,
+ failureClasses: Object.fromEntries([...groupBy(values, (row) => row.failureClass).entries()].map(([key, rows]) => [key, rows.length])),
+ clients: [...new Set(values.map((row) => row.client))].sort(),
+ fixtures: [...new Set(values.map((row) => row.fixture))].sort(),
+ contexts: values.slice(0, 24),
+ }))
+ .sort((a, b) => b.occurrences - a.occurrences || a.provider.localeCompare(b.provider));
+const repeated = [...groupBy(failures, (row) => `${String(row.provider || '').toLowerCase()}\u0000${row.failureClass}`).entries()]
+ .filter(([, values]) => values.length >= 2)
+ .map(([, values]) => ({
+ provider: String(values[0].provider || '').toLowerCase(), failureClass: values[0].failureClass,
+ occurrences: values.length, signatures: [...new Set(values.map((row) => row.signature))].slice(0, 12),
+ clients: [...new Set(values.map((row) => row.client))].sort(),
+ fixtures: [...new Set(values.map((row) => row.fixture))].sort(),
+ contexts: values.slice(0, 16),
+ }))
+ .sort((a, b) => b.occurrences - a.occurrences);
+
+const terminalRows = [...routeTerminals.values()];
+const executedProviders = [...new Set(terminalRows.map((row) => String(row.provider || '').toLowerCase()).filter(Boolean))].sort();
+const observedClients = [...new Set([
+ ...terminalRows.map((row) => row.client),
+ ...players.map((row) => row.client),
+].filter(Boolean))].sort();
+
+summary.schemaVersion = Math.max(4, Number(summary.schemaVersion || 0));
+summary.clients = observedClients.length ? observedClients : (Array.isArray(summary.clients) ? summary.clients : []);
+summary.providersObserved = executedProviders.length;
+summary.executions = routeTerminals.size;
+summary.mediaVerified = players.length;
+summary.nativeEvidenceComplete = summary.providersObserved > 0 && summary.executions > 0 && summary.mediaVerified > 0;
+summary.nativeReaderObserved = players.length;
+summary.nativeReaderHealthy = healthy.length;
+summary.nativeReaderFailures = failures.length;
+summary.readerFailureClasses = Object.fromEntries(byClass.map((row) => [row.failureClass, row.occurrences]));
+summary.readerFailureSignals = byClass;
+summary.providerReaderFailures = byProvider;
+summary.engineSignals = summary.engineSignals || {};
+summary.engineSignals.repeatedReaderFailures = repeated;
+summary.readerPrivacy = 'Sanitized only: no raw URLs, query tokens, cookie values, authorization values or response-header values.';
+
+fs.mkdirSync(path.dirname(summaryPath), { recursive: true });
+fs.writeFileSync(summaryPath, JSON.stringify(summary, null, 2) + '\n');
+console.log(
+ `FIELD_NATIVE_PLAYER_SUMMARY providers_observed=${summary.providersObserved} executions=${summary.executions} ` +
+ `media_verified=${summary.mediaVerified} complete=${summary.nativeEvidenceComplete} observed=${players.length} ` +
+ `healthy=${healthy.length} failures=${failures.length} classes=${byClass.length} repeated=${repeated.length}`
+);
diff --git a/scripts/gate_native_player_reached.cjs b/scripts/gate_native_player_reached.cjs
new file mode 100644
index 000000000..96944432b
--- /dev/null
+++ b/scripts/gate_native_player_reached.cjs
@@ -0,0 +1,38 @@
+'use strict';
+
+const fs = require('node:fs');
+
+const paths = process.argv.slice(2);
+if (!paths.length) {
+ console.error('usage: node gate_native_player_reached.cjs [log...]');
+ process.exit(2);
+}
+
+let readable = 0;
+let attempts = 0;
+const byClient = new Map();
+for (const file of paths) {
+ if (!fs.existsSync(file)) continue;
+ readable += 1;
+ const text = fs.readFileSync(file, 'utf8');
+ for (const raw of text.split(/\r?\n/)) {
+ const at = raw.indexOf('FIELD_NATIVE_PLAYER_BEGIN ');
+ if (at < 0) continue;
+ attempts += 1;
+ const line = raw.slice(at);
+ const match = line.match(/\bclient=([^\s]+)/);
+ const client = match ? match[1] : 'unknown';
+ byClient.set(client, Number(byClient.get(client) || 0) + 1);
+ }
+}
+
+if (!readable) {
+ console.error('FIELD_NATIVE_PLAYER_REACH_GATE status=fail reason=no_readable_logs attempts=0');
+ process.exit(3);
+}
+if (!attempts) {
+ console.error('FIELD_NATIVE_PLAYER_REACH_GATE status=fail reason=player_never_reached attempts=0');
+ process.exit(4);
+}
+const clients = [...byClient.entries()].map(([client, count]) => `${client}:${count}`).join(',');
+console.log(`FIELD_NATIVE_PLAYER_REACH_GATE status=pass attempts=${attempts} clients=${clients}`);
diff --git a/scripts/gate_native_reader_coverage.cjs b/scripts/gate_native_reader_coverage.cjs
new file mode 100644
index 000000000..575ecf177
--- /dev/null
+++ b/scripts/gate_native_reader_coverage.cjs
@@ -0,0 +1,149 @@
+#!/usr/bin/env node
+'use strict';
+
+const fs = require('node:fs');
+
+const argv = process.argv.slice(2);
+let streamScope = 'all';
+const scopeIndex = argv.indexOf('--streams');
+if (scopeIndex >= 0) {
+ streamScope = String(argv[scopeIndex + 1] || '').trim().toLowerCase();
+ argv.splice(scopeIndex, 2);
+}
+const logs = argv;
+if (!logs.length || !(streamScope === 'all' || /^[1-4]$/.test(streamScope))) {
+ console.error('usage: node scripts/gate_native_reader_coverage.cjs [--streams all|1|2|3|4] [log ...]');
+ process.exit(64);
+}
+
+const isPullRequest = String(process.env.GITHUB_EVENT_NAME || '').trim().toLowerCase() === 'pull_request';
+const DEFAULT_PR_STREAM_LIMIT = 2;
+const configuredPrLimit = Number(process.env.NIAKVIO_PR_STREAM_LIMIT || DEFAULT_PR_STREAM_LIMIT);
+const prReaderFloor = Number.isInteger(configuredPrLimit) && configuredPrLimit > 0
+ ? Math.min(configuredPrLimit, 4)
+ : DEFAULT_PR_STREAM_LIMIT;
+
+function decode(value) {
+ if (!value) return '';
+ let text = String(value).replace(/-/g, '+').replace(/_/g, '/');
+ while (text.length % 4) text += '=';
+ try { return Buffer.from(text, 'base64').toString('utf8'); } catch { return ''; }
+}
+function fields(line) {
+ const out = {};
+ const re = /([A-Za-z0-9_]+)=([^\s]+)/g;
+ let match;
+ while ((match = re.exec(line)) !== null) out[match[1]] = match[2];
+ return out;
+}
+function routeKey(client, fixture, provider, requestType) {
+ return `${client}\u0000${fixture}\u0000${provider.toLowerCase()}\u0000${String(requestType || 'unknown').toLowerCase()}`;
+}
+function providerKey(client, fixture, provider) {
+ return `${client}\u0000${fixture}\u0000${provider.toLowerCase()}`;
+}
+function expectedStreams(returned) {
+ if (streamScope === 'all') {
+ // PR runs keep a bounded native-reader floor for hosted-runner cost, but two
+ // distinct returned streams are required whenever available. Trusted main and
+ // manual runs remain exhaustive and must play every returned stream.
+ if (isPullRequest) return returned > 0 ? Math.min(returned, prReaderFloor) : 0;
+ return returned;
+ }
+ return Math.min(returned, Number(streamScope));
+}
+function streamCoverageSatisfied(observed, returned) {
+ const expected = expectedStreams(returned);
+ if (streamScope === 'all' && isPullRequest) {
+ return observed >= expected && observed <= returned;
+ }
+ return observed === expected;
+}
+
+const expectedProviders = new Map();
+const traversedProviders = new Set();
+const results = new Map();
+const players = new Map();
+let readable = 0;
+
+for (const file of logs) {
+ if (!fs.existsSync(file)) continue;
+ readable += 1;
+ for (const raw of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
+ const beginAt = raw.indexOf('FIELD_NATIVE_CORPUS_BEGIN ');
+ if (beginAt >= 0) {
+ const f = fields(raw.slice(beginAt));
+ expectedProviders.set(`${f.client || 'unknown'}\u0000${f.fixture || 'unknown'}`, Number(f.providers || 0));
+ continue;
+ }
+ const skippedAt = raw.indexOf('FIELD_NATIVE_PROVIDER_SKIPPED ');
+ if (skippedAt >= 0) {
+ const f = fields(raw.slice(skippedAt));
+ const provider = decode(f.provider64);
+ if (provider) traversedProviders.add(providerKey(f.client || 'unknown', f.fixture || 'unknown', provider));
+ continue;
+ }
+ const errorAt = raw.indexOf('FIELD_NATIVE_ERROR ');
+ if (errorAt >= 0) {
+ const f = fields(raw.slice(errorAt));
+ const provider = decode(f.provider64);
+ if (provider) traversedProviders.add(providerKey(f.client || 'unknown', f.fixture || 'unknown', provider));
+ continue;
+ }
+ const resultAt = raw.indexOf('FIELD_NATIVE_RESULT ');
+ if (resultAt >= 0) {
+ const f = fields(raw.slice(resultAt));
+ const provider = decode(f.provider64);
+ const requestType = f.request_type || 'unknown';
+ traversedProviders.add(providerKey(f.client || 'unknown', f.fixture || 'unknown', provider));
+ results.set(routeKey(f.client || 'unknown', f.fixture || 'unknown', provider, requestType), {
+ client: f.client || 'unknown', fixture: f.fixture || 'unknown', provider, requestType,
+ returned: Math.max(0, Number(f.count || 0) || 0),
+ });
+ continue;
+ }
+ const playerAt = raw.indexOf('FIELD_NATIVE_PLAYER ');
+ if (playerAt >= 0) {
+ const f = fields(raw.slice(playerAt));
+ const provider = decode(f.provider64);
+ const requestType = f.request_type || 'unknown';
+ const k = routeKey(f.client || 'unknown', f.fixture || 'unknown', provider, requestType);
+ if (!players.has(k)) players.set(k, new Set());
+ players.get(k).add(Math.max(0, Number(f.index || 0) || 0));
+ }
+ }
+}
+
+if (!readable) {
+ console.error('FIELD_NATIVE_READER_COVERAGE state=infra_error reason=no_readable_log');
+ process.exit(2);
+}
+if (!expectedProviders.size) {
+ console.error('FIELD_NATIVE_READER_COVERAGE state=infra_error reason=no_corpus_begin');
+ process.exit(2);
+}
+
+const failures = [];
+for (const [scope, expected] of expectedProviders) {
+ const [client, fixture] = scope.split('\u0000');
+ const observed = [...traversedProviders].filter((key) => key.startsWith(`${client}\u0000${fixture}\u0000`)).length;
+ if (expected !== observed) failures.push({ client, fixture, reason: 'provider_coverage', expected, observed });
+}
+for (const [k, result] of results) {
+ const observed = players.get(k)?.size || 0;
+ const expected = expectedStreams(result.returned);
+ if (!streamCoverageSatisfied(observed, result.returned)) {
+ failures.push({
+ client: result.client, fixture: result.fixture, provider: result.provider, requestType: result.requestType,
+ reason: 'stream_coverage', scope: streamScope, ciMode: isPullRequest ? 'pr-bounded' : 'deep',
+ returned: result.returned, expectedPlayed: expected, played: observed,
+ });
+ }
+}
+
+const returned = [...results.values()].reduce((sum, row) => sum + row.returned, 0);
+const expectedPlayed = [...results.values()].reduce((sum, row) => sum + expectedStreams(row.returned), 0);
+const played = [...players.values()].reduce((sum, set) => sum + set.size, 0);
+console.log(`FIELD_NATIVE_READER_COVERAGE state=${failures.length ? 'failed' : 'passed'} scope=${streamScope} ci_mode=${isPullRequest ? 'pr-bounded' : 'deep'} pr_stream_limit=${prReaderFloor} providers=${traversedProviders.size} routes=${results.size} returned=${returned} expected_played=${expectedPlayed} played=${played} failures=${failures.length}`);
+for (const failure of failures.slice(0, 120)) console.log(`FIELD_NATIVE_READER_COVERAGE_FAILURE ${JSON.stringify(failure)}`);
+process.exit(failures.length ? 1 : 0);
diff --git a/scripts/gate_native_reader_result.cjs b/scripts/gate_native_reader_result.cjs
new file mode 100644
index 000000000..9a5f4ac4b
--- /dev/null
+++ b/scripts/gate_native_reader_result.cjs
@@ -0,0 +1,96 @@
+#!/usr/bin/env node
+'use strict';
+
+const fs = require('node:fs');
+const { readerFailureClass, readerSignature, isReaderFailure } = require('./native_player_diagnostics.cjs');
+
+const logs = process.argv.slice(2);
+if (!logs.length) {
+ process.stderr.write('usage: node scripts/gate_native_reader_result.cjs [log ...]\n');
+ process.exit(64);
+}
+
+function decode(value) {
+ if (!value) return '';
+ let text = String(value).replace(/-/g, '+').replace(/_/g, '/');
+ while (text.length % 4) text += '=';
+ try { return Buffer.from(text, 'base64').toString('utf8'); } catch { return ''; }
+}
+function fields(line) {
+ const out = {};
+ const re = /([A-Za-z0-9_]+)=([^\s]+)/g;
+ let match;
+ while ((match = re.exec(line)) !== null) out[match[1]] = match[2];
+ return out;
+}
+
+const rows = [];
+let readable = 0;
+for (const file of logs) {
+ if (!fs.existsSync(file)) continue;
+ readable += 1;
+ for (const raw of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
+ const marker = raw.indexOf('FIELD_NATIVE_PLAYER ');
+ if (marker < 0) continue;
+ const f = fields(raw.slice(marker).trim());
+ const row = {
+ client: f.client || 'unknown', fixture: f.fixture || 'unknown', provider: decode(f.provider64),
+ requestType: String(f.request_type || 'unknown').toLowerCase(),
+ routeMode: String(f.route_mode || 'declared').toLowerCase(),
+ index: Number(f.index || 0), state: f.state || 'unknown', engine: f.engine || 'unknown',
+ httpStatus: Number(f.http_status || 0), failureStage: f.failure_stage || 'unknown',
+ durationSeconds: Number(f.duration_seconds || 0) || null, host: decode(f.host64),
+ errorClass: decode(f.error_class64), errorCode: decode(f.error_code64),
+ exceptionChain: decode(f.exception_chain64), responseHeaderNames: decode(f.response_header_names64),
+ loadBytes: Math.max(0, Number(f.load_bytes || 0) || 0),
+ loadDurationMs: Math.max(0, Number(f.load_duration_ms || 0) || 0),
+ mediaDataType: Number.isFinite(Number(f.media_data_type)) ? Number(f.media_data_type) : -1,
+ trackType: Number.isFinite(Number(f.track_type)) ? Number(f.track_type) : -1,
+ };
+ row.failureClass = readerFailureClass(row);
+ row.signature = readerSignature(row);
+ rows.push(row);
+ }
+}
+
+if (readable === 0) {
+ console.error('FIELD_NATIVE_READER_GATE state=infra_error reason=no_readable_log');
+ process.exit(2);
+}
+if (rows.length === 0) {
+ console.error('FIELD_NATIVE_READER_GATE state=infra_error reason=missing_native_reader_evidence');
+ process.exit(2);
+}
+
+const declared = rows.filter((row) => row.routeMode !== 'capability_probe');
+const probes = rows.filter((row) => row.routeMode === 'capability_probe');
+const failures = declared.filter(isReaderFailure);
+const healthy = declared.filter((row) => !isReaderFailure(row));
+const probeFailures = probes.filter(isReaderFailure);
+const probeHealthy = probes.filter((row) => !isReaderFailure(row));
+console.log(
+ `FIELD_NATIVE_READER_GATE state=${failures.length ? 'failed' : 'passed'} observed=${rows.length} ` +
+ `declared=${declared.length} healthy=${healthy.length} failures=${failures.length} ` +
+ `capability_probes=${probes.length} capability_probe_healthy=${probeHealthy.length} capability_probe_failures=${probeFailures.length}`
+);
+for (const row of failures.slice(0, 40)) {
+ // All fields are sanitized or structural. Never print raw stream URLs or request/response values here.
+ console.log(`FIELD_NATIVE_READER_GATE_FAILURE ${JSON.stringify({
+ client: row.client, fixture: row.fixture, provider: row.provider, requestType: row.requestType,
+ routeMode: row.routeMode, index: row.index,
+ failureClass: row.failureClass, httpStatus: row.httpStatus, failureStage: row.failureStage,
+ errorCode: row.errorCode, errorClass: row.errorClass, host: row.host,
+ durationSeconds: row.durationSeconds, signature: row.signature,
+ exceptionChain: row.exceptionChain, responseHeaderNames: row.responseHeaderNames,
+ loadBytes: row.loadBytes, loadDurationMs: row.loadDurationMs,
+ mediaDataType: row.mediaDataType, trackType: row.trackType,
+ })}`);
+}
+for (const row of probeFailures.slice(0, 40)) {
+ console.log(`FIELD_NATIVE_CAPABILITY_PROBE_REJECTED ${JSON.stringify({
+ client: row.client, fixture: row.fixture, provider: row.provider, requestType: row.requestType,
+ index: row.index, failureClass: row.failureClass, failureStage: row.failureStage,
+ httpStatus: row.httpStatus, errorCode: row.errorCode,
+ })}`);
+}
+process.exit(failures.length ? 1 : 0);
diff --git a/scripts/harden_nuvio_mobile_device_test.py b/scripts/harden_nuvio_mobile_device_test.py
index 2eeb1ccb2..7b127681f 100644
--- a/scripts/harden_nuvio_mobile_device_test.py
+++ b/scripts/harden_nuvio_mobile_device_test.py
@@ -1,12 +1,12 @@
#!/usr/bin/env python3
-"""Harden a prepared NuvioMobile Android device-test workspace.
+"""Apply infrastructure-only packaging compatibility to NuvioMobile device tests.
-The native lab preparation intentionally modifies a pinned upstream checkout.
-This helper keeps infrastructure-only Android requirements in one place:
-- resolve duplicate libc++_shared.so packaging in instrumentation APKs;
-- disable Sentry auto-init in the test process so no DSN is required.
+Human-UX invariant: this helper must not change Nuvio player, network, provider,
+application runtime, settings, or OS policy. It only resolves the duplicate
+libc++_shared.so merge conflict in the generated instrumentation APK so the real
+application can be launched and observed.
-It is idempotent and tolerant of whitespace changes in the prepared Gradle block.
+The change is scoped to the prepared ephemeral upstream checkout and is idempotent.
"""
from __future__ import annotations
@@ -17,55 +17,44 @@
def harden(repo: Path) -> None:
build = repo / "composeApp/build.gradle.kts"
- text = build.read_text(encoding="utf-8")
+ if not build.is_file():
+ raise SystemExit(f"missing NuvioMobile compose build file: {build}")
+ text = build.read_text(encoding="utf-8")
packaging_line = 'pickFirsts.add("lib/*/libc++_shared.so")'
- if packaging_line not in text:
- pattern = re.compile(
- r"(?P^[ \t]*withDeviceTest\s*\{.*?^[ \t]*\}\s*)"
- r"(?P^[ \t]*compilerOptions\s*\{)",
- re.MULTILINE | re.DOTALL,
- )
- matches = list(pattern.finditer(text))
- if len(matches) != 1:
- raise SystemExit(
- f"expected one prepared withDeviceTest/compilerOptions block, found {len(matches)}"
- )
- match = matches[0]
- indent = re.match(r"^[ \t]*", match.group("compiler")).group(0)
- packaging = (
- f'{indent}packaging {{\n'
- f'{indent} jniLibs {{\n'
- f'{indent} {packaging_line}\n'
- f'{indent} }}\n'
- f'{indent}}}\n\n'
+ if packaging_line in text:
+ if text.count(packaging_line) != 1:
+ raise SystemExit(f"unexpected libc++ packaging rule count={text.count(packaging_line)}")
+ print("NuvioMobile device-test packaging compatibility already applied")
+ return
+
+ pattern = re.compile(
+ r"(?P^[ \t]*withDeviceTest\s*\{.*?^[ \t]*\}\s*)"
+ r"(?P^[ \t]*compilerOptions\s*\{)",
+ re.MULTILINE | re.DOTALL,
+ )
+ matches = list(pattern.finditer(text))
+ if len(matches) != 1:
+ raise SystemExit(
+ f"expected one prepared withDeviceTest/compilerOptions block, found {len(matches)}"
)
- text = text[: match.start("compiler")] + packaging + text[match.start("compiler") :]
- build.write_text(text, encoding="utf-8")
- test_manifest = repo / "composeApp/src/androidDeviceTest/AndroidManifest.xml"
- test_manifest.parent.mkdir(parents=True, exist_ok=True)
- test_manifest.write_text(
- '''
-
-
-
-
-
-''',
- encoding="utf-8",
+ match = matches[0]
+ indent = re.match(r"^[ \t]*", match.group("compiler")).group(0)
+ packaging = (
+ f'{indent}packaging {{\n'
+ f'{indent} jniLibs {{\n'
+ f'{indent} {packaging_line}\n'
+ f'{indent} }}\n'
+ f'{indent}}}\n\n'
)
+ text = text[: match.start("compiler")] + packaging + text[match.start("compiler") :]
+ build.write_text(text, encoding="utf-8")
final = build.read_text(encoding="utf-8")
if final.count(packaging_line) != 1:
- raise SystemExit(
- f"unexpected libc++ shared packaging rule count={final.count(packaging_line)}"
- )
- print("NuvioMobile device-test hardening applied")
+ raise SystemExit(f"unexpected libc++ packaging rule count={final.count(packaging_line)}")
+ print("NuvioMobile device-test packaging compatibility applied")
def main() -> int:
diff --git a/scripts/instrument_native_client_evidence.py b/scripts/instrument_native_client_evidence.py
new file mode 100644
index 000000000..417e8f469
--- /dev/null
+++ b/scripts/instrument_native_client_evidence.py
@@ -0,0 +1,34 @@
+#!/usr/bin/env python3
+"""Compatibility shim: production Nuvio runtime instrumentation is disabled.
+
+Native labs are human-UX observation only. They may not patch Nuvio production
+source code to gain extra logging. Keep this historical entry point so old suite
+references are harmless; evidence must come from NiakVIO-owned test code, official
+Nuvio logs already emitted by the client, or external process output.
+"""
+from __future__ import annotations
+
+import argparse
+from pathlib import Path
+
+from audit_native_client_checkout import audit_checkout
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("client", choices=("tv", "mobile"))
+ parser.add_argument("repo")
+ args = parser.parse_args()
+ repo = Path(args.repo).resolve()
+ if not (repo / ".git").exists():
+ raise SystemExit(f"official Nuvio checkout missing: {repo}")
+ audit_checkout(repo, args.client)
+ print(
+ f"FIELD_NATIVE_RUNTIME_INSTRUMENTATION client={args.client} "
+ "status=disabled_by_human_ux_policy runtime_mutation=false"
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/instrument_native_desktop_evidence.py b/scripts/instrument_native_desktop_evidence.py
new file mode 100644
index 000000000..ec65b85cd
--- /dev/null
+++ b/scripts/instrument_native_desktop_evidence.py
@@ -0,0 +1,32 @@
+#!/usr/bin/env python3
+"""Compatibility shim: NuvioDesktop runtime instrumentation is disabled.
+
+The native Desktop lab must observe the official runtime without patching
+PluginRuntime, FetchBridge or any repository/network loader. This historical entry
+point is retained only so old callers remain harmless and policy-compliant.
+"""
+from __future__ import annotations
+
+import argparse
+from pathlib import Path
+
+from audit_native_client_checkout import audit_checkout
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("repo")
+ args = parser.parse_args()
+ repo = Path(args.repo).resolve()
+ if not (repo / ".git").exists():
+ raise SystemExit(f"official NuvioDesktop checkout missing: {repo}")
+ audit_checkout(repo, "desktop")
+ print(
+ "FIELD_NATIVE_RUNTIME_INSTRUMENTATION client=desktop "
+ "status=disabled_by_human_ux_policy runtime_mutation=false"
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/instrument_native_repository_http_evidence.py b/scripts/instrument_native_repository_http_evidence.py
new file mode 100644
index 000000000..6ebd1c356
--- /dev/null
+++ b/scripts/instrument_native_repository_http_evidence.py
@@ -0,0 +1,34 @@
+#!/usr/bin/env python3
+"""Compatibility shim: repository HTTP runtime instrumentation is disabled.
+
+Human-UX native labs must not inject interceptors into Nuvio production repository
+or network loaders. Keep this entry point only so historical suite references are
+safe and explicit. Repository evidence must be inferred from test-owned observation,
+official logs or external process output without modifying Nuvio runtime code.
+"""
+from __future__ import annotations
+
+import argparse
+from pathlib import Path
+
+from audit_native_client_checkout import audit_checkout
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("client", choices=("tv", "mobile", "desktop"))
+ parser.add_argument("repo")
+ args = parser.parse_args()
+ repo = Path(args.repo).resolve()
+ if not (repo / ".git").exists():
+ raise SystemExit(f"official Nuvio checkout missing: {repo}")
+ audit_checkout(repo, args.client)
+ print(
+ f"FIELD_NATIVE_REPOSITORY_RUNTIME_INSTRUMENTATION client={args.client} "
+ "status=disabled_by_human_ux_policy runtime_mutation=false"
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/merge_native_reader_backlog.py b/scripts/merge_native_reader_backlog.py
new file mode 100644
index 000000000..213590813
--- /dev/null
+++ b/scripts/merge_native_reader_backlog.py
@@ -0,0 +1,443 @@
+#!/usr/bin/env python3
+"""Merge complete native-reader diagnoses into persistent sanitized Brain memory.
+
+The backlog is keyed by client/provider/fixture/request-type/failure-class and is
+updated only from complete official-reader evidence. Capability probes are not
+bugs. Healthy fresh evidence resolves prior failures for the same route. The
+backlog lives under nativeReaderRepairMemory.readerBacklog so the daily Brain
+learning cycle preserves it automatically with the rest of reader memory.
+"""
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+
+SECTION_BEGIN = ""
+SECTION_END = ""
+
+
+def read_json(path: Path | None) -> dict[str, Any]:
+ if path is None or not path.is_file():
+ return {}
+ try:
+ value = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ return {}
+ return value if isinstance(value, dict) else {}
+
+
+def clean(value: Any, limit: int = 128) -> str:
+ text = str(value or "").strip()
+ folded = text.casefold()
+ if "://" in text or any(token in folded for token in ("authorization=", "cookie=", "token=", "secret=")):
+ return ""
+ return text[:limit]
+
+
+def non_negative(value: Any) -> int:
+ try:
+ return max(0, int(value or 0))
+ except (TypeError, ValueError):
+ return 0
+
+
+def route_key(client: str, provider: str, fixture: str, request_type: str) -> str:
+ return "\0".join((client, provider, fixture, request_type))
+
+
+def issue_key(client: str, provider: str, fixture: str, request_type: str, failure_class: str) -> str:
+ return "\0".join((client, provider, fixture, request_type, failure_class))
+
+
+def issue_id(key: str) -> str:
+ return hashlib.sha256(key.encode("utf-8")).hexdigest()[:20]
+
+
+def evidence_id(run_id: str, path: Path, payload: dict[str, Any]) -> str:
+ safe = {
+ "runId": run_id,
+ "file": path.name,
+ "generatedAt": clean(payload.get("generatedAt"), 64),
+ "readerObserved": non_negative(payload.get("readerObserved")),
+ "readerFailures": non_negative(payload.get("readerFailures")),
+ "providerLoadObservedFailures": non_negative(payload.get("providerLoadObservedFailures")),
+ "providerOutcomes": payload.get("providerOutcomes") if isinstance(payload.get("providerOutcomes"), list) else [],
+ }
+ raw = json.dumps(safe, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:32]
+
+
+def classification(failure_class: str, *, load_issue: dict[str, Any] | None = None) -> dict[str, Any]:
+ failure = clean(failure_class, 96) or "unknown_failure"
+ if load_issue is not None or failure.startswith("provider_repository_"):
+ return {
+ "layer": clean((load_issue or {}).get("layer"), 64) or "repository",
+ "scope": "repository_or_manifest",
+ "externalCandidate": False,
+ "providerJsMutationAllowed": False,
+ }
+ if failure in {"playback_runtime_setup", "runtime_contract_drift"}:
+ return {
+ "layer": "client_runtime",
+ "scope": "client_or_core",
+ "externalCandidate": False,
+ "providerJsMutationAllowed": False,
+ }
+ if failure in {
+ "playback_http_access", "playback_http_gone", "playback_rate_limited",
+ "playback_http_upstream", "playback_dns", "playback_tls",
+ }:
+ return {
+ "layer": "playback_transport",
+ "scope": "external_or_context",
+ "externalCandidate": True,
+ "providerJsMutationAllowed": False,
+ }
+ if failure in {
+ "playback_decoder", "short_media", "playback_duration_unknown",
+ "media_validation_gap", "playback_parser",
+ }:
+ return {
+ "layer": "media_candidate",
+ "scope": "provider_media_or_compatibility",
+ "externalCandidate": False,
+ "providerJsMutationAllowed": True,
+ }
+ return {
+ "layer": "provider_or_playback_context",
+ "scope": "provider_or_context",
+ "externalCandidate": False,
+ "providerJsMutationAllowed": True,
+ }
+
+
+def sanitize_existing(raw: Any) -> dict[str, Any] | None:
+ if not isinstance(raw, dict):
+ return None
+ client = clean(raw.get("client"), 32).lower()
+ provider = clean(raw.get("providerId"), 128).casefold()
+ fixture = clean(raw.get("fixture"), 96)
+ request_type = clean(raw.get("requestType"), 32).lower() or "unknown"
+ failure_class = clean(raw.get("failureClass"), 96) or "unknown_failure"
+ if not client or not provider or not fixture:
+ return None
+ key = issue_key(client, provider, fixture, request_type, failure_class)
+ return {
+ "id": clean(raw.get("id"), 32) or issue_id(key),
+ "client": client,
+ "providerId": provider,
+ "fixture": fixture,
+ "requestType": request_type,
+ "failureClass": failure_class,
+ "layer": clean(raw.get("layer"), 64) or "unknown",
+ "scope": clean(raw.get("scope"), 64) or "unknown",
+ "status": clean(raw.get("status"), 24) or "open",
+ "externalCandidate": bool(raw.get("externalCandidate", False)),
+ "providerJsMutationAllowed": bool(raw.get("providerJsMutationAllowed", False)),
+ "occurrences": non_negative(raw.get("occurrences")),
+ "consecutiveFailures": non_negative(raw.get("consecutiveFailures")),
+ "healthyRetests": non_negative(raw.get("healthyRetests")),
+ "firstSeenAt": clean(raw.get("firstSeenAt"), 64) or None,
+ "lastSeenAt": clean(raw.get("lastSeenAt"), 64) or None,
+ "resolvedAt": clean(raw.get("resolvedAt"), 64) or None,
+ "lastRunId": clean(raw.get("lastRunId"), 32) or None,
+ "lastOutcome": clean(raw.get("lastOutcome"), 32) or None,
+ "lastReason": clean(raw.get("lastReason"), 160) or None,
+ "hypotheses": [clean(v, 96) for v in raw.get("hypotheses") or [] if clean(v, 96)][:12],
+ }
+
+
+def render_markdown(base: str, backlog: dict[str, Any]) -> str:
+ if SECTION_BEGIN in base and SECTION_END in base:
+ prefix = base.split(SECTION_BEGIN, 1)[0].rstrip()
+ suffix = base.split(SECTION_END, 1)[1].lstrip()
+ base = prefix + ("\n\n" + suffix if suffix else "")
+ entries = [row for row in backlog.get("entries") or [] if isinstance(row, dict)]
+ open_rows = [row for row in entries if row.get("status") == "open"]
+ resolved_rows = [row for row in entries if row.get("status") == "resolved"]
+ external_rows = [row for row in open_rows if row.get("externalCandidate")]
+ lines = [
+ SECTION_BEGIN,
+ "## Native reader bug backlog",
+ "",
+ f"Open reader bugs: **{len(open_rows)}** ",
+ f"Resolved reader bugs retained: **{len(resolved_rows)}** ",
+ f"Open external/context candidates: **{len(external_rows)}**",
+ "",
+ ]
+ if open_rows:
+ lines.extend([
+ "| Provider | Client | Fixture | Failure | Scope | Count |",
+ "|---|---|---|---|---|---:|",
+ ])
+ for row in open_rows[:80]:
+ lines.append(
+ f"| `{clean(row.get('providerId'), 64)}` | {clean(row.get('client'), 24)} | "
+ f"{clean(row.get('fixture'), 64)} | `{clean(row.get('failureClass'), 64)}` | "
+ f"{clean(row.get('scope'), 48)} | {non_negative(row.get('occurrences'))} |"
+ )
+ lines.append("")
+ lines.append(SECTION_END)
+ return base.rstrip() + "\n\n" + "\n".join(lines) + "\n"
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--state", type=Path, required=True)
+ parser.add_argument("--previous-state", type=Path)
+ parser.add_argument("--diagnostics-root", type=Path, required=True)
+ parser.add_argument("--run-id", required=True)
+ parser.add_argument("--output", type=Path)
+ parser.add_argument("--markdown-input", type=Path)
+ parser.add_argument("--markdown-output", type=Path)
+ parser.add_argument("--max-entries", type=int, default=2000)
+ parser.add_argument("--max-evidence-ids", type=int, default=500)
+ args = parser.parse_args()
+
+ run_id = clean(args.run_id, 32)
+ if not run_id.isdigit():
+ raise SystemExit(f"invalid run id: {run_id!r}")
+ state = read_json(args.state)
+ previous = read_json(args.previous_state)
+ output = args.output or args.state
+ now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
+
+ reader_memory = state.get("nativeReaderRepairMemory") if isinstance(state.get("nativeReaderRepairMemory"), dict) else {}
+ previous_memory = previous.get("nativeReaderRepairMemory") if isinstance(previous.get("nativeReaderRepairMemory"), dict) else {}
+ current_backlog = reader_memory.get("readerBacklog") if isinstance(reader_memory.get("readerBacklog"), dict) else {}
+ previous_backlog = previous_memory.get("readerBacklog") if isinstance(previous_memory.get("readerBacklog"), dict) else {}
+ prior = current_backlog or previous_backlog
+
+ imported_evidence = [clean(v, 40) for v in prior.get("importedEvidenceIds") or [] if clean(v, 40)]
+ imported_evidence = list(dict.fromkeys(imported_evidence))[-max(1, int(args.max_evidence_ids)):]
+ imported_set = set(imported_evidence)
+ imported_runs = [clean(v, 32) for v in prior.get("importedRunIds") or [] if clean(v, 32).isdigit()]
+ imported_runs = list(dict.fromkeys(imported_runs))[-100:]
+
+ entries: dict[str, dict[str, Any]] = {}
+ for raw in prior.get("entries") or []:
+ row = sanitize_existing(raw)
+ if row:
+ entries[issue_key(row["client"], row["providerId"], row["fixture"], row["requestType"], row["failureClass"])] = row
+
+ roots = sorted(args.diagnostics_root.rglob("*brain.json")) if args.diagnostics_root.exists() else []
+ imported_files = 0
+ skipped_incomplete = 0
+ skipped_duplicate = 0
+ opened = 0
+ resolved = 0
+
+ for path in roots:
+ diagnosis = read_json(path)
+ if not diagnosis:
+ continue
+ if diagnosis.get("evidenceComplete") is not True:
+ skipped_incomplete += 1
+ continue
+ eid = evidence_id(run_id, path, diagnosis)
+ if eid in imported_set:
+ skipped_duplicate += 1
+ continue
+
+ observations = [row for row in diagnosis.get("observations") or [] if isinstance(row, dict)]
+ load_issues = [row for row in diagnosis.get("providerLoadIssues") or [] if isinstance(row, dict)]
+ plans = [row for row in diagnosis.get("plans") or [] if isinstance(row, dict)]
+ plan_hypotheses: dict[str, list[str]] = {}
+ for plan in plans:
+ client = clean(plan.get("client"), 32).lower()
+ provider = clean(plan.get("provider"), 128).casefold()
+ fixture = clean(plan.get("fixture"), 96)
+ request_type = clean(plan.get("requestType"), 32).lower() or "unknown"
+ failure = clean(plan.get("failureClass"), 96) or "unknown_failure"
+ if not client or not provider or not fixture:
+ continue
+ key = issue_key(client, provider, fixture, request_type, failure)
+ ids = []
+ for hypothesis in plan.get("hypotheses") or []:
+ value = clean(hypothesis.get("id"), 96) if isinstance(hypothesis, dict) else clean(hypothesis, 96)
+ if value and value not in ids:
+ ids.append(value)
+ plan_hypotheses[key] = ids[:12]
+
+ routes: dict[str, dict[str, Any]] = {}
+ providers_seen: set[tuple[str, str, str]] = set()
+ for raw in observations:
+ if clean(raw.get("routeMode"), 32).lower() == "capability_probe":
+ continue
+ client = clean(raw.get("client"), 32).lower()
+ provider = clean(raw.get("provider"), 128).casefold()
+ fixture = clean(raw.get("fixture"), 96)
+ request_type = clean(raw.get("requestType"), 32).lower() or "unknown"
+ failure = clean(raw.get("failureClass"), 96) or "unknown_failure"
+ if not client or not provider or not fixture:
+ continue
+ providers_seen.add((client, provider, fixture))
+ rkey = route_key(client, provider, fixture, request_type)
+ route = routes.setdefault(rkey, {
+ "client": client,
+ "provider": provider,
+ "fixture": fixture,
+ "requestType": request_type,
+ "healthy": 0,
+ "failures": {},
+ })
+ if failure == "healthy":
+ route["healthy"] += 1
+ else:
+ route["failures"][failure] = non_negative(route["failures"].get(failure)) + 1
+
+ load_keys: set[str] = set()
+ for issue in load_issues:
+ client = clean(issue.get("client"), 32).lower()
+ provider = clean(issue.get("provider"), 128).casefold()
+ fixture = clean(issue.get("fixture"), 96)
+ failure = clean(issue.get("failureClass"), 96) or "provider_repository_load_error"
+ if not client or not provider or not fixture:
+ continue
+ key = issue_key(client, provider, fixture, "repository", failure)
+ load_keys.add(key)
+ current = entries.get(key)
+ cls = classification(failure, load_issue=issue)
+ if current is None:
+ current = {
+ "id": issue_id(key), "client": client, "providerId": provider, "fixture": fixture,
+ "requestType": "repository", "failureClass": failure,
+ "occurrences": 0, "consecutiveFailures": 0, "healthyRetests": 0,
+ "firstSeenAt": now, "resolvedAt": None, "hypotheses": [],
+ }
+ opened += 1
+ current.update(cls)
+ current["status"] = "open"
+ current["occurrences"] = non_negative(current.get("occurrences")) + 1
+ current["consecutiveFailures"] = non_negative(current.get("consecutiveFailures")) + 1
+ current["lastSeenAt"] = now
+ current["resolvedAt"] = None
+ current["lastRunId"] = run_id
+ current["lastOutcome"] = "failure"
+ current["lastReason"] = clean(issue.get("reason"), 160) or failure
+ entries[key] = current
+
+ for route in routes.values():
+ client = route["client"]
+ provider = route["provider"]
+ fixture = route["fixture"]
+ request_type = route["requestType"]
+ failures: dict[str, int] = route["failures"]
+ base = route_key(client, provider, fixture, request_type)
+
+ for key, current in list(entries.items()):
+ if route_key(current["client"], current["providerId"], current["fixture"], current["requestType"]) != base:
+ continue
+ if current.get("status") != "open" or current["failureClass"] in failures:
+ continue
+ current["status"] = "resolved"
+ current["healthyRetests"] = non_negative(current.get("healthyRetests")) + (1 if route["healthy"] else 0)
+ current["consecutiveFailures"] = 0
+ current["resolvedAt"] = now
+ current["lastSeenAt"] = now
+ current["lastRunId"] = run_id
+ current["lastOutcome"] = "healthy" if route["healthy"] else "failure_class_changed"
+ current["lastReason"] = "fresh_native_reader_route_no_longer_reports_this_failure_class"
+ entries[key] = current
+ resolved += 1
+
+ for failure, count in failures.items():
+ key = issue_key(client, provider, fixture, request_type, failure)
+ current = entries.get(key)
+ cls = classification(failure)
+ if current is None:
+ current = {
+ "id": issue_id(key), "client": client, "providerId": provider, "fixture": fixture,
+ "requestType": request_type, "failureClass": failure,
+ "occurrences": 0, "consecutiveFailures": 0, "healthyRetests": 0,
+ "firstSeenAt": now, "resolvedAt": None, "hypotheses": [],
+ }
+ opened += 1
+ current.update(cls)
+ current["status"] = "open"
+ current["occurrences"] = non_negative(current.get("occurrences")) + max(1, count)
+ current["consecutiveFailures"] = non_negative(current.get("consecutiveFailures")) + 1
+ current["lastSeenAt"] = now
+ current["resolvedAt"] = None
+ current["lastRunId"] = run_id
+ current["lastOutcome"] = "failure"
+ current["lastReason"] = failure
+ current["hypotheses"] = plan_hypotheses.get(key, current.get("hypotheses") or [])[:12]
+ entries[key] = current
+
+ for key, current in list(entries.items()):
+ if current.get("status") != "open" or current.get("requestType") != "repository":
+ continue
+ triple = (current["client"], current["providerId"], current["fixture"])
+ if triple not in providers_seen or key in load_keys:
+ continue
+ current["status"] = "resolved"
+ current["healthyRetests"] = non_negative(current.get("healthyRetests")) + 1
+ current["consecutiveFailures"] = 0
+ current["resolvedAt"] = now
+ current["lastSeenAt"] = now
+ current["lastRunId"] = run_id
+ current["lastOutcome"] = "provider_loaded"
+ current["lastReason"] = "fresh_complete_evidence_reached_reader_after_prior_load_failure"
+ entries[key] = current
+ resolved += 1
+
+ imported_evidence.append(eid)
+ imported_set.add(eid)
+ imported_files += 1
+
+ if imported_files:
+ imported_runs = [v for v in imported_runs if v != run_id] + [run_id]
+
+ ordered = sorted(
+ entries.values(),
+ key=lambda row: (
+ 0 if row.get("status") == "open" else 1,
+ -non_negative(row.get("consecutiveFailures")),
+ str(row.get("lastSeenAt") or ""),
+ str(row.get("providerId") or ""),
+ ),
+ )[: max(1, int(args.max_entries))]
+
+ backlog = {
+ "schemaVersion": 1,
+ "updatedAt": now,
+ "lastRunId": run_id if imported_files else prior.get("lastRunId"),
+ "importedRunIds": imported_runs[-100:],
+ "importedEvidenceIds": imported_evidence[-max(1, int(args.max_evidence_ids)):],
+ "importedDiagnosisFilesThisRun": imported_files,
+ "skippedIncompleteThisRun": skipped_incomplete,
+ "skippedDuplicateThisRun": skipped_duplicate,
+ "openCount": sum(1 for row in ordered if row.get("status") == "open"),
+ "resolvedCount": sum(1 for row in ordered if row.get("status") == "resolved"),
+ "externalCandidateOpenCount": sum(1 for row in ordered if row.get("status") == "open" and row.get("externalCandidate")),
+ "entries": ordered,
+ }
+ reader_memory["readerBacklog"] = backlog
+ state["nativeReaderRepairMemory"] = reader_memory
+ state["privacy"] = "No raw URLs, query tokens, header values, cookies, credentials or private notes are persisted in Brain state. Reader backlog stores only sanitized route identities and aggregate outcomes."
+
+ output.parent.mkdir(parents=True, exist_ok=True)
+ output.write_text(json.dumps(state, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
+ if args.markdown_output:
+ base = "# NiakVIO Brain Learning\n"
+ if args.markdown_input and args.markdown_input.is_file():
+ base = args.markdown_input.read_text(encoding="utf-8")
+ args.markdown_output.parent.mkdir(parents=True, exist_ok=True)
+ args.markdown_output.write_text(render_markdown(base, backlog), encoding="utf-8")
+
+ print(
+ "FIELD_NATIVE_READER_BACKLOG "
+ f"run={run_id} imported_files={imported_files} opened={opened} resolved={resolved} "
+ f"open={backlog['openCount']} external_candidates={backlog['externalCandidateOpenCount']} "
+ f"skipped_incomplete={skipped_incomplete} skipped_duplicate={skipped_duplicate}"
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/merge_native_reader_repair_learning.py b/scripts/merge_native_reader_repair_learning.py
new file mode 100644
index 000000000..9f0b9fdac
--- /dev/null
+++ b/scripts/merge_native_reader_repair_learning.py
@@ -0,0 +1,294 @@
+#!/usr/bin/env python3
+"""Merge sanitized native-reader repair outcomes into persistent Brain memory.
+
+The input comparison contains no raw media locators or credential values. This
+script persists only provider/fixture/failure-class/generic-skill identities and
+aggregate success/failure counters so future Learning Lab repair attempts can
+prefer proven skills and avoid repeating failed ones.
+"""
+from __future__ import annotations
+
+import argparse
+import json
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+
+SECTION_BEGIN = ""
+SECTION_END = ""
+
+
+def read_json(path: Path | None) -> dict[str, Any]:
+ if path is None or not path.is_file():
+ return {}
+ try:
+ value = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ return {}
+ return value if isinstance(value, dict) else {}
+
+
+def clean(value: Any, limit: int = 128) -> str:
+ text = str(value or "").strip()
+ if "://" in text or any(token in text.casefold() for token in ("authorization=", "cookie=", "token=")):
+ return ""
+ return text[:limit]
+
+
+def memory_key(row: dict[str, Any]) -> str:
+ return "\0".join((row["providerId"], row["fixture"], row["failureClass"], row["skill"]))
+
+
+def sanitize_entry(raw: Any) -> dict[str, Any] | None:
+ if not isinstance(raw, dict):
+ return None
+ provider = clean(raw.get("providerId"), 128).casefold()
+ fixture = clean(raw.get("fixture"), 96)
+ failure = clean(raw.get("failureClass"), 96) or "unknown_failure"
+ skill = clean(raw.get("skill"), 96)
+ if not provider or not fixture or not skill:
+ return None
+ return {
+ "providerId": provider,
+ "fixture": fixture,
+ "failureClass": failure,
+ "skill": skill,
+ "attempts": max(0, int(raw.get("attempts") or 0)),
+ "successes": max(0, int(raw.get("successes") or 0)),
+ "failures": max(0, int(raw.get("failures") or 0)),
+ "inconclusive": max(0, int(raw.get("inconclusive") or 0)),
+ "consecutiveFailures": max(0, int(raw.get("consecutiveFailures") or 0)),
+ "lastOutcome": clean(raw.get("lastOutcome"), 32) or None,
+ "lastReason": clean(raw.get("lastReason"), 120) or None,
+ "lastSeenAt": clean(raw.get("lastSeenAt"), 48) or None,
+ }
+
+
+def sanitized_run_ids(*memories: Any, limit: int = 100) -> list[str]:
+ """Keep a bounded ordered set of previously imported GitHub Actions run IDs."""
+ values: list[str] = []
+ for memory in memories:
+ if not isinstance(memory, dict):
+ continue
+ for raw in memory.get("importedRunIds") or []:
+ value = clean(raw, 32)
+ if not value.isdigit() or value in values:
+ continue
+ values.append(value)
+ return values[-max(1, int(limit)):]
+
+
+def proposal_key(row: Any) -> str:
+ if not isinstance(row, dict):
+ return ""
+ return "|".join(
+ clean(row.get(key), 128)
+ for key in ("type", "providerId", "failureClass", "skill", "fixture")
+ )
+
+
+def render_markdown(base: str, entries: list[dict[str, Any]], stats: list[dict[str, Any]], imported: int) -> str:
+ if SECTION_BEGIN in base and SECTION_END in base:
+ prefix = base.split(SECTION_BEGIN, 1)[0].rstrip()
+ suffix = base.split(SECTION_END, 1)[1].lstrip()
+ base = prefix + ("\n\n" + suffix if suffix else "")
+ lines = [
+ SECTION_BEGIN,
+ "## Native reader repair memory",
+ "",
+ f"Reader repair memory entries: **{len(entries)}** ",
+ f"Reader repair outcomes imported this run: **{imported}** ",
+ f"Generic reader repair skills observed: **{len(stats)}**",
+ "",
+ ]
+ if stats:
+ lines.extend(["| Skill | Maturity | Successes | Failures | Proven providers |", "|---|---:|---:|---:|---:|"])
+ for row in stats[:20]:
+ lines.append(
+ f"| `{clean(row.get('skill'), 96)}` | {clean(row.get('maturity'), 24)} | "
+ f"{int(row.get('successes') or 0)} | {int(row.get('failures') or 0)} | {int(row.get('provenProviderCount') or 0)} |"
+ )
+ lines.append("")
+ lines.append(SECTION_END)
+ return base.rstrip() + "\n\n" + "\n".join(lines) + "\n"
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--state", type=Path, required=True)
+ parser.add_argument("--previous-state", type=Path)
+ parser.add_argument("--comparison", type=Path, required=True)
+ parser.add_argument("--output", type=Path)
+ parser.add_argument("--markdown-input", type=Path)
+ parser.add_argument("--markdown-output", type=Path)
+ parser.add_argument("--max-entries", type=int, default=1200)
+ parser.add_argument("--avoid-threshold", type=int, default=2)
+ args = parser.parse_args()
+
+ state = read_json(args.state)
+ previous = read_json(args.previous_state)
+ comparison = read_json(args.comparison)
+ output = args.output or args.state
+ now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
+
+ memory: dict[str, dict[str, Any]] = {}
+ previous_memory = previous.get("nativeReaderRepairMemory") if isinstance(previous.get("nativeReaderRepairMemory"), dict) else {}
+ current_memory = state.get("nativeReaderRepairMemory") if isinstance(state.get("nativeReaderRepairMemory"), dict) else {}
+ imported_run_ids = sanitized_run_ids(previous_memory, current_memory)
+ for source in (previous_memory, current_memory):
+ for raw in source.get("entries") or []:
+ entry = sanitize_entry(raw)
+ if entry:
+ memory[memory_key(entry)] = entry
+
+ outcome_groups = (
+ ("accepted", "accepted"),
+ ("rejected", "rejected"),
+ ("inconclusive", "inconclusive"),
+ )
+ imported = 0
+ for field, outcome in outcome_groups:
+ for row in comparison.get(field) or []:
+ if not isinstance(row, dict):
+ continue
+ provider = clean(row.get("provider"), 128).casefold()
+ fixture = clean(row.get("fixture") or comparison.get("fixture"), 96)
+ failure_classes = [clean(value, 96) for value in row.get("failureClasses") or [] if clean(value, 96)] or ["unknown_failure"]
+ skills = [clean(value, 96) for value in row.get("skills") or [] if clean(value, 96)]
+ reason = clean(row.get("reason"), 120)
+ if not provider or not fixture:
+ continue
+ for failure in failure_classes:
+ for skill in skills:
+ template = {
+ "providerId": provider,
+ "fixture": fixture,
+ "failureClass": failure,
+ "skill": skill,
+ "attempts": 0,
+ "successes": 0,
+ "failures": 0,
+ "inconclusive": 0,
+ "consecutiveFailures": 0,
+ "lastOutcome": None,
+ "lastReason": None,
+ "lastSeenAt": None,
+ }
+ key = memory_key(template)
+ entry = memory.get(key, template)
+ entry["attempts"] += 1
+ if outcome == "accepted":
+ entry["successes"] += 1
+ entry["consecutiveFailures"] = 0
+ elif outcome == "rejected":
+ entry["failures"] += 1
+ entry["consecutiveFailures"] += 1
+ else:
+ entry["inconclusive"] += 1
+ entry["lastOutcome"] = outcome
+ entry["lastReason"] = reason or outcome
+ entry["lastSeenAt"] = now
+ memory[key] = entry
+ imported += 1
+
+ entries = sorted(
+ memory.values(),
+ key=lambda row: (str(row.get("lastSeenAt") or ""), int(row.get("attempts") or 0)),
+ reverse=True,
+ )[: max(1, int(args.max_entries))]
+
+ skill_stats: dict[str, dict[str, Any]] = {}
+ for entry in entries:
+ skill = entry["skill"]
+ stats = skill_stats.setdefault(skill, {
+ "skill": skill,
+ "attempts": 0,
+ "successes": 0,
+ "failures": 0,
+ "inconclusive": 0,
+ "provenProviders": set(),
+ "failedProviders": set(),
+ })
+ for key in ("attempts", "successes", "failures", "inconclusive"):
+ stats[key] += int(entry.get(key) or 0)
+ if int(entry.get("successes") or 0) > 0:
+ stats["provenProviders"].add(entry["providerId"])
+ if int(entry.get("failures") or 0) > 0 and int(entry.get("successes") or 0) == 0:
+ stats["failedProviders"].add(entry["providerId"])
+
+ stats_rows = []
+ for stats in skill_stats.values():
+ proven = sorted(stats.pop("provenProviders"))
+ failed = sorted(stats.pop("failedProviders"))
+ stats_rows.append({
+ **stats,
+ "provenProviderCount": len(proven),
+ "failedProviderCount": len(failed),
+ "provenProviders": proven[:48],
+ "failedProviders": failed[:48],
+ "maturity": "reusable" if len(proven) >= 2 and int(stats["successes"]) >= 2 else ("promising" if proven else "experimental"),
+ })
+ stats_rows.sort(key=lambda row: (-int(row["successes"]), int(row["failures"]), row["skill"]))
+
+ avoid_threshold = max(1, int(args.avoid_threshold))
+ proposals = [row for row in state.get("proposals") or [] if isinstance(row, dict)]
+ for entry in entries:
+ if int(entry["successes"]) == 0 and int(entry["consecutiveFailures"]) >= avoid_threshold:
+ proposals.append({
+ "type": "avoid_native_reader_skill",
+ "priority": "high",
+ "providerId": entry["providerId"],
+ "fixture": entry["fixture"],
+ "failureClass": entry["failureClass"],
+ "skill": entry["skill"],
+ "evidenceCount": entry["failures"],
+ "reason": "Fresh native-reader retests repeatedly rejected this generic repair skill for the same provider/failure class.",
+ })
+ for stats in stats_rows:
+ if stats["maturity"] in {"promising", "reusable"}:
+ proposals.append({
+ "type": "native_reader_skill_evidence",
+ "priority": "high" if stats["maturity"] == "reusable" else "medium",
+ "skill": stats["skill"],
+ "maturity": stats["maturity"],
+ "evidenceCount": stats["successes"],
+ "provenProviderCount": stats["provenProviderCount"],
+ "reason": "The generic repair skill has fresh official-reader success evidence and should be preferred over unproven alternatives when causally compatible.",
+ })
+
+ deduped: dict[str, dict[str, Any]] = {}
+ for proposal in proposals:
+ key = proposal_key(proposal) or json.dumps(proposal, sort_keys=True)
+ deduped[key] = proposal
+ state["proposals"] = list(deduped.values())
+ state["nativeReaderRepairMemory"] = {
+ "schemaVersion": 1,
+ "updatedAt": now,
+ "importedOutcomesThisRun": imported,
+ "importedRunIds": imported_run_ids,
+ "entries": entries,
+ "skillStats": stats_rows,
+ }
+ native = state.get("nativeFeedback") if isinstance(state.get("nativeFeedback"), dict) else {}
+ native["readerRepairAccepted"] = int(comparison.get("acceptedCount") or 0)
+ native["readerRepairRejected"] = int(comparison.get("rejectedCount") or 0)
+ native["readerRepairInconclusive"] = int(comparison.get("inconclusiveCount") or 0)
+ state["nativeFeedback"] = native
+ state["privacy"] = "No raw URLs, tokens, header values, cookies or private notes are copied into persistent Brain learning state. Native reader repair memory stores only sanitized ids and aggregate outcomes."
+
+ output.parent.mkdir(parents=True, exist_ok=True)
+ output.write_text(json.dumps(state, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
+
+ if args.markdown_output:
+ base = "# NiakVIO Brain Learning\n"
+ if args.markdown_input and args.markdown_input.is_file():
+ base = args.markdown_input.read_text(encoding="utf-8")
+ args.markdown_output.parent.mkdir(parents=True, exist_ok=True)
+ args.markdown_output.write_text(render_markdown(base, entries, stats_rows, imported), encoding="utf-8")
+
+ print(f"FIELD_NATIVE_READER_LEARNING imported={imported} entries={len(entries)} skills={len(stats_rows)}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/native_client_test_bootstrap.py b/scripts/native_client_test_bootstrap.py
new file mode 100644
index 000000000..1ebf63276
--- /dev/null
+++ b/scripts/native_client_test_bootstrap.py
@@ -0,0 +1,99 @@
+#!/usr/bin/env python3
+"""Minimal test-only bootstrap for official Nuvio client checkouts.
+
+This module may enable an upstream project's instrumentation source set, test
+runner, test dependencies, and (for TV) a debug signing configuration required
+to install the debug APK on the emulator. It must never change production
+Android manifests, networking policy, player code, stream headers, storage,
+DNS, proxying, decoder settings, or any other runtime behaviour.
+"""
+from __future__ import annotations
+
+from pathlib import Path
+
+MOBILE_RUNNER = 'instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"'
+MOBILE_EXT_JUNIT = 'implementation("androidx.test.ext:junit:1.3.0")'
+MOBILE_TEST_RUNNER = 'implementation("androidx.test:runner:1.7.0")'
+TV_RUNNER = 'testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"'
+TV_EXT_JUNIT = 'androidTestImplementation("androidx.test.ext:junit:1.3.0")'
+TV_TEST_RUNNER = 'androidTestImplementation("androidx.test:runner:1.7.0")'
+
+
+def enable_mobile_device_tests(repo: Path) -> None:
+ """Enable only the Android device-test source set in NuvioMobile.
+
+ No main manifest is created or edited here. If the official application is
+ missing a capability required by playback, the native lab must observe that
+ failure rather than manufacture the capability.
+ """
+ build = Path(repo) / "composeApp/build.gradle.kts"
+ text = build.read_text(encoding="utf-8")
+
+ if "withDeviceTest {" not in text:
+ compilation_needle = " withHostTest {}\n\n compilerOptions {"
+ compilation_replacement = ''' withHostTest {}
+ withDeviceTest {
+ instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ execution = "HOST"
+ }
+
+ compilerOptions {'''
+ if text.count(compilation_needle) != 1:
+ raise SystemExit(
+ f"mobile device-test compilation anchor count={text.count(compilation_needle)}"
+ )
+ text = text.replace(compilation_needle, compilation_replacement, 1)
+
+ if "val androidDeviceTest by getting {" not in text:
+ source_needle = " commonMain.dependencies {"
+ source_replacement = ''' val androidDeviceTest by getting {
+ dependencies {
+ implementation("junit:junit:4.13.2")
+ implementation("androidx.test.ext:junit:1.3.0")
+ implementation("androidx.test:runner:1.7.0")
+ }
+ }
+ commonMain.dependencies {'''
+ if text.count(source_needle) != 1:
+ raise SystemExit(
+ f"mobile device-test dependency anchor count={text.count(source_needle)}"
+ )
+ text = text.replace(source_needle, source_replacement, 1)
+
+ build.write_text(text, encoding="utf-8")
+ print("FIELD_NATIVE_TEST_BOOTSTRAP client=mobile scope=test-only runtime_mutation=false")
+
+
+def enable_tv_tests(repo: Path) -> None:
+ """Enable NuvioTV instrumentation without touching production runtime code."""
+ build = Path(repo) / "app/build.gradle.kts"
+ text = build.read_text(encoding="utf-8")
+
+ # Hosted CI does not possess the official release signing key. Switching the
+ # debug variant back to the standard debug keystore is a packaging-only test
+ # prerequisite; it does not alter player/network/provider behaviour.
+ release_signing = ' debug {\n signingConfig = signingConfigs.getByName("release")'
+ debug_signing = ' debug {\n signingConfig = signingConfigs.getByName("debug")'
+ if release_signing in text:
+ text = text.replace(release_signing, debug_signing, 1)
+ elif debug_signing not in text:
+ raise SystemExit("NuvioTV debug signing contract missing")
+
+ if TV_RUNNER not in text:
+ default_config = " defaultConfig {\n"
+ if text.count(default_config) != 1:
+ raise SystemExit(
+ f"NuvioTV defaultConfig structural anchor count={text.count(default_config)}"
+ )
+ text = text.replace(default_config, default_config + f" {TV_RUNNER}\n", 1)
+
+ missing_dependencies = [
+ dependency for dependency in (TV_EXT_JUNIT, TV_TEST_RUNNER) if dependency not in text
+ ]
+ if missing_dependencies:
+ text = text.rstrip() + "\n\n\ndependencies {\n"
+ text += "\n".join(f" {dependency}" for dependency in missing_dependencies)
+ text += "\n}\n"
+
+ build.write_text(text, encoding="utf-8")
+ print("FIELD_NATIVE_TEST_BOOTSTRAP client=tv scope=test-only runtime_mutation=false")
diff --git a/scripts/native_evidence_completeness.cjs b/scripts/native_evidence_completeness.cjs
new file mode 100644
index 000000000..b8b23b507
--- /dev/null
+++ b/scripts/native_evidence_completeness.cjs
@@ -0,0 +1,370 @@
+'use strict';
+
+const fs = require('node:fs');
+const CANONICAL_REQUEST_TYPES = new Set(['movie', 'tv', 'anime']);
+
+function fields(line) {
+ const out = {};
+ const re = /([A-Za-z0-9_]+)=([^\s]+)/g;
+ let match;
+ while ((match = re.exec(line)) !== null) out[match[1]] = match[2];
+ return out;
+}
+
+function decode(value) {
+ if (!value) return '';
+ let text = String(value).replace(/-/g, '+').replace(/_/g, '/');
+ while (text.length % 4) text += '=';
+ try { return Buffer.from(text, 'base64').toString('utf8'); } catch { return ''; }
+}
+
+function providerName(f) {
+ return (decode(f.provider64) || String(f.provider || '')).trim().toLowerCase();
+}
+
+function requestType(f) {
+ return String(f.request_type || '').trim().toLowerCase();
+}
+
+function validRequestType(f) {
+ return CANONICAL_REQUEST_TYPES.has(requestType(f));
+}
+
+function scopeKey(client, fixture) {
+ return `${client}\u0000${fixture}`;
+}
+
+function routeKey(client, fixture, provider, requestedType) {
+ return `${client}\u0000${fixture}\u0000${provider}\u0000${String(requestedType || 'unknown').toLowerCase()}`;
+}
+
+function playerKey(client, fixture, provider, requestedType, index) {
+ return `${routeKey(client, fixture, provider, requestedType)}\u0000${Number(index || 0)}`;
+}
+
+function httpKey(client, provider, requestedType, method, endpoint) {
+ return `${client}\u0000${provider}\u0000${String(requestedType || 'unknown').toLowerCase()}\u0000${String(method || 'GET').toUpperCase()}\u0000${String(endpoint || '')}`;
+}
+
+function repositoryHttpKey(client, kind, method, endpoint) {
+ return `${client}\u0000${String(kind || 'repository').toLowerCase()}\u0000${String(method || 'GET').toUpperCase()}\u0000${String(endpoint || '')}`;
+}
+
+function ensureScope(scopes, client, fixture) {
+ const key = scopeKey(client, fixture);
+ if (!scopes.has(key)) {
+ scopes.set(key, {
+ client,
+ fixture,
+ expectedProviders: 0,
+ ended: false,
+ instrumented: false,
+ traversed: new Set(),
+ frontendPhases: new Set(),
+ results: 0,
+ httpRequests: 0,
+ repositoryHttpRequests: 0,
+ repositoryHttpTerminal: 0,
+ repositoryCacheHits: 0,
+ playerResults: 0,
+ providerRoutesBegun: 0,
+ repositoryLoadBegun: 0,
+ repositoryLoadTerminal: 0,
+ repositoryLoadFailed: false,
+ repositoryLoadExpected: 0,
+ providerLoadObserved: new Set(),
+ providerLoadResults: 0,
+ providerLoadErrors: 0,
+ providerLoadSkipped: 0,
+ });
+ }
+ return scopes.get(key);
+}
+
+function increment(map, key) {
+ map.set(key, Number(map.get(key) || 0) + 1);
+}
+
+function assessNativeEvidence(logPaths) {
+ const scopes = new Map();
+ const routesBegun = new Map();
+ const routesTerminal = new Map();
+ const playersBegun = new Map();
+ const playersTerminal = new Map();
+ const httpRequests = new Map();
+ const httpTerminal = new Map();
+ const repositoryHttpRequests = new Map();
+ const repositoryHttpTerminal = new Map();
+ const problems = [];
+ let readableLogs = 0;
+ let frontendErrors = 0;
+
+ const requireRoute = (kind, f, client, fixture, provider) => {
+ if (validRequestType(f)) return;
+ const raw = requestType(f) || '';
+ problems.push(`invalid_request_type:${kind}:${client}:${fixture}:${provider || ''}:${raw}`);
+ };
+
+ for (const file of logPaths) {
+ if (!fs.existsSync(file)) {
+ problems.push(`missing_log:${file}`);
+ continue;
+ }
+ readableLogs += 1;
+ const fileScopeKeys = new Set();
+ const fileFrontend = new Set();
+ const fileInstrumentedClients = new Set();
+ const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/);
+
+ for (const raw of lines) {
+ const markerAt = raw.indexOf('FIELD_NATIVE_');
+ if (markerAt < 0) continue;
+ const line = raw.slice(markerAt).trim();
+ const f = fields(line);
+ const client = f.client || 'unknown';
+ const fixture = f.fixture || 'unknown';
+
+ if (line.startsWith('FIELD_NATIVE_CORPUS_BEGIN ')) {
+ const scope = ensureScope(scopes, client, fixture);
+ scope.expectedProviders = Number(f.providers || 0);
+ fileScopeKeys.add(scopeKey(client, fixture));
+ } else if (line.startsWith('FIELD_NATIVE_CORPUS_END ')) {
+ const scope = ensureScope(scopes, client, fixture);
+ scope.ended = true;
+ fileScopeKeys.add(scopeKey(client, fixture));
+ } else if (line.startsWith('FIELD_NATIVE_EVIDENCE_INSTRUMENTED ')) {
+ fileInstrumentedClients.add(client);
+ } else if (line.startsWith('FIELD_NATIVE_FRONTEND_CAPTURE ')) {
+ if (f.phase) fileFrontend.add(f.phase);
+ } else if (line.startsWith('FIELD_NATIVE_FRONTEND_ERROR ')) {
+ frontendErrors += 1;
+ } else if (line.startsWith('FIELD_NATIVE_REPOSITORY_LOAD_BEGIN ')) {
+ const scope = ensureScope(scopes, client, fixture);
+ scope.repositoryLoadBegun += 1;
+ scope.repositoryLoadExpected = Math.max(scope.repositoryLoadExpected, Number(f.expected || 0));
+ fileScopeKeys.add(scopeKey(client, fixture));
+ } else if (line.startsWith('FIELD_NATIVE_REPOSITORY_CACHE_HIT ')) {
+ const scope = ensureScope(scopes, client, fixture);
+ scope.repositoryCacheHits += 1;
+ fileScopeKeys.add(scopeKey(client, fixture));
+ } else if (line.startsWith('FIELD_NATIVE_REPOSITORY_LOAD_RESULT ')) {
+ const scope = ensureScope(scopes, client, fixture);
+ scope.repositoryLoadTerminal += 1;
+ fileScopeKeys.add(scopeKey(client, fixture));
+ } else if (line.startsWith('FIELD_NATIVE_REPOSITORY_LOAD_ERROR ')) {
+ const scope = ensureScope(scopes, client, fixture);
+ scope.repositoryLoadTerminal += 1;
+ scope.repositoryLoadFailed = true;
+ fileScopeKeys.add(scopeKey(client, fixture));
+ } else if (line.startsWith('FIELD_NATIVE_REPOSITORY_HTTP_REQUEST ')) {
+ const key = repositoryHttpKey(client, f.kind, f.method, f.endpoint);
+ increment(repositoryHttpRequests, key);
+ for (const scopeId of fileScopeKeys) {
+ const scope = scopes.get(scopeId);
+ if (scope && scope.client === client) scope.repositoryHttpRequests += 1;
+ }
+ } else if (line.startsWith('FIELD_NATIVE_REPOSITORY_HTTP_RESPONSE ') || line.startsWith('FIELD_NATIVE_REPOSITORY_HTTP_ERROR ')) {
+ const key = repositoryHttpKey(client, f.kind, f.method, f.endpoint);
+ increment(repositoryHttpTerminal, key);
+ for (const scopeId of fileScopeKeys) {
+ const scope = scopes.get(scopeId);
+ if (scope && scope.client === client) scope.repositoryHttpTerminal += 1;
+ }
+ } else if (
+ line.startsWith('FIELD_NATIVE_PROVIDER_LOAD_RESULT ') ||
+ line.startsWith('FIELD_NATIVE_PROVIDER_LOAD_ERROR ') ||
+ line.startsWith('FIELD_NATIVE_PROVIDER_LOAD_SKIPPED ')
+ ) {
+ const scope = ensureScope(scopes, client, fixture);
+ const provider = providerName(f);
+ if (provider) scope.providerLoadObserved.add(provider);
+ if (line.startsWith('FIELD_NATIVE_PROVIDER_LOAD_RESULT ')) scope.providerLoadResults += 1;
+ else if (line.startsWith('FIELD_NATIVE_PROVIDER_LOAD_ERROR ')) scope.providerLoadErrors += 1;
+ else scope.providerLoadSkipped += 1;
+ fileScopeKeys.add(scopeKey(client, fixture));
+ } else if (line.startsWith('FIELD_NATIVE_PROVIDER_SKIPPED ')) {
+ const provider = providerName(f);
+ const scope = ensureScope(scopes, client, fixture);
+ if (provider) scope.traversed.add(provider);
+ fileScopeKeys.add(scopeKey(client, fixture));
+ } else if (line.startsWith('FIELD_NATIVE_PROVIDER_BEGIN ')) {
+ const provider = providerName(f);
+ requireRoute('provider_begin', f, client, fixture, provider);
+ const scope = ensureScope(scopes, client, fixture);
+ scope.providerRoutesBegun += 1;
+ const key = routeKey(client, fixture, provider, f.request_type);
+ increment(routesBegun, key);
+ fileScopeKeys.add(scopeKey(client, fixture));
+ } else if (line.startsWith('FIELD_NATIVE_RESULT ')) {
+ const provider = providerName(f);
+ requireRoute('provider_result', f, client, fixture, provider);
+ const scope = ensureScope(scopes, client, fixture);
+ if (provider) scope.traversed.add(provider);
+ scope.results += 1;
+ increment(routesTerminal, routeKey(client, fixture, provider, f.request_type));
+ fileScopeKeys.add(scopeKey(client, fixture));
+ } else if (line.startsWith('FIELD_NATIVE_ERROR ')) {
+ const provider = providerName(f);
+ requireRoute('provider_error', f, client, fixture, provider);
+ const scope = ensureScope(scopes, client, fixture);
+ if (provider) scope.traversed.add(provider);
+ increment(routesTerminal, routeKey(client, fixture, provider, f.request_type));
+ fileScopeKeys.add(scopeKey(client, fixture));
+ } else if (line.startsWith('FIELD_NATIVE_PLAYER_BEGIN ')) {
+ const provider = providerName(f);
+ requireRoute('player_begin', f, client, fixture, provider);
+ increment(playersBegun, playerKey(client, fixture, provider, f.request_type, f.index));
+ fileScopeKeys.add(scopeKey(client, fixture));
+ } else if (line.startsWith('FIELD_NATIVE_PLAYER ')) {
+ const provider = providerName(f);
+ requireRoute('player_result', f, client, fixture, provider);
+ increment(playersTerminal, playerKey(client, fixture, provider, f.request_type, f.index));
+ const scope = ensureScope(scopes, client, fixture);
+ scope.playerResults += 1;
+ fileScopeKeys.add(scopeKey(client, fixture));
+ } else if (line.startsWith('FIELD_NATIVE_HTTP_REQUEST ')) {
+ const provider = providerName(f);
+ requireRoute('http_request', f, client, fixture, provider);
+ increment(httpRequests, httpKey(client, provider, f.request_type, f.method, f.endpoint));
+ for (const key of fileScopeKeys) {
+ const scope = scopes.get(key);
+ if (scope && scope.client === client) scope.httpRequests += 1;
+ }
+ } else if (line.startsWith('FIELD_NATIVE_HTTP_RESPONSE ') || line.startsWith('FIELD_NATIVE_HTTP_ERROR ')) {
+ const provider = providerName(f);
+ requireRoute('http_terminal', f, client, fixture, provider);
+ increment(httpTerminal, httpKey(client, provider, f.request_type, f.method, f.endpoint));
+ }
+ }
+
+ for (const key of fileScopeKeys) {
+ const scope = scopes.get(key);
+ if (!scope) continue;
+ if (fileInstrumentedClients.has(scope.client)) scope.instrumented = true;
+ for (const phase of fileFrontend) scope.frontendPhases.add(phase);
+ }
+ }
+
+ if (readableLogs === 0) problems.push('no_readable_log');
+ if (scopes.size === 0) problems.push('missing_corpus_scope');
+ if (frontendErrors > 0) problems.push(`frontend_capture_errors:${frontendErrors}`);
+
+ for (const scope of scopes.values()) {
+ const label = `${scope.client}:${scope.fixture}`;
+ if (scope.expectedProviders <= 0) problems.push(`invalid_expected_provider_count:${label}`);
+ if (!scope.ended) problems.push(`missing_corpus_end:${label}`);
+ if (!scope.instrumented) problems.push(`missing_runtime_instrumentation:${label}`);
+ if (scope.expectedProviders > 0 && scope.traversed.size !== scope.expectedProviders) {
+ problems.push(`provider_traversal:${label}:${scope.traversed.size}/${scope.expectedProviders}`);
+ }
+ const executionProofs = scope.providerRoutesBegun + scope.providerLoadErrors + (scope.repositoryLoadFailed ? 1 : 0);
+ if (executionProofs <= 0) problems.push(`missing_provider_execution:${label}`);
+
+ if (scope.repositoryLoadBegun === 0) problems.push(`missing_repository_load:${label}`);
+ if (scope.repositoryLoadBegun !== scope.repositoryLoadTerminal) {
+ problems.push(`repository_load_terminal:${label}:${scope.repositoryLoadTerminal}/${scope.repositoryLoadBegun}`);
+ }
+ const expectedLoad = scope.repositoryLoadExpected || scope.expectedProviders;
+ if (expectedLoad > 0 && scope.providerLoadObserved.size !== expectedLoad) {
+ problems.push(`provider_load_coverage:${label}:${scope.providerLoadObserved.size}/${expectedLoad}`);
+ }
+
+ // Repository/provider load markers are the canonical proof. Passive HTTP markers
+ // are optional because the human-UX policy forbids patching Nuvio merely to emit
+ // them. When passive markers exist, they still have to form complete request /
+ // terminal pairs and their frontend phases remain checked below.
+ if (scope.repositoryHttpRequests !== scope.repositoryHttpTerminal) {
+ problems.push(`repository_http_terminal:${label}:${scope.repositoryHttpTerminal}/${scope.repositoryHttpRequests}`);
+ }
+
+ const requiredFrontend = new Set(['ui-launched', 'corpus-begin', 'corpus-end']);
+ if (scope.providerRoutesBegun > 0) requiredFrontend.add('provider-loading');
+ if (scope.repositoryLoadBegun > 0) {
+ requiredFrontend.add('repository-load');
+ if (!scope.repositoryLoadFailed) requiredFrontend.add('repository-loaded');
+ else requiredFrontend.add('repository-load-error');
+ }
+ if (scope.repositoryHttpRequests > 0) {
+ requiredFrontend.add('repository-http-request');
+ requiredFrontend.add('repository-http-terminal');
+ }
+ if (scope.providerLoadObserved.size > 0) requiredFrontend.add('provider-load-state');
+ if (scope.results > 0) requiredFrontend.add('provider-result');
+ if (scope.httpRequests > 0) {
+ requiredFrontend.add('provider-http-request');
+ requiredFrontend.add('provider-http-terminal');
+ }
+ if (scope.playerResults > 0) {
+ requiredFrontend.add('player-start');
+ requiredFrontend.add('player-result');
+ }
+ for (const phase of requiredFrontend) {
+ const legacyPhase = phase === 'repository-http-terminal'
+ ? 'repository-http-response'
+ : phase === 'provider-http-terminal'
+ ? 'provider-http-response'
+ : null;
+ const observed = scope.frontendPhases.has(phase) || (legacyPhase && scope.frontendPhases.has(legacyPhase));
+ if (!observed) problems.push(`missing_frontend_phase:${label}:${phase}`);
+ }
+ }
+
+ for (const [key, begun] of routesBegun) {
+ const terminal = Number(routesTerminal.get(key) || 0);
+ if (terminal !== begun) problems.push(`provider_route_terminal:${key.replace(/\u0000/g, ':')}:${terminal}/${begun}`);
+ }
+ for (const [key, terminal] of routesTerminal) {
+ const begun = Number(routesBegun.get(key) || 0);
+ if (terminal > begun) problems.push(`provider_route_missing_begin:${key.replace(/\u0000/g, ':')}:${terminal}/${begun}`);
+ }
+ for (const [key, begun] of playersBegun) {
+ const terminal = Number(playersTerminal.get(key) || 0);
+ if (terminal !== begun) problems.push(`player_terminal:${key.replace(/\u0000/g, ':')}:${terminal}/${begun}`);
+ }
+ for (const [key, terminal] of playersTerminal) {
+ const begun = Number(playersBegun.get(key) || 0);
+ if (terminal > begun) problems.push(`player_missing_begin:${key.replace(/\u0000/g, ':')}:${terminal}/${begun}`);
+ }
+ for (const [key, requested] of httpRequests) {
+ const terminal = Number(httpTerminal.get(key) || 0);
+ if (terminal !== requested) problems.push(`http_terminal:${key.replace(/\u0000/g, ':')}:${terminal}/${requested}`);
+ }
+ for (const [key, terminal] of httpTerminal) {
+ const requested = Number(httpRequests.get(key) || 0);
+ if (terminal > requested) problems.push(`http_missing_request:${key.replace(/\u0000/g, ':')}:${terminal}/${requested}`);
+ }
+ for (const [key, requested] of repositoryHttpRequests) {
+ const terminal = Number(repositoryHttpTerminal.get(key) || 0);
+ if (terminal !== requested) problems.push(`repository_http_pair:${key.replace(/\u0000/g, ':')}:${terminal}/${requested}`);
+ }
+ for (const [key, terminal] of repositoryHttpTerminal) {
+ const requested = Number(repositoryHttpRequests.get(key) || 0);
+ if (terminal > requested) problems.push(`repository_http_missing_request:${key.replace(/\u0000/g, ':')}:${terminal}/${requested}`);
+ }
+
+ const providerExecutions = [...routesTerminal.values()].reduce((a, b) => a + b, 0);
+ const providerLoadErrors = [...scopes.values()].reduce((sum, scope) => sum + scope.providerLoadErrors, 0);
+ const repositoryLoadFailures = [...scopes.values()].reduce((sum, scope) => sum + (scope.repositoryLoadFailed ? 1 : 0), 0);
+ const executionProofs = providerExecutions + providerLoadErrors + repositoryLoadFailures;
+ return {
+ complete: problems.length === 0,
+ problems,
+ stats: {
+ readableLogs,
+ scopes: scopes.size,
+ providerRoutes: routesBegun.size,
+ providerExecutions,
+ executionProofs,
+ providerLoads: [...scopes.values()].reduce((sum, scope) => sum + scope.providerLoadObserved.size, 0),
+ providerLoadErrors,
+ repositoryLoadFailures,
+ repositoryCacheHits: [...scopes.values()].reduce((sum, scope) => sum + scope.repositoryCacheHits, 0),
+ repositoryHttpRequests: [...repositoryHttpRequests.values()].reduce((a, b) => a + b, 0),
+ playerProbes: playersTerminal.size,
+ httpRequests: [...httpRequests.values()].reduce((a, b) => a + b, 0),
+ frontendErrors,
+ },
+ };
+}
+
+module.exports = { assessNativeEvidence };
diff --git a/scripts/native_player_diagnostics.cjs b/scripts/native_player_diagnostics.cjs
new file mode 100755
index 000000000..b8ca1514f
--- /dev/null
+++ b/scripts/native_player_diagnostics.cjs
@@ -0,0 +1,101 @@
+#!/usr/bin/env node
+'use strict';
+
+const READER_FAILURE_CLASSES = Object.freeze({
+ http_access: 'playback_http_access',
+ http_gone: 'playback_http_gone',
+ http_rate_limit: 'playback_rate_limited',
+ http_upstream: 'playback_http_upstream',
+ http_response: 'playback_http_response',
+ timeout: 'playback_timeout',
+ dns: 'playback_dns',
+ tls: 'playback_tls',
+ parser: 'playback_parser',
+ decoder: 'playback_decoder',
+ live_window: 'playback_live_window',
+ io: 'playback_io',
+ player_setup: 'playback_runtime_setup',
+ player: 'playback_player_error',
+ duration_identity: 'short_media',
+ duration_unknown: 'playback_duration_unknown',
+});
+
+const CLIENT_RUNTIME_FAILURE_CLASSES = new Set([
+ 'playback_runtime_setup',
+ 'playback_player_error',
+ 'playback_decoder',
+]);
+
+const CLIENT_RUNTIME_ERROR_PATTERNS = [
+ /java\.awt\.peer/i,
+ /componentpeer/i,
+ /inaccessibleobjectexception/i,
+ /module[_ ]java\.desktop/i,
+ /does[_ ]not[_ ]?["']?opens/i,
+ /unable[_ ]to[_ ]make[_ ]field/i,
+ /java[_ .]heap[_ ]space/i,
+ /outofmemoryerror/i,
+ /qemu.*(?:hang|shutdown|killed)/i,
+ /runner.*(?:shutdown|cancel)/i,
+];
+
+function readerFailureClass(row = {}) {
+ const state = String(row.state || '').toLowerCase();
+ if (state === 'ready' || state === 'ended') return 'healthy';
+ if (state === 'short_media') return 'short_media';
+ if (state === 'duration_unknown') return 'playback_duration_unknown';
+ const stage = String(row.failureStage || row.failure_stage || '').toLowerCase();
+ if (READER_FAILURE_CLASSES[stage]) return READER_FAILURE_CLASSES[stage];
+ const status = Number(row.httpStatus ?? row.http_status ?? 0);
+ if ([401, 403, 407, 451].includes(status)) return 'playback_http_access';
+ if ([404, 410].includes(status)) return 'playback_http_gone';
+ if (status === 429) return 'playback_rate_limited';
+ if (status >= 500 && status <= 599) return 'playback_http_upstream';
+ if (state === 'timeout') return 'playback_timeout';
+ if (state === 'error') return 'playback_player_error';
+ return state ? 'playback_unknown' : 'unknown_failure';
+}
+
+function readerFailureDomain(row = {}) {
+ const cls = readerFailureClass(row);
+ if (cls === 'healthy') return 'healthy';
+ if (CLIENT_RUNTIME_FAILURE_CLASSES.has(cls)) return 'client_runtime';
+ const details = [
+ row.errorClass, row.error_class,
+ row.errorCode, row.error_code,
+ row.exceptionChain, row.exception_chain,
+ ].filter(Boolean).join(' ');
+ if (CLIENT_RUNTIME_ERROR_PATTERNS.some((pattern) => pattern.test(details))) return 'client_runtime';
+ return 'provider_stream';
+}
+
+function providerMutationEligible(row = {}) {
+ return readerFailureDomain(row) === 'provider_stream';
+}
+
+function readerSignature(row = {}) {
+ const cls = readerFailureClass(row);
+ const status = Number(row.httpStatus ?? row.http_status ?? 0);
+ const code = String(row.errorCode || row.error_code || '').slice(0, 96);
+ const host = String(row.host || '').toLowerCase().slice(0, 160);
+ const requestType = String(row.requestType || row.request_type || '').trim().toLowerCase().slice(0, 24);
+ const base = [cls, status || '-', code || '-', host || '-'];
+ // Keep legacy signatures stable when no route exists, but once the real Nuvio
+ // request route is known it becomes part of the causal identity. This prevents
+ // a provider's anime and tv failures from being learned as the same observation.
+ return (requestType ? [requestType, ...base] : base).join(':');
+}
+
+function isReaderFailure(row = {}) {
+ return readerFailureClass(row) !== 'healthy';
+}
+
+module.exports = {
+ READER_FAILURE_CLASSES,
+ CLIENT_RUNTIME_FAILURE_CLASSES,
+ readerFailureClass,
+ readerFailureDomain,
+ providerMutationEligible,
+ readerSignature,
+ isReaderFailure,
+};
diff --git a/scripts/native_player_diagnostics_codegen.py b/scripts/native_player_diagnostics_codegen.py
new file mode 100644
index 000000000..51c897781
--- /dev/null
+++ b/scripts/native_player_diagnostics_codegen.py
@@ -0,0 +1,283 @@
+#!/usr/bin/env python3
+"""Augment Android native-corpus tests with production Nuvio playback observation.
+
+The generated lab never constructs a parallel Media3 player. TV launches NuvioTV's
+real Player screen and reads LastPlaybackDiagnostics written by PlayerRuntimeController.
+Mobile renders NuvioMobile's real PlatformPlayerSurface inside its real MainActivity,
+therefore preserving production header sanitation, player settings and ExoPlayer→libmpv
+fallback. Independent transport diagnostics run only after the player observation.
+"""
+from __future__ import annotations
+
+from typing import Iterable
+
+ANDROID_IMPORT_ANCHOR = "import android.util.Log\n"
+TEST_ANCHOR = " @Test\n"
+
+COMMON_IMPORTS = """import android.content.Intent
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicReference
+"""
+
+# Shared TV Hilt/repository imports are owned by augment_native_provider_loading.py.
+# Keeping them out of this player-only layer prevents two independent augmenters
+# from emitting the same Kotlin imports into the final native corpus source.
+TV_IMPORTS = """import androidx.activity.compose.setContent
+import androidx.navigation.compose.rememberNavController
+import com.nuvio.tv.MainActivity
+import com.nuvio.tv.core.player.LastPlaybackDiagnostics
+import com.nuvio.tv.data.local.PlayerSettingsDataStore
+import com.nuvio.tv.ui.navigation.NuvioNavHost
+import com.nuvio.tv.ui.navigation.Screen
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withTimeout
+"""
+
+MOBILE_IMPORTS = """import androidx.activity.compose.setContent
+import com.nuvio.app.MainActivity
+import com.nuvio.app.features.player.PlatformPlayerSurface
+import com.nuvio.app.features.player.PlayerPlaybackSnapshot
+import com.nuvio.app.features.player.PlayerResizeMode
+"""
+
+PROBE_MODEL = r'''
+ data class NativePlayerProbe(
+ val state: String,
+ val engine: String,
+ val errorClass: String,
+ val errorCode: String,
+ val httpStatus: Int,
+ val failureStage: String,
+ val host: String,
+ val durationSeconds: Double?,
+ val exceptionChain: String = "",
+ val responseHeaderNames: String = "",
+ val loadBytes: Long = 0L,
+ val loadDurationMs: Long = 0L,
+ val mediaDataType: Int = -1,
+ val trackType: Int = -1,
+ )
+
+ private fun sanitizeDiag(raw: String?): String = raw.orEmpty()
+ .replace(Regex("https?://\\S+", RegexOption.IGNORE_CASE), "")
+ .replace(Regex("(?i)(authorization|cookie|token|secret)\\s*[:=]\\s*\\S+"), "$1=")
+ .replace(Regex("\\s+"), " ")
+ .take(420)
+'''
+
+TV_HELPERS = PROBE_MODEL + r'''
+ @EntryPoint
+ @InstallIn(SingletonComponent::class)
+ interface NiakvioPlayerSettingsEntryPoint {
+ fun playerSettingsDataStore(): PlayerSettingsDataStore
+ }
+
+ private fun probeNativePlayer(
+ url: String,
+ headers: Map?,
+ streamType: String?,
+ expectedDurationMinutes: Int,
+ ): NativePlayerProbe {
+ val instrumentation = InstrumentationRegistry.getInstrumentation()
+ val context = instrumentation.targetContext
+ val host = hostOnly(url)
+ var activity: MainActivity? = null
+ return try {
+ val store = EntryPointAccessors.fromApplication(
+ context.applicationContext,
+ NiakvioPlayerSettingsEntryPoint::class.java,
+ ).playerSettingsDataStore()
+ val baseline = runBlocking { store.lastPlaybackDiagnostics.first().timestampMs }
+ val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)
+ ?: return NativePlayerProbe("error", "nuvio-tv", "MainActivity", "NO_LAUNCH_INTENT", 0, "player_setup", host, null)
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
+ activity = instrumentation.startActivitySync(intent) as? MainActivity
+ ?: return NativePlayerProbe("error", "nuvio-tv", "MainActivity", "WRONG_ACTIVITY", 0, "player_setup", host, null)
+ val route = Screen.Player.createRoute(
+ streamUrl = url,
+ title = "NiakVIO native reader",
+ streamName = "NiakVIO native reader",
+ headers = headers,
+ contentType = streamType,
+ autoPlayNav = true,
+ )
+ instrumentation.runOnMainSync {
+ activity?.setContent {
+ val navController = rememberNavController()
+ NuvioNavHost(navController = navController, startDestination = route)
+ }
+ }
+ val diagnostics = runBlocking {
+ withTimeout(__PLAYER_TIMEOUT_MS__L) {
+ store.lastPlaybackDiagnostics.first { row ->
+ row.timestampMs > baseline && row.result != "Pending"
+ }
+ }
+ }
+ val durationSeconds = diagnostics.durationMs.takeIf { it > 0L }?.div(1000.0)
+ val expected = expectedDurationMinutes.takeIf { it > 0 }?.times(60.0)
+ val shortMedia = durationSeconds != null && (
+ durationSeconds < 60.0 || (expected != null && durationSeconds / expected < 0.55)
+ )
+ when {
+ shortMedia -> NativePlayerProbe("short_media", "nuvio-tv-production", "", "", 0, "duration_identity", diagnostics.host.ifBlank { host }, durationSeconds)
+ diagnostics.result.startsWith("Played", ignoreCase = true) || diagnostics.firstFrameMs >= 0L ->
+ NativePlayerProbe("ready", "nuvio-tv-production", "", "", 0, "none", diagnostics.host.ifBlank { host }, durationSeconds)
+ diagnostics.result.startsWith("Error", ignoreCase = true) ->
+ NativePlayerProbe("error", "nuvio-tv-production", "PlayerRuntimeController", sanitizeDiag(diagnostics.result), 0, "player", diagnostics.host.ifBlank { host }, durationSeconds)
+ else -> NativePlayerProbe("error", "nuvio-tv-production", "PlayerRuntimeController", sanitizeDiag(diagnostics.result), 0, "player", diagnostics.host.ifBlank { host }, durationSeconds)
+ }
+ } catch (error: kotlinx.coroutines.TimeoutCancellationException) {
+ NativePlayerProbe("timeout", "nuvio-tv-production", error::class.qualifiedName.orEmpty(), "READER_TIMEOUT", 0, "timeout", host, null)
+ } catch (error: Throwable) {
+ NativePlayerProbe("error", "nuvio-tv-production", error::class.qualifiedName.orEmpty(), sanitizeDiag(error.message), 0, "player_setup", host, null)
+ } finally {
+ runCatching { instrumentation.runOnMainSync { activity?.finish() } }
+ }
+ }
+'''
+
+MOBILE_HELPERS = PROBE_MODEL + r'''
+ private fun probeNativePlayer(
+ url: String,
+ headers: Map?,
+ streamType: String?,
+ expectedDurationMinutes: Int,
+ ): NativePlayerProbe {
+ val instrumentation = InstrumentationRegistry.getInstrumentation()
+ val context = instrumentation.targetContext
+ val host = hostOnly(url)
+ val terminal = CountDownLatch(1)
+ val snapshotRef = AtomicReference(null)
+ val errorRef = AtomicReference(null)
+ var activity: MainActivity? = null
+ return try {
+ val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)
+ ?: return NativePlayerProbe("error", "nuvio-mobile", "MainActivity", "NO_LAUNCH_INTENT", 0, "player_setup", host, null)
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
+ activity = instrumentation.startActivitySync(intent) as? MainActivity
+ ?: return NativePlayerProbe("error", "nuvio-mobile", "MainActivity", "WRONG_ACTIVITY", 0, "player_setup", host, null)
+ instrumentation.runOnMainSync {
+ activity?.setContent {
+ // Real NuvioMobile production player entry. Its own code performs
+ // header sanitation, settings lookup and Auto engine fallback.
+ PlatformPlayerSurface(
+ sourceUrl = url,
+ sourceHeaders = headers.orEmpty(),
+ sourceResponseHeaders = emptyMap(),
+ externalSubtitles = emptyList(),
+ streamType = streamType,
+ useYoutubeChunkedPlayback = false,
+ playWhenReady = true,
+ initialPositionMs = 0L,
+ initialPositionRequestKey = "niakvio-native-reader",
+ resizeMode = PlayerResizeMode.Fit,
+ useNativeController = true,
+ onInitialPositionHandled = { _, _ -> },
+ onControllerReady = { _ -> },
+ onSnapshot = { snapshot ->
+ snapshotRef.set(snapshot)
+ if (snapshot.isEnded || (!snapshot.isLoading && (snapshot.isPlaying || snapshot.positionMs > 0L || snapshot.durationMs > 0L))) {
+ terminal.countDown()
+ }
+ },
+ onError = { message ->
+ // Auto mode intentionally emits null while switching from
+ // ExoPlayer to libmpv; do not terminate until Nuvio itself
+ // reports a final error or a playable snapshot.
+ if (!message.isNullOrBlank()) {
+ errorRef.compareAndSet(null, message)
+ terminal.countDown()
+ }
+ },
+ )
+ }
+ }
+ terminal.await(__PLAYER_TIMEOUT_MS__L, TimeUnit.MILLISECONDS)
+ val error = errorRef.get()
+ val snapshot = snapshotRef.get()
+ if (!error.isNullOrBlank()) {
+ return NativePlayerProbe("error", "nuvio-mobile-production", "PlatformPlayerSurface", sanitizeDiag(error), 0, "player", host, null)
+ }
+ val durationSeconds = snapshot?.durationMs?.takeIf { it > 0L }?.div(1000.0)
+ val expected = expectedDurationMinutes.takeIf { it > 0 }?.times(60.0)
+ val shortMedia = durationSeconds != null && (
+ durationSeconds < 60.0 || (expected != null && durationSeconds / expected < 0.55)
+ )
+ when {
+ shortMedia -> NativePlayerProbe("short_media", "nuvio-mobile-production", "", "", 0, "duration_identity", host, durationSeconds)
+ snapshot?.isEnded == true -> NativePlayerProbe("ended", "nuvio-mobile-production", "", "", 0, "none", host, durationSeconds)
+ snapshot != null && !snapshot.isLoading && (snapshot.isPlaying || snapshot.positionMs > 0L || snapshot.durationMs > 0L) ->
+ NativePlayerProbe("ready", "nuvio-mobile-production", "", "", 0, "none", host, durationSeconds)
+ else -> NativePlayerProbe("timeout", "nuvio-mobile-production", "PlatformPlayerSurface", "READER_TIMEOUT", 0, "timeout", host, durationSeconds)
+ }
+ } catch (error: Throwable) {
+ NativePlayerProbe("error", "nuvio-mobile-production", error::class.qualifiedName.orEmpty(), sanitizeDiag(error.message), 0, "player_setup", host, null)
+ } finally {
+ runCatching { instrumentation.runOnMainSync { activity?.finish() } }
+ }
+ }
+'''
+
+OLD_ANDROID_PROBE_BLOCK = ''' rows.firstOrNull()?.let { row ->
+ val probe = probeTransport(row.url, row.headers)
+ emit("FIELD_NATIVE_TRANSPORT client=__CLIENT__ fixture=$fixtureSlug provider64=${b64(provider.id)} state=${probe.state} kind=${probe.kind} status=${probe.status} content_type64=${b64(probe.contentType)} extm3u=${probe.extm3u} duration_seconds=${probe.durationSeconds ?: 0.0} host64=${b64(probe.host)} media_hint64=${b64(probe.mediaHint)}")
+ }
+'''
+
+NEW_ANDROID_PROBE_BLOCK = ''' rows.take(__MAX_PROBES__).forEachIndexed { index, row ->
+ // Human UX contract: Nuvio's production player is the first
+ // consumer. A diagnostic GET before playback can consume a
+ // signed/one-shot URL and manufacture a false failure.
+ emit("FIELD_NATIVE_PLAYER_BEGIN client=__CLIENT__ fixture=$fixtureSlug provider64=${b64(provider.id)} index=$index entry=nuvio-production-player")
+ val reader = probeNativePlayer(row.url, row.headers, row.type, __EXPECTED_MINUTES__)
+ emit("FIELD_NATIVE_PLAYER client=__CLIENT__ fixture=$fixtureSlug provider64=${b64(provider.id)} index=$index state=${reader.state} engine=${reader.engine} http_status=${reader.httpStatus} failure_stage=${reader.failureStage} duration_seconds=${reader.durationSeconds ?: 0.0} host64=${b64(reader.host)} error_class64=${b64(reader.errorClass)} error_code64=${b64(reader.errorCode)} exception_chain64=${b64(reader.exceptionChain)} response_header_names64=${b64(reader.responseHeaderNames)} load_bytes=${reader.loadBytes} load_duration_ms=${reader.loadDurationMs} media_data_type=${reader.mediaDataType} track_type=${reader.trackType}")
+ val transport = probeTransport(row.url, row.headers)
+ emit("FIELD_NATIVE_TRANSPORT client=__CLIENT__ fixture=$fixtureSlug provider64=${b64(provider.id)} index=$index state=${transport.state} kind=${transport.kind} status=${transport.status} content_type64=${b64(transport.contentType)} extm3u=${transport.extm3u} duration_seconds=${transport.durationSeconds ?: 0.0} host64=${b64(transport.host)} media_hint64=${b64(transport.mediaHint)}")
+ }
+'''
+
+
+def augment_android_test(
+ source: str,
+ *,
+ client: str,
+ expected_duration_minutes: int | float | None = None,
+ max_player_probes: int = 1,
+) -> str:
+ if client not in {"mobile", "tv"}:
+ raise ValueError(f"unsupported Android client: {client}")
+ max_player_probes = max(1, min(int(max_player_probes or 1), 4))
+ expected = max(0, int(expected_duration_minutes or 0))
+ if "FIELD_NATIVE_PLAYER " in source:
+ return source
+ if source.count(ANDROID_IMPORT_ANCHOR) != 1:
+ raise ValueError("android import anchor missing or ambiguous")
+ imports = COMMON_IMPORTS + (TV_IMPORTS if client == "tv" else MOBILE_IMPORTS)
+ source = source.replace(ANDROID_IMPORT_ANCHOR, ANDROID_IMPORT_ANCHOR + imports, 1)
+ helpers = (TV_HELPERS if client == "tv" else MOBILE_HELPERS).replace("__PLAYER_TIMEOUT_MS__", "22000")
+ if source.count(TEST_ANCHOR) != 1:
+ raise ValueError("test anchor missing or ambiguous")
+ source = source.replace(TEST_ANCHOR, helpers + "\n" + TEST_ANCHOR, 1)
+ old = OLD_ANDROID_PROBE_BLOCK.replace("__CLIENT__", client)
+ if source.count(old) != 1:
+ raise ValueError(f"transport probe anchor missing for {client}: count={source.count(old)}")
+ new = (
+ NEW_ANDROID_PROBE_BLOCK.replace("__CLIENT__", client)
+ .replace("__MAX_PROBES__", str(max_player_probes))
+ .replace("__EXPECTED_MINUTES__", str(expected))
+ )
+ return source.replace(old, new, 1)
+
+
+def filter_staged_providers(providers: Iterable[dict], provider: str | None) -> list[dict]:
+ rows = list(providers)
+ wanted = str(provider or "").strip().casefold()
+ if not wanted:
+ return rows
+ filtered = [row for row in rows if str(row.get("id") or "").strip().casefold() == wanted]
+ if not filtered:
+ available = ", ".join(str(row.get("id") or "") for row in rows[:40])
+ raise ValueError(f"unknown provider {provider!r}; available sample: {available}")
+ return filtered
diff --git a/scripts/nuvio_tv_test_bootstrap.py b/scripts/nuvio_tv_test_bootstrap.py
new file mode 100644
index 000000000..0c2598d93
--- /dev/null
+++ b/scripts/nuvio_tv_test_bootstrap.py
@@ -0,0 +1,5 @@
+#!/usr/bin/env python3
+"""Compatibility wrapper for the test-only NuvioTV bootstrap."""
+from native_client_test_bootstrap import enable_tv_tests
+
+__all__ = ["enable_tv_tests"]
diff --git a/scripts/prebuild_native_android_reader_suite.sh b/scripts/prebuild_native_android_reader_suite.sh
new file mode 100755
index 000000000..1a72265cd
--- /dev/null
+++ b/scripts/prebuild_native_android_reader_suite.sh
@@ -0,0 +1,66 @@
+#!/usr/bin/env bash
+# Precompile the first real native-reader route before QEMU starts. Hosted runners
+# otherwise make Gradle packaging and a 2 GiB emulator fight for the same memory.
+set -euo pipefail
+
+CLIENT="${1:-}"
+WORKSPACE="${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}"
+NIAKVIO="${WORKSPACE}/niakvio"
+FIXTURE="${NIAKVIO_PRIMARY_FIXTURE:-sinners-2025}"
+TARGET_MANIFEST="${NIAKVIO_TARGET_MANIFEST:-manifest.json}"
+TARGET_PROVIDER="${NIAKVIO_TARGET_PROVIDER:-all}"
+STREAM_SCOPE="${NIAKVIO_PRIMARY_STREAM_SCOPE:-all}"
+SOURCE_SHA="${NIAKVIO_SOURCE_SHA:-$(git -C "$NIAKVIO" rev-parse HEAD)}"
+SOURCE_REPOSITORY="${GITHUB_REPOSITORY:-niakw/NiakVIO}"
+export SOURCE_SHA SOURCE_REPOSITORY
+REPOSITORY_RESOLVER="${NIAKVIO}/scripts/resolve_native_repository.sh"
+LAB_TRANSPORT="${NIAKVIO}/scripts/configure_native_android_lab_transport.py"
+INSTRUMENTER="${NIAKVIO}/scripts/instrument_native_client_evidence.py"
+REPOSITORY_HTTP_INSTRUMENTER="${NIAKVIO}/scripts/instrument_native_repository_http_evidence.py"
+REQUEST_CONTRACT="${NIAKVIO}/scripts/augment_native_corpus_request_contract.py"
+PROVIDER_LOADING="${NIAKVIO}/scripts/augment_native_provider_loading.py"
+ACCEPTANCE_PREPARE="${NIAKVIO}/scripts/prepare_native_reader_acceptance.py"
+MOBILE_HARDEN="${NIAKVIO}/scripts/harden_nuvio_mobile_device_test.py"
+
+case "$CLIENT" in
+ tv)
+ ROOT="${WORKSPACE}/nuvio-tv"
+ TEST_SOURCE="${ROOT}/app/src/androidTest/java/com/nuvio/tv/core/plugin/NiakvioNativeCorpusTvTest.kt"
+ TEST_MANIFEST="${ROOT}/app/src/androidTest/AndroidManifest.xml"
+ PORT=18765
+ ;;
+ mobile)
+ ROOT="${WORKSPACE}/nuvio-mobile"
+ TEST_SOURCE="${ROOT}/composeApp/src/androidDeviceTest/kotlin/com/nuvio/app/features/plugins/NiakvioNativeCorpusMobileTest.kt"
+ TEST_MANIFEST="${ROOT}/composeApp/src/androidDeviceTest/AndroidManifest.xml"
+ PORT=18766
+ ;;
+ *) echo "usage: $0 tv|mobile" >&2; exit 64 ;;
+esac
+
+source "$REPOSITORY_RESOLVER"
+resolve_native_repository "$CLIENT" 10.0.2.2 "$PORT"
+trap cleanup_native_repository EXIT
+MANIFEST_URL="$NIAKVIO_RESOLVED_MANIFEST_URL"
+URL_ARGS=()
+if [[ "$NIAKVIO_RESOLVED_ALLOW_LOCAL" = "1" ]]; then URL_ARGS+=(--allow-local-lab-url); fi
+
+# Mobile hardening changes only test packaging/Sentry startup. Apply it before the
+# prebuild so QEMU never has to invalidate and rebuild the device-test APK later.
+if [[ "$CLIENT" = "mobile" ]]; then
+ python3 "$MOBILE_HARDEN" "$ROOT"
+fi
+python3 "$LAB_TRANSPORT" "$TEST_MANIFEST"
+python3 "$INSTRUMENTER" "$CLIENT" "$ROOT"
+python3 "$REPOSITORY_HTTP_INSTRUMENTER" "$CLIENT" "$ROOT"
+python3 "$ACCEPTANCE_PREPARE" "$CLIENT" --fixture "$FIXTURE" --workspace "$WORKSPACE" --provider "$TARGET_PROVIDER" --streams "$STREAM_SCOPE" --manifest "$TARGET_MANIFEST"
+python3 "$REQUEST_CONTRACT" "$CLIENT" --fixture "$FIXTURE" --manifest "$TARGET_MANIFEST" --source "$TEST_SOURCE"
+python3 "$PROVIDER_LOADING" "$CLIENT" --manifest "$TARGET_MANIFEST" --manifest-url "$MANIFEST_URL" --source "$TEST_SOURCE" "${URL_ARGS[@]}"
+
+if [[ "$CLIENT" = "tv" ]]; then
+ "$ROOT/gradlew" -p "$ROOT" :app:assembleFullDebug :app:assembleFullDebugAndroidTest --console=plain
+else
+ "$ROOT/gradlew" -p "$ROOT" :androidApp:assembleFullDebug :composeApp:packageAndroidDeviceTest -Pnuvio.android.distribution=full --console=plain
+fi
+
+echo "FIELD_NATIVE_ANDROID_PREBUILD client=$CLIENT fixture=$FIXTURE manifest=$TARGET_MANIFEST provider=$TARGET_PROVIDER streams=$STREAM_SCOPE source_sha=$SOURCE_SHA status=ready"
diff --git a/scripts/prepare_native_candidate_repository.py b/scripts/prepare_native_candidate_repository.py
new file mode 100644
index 000000000..dec0d8740
--- /dev/null
+++ b/scripts/prepare_native_candidate_repository.py
@@ -0,0 +1,99 @@
+#!/usr/bin/env python3
+"""Materialize an isolated repository root for native candidate device testing.
+
+The repair Brain writes candidate manifests/providers inside the working checkout.
+Official Nuvio clients expect repository files to be reachable relative to a manifest
+URL, so a device lab cannot simply point at a local filesystem path. This script
+copies the candidate manifest to /manifest.json and copies exactly every
+referenced provider file under the same relative path. A loopback-only HTTP server
+can then expose the result to Nuvio without publishing the candidate first.
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import shutil
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def safe_repo_path(raw: object) -> Path:
+ value = str(raw or "").strip().replace("\\", "/")
+ if not value:
+ raise SystemExit("candidate repository provider has empty filename")
+ candidate = Path(value)
+ if candidate.is_absolute() or ".." in candidate.parts:
+ raise SystemExit(f"unsafe candidate repository filename: {value}")
+ return candidate
+
+
+def materialize(manifest_path: Path, serve_root: Path) -> int:
+ manifest_path = manifest_path.resolve()
+ try:
+ manifest_path.relative_to(ROOT)
+ except ValueError as error:
+ raise SystemExit(f"candidate manifest must live inside NiakVIO checkout: {manifest_path}") from error
+
+ data = json.loads(manifest_path.read_text(encoding="utf-8"))
+ scrapers = data.get("scrapers")
+ if not isinstance(scrapers, list) or not scrapers:
+ raise SystemExit(f"candidate manifest has no scrapers: {manifest_path}")
+
+ serve_root = serve_root.resolve()
+ if serve_root == ROOT or ROOT in serve_root.parents:
+ # A nested temporary directory inside the checkout is fine, but never delete
+ # the checkout root itself. The condition above catches the root exactly;
+ # nested roots are intentionally allowed and cleaned below.
+ if serve_root == ROOT:
+ raise SystemExit("serve root must not be the NiakVIO repository root")
+ shutil.rmtree(serve_root, ignore_errors=True)
+ serve_root.mkdir(parents=True, exist_ok=True)
+
+ copied = 0
+ seen: set[str] = set()
+ for row in scrapers:
+ if not isinstance(row, dict):
+ raise SystemExit("candidate manifest contains a non-object scraper")
+ provider_id = str(row.get("id") or "").strip()
+ key = provider_id.casefold()
+ if not provider_id:
+ raise SystemExit("candidate manifest contains provider without id")
+ if key in seen:
+ raise SystemExit(f"candidate manifest contains duplicate provider id: {provider_id}")
+ seen.add(key)
+ relative = safe_repo_path(row.get("filename"))
+ source = (ROOT / relative).resolve()
+ try:
+ source.relative_to(ROOT)
+ except ValueError as error:
+ raise SystemExit(f"provider escapes repository root: {provider_id} -> {relative}") from error
+ if not source.is_file():
+ raise SystemExit(f"candidate provider file missing: {provider_id} -> {relative}")
+ destination = serve_root / relative
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(source, destination)
+ copied += 1
+
+ shutil.copy2(manifest_path, serve_root / "manifest.json")
+ print(
+ f"FIELD_NATIVE_CANDIDATE_REPOSITORY_READY manifest={manifest_path.relative_to(ROOT)} "
+ f"providers={len(scrapers)} copied={copied} serve_root={serve_root}"
+ )
+ return copied
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--manifest", required=True)
+ parser.add_argument("--serve-root", required=True)
+ args = parser.parse_args()
+ manifest = Path(args.manifest)
+ if not manifest.is_absolute():
+ manifest = ROOT / manifest
+ copied = materialize(manifest, Path(args.serve_root))
+ return 0 if copied > 0 else 2
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/prepare_native_corpus_client.py b/scripts/prepare_native_corpus_client.py
index bc13f8ca5..55f37a07a 100644
--- a/scripts/prepare_native_corpus_client.py
+++ b/scripts/prepare_native_corpus_client.py
@@ -4,16 +4,167 @@
This intentionally wraps the existing corpus generator instead of duplicating its
runtime contract. Provider/runtime failures are observations; the generated test
only fails when the corpus itself cannot be constructed/traversed.
+
+Targeted reader runs may select another in-repository manifest. Raw GitHub
+filenames from that manifest are resolved back to the exact local provider bundle
+so the lab executes the same code without downloading mutable remote content.
+
+Before any provider is staged, the checkout is materialized through the same
+runtime-profile + published-override pipeline used for publication. This is a
+critical Brain/lab boundary: native readers must exercise the JavaScript produced
+by the current repair rules, not stale provider bundles committed before those
+rules changed. A checkout-local sentinel makes the operation once-per-job while
+all subsequent fixture restages reuse the exact same materialized transaction.
"""
from __future__ import annotations
import argparse
+import json
+import shutil
+import subprocess
import sys
from pathlib import Path
+from urllib.parse import urlparse
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts"))
import prepare_native_corpus_validation as corpus # noqa: E402
+import native_player_diagnostics_codegen as reader_diag # noqa: E402
+from audit_native_client_checkout import audit_checkout # noqa: E402
+from native_client_test_bootstrap import ( # noqa: E402
+ enable_mobile_device_tests,
+ enable_tv_tests,
+)
+
+MATERIALIZED_SENTINEL = ROOT / ".native-provider-overrides-materialized"
+
+
+def ensure_materialized_provider_transaction() -> None:
+ """Apply the current Brain/runtime repair transaction before native staging.
+
+ Each GitHub Actions job owns an isolated checkout, so a simple sentinel is
+ sufficient and deliberately avoids re-running the ~60-provider materializer
+ once per representative fixture. The sentinel is written only after dependency
+ installation and both materialization commands succeed; a failed transaction
+ therefore remains fail-closed.
+ """
+ if MATERIALIZED_SENTINEL.is_file():
+ return
+
+ # The publication materializer validates generated provider JS with Node and
+ # therefore needs the repository's pinned runtime modules. Native workflows
+ # set up Node but intentionally do not run npm ci themselves, so make this
+ # Brain->native boundary self-contained instead of depending on workflow order.
+ package_lock = ROOT / "package-lock.json"
+ if package_lock.is_file() and not (ROOT / "node_modules").is_dir():
+ npm = shutil.which("npm") or shutil.which("npm.cmd")
+ if not npm:
+ raise RuntimeError("npm is required to materialize provider overrides for native readers")
+ subprocess.run(
+ [npm, "ci", "--ignore-scripts", "--no-audit", "--no-fund"],
+ cwd=ROOT,
+ check=True,
+ )
+
+ commands = (
+ [sys.executable, str(ROOT / "scripts/build_provider_runtime_profiles.py")],
+ [sys.executable, str(ROOT / "scripts/reapply_published_overrides.py")],
+ )
+ for command in commands:
+ subprocess.run(command, cwd=ROOT, check=True)
+ MATERIALIZED_SENTINEL.write_text("materialized\n", encoding="utf-8")
+ print("FIELD_NATIVE_PROVIDER_TRANSACTION_MATERIALIZED source=brain-overrides status=ok")
+
+
+def _manifest_path(value: str | Path) -> Path:
+ raw = Path(value)
+ path = raw if raw.is_absolute() else ROOT / raw
+ path = path.resolve()
+ try:
+ path.relative_to(ROOT.resolve())
+ except ValueError as error:
+ raise SystemExit(f"native corpus manifest must live inside the repository: {value}") from error
+ if not path.is_file():
+ raise SystemExit(f"native corpus manifest not found: {path}")
+ return path
+
+
+def _provider_source(filename: str) -> Path:
+ raw = str(filename or "").strip()
+ if not raw:
+ raise SystemExit("manifest provider has no filename")
+ if raw.startswith(("http://", "https://")):
+ parsed = urlparse(raw)
+ if parsed.hostname != "raw.githubusercontent.com":
+ raise SystemExit(f"targeted reader refuses non-repository remote provider: {parsed.hostname or raw}")
+ parts = [part for part in parsed.path.split("/") if part]
+ try:
+ providers_index = parts.index("providers")
+ except ValueError as error:
+ raise SystemExit(f"raw provider URL does not point to providers/: {raw}") from error
+ if len(parts) <= providers_index + 1:
+ raise SystemExit(f"raw provider URL has no bundle name: {raw}")
+ relative = Path(*parts[providers_index:])
+ source = (ROOT / relative).resolve()
+ else:
+ source = (ROOT / raw).resolve()
+ try:
+ source.relative_to(ROOT.resolve())
+ except ValueError as error:
+ raise SystemExit(f"provider bundle escapes repository: {raw}") from error
+ if not source.is_file():
+ raise SystemExit(f"manifest provider bundle missing locally: {raw} -> {source}")
+ return source
+
+
+def manifest_providers(manifest_path: str | Path) -> list[dict]:
+ ensure_materialized_provider_transaction()
+ manifest_file = _manifest_path(manifest_path)
+ data = json.loads(manifest_file.read_text(encoding="utf-8"))
+ providers: list[dict] = []
+ seen: set[str] = set()
+ for row in data.get("scrapers", []):
+ if not isinstance(row, dict):
+ continue
+ provider_id = str(row.get("id") or "").strip()
+ filename = str(row.get("filename") or "").strip()
+ key = provider_id.casefold()
+ if not provider_id or not filename or key in seen:
+ continue
+ seen.add(key)
+ providers.append(
+ {
+ "id": provider_id,
+ "enabled": bool(row.get("enabled")),
+ "filename": filename,
+ "source": _provider_source(filename),
+ "manifest": str(manifest_file.relative_to(ROOT)),
+ }
+ )
+ if not providers:
+ raise SystemExit(f"selected manifest contains no stageable providers: {manifest_file}")
+ return providers
+
+
+def stage_manifest_providers(destination: Path, manifest_path: str | Path) -> list[dict]:
+ providers = manifest_providers(manifest_path)
+ destination.mkdir(parents=True, exist_ok=True)
+ for stale in destination.glob("p[0-9][0-9][0-9].js"):
+ stale.unlink()
+ staged: list[dict] = []
+ for index, provider in enumerate(providers):
+ asset = f"p{index:03d}.js"
+ shutil.copy2(provider["source"], destination / asset)
+ staged.append({**provider, "asset": asset})
+ print(
+ f"FIELD_NATIVE_CORPUS_STAGE_SELECTED manifest={providers[0]['manifest']} providers={len(staged)} "
+ f"enabled={sum(1 for row in staged if row['enabled'])} disabled={sum(1 for row in staged if not row['enabled'])}"
+ )
+ return staged
+
+
+def staged_manifest_providers(manifest_path: str | Path) -> list[dict]:
+ return [{**row, "asset": f"p{index:03d}.js"} for index, row in enumerate(manifest_providers(manifest_path))]
def _collector_test(source: str, client: str) -> str:
@@ -29,14 +180,7 @@ def _collector_test(source: str, client: str) -> str:
def _isolate_tv_android_test_sources(tv: Path) -> int:
- """Remove unrelated upstream instrumented sources from the ephemeral TV lab checkout.
-
- connectedFullDebugAndroidTest compiles the complete androidTest source set before
- applying instrumentation filters. Upstream UI tests can therefore break the
- provider corpus when their own app APIs change, even though NiakVIO never invokes
- those tests. Keep manifests/resources/assets intact and remove only Kotlin/Java
- test sources before writing the standalone NiakVIO corpus test.
- """
+ """Remove unrelated upstream instrumented sources from the ephemeral TV lab checkout."""
removed = 0
for source_dir in (tv / "app/src/androidTest/java", tv / "app/src/androidTest/kotlin"):
if not source_dir.is_dir():
@@ -49,32 +193,60 @@ def _isolate_tv_android_test_sources(tv: Path) -> int:
return removed
-def prepare_desktop(workspace: Path, fixture: dict) -> None:
- providers = corpus.stage_providers(ROOT / "native-corpus-stage")
- target = workspace / "nuvio-desktop/composeApp/src/desktopTest/kotlin/com/nuvio/app/features/plugins/NiakvioNativeCorpusDesktopTest.kt"
+def _selected(providers: list[dict], provider: str | None) -> list[dict]:
+ try:
+ return reader_diag.filter_staged_providers(providers, provider)
+ except ValueError as error:
+ raise SystemExit(str(error)) from error
+
+
+def prepare_desktop(workspace: Path, fixture: dict, provider: str | None, manifest_path: str | Path) -> None:
+ staged = stage_manifest_providers(ROOT / "native-corpus-stage", manifest_path)
+ providers = _selected(staged, provider)
+ repo = workspace / "nuvio-desktop"
+ target = repo / "composeApp/src/desktopTest/kotlin/com/nuvio/app/features/plugins/NiakvioNativeCorpusDesktopTest.kt"
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(_collector_test(corpus.desktop_test(fixture, providers), "desktop"), encoding="utf-8")
+ audit_checkout(repo, "desktop")
-def prepare_mobile(workspace: Path, fixture: dict) -> None:
+def prepare_mobile(workspace: Path, fixture: dict, provider: str | None, player_probes: int, manifest_path: str | Path) -> None:
mobile = workspace / "nuvio-mobile"
- corpus.enable_mobile_device_tests(mobile)
+ enable_mobile_device_tests(mobile)
assets = mobile / "composeApp/src/androidDeviceTest/assets/niakvio"
- providers = corpus.stage_providers(assets)
+ staged = stage_manifest_providers(assets, manifest_path)
+ providers = _selected(staged, provider)
+ source = corpus.android_test(fixture, providers, "mobile")
+ source = reader_diag.augment_android_test(
+ source,
+ client="mobile",
+ expected_duration_minutes=fixture.get("expectedDurationMinutes"),
+ max_player_probes=player_probes,
+ )
target = mobile / "composeApp/src/androidDeviceTest/kotlin/com/nuvio/app/features/plugins/NiakvioNativeCorpusMobileTest.kt"
target.parent.mkdir(parents=True, exist_ok=True)
- target.write_text(_collector_test(corpus.android_test(fixture, providers, "mobile"), "mobile"), encoding="utf-8")
+ target.write_text(_collector_test(source, "mobile"), encoding="utf-8")
+ audit_checkout(mobile, "mobile")
-def prepare_tv(workspace: Path, fixture: dict) -> None:
+def prepare_tv(workspace: Path, fixture: dict, provider: str | None, player_probes: int, manifest_path: str | Path) -> None:
tv = workspace / "nuvio-tv"
- corpus.enable_tv_tests(tv)
+ enable_tv_tests(tv)
_isolate_tv_android_test_sources(tv)
assets = tv / "app/src/androidTest/assets/niakvio"
- providers = corpus.stage_providers(assets)
+ staged = stage_manifest_providers(assets, manifest_path)
+ providers = _selected(staged, provider)
+ source = corpus.android_test(fixture, providers, "tv")
+ source = reader_diag.augment_android_test(
+ source,
+ client="tv",
+ expected_duration_minutes=fixture.get("expectedDurationMinutes"),
+ max_player_probes=player_probes,
+ )
target = tv / "app/src/androidTest/java/com/nuvio/tv/core/plugin/NiakvioNativeCorpusTvTest.kt"
target.parent.mkdir(parents=True, exist_ok=True)
- target.write_text(_collector_test(corpus.android_test(fixture, providers, "tv"), "tv"), encoding="utf-8")
+ target.write_text(_collector_test(source, "tv"), encoding="utf-8")
+ audit_checkout(tv, "tv")
def main() -> int:
@@ -82,16 +254,28 @@ def main() -> int:
parser.add_argument("target", choices=("desktop", "mobile", "tv"))
parser.add_argument("--fixture", required=True)
parser.add_argument("--workspace", required=True)
+ parser.add_argument("--provider", default="", help="optional exact provider id for a targeted human-style run")
+ parser.add_argument("--player-probes", type=int, default=1, help="number of returned streams played by the native reader (1-4)")
+ parser.add_argument("--manifest", default="manifest.json", help="in-repository manifest whose exact provider bundles are staged")
args = parser.parse_args()
fixture = corpus.fixture_by_slug(args.fixture)
workspace = Path(args.workspace).resolve()
- {"desktop": prepare_desktop, "mobile": prepare_mobile, "tv": prepare_tv}[args.target](workspace, fixture)
+ provider = args.provider.strip() or None
+ probes = max(1, min(args.player_probes, 4))
+ manifest_path = str(_manifest_path(args.manifest).relative_to(ROOT))
+ if args.target == "desktop":
+ prepare_desktop(workspace, fixture, provider, manifest_path)
+ elif args.target == "mobile":
+ prepare_mobile(workspace, fixture, provider, probes, manifest_path)
+ else:
+ prepare_tv(workspace, fixture, provider, probes, manifest_path)
print(
f"FIELD_NATIVE_CORPUS_PREPARED_ISOLATED target={args.target} fixture={args.fixture} "
- f"title={fixture.get('title')} tmdb={fixture.get('tmdbId')}"
+ f"title={fixture.get('title')} tmdb={fixture.get('tmdbId')} provider={provider or 'all'} "
+ f"player_probes={probes} manifest={manifest_path}"
)
return 0
if __name__ == "__main__":
- raise SystemExit(main())
\ No newline at end of file
+ raise SystemExit(main())
diff --git a/scripts/prepare_native_reader_acceptance.py b/scripts/prepare_native_reader_acceptance.py
new file mode 100644
index 000000000..5fa5f3512
--- /dev/null
+++ b/scripts/prepare_native_reader_acceptance.py
@@ -0,0 +1,259 @@
+#!/usr/bin/env python3
+"""Prepare real-reader acceptance corpus runs for official Nuvio Android clients.
+
+The fixture can provide a curated provider scope, while ``--provider all`` deliberately
+selects every stageable provider from the chosen manifest, including entries whose
+published ``enabled`` flag is false. Primary acceptance can play every returned
+stream; broad regression can sample a bounded number of streams. No provider-specific
+repair logic lives here.
+
+Pull-request validation is intentionally bounded: a small provider sample from each
+fixture and a small returned-stream sample are enough to prove the real reader path
+while the trusted-main/manual runs keep the exhaustive all-provider/all-stream
+evidence path.
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import re
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT / "scripts"))
+
+import native_player_diagnostics_codegen as reader_diag # noqa: E402
+import prepare_native_corpus_client as client_prepare # noqa: E402
+import prepare_native_corpus_validation as corpus # noqa: E402
+from audit_native_client_checkout import audit_checkout # noqa: E402
+from native_client_test_bootstrap import ( # noqa: E402
+ enable_mobile_device_tests,
+ enable_tv_tests,
+)
+
+CORPUS_PATH = ROOT / ".github/triggers/nuvio-client-lab.json"
+DEFAULT_PR_PROVIDER_LIMIT = 4
+DEFAULT_PR_STREAM_LIMIT = 2
+
+
+def _is_pull_request() -> bool:
+ return os.environ.get("GITHUB_EVENT_NAME", "").strip().lower() == "pull_request"
+
+
+def _pr_provider_limit() -> int:
+ raw = os.environ.get("NIAKVIO_PR_PROVIDER_LIMIT", str(DEFAULT_PR_PROVIDER_LIMIT)).strip()
+ try:
+ return max(1, min(int(raw), 12))
+ except ValueError:
+ return DEFAULT_PR_PROVIDER_LIMIT
+
+
+def _pr_stream_limit() -> int:
+ raw = os.environ.get("NIAKVIO_PR_STREAM_LIMIT", str(DEFAULT_PR_STREAM_LIMIT)).strip()
+ try:
+ return max(1, min(int(raw), 4))
+ except ValueError:
+ return DEFAULT_PR_STREAM_LIMIT
+
+
+def fixture_row(slug: str) -> dict:
+ data = json.loads(CORPUS_PATH.read_text(encoding="utf-8"))
+ for row in data.get("fixtures", []):
+ if isinstance(row, dict) and str(row.get("slug") or "") == slug:
+ fixture = row.get("fixture")
+ providers = row.get("providers")
+ if not isinstance(fixture, dict) or not isinstance(providers, list):
+ break
+ return {"slug": slug, "fixture": {"slug": slug, **fixture}, "providers": providers}
+ raise SystemExit(f"unknown or malformed native reader fixture: {slug}")
+
+
+def select_providers(manifest_path: str, slug: str, provider: str | None) -> list[dict]:
+ available = client_prepare.manifest_providers(manifest_path)
+ by_id = {str(row.get("id") or "").casefold(): row for row in available}
+ requested: list[str]
+ mode = str(provider or "fixture").strip().casefold()
+ if mode == "all":
+ requested = [str(row.get("id") or "") for row in available if str(row.get("id") or "").strip()]
+ elif mode == "fixture":
+ requested = [str(value) for value in fixture_row(slug)["providers"] if str(value).strip()]
+ else:
+ requested = [str(provider)]
+ selected: list[dict] = []
+ missing: list[str] = []
+ seen: set[str] = set()
+ for raw in requested:
+ key = raw.casefold()
+ if key in seen:
+ continue
+ seen.add(key)
+ row = by_id.get(key)
+ if row is None:
+ missing.append(raw)
+ else:
+ selected.append(row)
+ if missing:
+ raise SystemExit(
+ f"native reader fixture {slug} references provider(s) absent from {manifest_path}: "
+ + ", ".join(missing)
+ )
+ if not selected:
+ raise SystemExit(f"native reader fixture {slug} selected no providers from {manifest_path}")
+ return selected
+
+
+def stage_selected(destination: Path, providers: list[dict]) -> list[dict]:
+ destination.mkdir(parents=True, exist_ok=True)
+ for stale in destination.glob("p[0-9][0-9][0-9].js"):
+ stale.unlink()
+ staged: list[dict] = []
+ for index, provider in enumerate(providers):
+ asset = f"p{index:03d}.js"
+ shutil.copy2(provider["source"], destination / asset)
+ staged.append({**provider, "asset": asset})
+ return staged
+
+
+def parse_stream_scope(value: str | int) -> str | int:
+ raw = str(value).strip().casefold()
+ if raw == "all":
+ return "all"
+ try:
+ count = int(raw)
+ except ValueError as error:
+ raise SystemExit(f"invalid stream scope {value!r}; expected all or 1-4") from error
+ if count < 1 or count > 4:
+ raise SystemExit(f"invalid stream scope {value!r}; expected all or 1-4")
+ return count
+
+
+def reader_source(source: str, client: str, expected_duration_minutes: int | float | None, stream_scope: str | int) -> str:
+ scope = parse_stream_scope(stream_scope)
+ probes = 4 if scope == "all" else int(scope)
+ output = reader_diag.augment_android_test(
+ source,
+ client=client,
+ expected_duration_minutes=expected_duration_minutes,
+ max_player_probes=probes,
+ )
+ if scope != "all":
+ return output
+ output, replacements = re.subn(r"rows\.take\(\d+\)\.forEachIndexed", "rows.forEachIndexed", output)
+ if replacements < 2:
+ raise SystemExit(f"exhaustive reader rewrite incomplete: replacements={replacements}")
+ if re.search(r"rows\.take\(\d+\)\.forEachIndexed", output):
+ raise SystemExit("exhaustive reader source still contains sampled stream iteration")
+ return output
+
+
+def prepare(
+ target: str,
+ workspace: Path,
+ slug: str,
+ manifest_path: str,
+ provider: str | None,
+ initial: bool,
+ stream_scope: str | int,
+) -> Path:
+ row = fixture_row(slug)
+ fixture = row["fixture"]
+ requested_provider = str(provider or "fixture").strip() or "fixture"
+ requested_stream_scope = stream_scope
+ effective_provider = requested_provider
+ effective_stream_scope = stream_scope
+ pr_bounded = _is_pull_request()
+
+ if pr_bounded:
+ if effective_provider.casefold() == "all":
+ effective_provider = "fixture"
+ if str(effective_stream_scope).strip().casefold() == "all":
+ effective_stream_scope = _pr_stream_limit()
+
+ selected = select_providers(manifest_path, slug, effective_provider)
+ if pr_bounded and effective_provider.casefold() == "fixture":
+ selected = selected[: _pr_provider_limit()]
+ scope = parse_stream_scope(effective_stream_scope)
+
+ if target == "tv":
+ repo = workspace / "nuvio-tv"
+ if initial:
+ enable_tv_tests(repo)
+ client_prepare._isolate_tv_android_test_sources(repo)
+ assets = repo / "app/src/androidTest/assets/niakvio"
+ staged = stage_selected(assets, selected)
+ source = corpus.android_test(fixture, staged, "tv")
+ source = reader_source(source, "tv", fixture.get("expectedDurationMinutes"), scope)
+ source = client_prepare._collector_test(source, "tv")
+ output = repo / "app/src/androidTest/java/com/nuvio/tv/core/plugin/NiakvioNativeCorpusTvTest.kt"
+ else:
+ repo = workspace / "nuvio-mobile"
+ if initial:
+ enable_mobile_device_tests(repo)
+ assets = repo / "composeApp/src/androidDeviceTest/assets/niakvio"
+ staged = stage_selected(assets, selected)
+ source = corpus.android_test(fixture, staged, "mobile")
+ source = reader_source(source, "mobile", fixture.get("expectedDurationMinutes"), scope)
+ source = client_prepare._collector_test(source, "mobile")
+ output = repo / "composeApp/src/androidDeviceTest/kotlin/com/nuvio/app/features/plugins/NiakvioNativeCorpusMobileTest.kt"
+
+ output.parent.mkdir(parents=True, exist_ok=True)
+ output.write_text(source, encoding="utf-8")
+ audit_checkout(repo, target)
+ ids = ",".join(str(row.get("id") or "") for row in staged)
+ enabled = sum(1 for provider_row in staged if provider_row.get("enabled"))
+ disabled = len(staged) - enabled
+ print(
+ f"FIELD_NATIVE_READER_ACCEPTANCE_PREPARED client={target} fixture={slug} "
+ f"manifest={manifest_path} providers={len(staged)} enabled={enabled} disabled={disabled} "
+ f"provider_ids={ids} streams={scope} initial={str(initial).lower()} "
+ f"ci_mode={'pr-bounded' if pr_bounded else 'deep'} requested_provider={requested_provider} "
+ f"requested_streams={requested_stream_scope} provider_limit={_pr_provider_limit() if pr_bounded else 0} "
+ f"stream_limit={_pr_stream_limit() if pr_bounded else 0}"
+ )
+ return output
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("target", choices=("tv", "mobile"))
+ parser.add_argument("--fixture", required=True)
+ parser.add_argument("--workspace", required=True)
+ parser.add_argument("--manifest", default="manifest.json")
+ parser.add_argument("--provider", default="fixture", help="all selects every manifest provider, including disabled; fixture uses fixture scope; otherwise exact provider id")
+ parser.add_argument("--streams", default="all", help="all, or a bounded reader sample 1-4")
+ parser.add_argument("--initial", action="store_true", help="enable official client device tests before first fixture")
+ args = parser.parse_args()
+
+ workspace = Path(args.workspace).resolve()
+ manifest = str(client_prepare._manifest_path(args.manifest).relative_to(ROOT))
+ prepare(
+ args.target,
+ workspace,
+ args.fixture,
+ manifest,
+ args.provider.strip() or "fixture",
+ args.initial,
+ args.streams,
+ )
+ if (
+ args.initial
+ and os.environ.get("GITHUB_ACTIONS", "").strip().lower() == "true"
+ and os.environ.get("NIAKVIO_SKIP_ANDROID_PREBUILD", "0").strip() != "1"
+ ):
+ env = os.environ.copy()
+ env["GITHUB_WORKSPACE"] = str(workspace)
+ subprocess.run(
+ ["bash", str(ROOT / "scripts/prebuild_native_android_reader_suite.sh"), args.target],
+ cwd=ROOT,
+ env=env,
+ check=True,
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/provider_patches/global_media_enrichment_v1.py b/scripts/provider_patches/global_media_enrichment_v1.py
index ec149b46b..e8e8b5428 100644
--- a/scripts/provider_patches/global_media_enrichment_v1.py
+++ b/scripts/provider_patches/global_media_enrichment_v1.py
@@ -3,13 +3,18 @@
The enrichment is intentionally bounded and does not transcode or proxy media.
When a provider returns a public player/embed page, Niakvio follows only a small
-candidate graph, proves direct HLS/DASH/container media, and emits an additional
-direct row. The direct row carries the same browser request context used to
-resolve it (Referer, Origin, User-Agent and domain/path-scoped session cookies),
-so native players do not lose information that was implicitly present inside
-the web player. Cookies are ephemeral per source row and never shared between
-providers or unrelated domains. Textual HLS/DASH proof also works on NuvioTV's
-text-only QuickJS fetch Response; binary proof still requires arrayBuffer().
+candidate graph, proves direct HLS/DASH/container media, and emits a direct row.
+The direct row carries the same browser request context used to resolve it
+(Referer, Origin, User-Agent and domain/path-scoped session cookies), so native
+players do not lose information that was implicitly present inside the web
+player. Cookies are ephemeral per source row and never shared between providers
+or unrelated domains.
+
+NuvioTV's native QuickJS Response can be text-oriented and lack arrayBuffer().
+Opaque binary media therefore cannot rely only on body signatures. The resolver
+also proves direct containers from trustworthy final response metadata
+(Content-Type, Content-Disposition and final redirect URL) without consuming the
+binary body. This preserves signed/one-shot media for the real native reader.
"""
from __future__ import annotations
@@ -35,10 +40,9 @@ def _strip_existing_wrapper(text: str) -> str:
def _insert_before_runtime_safety(text: str, wrapper: str) -> str:
"""Keep recovery/enrichment inside the final runtime safety wrapper.
- Patch reapplication must not silently reorder wrappers. The final media
- safety guard validates the rows produced by enrichment, so enrichment must
- be installed before that guard when it is already present in a published
- bundle. If no safety guard exists yet, normal append semantics are kept.
+ Patch reapplication must not silently reorder wrappers. The final media
+ safety guard validates rows produced by enrichment, so enrichment must be
+ installed before that guard when it is already present.
"""
safety = text.find(f"/* {SAFETY_MARKER}:")
clean_wrapper = wrapper.lstrip().rstrip()
@@ -58,7 +62,7 @@ def apply(text: str, options: dict[str, Any] | None = None, **_kwargs: Any) -> s
"timeoutMs": max(2500, min(int(cfg.get("timeout_ms", 6500)), 12000)),
"preserveOriginal": bool(cfg.get("preserve_original", True)),
"defaultUserAgent": str(cfg.get("default_user_agent") or ""),
- "implementationRevision": "scoped-playback-context-v5-direct-safe",
+ "implementationRevision": "scoped-playback-context-v6-direct-safe-opaque-media",
}
serialized = json.dumps(payload, separators=(",", ":"))
marker = f"{MARKER}:{hashlib.sha256(serialized.encode()).hexdigest()[:12]}"
@@ -79,11 +83,12 @@ def apply(text: str, options: dict[str, Any] | None = None, **_kwargs: Any) -> s
function abs(v,b){try{return new URL(s(v),b).toString()}catch(_){return""}}
function host(v){try{return new URL(v).hostname.toLowerCase()}catch(_){return""}}
function rejected(v){var h=host(v);return !/^https?:\/\//i.test(v)||!h||BADHOST.test(h)||ASSET.test(v)||/(?:trailer|bande-annonce|big[_-]?buck[_-]?bunny|sample[-_]?video|\/troll\/master\.m3u8)/i.test(v)}
-function directByName(v){return /\.(?:m3u8|mpd|mp4|mkv|webm)(?:[?#]|$)|\/hls2?\//i.test(v)}
-function declaredDirect(row,v){var t=s(row&&(row.type||row.format||row.mimeType||row.contentType)).toLowerCase();return !!(row&&row.isDirect===true)||directByName(v)||/hls|mpegurl|m3u8|dash|mpd|mp4|matroska|mkv|webm|video\//i.test(t)}
+function directByName(v){return /\.(?:m3u8|mpd|mp4|m4v|mkv|webm|ts)(?:[?#]|$)|\/hls2?\//i.test(v)}
+function declaredDirect(row,v){var t=s(row&&(row.type||row.format||row.mimeType||row.contentType)).toLowerCase();return !!(row&&row.isDirect===true)||directByName(v)||/hls|mpegurl|m3u8|dash|mpd|mp4|m4v|matroska|mkv|webm|mpegts|mp2t|video\//i.test(t)}
function timeout(){try{return typeof AbortSignal!=="undefined"&&AbortSignal.timeout?AbortSignal.timeout(c.timeoutMs):undefined}catch(_){return undefined}}
function keyOf(o,name){var keys=Object.keys(o||{}),want=String(name||"").toLowerCase();for(var i=0;i s
return out;
}
function kindBytes(bytes){if(!bytes||bytes.length<4)return null;if(bytes.length>=12&&String.fromCharCode(bytes[4],bytes[5],bytes[6],bytes[7])==="ftyp")return"mp4";if(bytes[0]===26&&bytes[1]===69&&bytes[2]===223&&bytes[3]===163)return"mkv";if(bytes[0]===71&&(bytes.length<189||bytes[188]===71))return"mpegts";return null}
+function extensionKind(value){var m=s(value).toLowerCase().match(/\.(m3u8|mpd|mp4|m4v|mkv|webm|ts)(?:[?"'\s;]|$)/);if(!m)return null;return m[1]==="m3u8"?"hls":m[1]==="mpd"?"dash":m[1]==="m4v"?"mp4":m[1]==="ts"?"mpegts":m[1]}
+function metadataKind(type,disposition,url){
+ var ct=s(type).toLowerCase(),byName=extensionKind(s(disposition)+" "+s(url));
+ if(/application\/(?:vnd\.apple\.mpegurl|x-mpegurl)|audio\/(?:mpegurl|x-mpegurl)/i.test(ct))return"hls";
+ if(/application\/dash\+xml/i.test(ct))return"dash";
+ if(/video\/(?:x-)?matroska|application\/(?:x-)?matroska/i.test(ct))return"mkv";
+ if(/video\/(?:mp4|x-m4v|quicktime)/i.test(ct))return"mp4";
+ if(/video\/webm/i.test(ct))return"webm";
+ if(/video\/(?:mp2t|mpegts)/i.test(ct))return"mpegts";
+ if(/^video\//i.test(ct))return byName||"video";
+ if(/application\/(?:octet-stream|force-download)/i.test(ct)&&byName&&byName!=="hls"&&byName!=="dash")return byName;
+ return byName&&byName!=="hls"&&byName!=="dash"?byName:null;
+}
function decode(bytes){try{return new TextDecoder("utf-8").decode(bytes)}catch(_){var x="";for(var i=0;i]/i.test(t.slice(0,4096))||/application\/dash\+xml/i.test(r.type))return"dash";var b=kindBytes(r.bytes);if(b)return b;if(/^video\//i.test(r.type)&&r.bytes&&r.bytes.length>12)return"video";return null}
-function candidates(text,base){var out=[],seen={};function add(v){var u=abs(v,base);if(!u||rejected(u)||seen[u])return;seen[u]=1;out.push(u)}var body=s(text),patterns=[/(?:src|href|data-src|data-url|data-embed|data-player|data-file)=["']([^"']+)["']/gi,/(?:file|source|src|url|playlist|embedUrl|embed_url|contentUrl)\s*[:=]\s*["'](https?:\/\/[^"']+)["']/gi,/(https?:\/\/[^"'<>\s\\]+(?:m3u8|mpd|mp4|mkv|webm|embed|player|\/e\/|\/hls2?\/)[^"'<>\s\\]*)/gi],m;for(var i=0;i=c.maxCandidates)return out}}return out}
+function proof(r){if(!r||!r.ok)return null;var t=s(r.text).trimStart();if(t.indexOf("#EXTM3U")===0)return"hls";if(/]/i.test(t.slice(0,4096))||/application\/dash\+xml/i.test(r.type))return"dash";var b=kindBytes(r.bytes);if(b)return b;if(r.metadataKind)return r.metadataKind;if(/^video\//i.test(r.type)&&r.bytes&&r.bytes.length>12)return"video";return null}
+function candidates(text,base){var out=[],seen={};function add(v){var u=abs(v,base);if(!u||rejected(u)||seen[u])return;seen[u]=1;out.push(u)}var body=s(text),patterns=[/(?:src|href|data-src|data-url|data-embed|data-player|data-file)=["']([^"']+)["']/gi,/(?:file|source|src|url|playlist|embedUrl|embed_url|contentUrl)\s*[:=]\s*["'](https?:\/\/[^"']+)["']/gi,/(https?:\/\/[^"'<>\s\\]+(?:m3u8|mpd|mp4|m4v|mkv|webm|ts|embed|player|\/e\/|\/hls2?\/)[^"'<>\s\\]*)/gi],m;for(var i=0;i=c.maxCandidates)return out}}return out}
async function resolve(url,row,referer,depth,seen,jar){if(depth>c.maxDepth||rejected(url))return[];seen=seen||{};if(seen[url])return[];seen[url]=1;var r=await fetchResource(url,row,referer,jar);if(!r)return[];var k=proof(r);if(k)return[{url:r.url||url,kind:k,headers:r.headers}];if(!/html|text|json|javascript|xml/i.test(r.type)&&!/[<>{}\[\]"']/.test(r.text||""))return[];var next=candidates(r.text,r.url||url),out=[];for(var i=0;i str:
+ start = text.find(f"/* {MARKER}:")
+ if start < 0:
+ return text
+ call = text.find('})(typeof globalThis!=="undefined"?globalThis:this,', start)
+ end = text.find(");", call) if call >= 0 else -1
+ if call < 0 or end < 0:
+ raise ValueError("unterminated global stream presentation wrapper")
+ return (text[:start] + text[end + 2 :]).rstrip()
+
+
+def apply(text: str, options: dict[str, Any] | None = None, **kwargs: Any) -> str:
+ context = kwargs.get("context") if isinstance(kwargs.get("context"), dict) else {}
+ provider_id = str(context.get("provider_id") or "").strip().casefold()
+ cfg = dict(options or {})
+ payload = {
+ "providerId": provider_id,
+ "tmdbKey": str(cfg.get("tmdb_key") or TMDB_KEY),
+ "tmdbTimeoutMs": max(350, min(int(cfg.get("tmdb_timeout_ms", 1200)), 2500)),
+ "implementationRevision": "facts-first-shared-display-v4-native-terminal",
+ }
+ serialized = json.dumps(payload, separators=(",", ":"))
+ marker = f"{MARKER}:{hashlib.sha256(serialized.encode()).hexdigest()[:12]}"
+ text = _strip_existing(text)
+
+ wrapper = r'''
+/* MARKER_PLACEHOLDER */
+;(function(g,c){"use strict";
+function s(v){return String(v==null?"":v).trim()}
+function meaningful(v){var x=s(v);return x&&!/^(?:unknown|inconnue?|n\/?a|null|undefined|none|-+)$/i.test(x)}
+function slot(v){if(Array.isArray(v))return{key:null,list:v};if(v&&typeof v==="object"){for(var i=0;i<3;i++){var k=["streams","results","data"][i];if(Array.isArray(v[k]))return{key:k,list:v[k]}}}return null}
+function rebuild(v,x,list){if(x.key===null)return list;var o=Object.assign({},v);o[x.key]=list;return o}
+function req(a){var first=a[0],q=first&&typeof first==="object"&&!Array.isArray(first)?Object.assign({},first):{tmdbId:first,mediaType:a[1],season:a[2],episode:a[3]};q.tmdbId=s(q.tmdbId||q.id||first).replace(/^tmdb:/i,"").split(":")[0];q.mediaType=s(q.mediaType||q.type||a[1]||"movie").toLowerCase();q.title=s(q.title||q.name||q.label);q.year=Number(q.year||0)||0;q.season=Number(q.season||a[2]||0)||0;q.episode=Number(q.episode||a[3]||0)||0;return q}
+function blob(row){return [row&&row.name,row&&row.title,row&&row.size,row&&row.description,row&&row.quality,row&&row.language,row&&row.codec,row&&row.audio,row&&row.sourceType].map(s).join(" ")}
+function quality(row){var v=meaningful(row&&row.quality)?s(row.quality):blob(row),u=v.toUpperCase();if(/(?:\b4K\b|\b2160P?\b)/.test(u))return"2160p";var m=u.match(/\b(1440|1080|720|576|540|480|360)P?\b/);return m?m[1]+"p":""}
+function language(row){var v=meaningful(row&&row.language)?s(row.language):blob(row),u=v.toUpperCase();if(/\bDUAL(?:[- ]?AUDIO)?\b/.test(u))return"Dual Audio";if(/\bVOSTFR\b/.test(u))return"VOSTFR";if(/\bVFQ\b/.test(u))return"VFQ";if(/\bVFF\b/.test(u))return"VFF";if(/\bVF\b/.test(u))return"VF";if(/\bVO\b/.test(u))return"VO";return meaningful(row&&row.language)?s(row.language):""}
+function codec(row){var v=meaningful(row&&row.codec)?s(row.codec):blob(row),u=v.toUpperCase();if(/\b(?:HEVC|H\.?265|X265)\b/.test(u))return"HEVC";if(/\bAV1\b/.test(u))return"AV1";if(/\bVP9\b/.test(u))return"VP9";if(/\b(?:AVC|H\.?264|X264)\b/.test(u))return"H.264";return meaningful(row&&row.codec)?s(row.codec):""}
+function audio(row){var v=meaningful(row&&row.audio)?s(row.audio):blob(row),u=v.toUpperCase(),ch="",cm=u.match(/\b(7\.1|5\.1|2\.1|2\.0)\b/);if(cm)ch=" "+cm[1];if(/\b(?:ATMOS|DOLBY ATMOS)\b/.test(u))return"ATMOS"+ch;if(/\b(?:E-?AC-?3|DDP|DD\+)\b/.test(u))return"E-AC3"+ch;if(/\bAC-?3\b/.test(u))return"AC3"+ch;if(/\bDTS(?:-HD)?\b/.test(u))return(/\bDTS-HD\b/.test(u)?"DTS-HD":"DTS")+ch;if(/\bAAC\b/.test(u))return"AAC"+ch;return meaningful(row&&row.audio)?s(row.audio):""}
+function duration(row){var raw=row&&row.duration;if(typeof raw==="number"&&Number.isFinite(raw)&&raw>0)return raw>600?Math.round(raw/60):Math.round(raw);var direct=s(raw),m=direct.match(/(\d{1,4})\s*(?:min|minutes?)\b/i);if(m)return Number(m[1]);var x=blob(row).match(/\b(\d{1,3})\s*(?:min|minutes?)\b/i);return x?Number(x[1]):0}
+function sourceType(row){var v=meaningful(row&&row.sourceType)?s(row.sourceType):blob(row),u=v.toUpperCase();if(/\b(?:BLU[- ]?RAY|BDRIP|BRRIP|BDREMUX|REMUX)\b/.test(u))return"BLU-RAY";if(/\bWEB[- .]?DL\b/.test(u))return"WEB-DL";if(/\bWEB[- .]?RIP\b/.test(u))return"WEBRIP";return meaningful(row&&row.sourceType)?s(row.sourceType):""}
+function age(row){var v=row&&(row.ageRating||row.certification);return meaningful(v)?s(v):""}
+function providerName(row){var n=s(row&&row.name).split(/[|\n]/)[0].trim();if(n&&n.length<=40&&!/^(?:4k|2160p|1080p|720p|vf|vff|vfq|vostfr)$/i.test(n))return n;var id=s(c.providerId).replace(/[-_]+/g," ");return id?id.replace(/\b\w/g,function(x){return x.toUpperCase()}):"Source"}
+function legacyLabel(v){var x=s(v);return x.length>140||/\r|\n/.test(x)||(/🔥|🎯|🎧/.test(x)&&x.length>70)}
+function qualityBadge(v){if(v==="2160p")return"【4K】";return v?"【"+v.toUpperCase()+"】":""}
+function two(v){return v<10?"0"+v:String(v)}
+function fmtDuration(minutes){var n=Number(minutes||0);if(!n)return"";var h=Math.floor(n/60),m=n%60;return h?(h+"h"+(m?two(m):"")):n+"min"}
+function unique(list){var out=[];list.forEach(function(v){if(v&&out.indexOf(v)<0)out.push(v)});return out}
+function nativeFetchBridge(){try{return !!(g&&typeof g.__native_fetch==="function")}catch(_e){return false}}
+function safeSignal(){try{if(typeof AbortSignal!=="undefined"&&typeof AbortSignal.timeout==="function")return AbortSignal.timeout(c.tmdbTimeoutMs)}catch(_e){}return null}
+function certification(d,kind){var rows=kind==="movie"?(d&&d.release_dates&&d.release_dates.results):(d&&d.content_ratings&&d.content_ratings.results);if(!Array.isArray(rows))return"";var row=rows.find(function(x){return s(x&&x.iso_3166_1).toUpperCase()==="FR"})||rows.find(function(x){return s(x&&x.iso_3166_1).toUpperCase()==="US"})||rows[0];if(!row)return"";if(kind==="movie"){var releases=Array.isArray(row.release_dates)?row.release_dates:[];for(var i=0;i0?Math.round(runtime):0,age:certification(d,kind)}}catch(_e){return null}}
+function present(row,meta,q){if(!row||typeof row!=="object")return row;var out=Object.assign({},row),ql=quality(row),lang=language(row),co=codec(row),au=audio(row),du=duration(row)||(meta&&meta.runtime)||0,src=sourceType(row),ag=age(row)||(meta&&meta.age)||"";if(ql)out.quality=ql;if(lang)out.language=lang;if(co)out.codec=co;if(au)out.audio=au;if(du)out.duration=du;if(src)out.sourceType=src;if(ag)out.ageRating=ag;var badges=unique([qualityBadge(ql),src?"【"+src+"】":"",lang?"🌐 "+lang:"",co?"🎞 "+co:"",au?"🔊 "+au:"",du?"⏱ "+fmtDuration(du):"",ag?"🔞 "+ag:""]);out.displayBadges=badges;var title=s((meta&&meta.title)||q.title),year=Number((meta&&meta.year)||q.year||0)||0,lines=[];if(badges.length)lines.push(badges.join(" "));if(title||year)lines.push([title,year?String(year):""].filter(Boolean).join(" • "));if(!lines.length)lines.push("🎬 "+providerName(row));out.description=lines.join("\n");if(!meaningful(out.title)||legacyLabel(out.title))out.title=providerName(row);if(legacyLabel(out.size))out.size=badges.slice(0,4).join(" ");if(!meaningful(out.name))out.name=providerName(row);return out}
+function install(o,k){if(!o||typeof o[k]!=="function"||o[k].__nuvioGlobalStreamPresentationV1)return false;var native=o[k];var wrap=async function(){var q=req(arguments),v=await native.apply(this,arguments),x=slot(v);if(!x||!x.list.length)return v;var meta=null;try{meta=await tmdb(q)}catch(_e){}return rebuild(v,x,x.list.map(function(row){return present(row,meta,q)}))};wrap.__nuvioGlobalStreamPresentationV1=true;o[k]=wrap;return true}
+var ok=false;try{if(typeof module!=="undefined"&&module.exports){ok=install(module.exports,"getStreams")||install(module.exports,"streams")}}catch(_e){}try{if(g&&typeof g.getStreams==="function"){if(ok&&typeof module!=="undefined"&&module.exports)g.getStreams=module.exports.getStreams;else install(g,"getStreams")}}catch(_e){}
+})(typeof globalThis!=="undefined"?globalThis:this,CONFIG_PLACEHOLDER);
+'''.replace("MARKER_PLACEHOLDER", marker).replace("CONFIG_PLACEHOLDER", serialized)
+ return text.rstrip() + "\n" + wrapper.lstrip()
diff --git a/scripts/provider_patches/hls_master_audio_preserver_impl_v1.py b/scripts/provider_patches/hls_master_audio_preserver_impl_v1.py
new file mode 100644
index 000000000..d06b0f0cf
--- /dev/null
+++ b/scripts/provider_patches/hls_master_audio_preserver_impl_v1.py
@@ -0,0 +1,356 @@
+#!/usr/bin/env python3
+"""Preserve HLS master audio and append the global runtime media safety guard.
+
+The audio rewrite keeps HLS masters with external audio renditions intact. The
+runtime guard rejects only conclusively dead/non-media rows, keeps the existing
+NetMirror duration identity check and MovieBox strict playback gate, and now
+uses the real NuvioTV bridge fingerprint instead of treating every native
+QuickJS client as TV. It also normalizes final playback context so libmpv and
+Media3 receive the same browser user-agent used by the provider fetch bridges,
+while TV-only display compatibility fills NuvioTV's ``size``/description slot
+without polluting Desktop/Mobile metadata.
+"""
+from __future__ import annotations
+
+import hashlib
+import json
+import re
+from typing import Any
+
+AUDIO_MARKER = "NUVIO_HLS_MASTER_AUDIO_PRESERVER_V1"
+SAFETY_MARKER = "NUVIO_GLOBAL_RUNTIME_MEDIA_SAFETY_V1"
+HLS_INTEGRITY_MARKER = "NUVIO_HLS_RUNTIME_INTEGRITY_V1"
+
+GUARD = re.compile(
+ r"if\s*\(\s*!\s*/#EXT-X-STREAM-INF/i\.test\((?P[A-Za-z_$][A-Za-z0-9_$]*)\)\s*\)\s*return"
+)
+
+# Nuvio Desktop/Mobile also expose __native_fetch. Their fetch polyfill passes
+# a fifth followRedirects argument; NuvioTV uses a four-argument bridge and
+# wraps options.signal around it. Keep the exact same fingerprint as the
+# dedicated TV ordering patch so all platform-specific behavior agrees.
+TV_PREDICATE = r'''function isTv(){try{var ua=String((g.navigator&&g.navigator.userAgent)||"");if(/NuvioTV|Android TV/i.test(ua))return true;if(g&&g.__NUVIO_TV_RUNTIME__===true)return true;if(typeof g.__native_fetch!=="function"||typeof g.fetch!=="function")return false;var src="";try{src=Function.prototype.toString.call(g.fetch)}catch(_e){src=String(g.fetch||"")}if(/followRedirects/.test(src))return false;var signalAware=/options\.signal|var\s+signal\s*=/.test(src);var fourArgNative=/__native_fetch\s*\(\s*url\s*,\s*method\s*,\s*JSON\.stringify\(headers\)\s*,\s*body\s*\)/.test(src);return signalAware&&fourArgNative;}catch(_e){return false}}'''
+
+SAFETY_WRAPPER = r"""
+/* SAFETY_MARKER_PLACEHOLDER */
+;(function(g,c){
+ "use strict";
+ var DEFAULT_UA="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36";
+ function s(v){return String(v==null?"":v).trim()}
+ function slot(v){
+ if(Array.isArray(v))return {key:null,list:v};
+ if(v&&typeof v==="object"){
+ for(var i=0;i<3;i++){var k=["streams","results","data"][i];if(Array.isArray(v[k]))return {key:k,list:v[k]}}
+ }
+ return null;
+ }
+ function rebuild(v,x,list){
+ if(x.key===null)return list;
+ var o=Object.assign({},v);o[x.key]=list;return o;
+ }
+ function req(a){
+ var first=a[0],q=first&&typeof first==="object"&&!Array.isArray(first)?Object.assign({},first):{
+ tmdbId:first,mediaType:a[1],season:a[2],episode:a[3]
+ };
+ q.tmdbId=s(q.tmdbId||q.id||first).replace(/^tmdb:/i,"").split(":")[0];
+ q.mediaType=s(q.mediaType||q.type||a[1]||"movie").toLowerCase();
+ q.season=Number(q.season||a[2]||0)||0;
+ q.episode=Number(q.episode||a[3]||0)||0;
+ return q;
+ }
+ TV_PREDICATE_PLACEHOLDER
+ function headers(row,range){
+ var out={},src=row&&row.headers&&typeof row.headers==="object"?row.headers:{};
+ Object.keys(src).forEach(function(k){out[k]=s(src[k])});
+ try{
+ var bh=row&&row.behaviorHints&&row.behaviorHints.proxyHeaders&&row.behaviorHints.proxyHeaders.request;
+ if(bh&&typeof bh==="object")Object.keys(bh).forEach(function(k){if(!(k in out))out[k]=s(bh[k])});
+ }catch(_e){}
+ if(c.defaultUserAgent&&!Object.keys(out).some(function(k){return k.toLowerCase()==="user-agent"}))out["User-Agent"]=c.defaultUserAgent;
+ if(range&&!Object.keys(out).some(function(k){return k.toLowerCase()==="range"}))out.Range="bytes=0-65535";
+ if(!Object.keys(out).some(function(k){return k.toLowerCase()==="accept"}))out.Accept="application/vnd.apple.mpegurl,application/x-mpegURL,video/*,*/*";
+ return out;
+ }
+ function timeoutSignal(ms){
+ try{if(typeof AbortSignal!=="undefined"&&AbortSignal.timeout)return AbortSignal.timeout(ms)}catch(_e){}
+ return void 0;
+ }
+ async function responseText(r){
+ if(!r)return "";
+ try{if(typeof r.text==="function")return s(await r.text())}catch(_e){}
+ try{
+ if(typeof r.arrayBuffer==="function"){
+ var ab=await r.arrayBuffer();
+ if(ab){
+ if(typeof TextDecoder!=="undefined")return s(new TextDecoder("utf-8").decode(new Uint8Array(ab)));
+ if(typeof Buffer!=="undefined")return s(Buffer.from(ab).toString("utf8"));
+ }
+ }
+ }catch(_e){}
+ try{
+ if(r.body&&typeof r.body.getReader==="function"){
+ var reader=r.body.getReader(),chunks=[],total=0;
+ while(total<262144){
+ var part=await reader.read();
+ if(part&&part.value){chunks.push(part.value);total+=part.value.byteLength||part.value.length||0}
+ if(!part||part.done)break;
+ if(total>0)break;
+ }
+ try{if(typeof reader.cancel==="function")await reader.cancel()}catch(_e){}
+ if(total){
+ var merged=new Uint8Array(total),offset=0;
+ for(var i=0;i=total)break;
+ }
+ if(typeof TextDecoder!=="undefined")return s(new TextDecoder("utf-8").decode(merged));
+ if(typeof Buffer!=="undefined")return s(Buffer.from(merged).toString("utf8"));
+ }
+ }
+ }catch(_e){}
+ return "";
+ }
+ async function fetchText(url,row,range){
+ try{
+ var r=await g.fetch(url,{method:"GET",redirect:"follow",headers:headers(row,range),signal:timeoutSignal(c.timeoutMs)});
+ if(!r)return {state:"unknown",reason:"no_response"};
+ var st=Number(r.status||0),ct=s(r.headers&&r.headers.get?r.headers.get("content-type"):"").toLowerCase();
+ if(st===401||st===403||st===404||st===410||st>=500)return {state:"dead",status:st,contentType:ct};
+ if(!r.ok)return {state:"unknown",status:st,contentType:ct};
+ var text=await responseText(r);
+ return {state:"ok",status:st,url:s(r.url||url),contentType:ct,text:text};
+ }catch(e){return {state:"unknown",reason:e&&e.name==="AbortError"?"timeout":"network_error"}}
+ }
+ function playlistKind(text){
+ var body=s(text).replace(/^\uFEFF/,"");
+ if(!/^#EXTM3U(?:\s|$)/i.test(body))return "invalid";
+ if(/#EXT-X-STREAM-INF\s*:/i.test(body))return "master";
+ if(/#EXTINF\s*:/i.test(body))return "media";
+ return "unknown";
+ }
+ function firstVariant(text,base){
+ var lines=s(text).split(/\r?\n/);
+ for(var i=0;i0){total+=n;count++}}
+ if(count<2||total<60)return null;
+ return total;
+ }
+ async function inspectHls(row,url){
+ var r=await fetchText(url,row,false);
+ if(r.state!=="ok")return r;
+ var kind=playlistKind(r.text);
+ if(kind==="invalid")return {state:"dead",reason:"not_hls",status:r.status};
+ if(kind==="media")return {state:"ok",duration:durationSeconds(r.text),url:r.url||url};
+ if(kind==="master"){
+ var child=firstVariant(r.text,r.url||url);
+ if(!child)return {state:"dead",reason:"master_without_variant"};
+ var cr=await fetchText(child,row,false);
+ if(cr.state!=="ok")return cr;
+ var ck=playlistKind(cr.text);
+ if(ck!=="media"&&ck!=="master")return {state:"dead",reason:"invalid_child"};
+ return {state:"ok",duration:durationSeconds(cr.text),url:r.url||url};
+ }
+ return {state:"ok",duration:null,url:r.url||url};
+ }
+ function mediaKind(row){
+ var u=s(row&&row.url).toLowerCase(),t=s(row&&(row.type||row.format)).toLowerCase();
+ if(/\.m3u8(?:[?#]|$)|\/hls2?\//i.test(u)||/hls|mpegurl|m3u8/.test(t))return "hls";
+ if(/\.(?:mp4|mkv|webm)(?:[?#]|$)/i.test(u)||/mp4|matroska|webm|video\//.test(t))return "direct";
+ return "other";
+ }
+ function meaningful(v){var x=s(v);return x&&!/^(?:unknown|inconnue?|n\/?a|null|undefined|-+)$/i.test(x)}
+ function compactLanguage(row){
+ var l=s(row&&row.language);if(meaningful(l))return l;
+ var text=(s(row&&row.name)+" "+s(row&&row.title)).toUpperCase();
+ if(/\bDUAL(?:\s+AUDIO)?\b/.test(text))return "Dual Audio";
+ if(/\bVOSTFR\b/.test(text))return "VOSTFR";
+ if(/\bVFQ\b/.test(text))return "VFQ";
+ if(/\bVFF\b/.test(text))return "VFF";
+ if(/\bVF\b/.test(text))return "VF";
+ return "";
+ }
+ function ensurePlaybackContext(row){
+ if(!row||typeof row!=="object"||mediaKind(row)==="other")return row;
+ var out=Object.assign({},row),h={},has=false;
+ try{var src=row.headers&&typeof row.headers==="object"?row.headers:{};Object.keys(src).forEach(function(k){if(s(src[k])){h[k]=String(src[k]);has=true}})}catch(_e){}
+ if(c.defaultUserAgent&&!Object.keys(h).some(function(k){return k.toLowerCase()==="user-agent"})){h["User-Agent"]=c.defaultUserAgent;has=true}
+ if(has)out.headers=h;
+ return out;
+ }
+ function tvDisplayCompat(row,tv){
+ if(!tv||!row||typeof row!=="object"||meaningful(row.size))return row;
+ var label=meaningful(row.description)?s(row.description):"";
+ if(!label){
+ var parts=[],lang=compactLanguage(row),kind=mediaKind(row);
+ if(lang)parts.push(lang);
+ if(kind==="hls")parts.push("HLS");else if(kind==="direct")parts.push("Direct");
+ if(!parts.length&&meaningful(row.quality))parts.push(s(row.quality));
+ label=parts.join(" • ");
+ }
+ if(!label)return row;
+ var out=Object.assign({},row);out.size=label;return out;
+ }
+ async function expectedSeconds(q){
+ if(!c.durationIdentity||!q||!/^\d+$/.test(q.tmdbId||""))return null;
+ var kind=(q.mediaType==="tv"||q.mediaType==="anime"||q.mediaType==="series")?"tv":"movie",url;
+ if(kind==="tv"&&q.season>0&&q.episode>0){
+ url="https://api.themoviedb.org/3/tv/"+encodeURIComponent(q.tmdbId)+"/season/"+q.season+"/episode/"+q.episode+"?api_key="+c.tmdbKey;
+ }else url="https://api.themoviedb.org/3/"+kind+"/"+encodeURIComponent(q.tmdbId)+"?api_key="+c.tmdbKey;
+ try{
+ var r=await g.fetch(url,{headers:{Accept:"application/json"},signal:timeoutSignal(c.tmdbTimeoutMs)});
+ if(!r||!r.ok)return null;
+ var d=await r.json(),minutes=Number(d&&d.runtime||0);
+ if(!minutes&&kind==="tv"&&Array.isArray(d&&d.episode_run_time)&&d.episode_run_time.length)minutes=Number(d.episode_run_time[0]||0);
+ return minutes>=5?minutes*60:null;
+ }catch(_e){return null}
+ }
+ async function directPlayable(row,url){
+ var r=await fetchText(url,row,true);
+ if(r.state!=="ok")return r;
+ if(/text\/html|application\/xhtml/i.test(r.contentType)||/^c.maxDurationRatio)return {keep:false,reason:"duration_identity_mismatch",ratio:ratio};
+ }
+ return {keep:true};
+ }
+ function install(o,k){
+ if(!o||typeof o[k]!=="function"||o[k].__nuvioRuntimeMediaSafetyV1)return false;
+ var native=o[k];
+ var wrap=async function(){
+ var v=await native.apply(this,arguments),x=slot(v);
+ if(!x||!x.list.length)return v;
+ var q=req(arguments),tv=isTv(),expected=await expectedSeconds(q);
+ var head=x.list.slice(0,c.maxRows),tail=x.list.slice(c.maxRows);
+ var checks=await Promise.all(head.map(function(row){return check(row,expected,tv)}));
+ var kept=head.filter(function(_row,i){return checks[i]&&checks[i].keep}).concat(tail);
+ kept=kept.map(function(row){return tvDisplayCompat(ensurePlaybackContext(row),tv)});
+ return rebuild(v,x,kept);
+ };
+ wrap.__nuvioRuntimeMediaSafetyV1=true;o[k]=wrap;return true;
+ }
+ var ok=false;
+ try{if(typeof module!=="undefined"&&module.exports)ok=install(module.exports,"getStreams")}catch(_e){}
+ try{
+ if(g&&typeof g.getStreams==="function"){
+ if(ok&&typeof module!=="undefined"&&module.exports)g.getStreams=module.exports.getStreams;
+ else install(g,"getStreams");
+ }
+ }catch(_e){}
+})(typeof globalThis!=="undefined"?globalThis:this,CONFIG_PLACEHOLDER);
+"""
+
+
+def _strip_existing_safety_wrapper(text: str) -> str:
+ old = text.find(f"/* {SAFETY_MARKER}:")
+ if old < 0:
+ return text
+ call = text.find('})(typeof globalThis!=="undefined"?globalThis:this,', old)
+ end = text.find(");", call) if call >= 0 else -1
+ if call < 0 or end < 0:
+ raise ValueError("unterminated global runtime media safety wrapper")
+ return (text[:old] + text[end + 2 :]).rstrip()
+
+
+def _move_hls_integrity_to_tail(text: str) -> str:
+ """Keep HLS validation as the final outer wrapper.
+
+ Catalogue and media recovery may need to resolve an embed/player first and
+ attach scoped Referer/Origin/Cookie/User-Agent context. The HLS validator
+ must inspect those final rows, not run inside the recovery graph where it
+ can only see the provider's intermediate output.
+ """
+ start = text.find(f"/* {HLS_INTEGRITY_MARKER}:")
+ if start < 0:
+ return text
+ call = text.find('})(typeof globalThis!=="undefined"?globalThis:this,', start)
+ end = text.find(");", call) if call >= 0 else -1
+ if call < 0 or end < 0:
+ raise ValueError("unterminated HLS runtime integrity wrapper")
+ if not text[end + 2 :].strip():
+ return text
+ segment = text[start : end + 2].strip()
+ before = text[:start].rstrip()
+ after = text[end + 2 :].strip()
+ body = before
+ if after:
+ body = (body + "\n" + after) if body else after
+ return body.rstrip() + "\n" + segment + "\n"
+
+
+def apply(text: str, options: dict[str, Any] | None = None, **kwargs: Any) -> str:
+ context = kwargs.get("context") if isinstance(kwargs.get("context"), dict) else {}
+ provider_id = str(context.get("provider_id") or "").strip().casefold()
+ cfg = dict(options or {})
+
+ output = text
+ if AUDIO_MARKER not in output:
+ changed = 0
+
+ def replacement(match: re.Match[str]) -> str:
+ nonlocal changed
+ changed += 1
+ variable = match.group("var")
+ return (
+ f"if(!/#EXT-X-STREAM-INF/i.test({variable})||"
+ f"/#EXT-X-MEDIA:[^\\r\\n]*TYPE=AUDIO/i.test({variable}))return"
+ )
+
+ output = GUARD.sub(replacement, output)
+ if changed:
+ output = output.rstrip() + f"\n/* {AUDIO_MARKER} */\n"
+
+ cfg = {
+ "providerId": provider_id,
+ "timeoutMs": 6500,
+ "tmdbTimeoutMs": 4500,
+ "maxRows": 4,
+ "minDurationRatio": 0.55,
+ "maxDurationRatio": 1.8,
+ "durationIdentity": provider_id == "netmirror",
+ "strictPlayback": provider_id == "moviebox",
+ "failClosedUnknown": bool(cfg.get("fail_closed_unknown", False)),
+ "defaultUserAgent": str(cfg.get("default_user_agent") or ""),
+ "tmdbKey": "1865f43a0549ca50d341dd9ab8b29f49",
+ "implementationRevision": "scoped-playback-context-v4",
+ }
+ payload = json.dumps(cfg, separators=(",", ":"))
+ marker = f"{SAFETY_MARKER}:{hashlib.sha256(payload.encode()).hexdigest()[:12]}"
+ marker_comment = f"/* {marker} */"
+ current = output.find(marker_comment)
+ if current >= 0:
+ later_media = output.find("/* NUVIO_GLOBAL_MEDIA_ENRICHMENT_V1:", current + 1)
+ if later_media < 0:
+ return _move_hls_integrity_to_tail(output)
+
+ output = _strip_existing_safety_wrapper(output)
+ wrapper = (
+ SAFETY_WRAPPER.replace("SAFETY_MARKER_PLACEHOLDER", marker)
+ .replace("TV_PREDICATE_PLACEHOLDER", TV_PREDICATE)
+ .replace("CONFIG_PLACEHOLDER", payload)
+ )
+ output = output.rstrip() + "\n" + wrapper.lstrip()
+ return _move_hls_integrity_to_tail(output)
diff --git a/scripts/provider_patches/hls_master_audio_preserver_v1.py b/scripts/provider_patches/hls_master_audio_preserver_v1.py
index d06b0f0cf..379370fd3 100644
--- a/scripts/provider_patches/hls_master_audio_preserver_v1.py
+++ b/scripts/provider_patches/hls_master_audio_preserver_v1.py
@@ -1,356 +1,61 @@
#!/usr/bin/env python3
-"""Preserve HLS master audio and append the global runtime media safety guard.
+"""Compose playback safety with the final shared stream presentation layer.
-The audio rewrite keeps HLS masters with external audio renditions intact. The
-runtime guard rejects only conclusively dead/non-media rows, keeps the existing
-NetMirror duration identity check and MovieBox strict playback gate, and now
-uses the real NuvioTV bridge fingerprint instead of treating every native
-QuickJS client as TV. It also normalizes final playback context so libmpv and
-Media3 receive the same browser user-agent used by the provider fetch bridges,
-while TV-only display compatibility fills NuvioTV's ``size``/description slot
-without polluting Desktop/Mobile metadata.
+The historical HLS/audio/runtime-safety implementation is retained byte-for-byte
+in ``hls_master_audio_preserver_impl_v1.py``. This stable public hook normalizes
+the layer stack before applying playback safety, then materializes exactly one
+common NiakVIO presentation wrapper as the final outer layer.
+
+Presentation is therefore outside playback validation: it can decorate only rows
+that survived the real media guards and it never rewrites URL/headers. Removing
+an existing presentation wrapper before re-running the inner HLS compositor also
+keeps repeated override application byte-identical for stable hashes/cache keys.
"""
from __future__ import annotations
-import hashlib
-import json
-import re
+import importlib.util
+from pathlib import Path
from typing import Any
-AUDIO_MARKER = "NUVIO_HLS_MASTER_AUDIO_PRESERVER_V1"
-SAFETY_MARKER = "NUVIO_GLOBAL_RUNTIME_MEDIA_SAFETY_V1"
-HLS_INTEGRITY_MARKER = "NUVIO_HLS_RUNTIME_INTEGRITY_V1"
-
-GUARD = re.compile(
- r"if\s*\(\s*!\s*/#EXT-X-STREAM-INF/i\.test\((?P[A-Za-z_$][A-Za-z0-9_$]*)\)\s*\)\s*return"
-)
-
-# Nuvio Desktop/Mobile also expose __native_fetch. Their fetch polyfill passes
-# a fifth followRedirects argument; NuvioTV uses a four-argument bridge and
-# wraps options.signal around it. Keep the exact same fingerprint as the
-# dedicated TV ordering patch so all platform-specific behavior agrees.
-TV_PREDICATE = r'''function isTv(){try{var ua=String((g.navigator&&g.navigator.userAgent)||"");if(/NuvioTV|Android TV/i.test(ua))return true;if(g&&g.__NUVIO_TV_RUNTIME__===true)return true;if(typeof g.__native_fetch!=="function"||typeof g.fetch!=="function")return false;var src="";try{src=Function.prototype.toString.call(g.fetch)}catch(_e){src=String(g.fetch||"")}if(/followRedirects/.test(src))return false;var signalAware=/options\.signal|var\s+signal\s*=/.test(src);var fourArgNative=/__native_fetch\s*\(\s*url\s*,\s*method\s*,\s*JSON\.stringify\(headers\)\s*,\s*body\s*\)/.test(src);return signalAware&&fourArgNative;}catch(_e){return false}}'''
-
-SAFETY_WRAPPER = r"""
-/* SAFETY_MARKER_PLACEHOLDER */
-;(function(g,c){
- "use strict";
- var DEFAULT_UA="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36";
- function s(v){return String(v==null?"":v).trim()}
- function slot(v){
- if(Array.isArray(v))return {key:null,list:v};
- if(v&&typeof v==="object"){
- for(var i=0;i<3;i++){var k=["streams","results","data"][i];if(Array.isArray(v[k]))return {key:k,list:v[k]}}
- }
- return null;
- }
- function rebuild(v,x,list){
- if(x.key===null)return list;
- var o=Object.assign({},v);o[x.key]=list;return o;
- }
- function req(a){
- var first=a[0],q=first&&typeof first==="object"&&!Array.isArray(first)?Object.assign({},first):{
- tmdbId:first,mediaType:a[1],season:a[2],episode:a[3]
- };
- q.tmdbId=s(q.tmdbId||q.id||first).replace(/^tmdb:/i,"").split(":")[0];
- q.mediaType=s(q.mediaType||q.type||a[1]||"movie").toLowerCase();
- q.season=Number(q.season||a[2]||0)||0;
- q.episode=Number(q.episode||a[3]||0)||0;
- return q;
- }
- TV_PREDICATE_PLACEHOLDER
- function headers(row,range){
- var out={},src=row&&row.headers&&typeof row.headers==="object"?row.headers:{};
- Object.keys(src).forEach(function(k){out[k]=s(src[k])});
- try{
- var bh=row&&row.behaviorHints&&row.behaviorHints.proxyHeaders&&row.behaviorHints.proxyHeaders.request;
- if(bh&&typeof bh==="object")Object.keys(bh).forEach(function(k){if(!(k in out))out[k]=s(bh[k])});
- }catch(_e){}
- if(c.defaultUserAgent&&!Object.keys(out).some(function(k){return k.toLowerCase()==="user-agent"}))out["User-Agent"]=c.defaultUserAgent;
- if(range&&!Object.keys(out).some(function(k){return k.toLowerCase()==="range"}))out.Range="bytes=0-65535";
- if(!Object.keys(out).some(function(k){return k.toLowerCase()==="accept"}))out.Accept="application/vnd.apple.mpegurl,application/x-mpegURL,video/*,*/*";
- return out;
- }
- function timeoutSignal(ms){
- try{if(typeof AbortSignal!=="undefined"&&AbortSignal.timeout)return AbortSignal.timeout(ms)}catch(_e){}
- return void 0;
- }
- async function responseText(r){
- if(!r)return "";
- try{if(typeof r.text==="function")return s(await r.text())}catch(_e){}
- try{
- if(typeof r.arrayBuffer==="function"){
- var ab=await r.arrayBuffer();
- if(ab){
- if(typeof TextDecoder!=="undefined")return s(new TextDecoder("utf-8").decode(new Uint8Array(ab)));
- if(typeof Buffer!=="undefined")return s(Buffer.from(ab).toString("utf8"));
- }
- }
- }catch(_e){}
- try{
- if(r.body&&typeof r.body.getReader==="function"){
- var reader=r.body.getReader(),chunks=[],total=0;
- while(total<262144){
- var part=await reader.read();
- if(part&&part.value){chunks.push(part.value);total+=part.value.byteLength||part.value.length||0}
- if(!part||part.done)break;
- if(total>0)break;
- }
- try{if(typeof reader.cancel==="function")await reader.cancel()}catch(_e){}
- if(total){
- var merged=new Uint8Array(total),offset=0;
- for(var i=0;i=total)break;
- }
- if(typeof TextDecoder!=="undefined")return s(new TextDecoder("utf-8").decode(merged));
- if(typeof Buffer!=="undefined")return s(Buffer.from(merged).toString("utf8"));
- }
- }
- }catch(_e){}
- return "";
- }
- async function fetchText(url,row,range){
- try{
- var r=await g.fetch(url,{method:"GET",redirect:"follow",headers:headers(row,range),signal:timeoutSignal(c.timeoutMs)});
- if(!r)return {state:"unknown",reason:"no_response"};
- var st=Number(r.status||0),ct=s(r.headers&&r.headers.get?r.headers.get("content-type"):"").toLowerCase();
- if(st===401||st===403||st===404||st===410||st>=500)return {state:"dead",status:st,contentType:ct};
- if(!r.ok)return {state:"unknown",status:st,contentType:ct};
- var text=await responseText(r);
- return {state:"ok",status:st,url:s(r.url||url),contentType:ct,text:text};
- }catch(e){return {state:"unknown",reason:e&&e.name==="AbortError"?"timeout":"network_error"}}
- }
- function playlistKind(text){
- var body=s(text).replace(/^\uFEFF/,"");
- if(!/^#EXTM3U(?:\s|$)/i.test(body))return "invalid";
- if(/#EXT-X-STREAM-INF\s*:/i.test(body))return "master";
- if(/#EXTINF\s*:/i.test(body))return "media";
- return "unknown";
- }
- function firstVariant(text,base){
- var lines=s(text).split(/\r?\n/);
- for(var i=0;i0){total+=n;count++}}
- if(count<2||total<60)return null;
- return total;
- }
- async function inspectHls(row,url){
- var r=await fetchText(url,row,false);
- if(r.state!=="ok")return r;
- var kind=playlistKind(r.text);
- if(kind==="invalid")return {state:"dead",reason:"not_hls",status:r.status};
- if(kind==="media")return {state:"ok",duration:durationSeconds(r.text),url:r.url||url};
- if(kind==="master"){
- var child=firstVariant(r.text,r.url||url);
- if(!child)return {state:"dead",reason:"master_without_variant"};
- var cr=await fetchText(child,row,false);
- if(cr.state!=="ok")return cr;
- var ck=playlistKind(cr.text);
- if(ck!=="media"&&ck!=="master")return {state:"dead",reason:"invalid_child"};
- return {state:"ok",duration:durationSeconds(cr.text),url:r.url||url};
- }
- return {state:"ok",duration:null,url:r.url||url};
- }
- function mediaKind(row){
- var u=s(row&&row.url).toLowerCase(),t=s(row&&(row.type||row.format)).toLowerCase();
- if(/\.m3u8(?:[?#]|$)|\/hls2?\//i.test(u)||/hls|mpegurl|m3u8/.test(t))return "hls";
- if(/\.(?:mp4|mkv|webm)(?:[?#]|$)/i.test(u)||/mp4|matroska|webm|video\//.test(t))return "direct";
- return "other";
- }
- function meaningful(v){var x=s(v);return x&&!/^(?:unknown|inconnue?|n\/?a|null|undefined|-+)$/i.test(x)}
- function compactLanguage(row){
- var l=s(row&&row.language);if(meaningful(l))return l;
- var text=(s(row&&row.name)+" "+s(row&&row.title)).toUpperCase();
- if(/\bDUAL(?:\s+AUDIO)?\b/.test(text))return "Dual Audio";
- if(/\bVOSTFR\b/.test(text))return "VOSTFR";
- if(/\bVFQ\b/.test(text))return "VFQ";
- if(/\bVFF\b/.test(text))return "VFF";
- if(/\bVF\b/.test(text))return "VF";
- return "";
- }
- function ensurePlaybackContext(row){
- if(!row||typeof row!=="object"||mediaKind(row)==="other")return row;
- var out=Object.assign({},row),h={},has=false;
- try{var src=row.headers&&typeof row.headers==="object"?row.headers:{};Object.keys(src).forEach(function(k){if(s(src[k])){h[k]=String(src[k]);has=true}})}catch(_e){}
- if(c.defaultUserAgent&&!Object.keys(h).some(function(k){return k.toLowerCase()==="user-agent"})){h["User-Agent"]=c.defaultUserAgent;has=true}
- if(has)out.headers=h;
- return out;
- }
- function tvDisplayCompat(row,tv){
- if(!tv||!row||typeof row!=="object"||meaningful(row.size))return row;
- var label=meaningful(row.description)?s(row.description):"";
- if(!label){
- var parts=[],lang=compactLanguage(row),kind=mediaKind(row);
- if(lang)parts.push(lang);
- if(kind==="hls")parts.push("HLS");else if(kind==="direct")parts.push("Direct");
- if(!parts.length&&meaningful(row.quality))parts.push(s(row.quality));
- label=parts.join(" • ");
- }
- if(!label)return row;
- var out=Object.assign({},row);out.size=label;return out;
- }
- async function expectedSeconds(q){
- if(!c.durationIdentity||!q||!/^\d+$/.test(q.tmdbId||""))return null;
- var kind=(q.mediaType==="tv"||q.mediaType==="anime"||q.mediaType==="series")?"tv":"movie",url;
- if(kind==="tv"&&q.season>0&&q.episode>0){
- url="https://api.themoviedb.org/3/tv/"+encodeURIComponent(q.tmdbId)+"/season/"+q.season+"/episode/"+q.episode+"?api_key="+c.tmdbKey;
- }else url="https://api.themoviedb.org/3/"+kind+"/"+encodeURIComponent(q.tmdbId)+"?api_key="+c.tmdbKey;
- try{
- var r=await g.fetch(url,{headers:{Accept:"application/json"},signal:timeoutSignal(c.tmdbTimeoutMs)});
- if(!r||!r.ok)return null;
- var d=await r.json(),minutes=Number(d&&d.runtime||0);
- if(!minutes&&kind==="tv"&&Array.isArray(d&&d.episode_run_time)&&d.episode_run_time.length)minutes=Number(d.episode_run_time[0]||0);
- return minutes>=5?minutes*60:null;
- }catch(_e){return null}
- }
- async function directPlayable(row,url){
- var r=await fetchText(url,row,true);
- if(r.state!=="ok")return r;
- if(/text\/html|application\/xhtml/i.test(r.contentType)||/^c.maxDurationRatio)return {keep:false,reason:"duration_identity_mismatch",ratio:ratio};
- }
- return {keep:true};
- }
- function install(o,k){
- if(!o||typeof o[k]!=="function"||o[k].__nuvioRuntimeMediaSafetyV1)return false;
- var native=o[k];
- var wrap=async function(){
- var v=await native.apply(this,arguments),x=slot(v);
- if(!x||!x.list.length)return v;
- var q=req(arguments),tv=isTv(),expected=await expectedSeconds(q);
- var head=x.list.slice(0,c.maxRows),tail=x.list.slice(c.maxRows);
- var checks=await Promise.all(head.map(function(row){return check(row,expected,tv)}));
- var kept=head.filter(function(_row,i){return checks[i]&&checks[i].keep}).concat(tail);
- kept=kept.map(function(row){return tvDisplayCompat(ensurePlaybackContext(row),tv)});
- return rebuild(v,x,kept);
- };
- wrap.__nuvioRuntimeMediaSafetyV1=true;o[k]=wrap;return true;
- }
- var ok=false;
- try{if(typeof module!=="undefined"&&module.exports)ok=install(module.exports,"getStreams")}catch(_e){}
- try{
- if(g&&typeof g.getStreams==="function"){
- if(ok&&typeof module!=="undefined"&&module.exports)g.getStreams=module.exports.getStreams;
- else install(g,"getStreams");
- }
- }catch(_e){}
-})(typeof globalThis!=="undefined"?globalThis:this,CONFIG_PLACEHOLDER);
-"""
+HERE = Path(__file__).resolve().parent
-def _strip_existing_safety_wrapper(text: str) -> str:
- old = text.find(f"/* {SAFETY_MARKER}:")
- if old < 0:
- return text
- call = text.find('})(typeof globalThis!=="undefined"?globalThis:this,', old)
- end = text.find(");", call) if call >= 0 else -1
- if call < 0 or end < 0:
- raise ValueError("unterminated global runtime media safety wrapper")
- return (text[:old] + text[end + 2 :]).rstrip()
+def _load(filename: str, module_name: str):
+ path = HERE / filename
+ spec = importlib.util.spec_from_file_location(module_name, path)
+ if spec is None or spec.loader is None:
+ raise RuntimeError(f"cannot load provider Core layer: {path}")
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
-def _move_hls_integrity_to_tail(text: str) -> str:
- """Keep HLS validation as the final outer wrapper.
+_IMPL = _load("hls_master_audio_preserver_impl_v1.py", "nuvio_hls_master_audio_impl_v1")
+_PRESENTATION = _load("global_stream_presentation_v1.py", "nuvio_global_stream_presentation_v1")
- Catalogue and media recovery may need to resolve an embed/player first and
- attach scoped Referer/Origin/Cookie/User-Agent context. The HLS validator
- must inspect those final rows, not run inside the recovery graph where it
- can only see the provider's intermediate output.
- """
- start = text.find(f"/* {HLS_INTEGRITY_MARKER}:")
- if start < 0:
- return text
- call = text.find('})(typeof globalThis!=="undefined"?globalThis:this,', start)
- end = text.find(");", call) if call >= 0 else -1
- if call < 0 or end < 0:
- raise ValueError("unterminated HLS runtime integrity wrapper")
- if not text[end + 2 :].strip():
- return text
- segment = text[start : end + 2].strip()
- before = text[:start].rstrip()
- after = text[end + 2 :].strip()
- body = before
- if after:
- body = (body + "\n" + after) if body else after
- return body.rstrip() + "\n" + segment + "\n"
+# Preserve the module API used by existing tests/tools.
+AUDIO_MARKER = _IMPL.AUDIO_MARKER
+SAFETY_MARKER = _IMPL.SAFETY_MARKER
+HLS_INTEGRITY_MARKER = _IMPL.HLS_INTEGRITY_MARKER
+GUARD = _IMPL.GUARD
+TV_PREDICATE = _IMPL.TV_PREDICATE
+SAFETY_WRAPPER = _IMPL.SAFETY_WRAPPER
def apply(text: str, options: dict[str, Any] | None = None, **kwargs: Any) -> str:
- context = kwargs.get("context") if isinstance(kwargs.get("context"), dict) else {}
- provider_id = str(context.get("provider_id") or "").strip().casefold()
- cfg = dict(options or {})
-
- output = text
- if AUDIO_MARKER not in output:
- changed = 0
-
- def replacement(match: re.Match[str]) -> str:
- nonlocal changed
- changed += 1
- variable = match.group("var")
- return (
- f"if(!/#EXT-X-STREAM-INF/i.test({variable})||"
- f"/#EXT-X-MEDIA:[^\\r\\n]*TYPE=AUDIO/i.test({variable}))return"
- )
-
- output = GUARD.sub(replacement, output)
- if changed:
- output = output.rstrip() + f"\n/* {AUDIO_MARKER} */\n"
-
- cfg = {
- "providerId": provider_id,
- "timeoutMs": 6500,
- "tmdbTimeoutMs": 4500,
- "maxRows": 4,
- "minDurationRatio": 0.55,
- "maxDurationRatio": 1.8,
- "durationIdentity": provider_id == "netmirror",
- "strictPlayback": provider_id == "moviebox",
- "failClosedUnknown": bool(cfg.get("fail_closed_unknown", False)),
- "defaultUserAgent": str(cfg.get("default_user_agent") or ""),
- "tmdbKey": "1865f43a0549ca50d341dd9ab8b29f49",
- "implementationRevision": "scoped-playback-context-v4",
- }
- payload = json.dumps(cfg, separators=(",", ":"))
- marker = f"{SAFETY_MARKER}:{hashlib.sha256(payload.encode()).hexdigest()[:12]}"
- marker_comment = f"/* {marker} */"
- current = output.find(marker_comment)
- if current >= 0:
- later_media = output.find("/* NUVIO_GLOBAL_MEDIA_ENRICHMENT_V1:", current + 1)
- if later_media < 0:
- return _move_hls_integrity_to_tail(output)
-
- output = _strip_existing_safety_wrapper(output)
- wrapper = (
- SAFETY_WRAPPER.replace("SAFETY_MARKER_PLACEHOLDER", marker)
- .replace("TV_PREDICATE_PLACEHOLDER", TV_PREDICATE)
- .replace("CONFIG_PLACEHOLDER", payload)
+ # Normalize the outer presentation layer away before asking the historical
+ # HLS compositor to reason about tail ordering. Otherwise the inner helper
+ # quite correctly moves HLS integrity behind that later wrapper on a second
+ # application, after which presentation moves itself back to the end. The
+ # semantics stay equal but bytes/hash change. Starting from the same inner
+ # stack makes the whole composed transform strictly idempotent.
+ without_presentation = _PRESENTATION._strip_existing(text)
+ playback_safe = _IMPL.apply(without_presentation, options=options, **kwargs)
+
+ # Metadata presentation is optional/bounded and may never become a playback
+ # dependency. Keep its timeout short even when a provider raises HLS bounds.
+ return _PRESENTATION.apply(
+ playback_safe,
+ options={"tmdb_timeout_ms": 1200},
+ **kwargs,
)
- output = output.rstrip() + "\n" + wrapper.lstrip()
- return _move_hls_integrity_to_tail(output)
diff --git a/scripts/provider_patches/hls_runtime_integrity_v1.py b/scripts/provider_patches/hls_runtime_integrity_v1.py
index 3963ef801..530ca56af 100644
--- a/scripts/provider_patches/hls_runtime_integrity_v1.py
+++ b/scripts/provider_patches/hls_runtime_integrity_v1.py
@@ -29,17 +29,17 @@ def apply(text: str, options: dict[str, Any] | None = None, **_kwargs: Any) -> s
"maxChildren": max_children,
"maxRecoveryPages": max_recovery_pages,
"maxRecoveryCandidates": max_recovery_candidates,
- "implementationRevision": "recovery-first-v3",
+ "implementationRevision": "recovery-first-v4-timer-safe",
}
# Preserve byte-for-byte idempotence for the repository-wide default. Only
# providers which explicitly require a strict final-output gate receive the
- # v4 payload and its two additional flags.
+ # payload with its two additional flags.
if probe_all_urls or fail_closed_unknown:
payload_config.update(
{
"probeAllUrls": probe_all_urls,
"failClosedUnknown": fail_closed_unknown,
- "implementationRevision": "final-output-order-v4",
+ "implementationRevision": "final-output-order-v5-timer-safe",
}
)
payload = json.dumps(payload_config, separators=(",", ":"))
@@ -99,7 +99,8 @@ def apply(text: str, options: dict[str, Any] | None = None, **_kwargs: Any) -> s
async function fetchBounded(url,stream,referer,range){
if(!g||typeof g.fetch!=="function")return {state:"unknown",reason:"fetch_unavailable"};
var controller=typeof AbortController!=="undefined"?new AbortController():null;
- var timer=setTimeout(function(){try{if(controller)controller.abort()}catch(_e){}},config.timeoutMs);
+ var timer=null;
+ if(controller&&typeof setTimeout==="function")timer=setTimeout(function(){try{controller.abort()}catch(_e){}},config.timeoutMs);
try{
var response=await g.fetch(url,{method:"GET",redirect:"follow",headers:requestHeaders(stream,referer,range),signal:controller?controller.signal:void 0});
if(!response)return {state:"unknown",reason:"no_response"};
@@ -108,7 +109,7 @@ def apply(text: str, options: dict[str, Any] | None = None, **_kwargs: Any) -> s
var contentType=String(response.headers&&response.headers.get?response.headers.get("content-type")||"":"").toLowerCase();
return {state:"ok",response:response,url:String(response.url||url),contentType:contentType};
}catch(error){return {state:"unknown",reason:error&&error.name==="AbortError"?"timeout":"network_error"}}
- finally{clearTimeout(timer)}
+ finally{if(timer!==null&&typeof clearTimeout==="function")try{clearTimeout(timer)}catch(_e){}}
}
async function responseText(result){
var response=result&&result.response;if(!response)return "";
diff --git a/scripts/provider_patches/nuvio_tv_target_media_v5.py b/scripts/provider_patches/nuvio_tv_target_media_v5.py
index 10ae19f53..8a7676e2a 100644
--- a/scripts/provider_patches/nuvio_tv_target_media_v5.py
+++ b/scripts/provider_patches/nuvio_tv_target_media_v5.py
@@ -8,12 +8,15 @@
* Referer and Origin are refreshed to the immediate parent on every hop.
* A small per-row cookie jar captures Set-Cookie and scopes Cookie by domain/path.
* The browser User-Agent used by the provider bridge is preserved for playback.
+* HLS proof tolerates UTF-8 BOM/mojibake and a missing #EXTM3U line when the
+ payload otherwise contains structural HLS tags. The final HLS integrity layer
+ remains responsible for strict validation/repair before native playback.
* The compact TV row keeps a useful size/description fallback without changing
Desktop/Mobile mapping semantics.
V4 markers remain present so existing release-integrity and profile checks keep
-working. Existing materialized V4 bundles are upgraded in place and reapply is
-byte-idempotent.
+working. Existing materialized V4/V5 bundles are upgraded in place and reapply
+is byte-idempotent.
"""
from __future__ import annotations
@@ -40,6 +43,7 @@ def _load_apply(path: Path):
V4_MARKER = "/* NUVIO_TV_TARGET_MEDIA_V4 */"
V5_MARKER = "/* NUVIO_TV_TARGET_MEDIA_V5_PLAYBACK_CONTEXT */"
+HLS_PROOF_MARKER = "/* NUVIO_TV_TARGET_MEDIA_HLS_PROOF_V6 */"
FETCH_COMPAT_MARKER = "/* NUVIO_TV_TEXT_ONLY_FETCH_COMPAT_V1 */"
PROTOCOL_RELATIVE_MARKER = "/* NUVIO_TV_PROTOCOL_RELATIVE_URL_COMPAT_V1 */"
VIDZY_DECODER_START = "function decodeVidzy(text){"
@@ -55,6 +59,10 @@ def _load_apply(path: Path):
ABS_V4 = r'''function abs(v,b){try{var raw=clean(v);if(/^\/\//.test(raw)){var scheme=/^https:/i.test(String(b||""))?"https:":"http:";return scheme+raw}return new URL(raw,b).toString()}catch(_){return ""}}'''
RESOURCE_V3 = r'''async function resource(u,base,ref){try{var h=probeHeaders(base,ref,u),r=await g.fetch(u,{headers:h,redirect:"follow",signal:timeout()});if(!r)return null;var type=r.headers&&r.headers.get?s(r.headers.get("content-type")):"",buffer=await r.arrayBuffer(),bytes=new Uint8Array(buffer),text=decode(bytes.slice(0,300000));return{ok:!!r.ok,status:r.status,url:s(r.url||u),type:type,bytes:bytes,text:text,headers:outputHeaders(base,ref,u)}}catch(_){return null}}'''
RESOURCE_V4 = r'''async function resource(u,base,ref){try{var h=probeHeaders(base,ref,u),r=await g.fetch(u,{headers:h,redirect:"follow",signal:timeout()});if(!r)return null;var type=r.headers&&r.headers.get?s(r.headers.get("content-type")):"",bytes=null,text="";if(typeof r.arrayBuffer==="function"){var buffer=await r.arrayBuffer();bytes=new Uint8Array(buffer);text=decode(bytes.slice(0,300000))}else if(typeof r.text==="function"){text=String(await r.text()||"").slice(0,300000)}return{ok:!!r.ok,status:r.status,url:s(r.url||u),type:type,bytes:bytes,text:text,headers:outputHeaders(base,ref,u)}}catch(_){return null}}'''
+PROOF_V3 = r'''function proof(r){if(!r)return null;if(startsHls(r.text))return"hls";if(startsDash(r.text)||/application\/dash\+xml/i.test(r.type))return"dash";var binary=bytesKind(r.bytes);if(binary)return binary;if(/^video\//i.test(r.type)&&r.bytes&&r.bytes.length>12)return"video";return null}'''
+PROOF_V6 = r'''function normalizedHlsProofText(text){return String(text==null?"":text).replace(/^(?:\uFEFF|)/,"").trimStart()}
+function hlsStructure(text){var value=normalizedHlsProofText(text);if(/^#EXTM3U(?:\s|$)/i.test(value))return true;return /(?:^|\n)#EXT-(?:X-[A-Z0-9-]+|INF)\s*:/i.test(value)}
+function proof(r){if(!r)return null;if(hlsStructure(r.text))return"hls";if(startsDash(r.text)||/application\/dash\+xml/i.test(r.type))return"dash";var binary=bytesKind(r.bytes);if(binary)return binary;if(/^video\//i.test(r.type)&&r.bytes&&r.bytes.length>12)return"video";return null}'''
STRICT_BLOCKED_HOSTS = {
"cloudflare.com", "googletagmanager.com", "google-analytics.com",
@@ -182,6 +190,17 @@ def upgrade_player_decoders(text: str, blocked_hosts: list[str]) -> str:
return text
+def upgrade_hls_proof(text: str) -> str:
+ if HLS_PROOF_MARKER in text:
+ return text
+ if PROOF_V6 not in text:
+ count = text.count(PROOF_V3)
+ if count != 1:
+ raise RuntimeError(f"target-media HLS proof anchor count={count}")
+ text = text.replace(PROOF_V3, PROOF_V6, 1)
+ return text.rstrip() + "\n" + HLS_PROOF_MARKER + "\n"
+
+
def _replace_once(text: str, old: str, new: str, label: str) -> str:
count = text.count(old)
if count != 1:
@@ -205,7 +224,8 @@ def apply(text: str, options: dict[str, Any] | None = None, **kwargs: Any) -> st
if V4_MARKER in text:
patched = upgrade_fetch_capability(text)
patched = upgrade_protocol_relative_urls(patched)
- return upgrade_playback_context(patched)
+ patched = upgrade_playback_context(patched)
+ return upgrade_hls_proof(patched)
blocked_hosts = sorted(
STRICT_BLOCKED_HOSTS
@@ -221,4 +241,5 @@ def apply(text: str, options: dict[str, Any] | None = None, **kwargs: Any) -> st
patched = EXPOSE(patched)
patched = FILTER(patched, options=cfg)
patched = patched.rstrip() + "\n" + V4_MARKER + "\n"
- return upgrade_playback_context(patched)
+ patched = upgrade_playback_context(patched)
+ return upgrade_hls_proof(patched)
diff --git a/scripts/provider_patches/purstream_stream_facts_v1.py b/scripts/provider_patches/purstream_stream_facts_v1.py
new file mode 100644
index 000000000..b76692067
--- /dev/null
+++ b/scripts/provider_patches/purstream_stream_facts_v1.py
@@ -0,0 +1,41 @@
+#!/usr/bin/env python3
+"""Expose Purstream stream facts without owning their final presentation.
+
+The upstream bundle already encodes quality/language/codec/audio/runtime in its
+legacy multiline labels but historically publishes empty ``quality`` and
+``language`` fields. This adapter projects those existing facts into explicit
+fields while preserving URL, headers and the legacy text for downstream identity
+checks. The global Core presentation layer owns the user-facing description.
+"""
+from __future__ import annotations
+
+import hashlib
+from typing import Any
+
+MARKER = "NUVIO_PURSTREAM_STREAM_FACTS_V1"
+
+
+def apply(text: str, options: dict[str, Any] | None = None, **_kwargs: Any) -> str:
+ marker = f"{MARKER}:{hashlib.sha256(b'facts-v1').hexdigest()[:12]}"
+ if marker in text:
+ return text
+ wrapper = r'''
+/* MARKER_PLACEHOLDER */
+;(function(g){"use strict";
+function s(v){return String(v==null?"":v).trim()}
+function meaningful(v){var x=s(v);return x&&!/^(?:unknown|inconnue?|n\/?a|null|undefined|none|-+)$/i.test(x)}
+function slot(v){if(Array.isArray(v))return{key:null,list:v};if(v&&typeof v==="object"){for(var i=0;i<3;i++){var k=["streams","results","data"][i];if(Array.isArray(v[k]))return{key:k,list:v[k]}}}return null}
+function rebuild(v,x,list){if(x.key===null)return list;var o=Object.assign({},v);o[x.key]=list;return o}
+function blob(row){return [row&&row.name,row&&row.title,row&&row.size,row&&row.description].map(s).join(" ")}
+function quality(row,b){if(meaningful(row.quality)){var v=s(row.quality);return /^(?:4k|2160p)$/i.test(v)?"2160p":v}var u=b.toUpperCase();if(/(?:\b4K\b|\b2160P?\b)/.test(u))return"2160p";var m=u.match(/\b(1440|1080|720|576|540|480|360)P?\b/);return m?m[1]+"p":""}
+function language(row,b){if(meaningful(row.language))return s(row.language);var u=b.toUpperCase();if(/\bDUAL(?:[- ]?AUDIO)?\b/.test(u))return"Dual Audio";if(/\bVOSTFR\b/.test(u))return"VOSTFR";if(/\bVFQ\b/.test(u))return"VFQ";if(/\bVFF\b/.test(u))return"VFF";if(/\bVF\b/.test(u))return"VF";if(/\bVO\b/.test(u))return"VO";return""}
+function codec(row,b){if(meaningful(row.codec))return s(row.codec);var u=b.toUpperCase();if(/\b(?:HEVC|H\.?265|X265)\b/.test(u))return"HEVC";if(/\bAV1\b/.test(u))return"AV1";if(/\bVP9\b/.test(u))return"VP9";if(/\b(?:AVC|H\.?264|X264)\b/.test(u))return"H.264";return""}
+function audio(row,b){if(meaningful(row.audio))return s(row.audio);var u=b.toUpperCase(),ch="",m=u.match(/\b(7\.1|5\.1|2\.1|2\.0)\b/);if(m)ch=" "+m[1];if(/\b(?:ATMOS|DOLBY ATMOS)\b/.test(u))return"ATMOS"+ch;if(/\b(?:E-?AC-?3|DDP|DD\+)\b/.test(u))return"E-AC3"+ch;if(/\bAC-?3\b/.test(u))return"AC3"+ch;if(/\bDTS(?:-HD)?\b/.test(u))return(/\bDTS-HD\b/.test(u)?"DTS-HD":"DTS")+ch;if(/\bAAC\b/.test(u))return"AAC"+ch;return""}
+function duration(row,b){if(typeof row.duration==="number"&&Number.isFinite(row.duration)&&row.duration>0)return row.duration>600?Math.round(row.duration/60):Math.round(row.duration);var direct=s(row.duration),m=direct.match(/(\d{1,4})\s*(?:min|minutes?)\b/i);if(m)return Number(m[1]);var x=b.match(/\b(\d{1,3})\s*(?:min|minutes?)\b/i);return x?Number(x[1]):0}
+function sourceType(row,b){if(meaningful(row.sourceType))return s(row.sourceType);var u=b.toUpperCase();if(/\b(?:BLU[- ]?RAY|BDRIP|BRRIP|BDREMUX|REMUX)\b/.test(u))return"BLU-RAY";if(/\bWEB[- .]?DL\b/.test(u))return"WEB-DL";if(/\bWEB[- .]?RIP\b/.test(u))return"WEBRIP";return""}
+function facts(row){if(!row||typeof row!=="object")return row;var out=Object.assign({},row),b=blob(row),q=quality(row,b),l=language(row,b),c=codec(row,b),a=audio(row,b),d=duration(row,b),st=sourceType(row,b);if(q)out.quality=q;if(l)out.language=l;if(c)out.codec=c;if(a)out.audio=a;if(d)out.duration=d;if(st)out.sourceType=st;return out}
+function install(o,k){if(!o||typeof o[k]!=="function"||o[k].__nuvioPurstreamStreamFactsV1)return false;var native=o[k];var wrap=async function(){var v=await native.apply(this,arguments),x=slot(v);return x?rebuild(v,x,x.list.map(facts)):v};wrap.__nuvioPurstreamStreamFactsV1=true;o[k]=wrap;return true}
+var ok=false;try{if(typeof module!=="undefined"&&module.exports)ok=install(module.exports,"getStreams")}catch(_e){}try{if(g&&typeof g.getStreams==="function"){if(ok&&typeof module!=="undefined"&&module.exports)g.getStreams=module.exports.getStreams;else install(g,"getStreams")}}catch(_e){}
+})(typeof globalThis!=="undefined"?globalThis:this);
+'''.replace("MARKER_PLACEHOLDER", marker)
+ return text.rstrip() + "\n" + wrapper.lstrip()
diff --git a/scripts/provider_patches/purstream_tv_identity_impl_v3.py b/scripts/provider_patches/purstream_tv_identity_impl_v3.py
new file mode 100644
index 000000000..7a17595dc
--- /dev/null
+++ b/scripts/provider_patches/purstream_tv_identity_impl_v3.py
@@ -0,0 +1,52 @@
+"""Reject Purstream episodic HLS whose real duration contradicts its metadata.
+
+Purstream can return a technically valid HLS with the requested title/episode in
+its display metadata while the media itself belongs to another work. For TV,
+series and anime requests, rows that advertise a duration are therefore checked
+against the media playlist #EXTINF total. A large mismatch is fail-closed.
+Movies and rows without an advertised duration are left unchanged.
+"""
+from __future__ import annotations
+
+import hashlib
+import json
+from typing import Any
+
+MARKER = "NUVIO_PURSTREAM_TV_IDENTITY_V3"
+
+
+def apply(source: str, options: dict[str, Any] | None = None, **_kwargs: Any) -> str:
+ cfg = dict(options or {})
+ tolerance = max(0.15, min(float(cfg.get("duration_tolerance", 0.35)), 0.50))
+ timeout_ms = max(2000, min(int(cfg.get("timeout_ms", 7000)), 12000))
+ max_probes = max(1, min(int(cfg.get("max_probes", 3)), 5))
+ payload = {
+ "durationTolerance": tolerance,
+ "timeoutMs": timeout_ms,
+ "maxProbes": max_probes,
+ }
+ serialized = json.dumps(payload, separators=(",", ":"))
+ marker = f"{MARKER}:{hashlib.sha256(serialized.encode()).hexdigest()[:12]}"
+ if marker in source:
+ return source
+
+ shim = r'''
+/* MARKER_PLACEHOLDER */
+;(function(g,c){"use strict";
+function txt(v){return String(v==null?"":v)}
+function req(a){var o=a[0]&&typeof a[0]==="object"?a[0]:{};return {type:txt(o.mediaType||o.type||a[1]||"movie").toLowerCase(),season:Number(o.season??a[2]??0)||0,episode:Number(o.episode??a[3]??0)||0}}
+function episodic(t){return t==="tv"||t==="series"||t==="anime"}
+function expected(row){var blob=[row&&row.name,row&&row.title,row&&row.size,row&&row.description].map(txt).join(" "),re=/\b(\d{1,3})\s*min(?:ute)?s?\b/ig,m,best=0;while((m=re.exec(blob))!==null)best=Math.max(best,Number(m[1])||0);return best?best*60:0}
+function hls(row){return /\.m3u8(?:[?#]|$)/i.test(txt(row&&row.url))||txt(row&&row.type).toLowerCase()==="hls"||txt(row&&row.format).toLowerCase()==="m3u8"}
+function headers(row){var h={};try{Object.assign(h,row&&row.headers||{},row&&row.behaviorHints&&row.behaviorHints.proxyHeaders&&row.behaviorHints.proxyHeaders.request||{})}catch(_e){}if(!h.Accept)h.Accept="*/*";if(!h["User-Agent"])h["User-Agent"]="Mozilla/5.0 (Linux; Android 14; Android TV) NuvioTV";return h}
+async function get(url,h){var ctrl=typeof AbortController!=="undefined"?new AbortController():null,timer=null;try{if(ctrl)timer=setTimeout(function(){try{ctrl.abort()}catch(_e){}},c.timeoutMs);var r=await g.fetch(url,{method:"GET",headers:h,redirect:"follow",signal:ctrl?ctrl.signal:void 0});if(!r||!r.ok)return null;var body=await r.text();return {body:txt(body).replace(/^\uFEFF/,"").trimStart(),url:txt(r.url||url)}}catch(_e){return null}finally{if(timer)clearTimeout(timer)}}
+function child(master,base){var lines=txt(master).split(/\r?\n/);for(var i=0;i=(1-c.durationTolerance)&&ratio<=(1+c.durationTolerance)}
+function rows(v){if(Array.isArray(v))return {key:null,list:v};if(v&&typeof v==="object"){for(var i=0;i<3;i++){var k=["streams","results","data"][i];if(Array.isArray(v[k]))return {key:k,list:v[k]}}}return null}
+function rebuild(v,slot,list){if(slot.key===null)return list;var out=Object.assign({},v);out[slot.key]=list;return out}
+function install(o,k){if(!o||typeof o[k]!=="function"||o[k].__nuvioPurstreamTvIdentityV3)return false;var native=o[k];var wrap=async function(){var r=req(arguments),v=await native.apply(this,arguments);if(!episodic(r.type))return v;var slot=rows(v);if(!slot)return v;var kept=[],probes=0;for(var i=0;i=c.maxProbes)continue;probes++;if(await prove(row,seconds))kept.push(row);continue}kept.push(row)}return rebuild(v,slot,kept)};wrap.__nuvioPurstreamTvIdentityV3=true;wrap.__nuvioPurstreamTvIdentityOriginal=native;o[k]=wrap;return true}
+var ok=false;try{if(typeof module!=="undefined"&&module.exports)ok=install(module.exports,"getStreams")}catch(_e){}try{if(g&&typeof g.getStreams==="function"){if(ok&&typeof module!=="undefined"&&module.exports)g.getStreams=module.exports.getStreams;else install(g,"getStreams")}}catch(_e){}
+})(typeof globalThis!=="undefined"?globalThis:this,CONFIG_PLACEHOLDER);
+'''.replace("MARKER_PLACEHOLDER", marker).replace("CONFIG_PLACEHOLDER", serialized)
+ return source.rstrip() + "\n" + shim.lstrip()
diff --git a/scripts/provider_patches/purstream_tv_identity_v3.py b/scripts/provider_patches/purstream_tv_identity_v3.py
index 7a17595dc..5e13bd1a3 100644
--- a/scripts/provider_patches/purstream_tv_identity_v3.py
+++ b/scripts/provider_patches/purstream_tv_identity_v3.py
@@ -1,52 +1,35 @@
-"""Reject Purstream episodic HLS whose real duration contradicts its metadata.
+#!/usr/bin/env python3
+"""Compose Purstream factual metadata projection with TV identity validation.
-Purstream can return a technically valid HLS with the requested title/episode in
-its display metadata while the media itself belongs to another work. For TV,
-series and anime requests, rows that advertise a duration are therefore checked
-against the media playlist #EXTINF total. A large mismatch is fail-closed.
-Movies and rows without an advertised duration are left unchanged.
+Purstream's provider adapter first exposes language/quality/codec/audio/duration
+as explicit stream facts. The existing episodic duration-identity guard then
+validates those rows. Final user-facing formatting remains entirely owned by
+the repository-wide Core presentation layer.
"""
from __future__ import annotations
-import hashlib
-import json
+import importlib.util
+from pathlib import Path
from typing import Any
-MARKER = "NUVIO_PURSTREAM_TV_IDENTITY_V3"
-
-
-def apply(source: str, options: dict[str, Any] | None = None, **_kwargs: Any) -> str:
- cfg = dict(options or {})
- tolerance = max(0.15, min(float(cfg.get("duration_tolerance", 0.35)), 0.50))
- timeout_ms = max(2000, min(int(cfg.get("timeout_ms", 7000)), 12000))
- max_probes = max(1, min(int(cfg.get("max_probes", 3)), 5))
- payload = {
- "durationTolerance": tolerance,
- "timeoutMs": timeout_ms,
- "maxProbes": max_probes,
- }
- serialized = json.dumps(payload, separators=(",", ":"))
- marker = f"{MARKER}:{hashlib.sha256(serialized.encode()).hexdigest()[:12]}"
- if marker in source:
- return source
-
- shim = r'''
-/* MARKER_PLACEHOLDER */
-;(function(g,c){"use strict";
-function txt(v){return String(v==null?"":v)}
-function req(a){var o=a[0]&&typeof a[0]==="object"?a[0]:{};return {type:txt(o.mediaType||o.type||a[1]||"movie").toLowerCase(),season:Number(o.season??a[2]??0)||0,episode:Number(o.episode??a[3]??0)||0}}
-function episodic(t){return t==="tv"||t==="series"||t==="anime"}
-function expected(row){var blob=[row&&row.name,row&&row.title,row&&row.size,row&&row.description].map(txt).join(" "),re=/\b(\d{1,3})\s*min(?:ute)?s?\b/ig,m,best=0;while((m=re.exec(blob))!==null)best=Math.max(best,Number(m[1])||0);return best?best*60:0}
-function hls(row){return /\.m3u8(?:[?#]|$)/i.test(txt(row&&row.url))||txt(row&&row.type).toLowerCase()==="hls"||txt(row&&row.format).toLowerCase()==="m3u8"}
-function headers(row){var h={};try{Object.assign(h,row&&row.headers||{},row&&row.behaviorHints&&row.behaviorHints.proxyHeaders&&row.behaviorHints.proxyHeaders.request||{})}catch(_e){}if(!h.Accept)h.Accept="*/*";if(!h["User-Agent"])h["User-Agent"]="Mozilla/5.0 (Linux; Android 14; Android TV) NuvioTV";return h}
-async function get(url,h){var ctrl=typeof AbortController!=="undefined"?new AbortController():null,timer=null;try{if(ctrl)timer=setTimeout(function(){try{ctrl.abort()}catch(_e){}},c.timeoutMs);var r=await g.fetch(url,{method:"GET",headers:h,redirect:"follow",signal:ctrl?ctrl.signal:void 0});if(!r||!r.ok)return null;var body=await r.text();return {body:txt(body).replace(/^\uFEFF/,"").trimStart(),url:txt(r.url||url)}}catch(_e){return null}finally{if(timer)clearTimeout(timer)}}
-function child(master,base){var lines=txt(master).split(/\r?\n/);for(var i=0;i=(1-c.durationTolerance)&&ratio<=(1+c.durationTolerance)}
-function rows(v){if(Array.isArray(v))return {key:null,list:v};if(v&&typeof v==="object"){for(var i=0;i<3;i++){var k=["streams","results","data"][i];if(Array.isArray(v[k]))return {key:k,list:v[k]}}}return null}
-function rebuild(v,slot,list){if(slot.key===null)return list;var out=Object.assign({},v);out[slot.key]=list;return out}
-function install(o,k){if(!o||typeof o[k]!=="function"||o[k].__nuvioPurstreamTvIdentityV3)return false;var native=o[k];var wrap=async function(){var r=req(arguments),v=await native.apply(this,arguments);if(!episodic(r.type))return v;var slot=rows(v);if(!slot)return v;var kept=[],probes=0;for(var i=0;i=c.maxProbes)continue;probes++;if(await prove(row,seconds))kept.push(row);continue}kept.push(row)}return rebuild(v,slot,kept)};wrap.__nuvioPurstreamTvIdentityV3=true;wrap.__nuvioPurstreamTvIdentityOriginal=native;o[k]=wrap;return true}
-var ok=false;try{if(typeof module!=="undefined"&&module.exports)ok=install(module.exports,"getStreams")}catch(_e){}try{if(g&&typeof g.getStreams==="function"){if(ok&&typeof module!=="undefined"&&module.exports)g.getStreams=module.exports.getStreams;else install(g,"getStreams")}}catch(_e){}
-})(typeof globalThis!=="undefined"?globalThis:this,CONFIG_PLACEHOLDER);
-'''.replace("MARKER_PLACEHOLDER", marker).replace("CONFIG_PLACEHOLDER", serialized)
- return source.rstrip() + "\n" + shim.lstrip()
+HERE = Path(__file__).resolve().parent
+
+
+def _load(filename: str, module_name: str):
+ path = HERE / filename
+ spec = importlib.util.spec_from_file_location(module_name, path)
+ if spec is None or spec.loader is None:
+ raise RuntimeError(f"cannot load Purstream layer: {path}")
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+_FACTS = _load("purstream_stream_facts_v1.py", "nuvio_purstream_stream_facts_v1")
+_IDENTITY = _load("purstream_tv_identity_impl_v3.py", "nuvio_purstream_tv_identity_impl_v3")
+MARKER = _IDENTITY.MARKER
+
+
+def apply(source: str, options: dict[str, Any] | None = None, **kwargs: Any) -> str:
+ with_facts = _FACTS.apply(source, options=options, **kwargs)
+ return _IDENTITY.apply(with_facts, options=options, **kwargs)
diff --git a/scripts/provider_patches/stream_output_sanitizer_v5.py b/scripts/provider_patches/stream_output_sanitizer_v5.py
index 1877638fa..a5c186dba 100644
--- a/scripts/provider_patches/stream_output_sanitizer_v5.py
+++ b/scripts/provider_patches/stream_output_sanitizer_v5.py
@@ -1,15 +1,26 @@
#!/usr/bin/env python3
-"""Apply the strict stream sanitizer with UTF-8 BOM-aware HLS parsing.
+"""Upgrade the stream sanitizer with generic playback repair.
-The V4 sanitizer intentionally validates the response body rather than trusting
-an URL suffix or Content-Type. V5 keeps that policy and fixes the binary-prefix
-reader case where an UTF-8 BOM (EF BB BF) becomes the mojibake string  before
-`#EXTM3U`.
+The base sanitizer validates fetched content instead of trusting suffixes. This
+layer keeps the UTF-8 BOM fix and adds two transport repairs that apply to every
+provider using the sanitizer:
+
+* a structurally valid HLS playlist that is missing only the mandatory #EXTM3U
+ line is rebuilt as HLS instead of being discarded; relative playlist URIs are
+ absolutized before the repaired manifest is handed to the native reader;
+* HTML/JSON resolver pages are treated as indirect player responses, not media.
+ A bounded resolver follows explicit video/source/iframe/file URLs and preserves
+ the parent Referer/Origin for the resolved media. Generic HTML/error pages that
+ do not resolve to media are still rejected.
+
+No authentication, token, DRM or access-control bypass is attempted. Explicit
+provider headers are preserved, and legacy ``stream.headers`` are mirrored into
+Stremio/Nuvio ``behaviorHints.proxyHeaders.request`` so native readers actually
+receive them.
Reapplication is deliberately supported: durable provider overrides may change
-probe policy later. A stale V5 marker must therefore never cause a newly
-regenerated V4 wrapper to skip the V5 normalization step, while an unchanged
-configuration must remain byte-for-byte idempotent.
+probe policy later. The repair marker is separate from the historical V5 marker
+so already-published V5 providers are upgraded on their next materialization.
"""
from __future__ import annotations
@@ -21,6 +32,8 @@
BASE_PATH = ROOT / "stream_output_sanitizer.py"
MARKER = "NUVIO_STREAM_OUTPUT_SANITIZER_UTF8_BOM_V5"
MARKER_COMMENT = f"/* {MARKER} */"
+REPAIR_MARKER = "NUVIO_STREAM_OUTPUT_HLS_HTML_REPAIR_V7"
+REPAIR_MARKER_COMMENT = f"/* {REPAIR_MARKER} */"
def _load_base_apply():
@@ -35,19 +48,26 @@ def _load_base_apply():
BASE_APPLY = _load_base_apply()
+def _replace_once(text: str, old: str, new: str, label: str) -> str:
+ if new in text:
+ return text
+ count = text.count(old)
+ if count != 1:
+ raise ValueError(f"stream sanitizer {label} hook count={count}")
+ return text.replace(old, new, 1)
+
+
def apply(text: str, options: dict[str, Any] | None = None, **kwargs: Any) -> str:
- # Let V4 decide first. If its content-addressed configuration marker already
- # matches, it returns the original text untouched. In that common case an
- # existing V5 marker proves this exact wrapper was already normalized, so
- # preserve the bytes exactly instead of moving the marker to the file tail.
+ # Let V4 decide first. Its marker is content-addressed, so changed probe policy
+ # still regenerates the underlying wrapper. The V7 repair marker, not the old
+ # V5 marker, proves that all generic playback repairs are present.
patched = BASE_APPLY(text, options=options, **kwargs)
- if patched == text and MARKER_COMMENT in text:
+ if patched == text and REPAIR_MARKER_COMMENT in text:
return text
- # When V4 really regenerated its wrapper, an older standalone V5 marker may
- # survive outside the replaced V4 block. Remove only that marker, normalize
- # the newly generated wrapper, then append one canonical V5 marker.
- patched = patched.replace(MARKER_COMMENT, "").rstrip()
+ patched = patched.replace(MARKER_COMMENT, "").replace(REPAIR_MARKER_COMMENT, "").rstrip()
+
+ # UTF-8 BOM may arrive either as U+FEFF or as the mojibake byte sequence.
source = 'replace(/^\\uFEFF/,"").trimStart()'
target = 'replace(/^(?:\\uFEFF|\\u00EF\\u00BB\\u00BF)/,"").trimStart()'
if target not in patched:
@@ -55,4 +75,246 @@ def apply(text: str, options: dict[str, Any] | None = None, **kwargs: Any) -> st
raise ValueError("stream sanitizer HLS normalization hook not found")
patched = patched.replace(source, target, 1)
- return patched.rstrip() + f"\n{MARKER_COMMENT}\n"
+ old_headers = r''' function headersFor(stream){
+ var output={"Accept":"application/vnd.apple.mpegurl,application/x-mpegURL,application/dash+xml,video/*,*/*;q=0.8","Range":"bytes=0-4095"};
+ var source=stream&&stream.headers;
+ if(source&&typeof source==="object"){
+ try{Object.keys(source).forEach(function(key){if(source[key]!=null)output[key]=String(source[key])})}catch(_e){}
+ }
+ return output;
+ }
+'''
+ new_headers = r''' function hasHeader(headers,name){
+ if(!headers||typeof headers!=="object")return false;
+ var wanted=String(name||"").toLowerCase(),keys=[];
+ try{keys=Object.keys(headers)}catch(_e){}
+ for(var i=0;i32768)value=value.slice(0,32768-total);
+ chunks.push(value);total+=value.length;
+ }
+ var output=new Uint8Array(total),offset=0;
+ for(var i=0;i4096)return;
+ candidate=absoluteMediaUri(candidate,baseUrl);
+ if(!candidate||candidate===baseUrl||blocked(candidate)||seen[candidate])return;
+ var path="";try{path=new URL(candidate).pathname.toLowerCase()}catch(_e){}
+ var embedLike=/\/(?:embed|e|player|watch)(?:[-/]|$)/i.test(path);
+ if(!allowOpaque&&!directExtension(candidate)&&!embedLike)return;
+ seen[candidate]=1;rows.push({url:candidate,direct:isDirect(null,candidate)?0:1});
+ }
+ var match,re=/<(?:video|source|iframe)\b[^>]*?\b(?:src|data-src)\s*=\s*["']([^"']+)["']/gi;
+ while((match=re.exec(value))!==null)push(match[1],true);
+ re=/(?:["']?(?:file|src|source|url)["']?\s*[:=]\s*)["']([^"']+)["']/gi;
+ while((match=re.exec(value))!==null)push(match[1],true);
+ re=/https?:\/\/[^\s"'<>\\]+/gi;
+ while((match=re.exec(value))!==null)push(match[0],false);
+ rows.sort(function(a,b){return a.direct-b.direct});
+ return rows.slice(0,2).map(function(row){return row.url});
+ }
+'''
+ if helpers not in patched:
+ if valid_anchor not in patched:
+ raise ValueError("stream sanitizer HLS helper anchor not found")
+ patched = patched.replace(valid_anchor, helpers + valid_anchor, 1)
+
+ patched = _replace_once(
+ patched,
+ " async function probe(stream,url){\n",
+ " async function probeResolved(stream,url,depth,referer){\n",
+ "recursive probe",
+ )
+ patched = patched.replace(
+ 'headers:headersFor(stream),redirect:"follow",signal:controller.signal',
+ 'headers:headersFor(stream,referer),redirect:"follow",signal:controller.signal',
+ 1,
+ )
+
+ old_hls = r''' if(/(?:\.m3u8?)(?:[?#]|$)/i.test(url)||/(?:\.m3u8?)(?:[?#]|$)/i.test(finalUrl)||/(?:mpegurl|vnd\.apple)/.test(contentType)||/^\s*#EXTM3U/i.test(text)){
+ if(!validHls(text))return false;
+ markDirect(stream,finalUrl);
+ return true;
+ }
+'''
+ new_hls = r''' if(/(?:\.m3u8?)(?:[?#]|$)/i.test(url)||/(?:\.m3u8?)(?:[?#]|$)/i.test(finalUrl)||/(?:mpegurl|vnd\.apple)/.test(contentType)||/^\s*#EXT(?:M3U|-(?:X-[A-Z0-9-]+|INF)\s*:)/i.test(text)){
+ var hls=normalizeHlsText(text,finalUrl);
+ if(!hls)return false;
+ if(hls.repaired){
+ stream.url=repairedHlsUrl(hls.text);
+ if(!stream.type)stream.type="hls";
+ if(!stream.mimeType)stream.mimeType="application/vnd.apple.mpegurl";
+ stream.isDirect=true;
+ }else markDirect(stream,finalUrl);
+ syncPlaybackHeaders(stream);
+ return true;
+ }
+'''
+ patched = _replace_once(patched, old_hls, new_hls, "generic HLS repair")
+
+ old_text_reject = r''' if(/(?:text\/html|application\/json|text\/plain)/.test(contentType)||/^\s*(?:0;\n",
+ ' if(/^text\\//i.test(contentType))return false;\n return bytes.length>0;\n',
+ 1,
+ )
+
+ install_anchor = " function install(container,key){\n"
+ probe_wrapper = ' async function probe(stream,url){return await probeResolved(stream,url,0,"")}\n'
+ if probe_wrapper not in patched:
+ if install_anchor not in patched:
+ raise ValueError("stream sanitizer install anchor not found")
+ patched = patched.replace(install_anchor, probe_wrapper + install_anchor, 1)
+
+ # Mirror legacy headers before ranking/probing so both the sanitizer and the
+ # actual Nuvio reader use the same request contract.
+ patched = patched.replace(
+ " var stream=result[i],url=urlOf(stream);\n",
+ " var stream=result[i];syncPlaybackHeaders(stream);var url=urlOf(stream);\n",
+ 1,
+ )
+
+ return patched.rstrip() + f"\n{MARKER_COMMENT}\n{REPAIR_MARKER_COMMENT}\n"
diff --git a/scripts/provider_patches/stream_output_sanitizer_v6.py b/scripts/provider_patches/stream_output_sanitizer_v6.py
index a99caaa9d..55e14f13b 100644
--- a/scripts/provider_patches/stream_output_sanitizer_v6.py
+++ b/scripts/provider_patches/stream_output_sanitizer_v6.py
@@ -1,15 +1,25 @@
#!/usr/bin/env python3
-"""Harden V5 all-URL probing so unproven overflow rows are fail-closed.
+"""Harden V5 all-URL probing and preserve terminal wrapper order.
V5 treats ``maxProbes`` as a probing budget, but rows beyond that budget are
returned unchanged even when ``probe_all_urls`` is true. That is acceptable for
-best-effort probing, but not for a NuvioTV-only provider whose desktop/mobile
-platforms are deliberately blocked: every published row must either have media
-proof or be discarded.
+best-effort probing, but not for a strict publication boundary: every published
+row must either have media proof or be discarded.
-V6 preserves V5 for all parsing/probing behavior and changes only that overflow
-semantic. It is intentionally opt-in via a separate patch script so providers
-that rely on best-effort V5 behavior are not changed implicitly.
+A second subtlety matters on durable/LKG rematerialization. Some target-media
+profiles deliberately remove their old wrapper and append a fresh one. If an
+already-materialized sanitizer is left in place, the new target-media wrapper is
+installed after it and therefore becomes the outer wrapper at runtime. The final
+media URL then bypasses the terminal sanitizer entirely. V6 detects that stale
+textual order, removes only its own sanitizer wrapper/markers, and lets V5
+rebuild it after target-media. This keeps reapplication deterministic without
+weakening fail-closed probing or adding provider-specific behavior.
+
+V5 historically detects its compatibility ``probe()`` alias with a whole-file
+substring search. A provider or another wrapper may coincidentally define the
+same function, causing V5 to skip the alias inside the sanitizer and fail at
+runtime with ``ReferenceError: probe is not defined``. V6 verifies the alias in
+the sanitizer's own lexical region and injects it there when needed.
"""
from __future__ import annotations
@@ -22,6 +32,21 @@
MARKER = "/* NUVIO_STREAM_OUTPUT_SANITIZER_ALL_URL_FAIL_CLOSED_V6 */"
OLD = "if(!item.probe)return item.stream;"
NEW = "if(!item.probe)return config.probeAllUrls?null:item.stream;"
+SANITIZER_PREFIX = "/* NUVIO_STREAM_OUTPUT_SANITIZER_V4:"
+SANITIZER_CALL = '})(typeof globalThis!=="undefined"?globalThis:this,'
+V5_MARKERS = (
+ "/* NUVIO_STREAM_OUTPUT_SANITIZER_UTF8_BOM_V5 */",
+ "/* NUVIO_STREAM_OUTPUT_HLS_HTML_REPAIR_V7 */",
+)
+TARGET_MEDIA_MARKERS = (
+ "/* NUVIO_TV_TARGET_MEDIA_V3:",
+ "/* NUVIO_TV_TARGET_MEDIA_V4 */",
+ "/* NUVIO_TV_TARGET_MEDIA_V5_PLAYBACK_CONTEXT */",
+ "/* NUVIO_TV_TARGET_MEDIA_HLS_PROOF_V6 */",
+)
+PROBE_RESOLVED = " async function probeResolved(stream,url,depth,referer){\n"
+PROBE_ALIAS = ' async function probe(stream,url){return await probeResolved(stream,url,0,"")}\n'
+INSTALL_ANCHOR = " function install(container,key){\n"
def _load_v5_apply():
@@ -36,6 +61,60 @@ def _load_v5_apply():
V5_APPLY = _load_v5_apply()
+def _latest_target_media_position(text: str) -> int:
+ return max((text.rfind(marker) for marker in TARGET_MEDIA_MARKERS), default=-1)
+
+
+def _sanitizer_position(text: str) -> int:
+ return text.find(SANITIZER_PREFIX)
+
+
+def _needs_relocation(text: str) -> bool:
+ sanitizer = _sanitizer_position(text)
+ target = _latest_target_media_position(text)
+ return sanitizer >= 0 and target > sanitizer
+
+
+def _strip_existing_sanitizer(text: str) -> str:
+ """Remove the existing V4/V5/V6 sanitizer materialization only.
+
+ The wrapper boundary is stable because the base sanitizer is emitted as one
+ IIFE. Other provider/global wrappers are preserved byte-for-byte.
+ """
+ output = text
+ removed = 0
+ while True:
+ start = output.find(SANITIZER_PREFIX)
+ if start < 0:
+ break
+ call = output.find(SANITIZER_CALL, start)
+ end = output.find(");", call) if call >= 0 else -1
+ if call < 0 or end < 0:
+ raise ValueError("unterminated stream sanitizer wrapper during relocation")
+ output = (output[:start] + output[end + 2 :]).rstrip()
+ removed += 1
+ if removed == 0:
+ raise ValueError("stream sanitizer relocation requested without wrapper")
+ for marker in (*V5_MARKERS, MARKER):
+ output = output.replace(marker, "")
+ return output.rstrip()
+
+
+def _ensure_local_probe_alias(text: str) -> str:
+ """Ensure ``probe()`` exists inside this sanitizer, not merely elsewhere."""
+ sanitizer = _sanitizer_position(text)
+ if sanitizer < 0:
+ return text
+ resolved = text.find(PROBE_RESOLVED, sanitizer)
+ install = text.find(INSTALL_ANCHOR, resolved if resolved >= 0 else sanitizer)
+ if resolved < 0 or install < 0:
+ raise ValueError("stream sanitizer local probe region not found")
+ region = text[resolved:install]
+ if PROBE_ALIAS in region:
+ return text
+ return text[:install] + PROBE_ALIAS + text[install:]
+
+
def apply(text: str, options: dict[str, Any] | None = None, **kwargs: Any) -> str:
cfg = dict(options or {})
if not bool(cfg.get("probe_all_urls")):
@@ -43,11 +122,15 @@ def apply(text: str, options: dict[str, Any] | None = None, **kwargs: Any) -> st
if int(cfg.get("max_probes") or 0) <= 0:
raise ValueError("stream sanitizer v6 requires max_probes>0")
+ relocated = _needs_relocation(text)
+ source = _strip_existing_sanitizer(text) if relocated else text
+
# V5 owns the content-addressed configuration. Always let it run first so
# changing blocked paths or probe policy cannot be hidden by a static V6
# marker from an older materialization.
- patched = V5_APPLY(text, options=cfg, **kwargs)
- if patched == text and MARKER in text:
+ patched = V5_APPLY(source, options=cfg, **kwargs)
+ patched = _ensure_local_probe_alias(patched)
+ if not relocated and patched == text and MARKER in text:
return text
patched = patched.replace(MARKER, "").rstrip()
@@ -55,6 +138,16 @@ def apply(text: str, options: dict[str, Any] | None = None, **kwargs: Any) -> st
if OLD not in patched:
raise ValueError("stream sanitizer all-URL overflow hook not found")
patched = patched.replace(OLD, NEW, 1)
+
+ # The actual sanitizer IIFE—not a trailing compatibility marker—must be
+ # installed after the latest target-media wrapper so final media is probed.
+ sanitizer = _sanitizer_position(patched)
+ target = _latest_target_media_position(patched)
+ if sanitizer < 0 or (target >= 0 and sanitizer <= target):
+ raise ValueError(
+ f"stream sanitizer terminal order invalid: sanitizer={sanitizer} target_media={target}"
+ )
+
return patched.rstrip() + "\n" + MARKER + "\n"
diff --git a/scripts/resolve_native_repository.sh b/scripts/resolve_native_repository.sh
new file mode 100644
index 000000000..3291a2c26
--- /dev/null
+++ b/scripts/resolve_native_repository.sh
@@ -0,0 +1,165 @@
+#!/usr/bin/env bash
+# Source from a native client suite after WORKSPACE, NIAKVIO, TARGET_MANIFEST,
+# SOURCE_SHA and SOURCE_REPOSITORY are defined.
+#
+# Human-UX invariant: repository staging may make the NiakVIO candidate reachable,
+# but it must never grant the Nuvio process privileges or network capabilities that
+# a normal user process does not have. No sudo/root, proxy, DNS, TLS or OS-policy
+# bypass belongs here. If ordinary process access cannot reach the staged candidate,
+# the evidence is infrastructure-invalid and the suite must fail closed.
+
+NIAKVIO_RESOLVED_MANIFEST_URL=""
+NIAKVIO_RESOLVED_ALLOW_LOCAL="0"
+NIAKVIO_LOCAL_REPOSITORY_PID=""
+NIAKVIO_LOCAL_REPOSITORY_LOG=""
+NIAKVIO_LOCAL_REPOSITORY_ROOT=""
+NIAKVIO_LOCAL_REPOSITORY_KEY=""
+
+probe_native_repository_loopback() {
+ local port="${1:?port required}"
+ local candidate_dir="${2:?candidate dir required}"
+ python3 - "$port" "$candidate_dir" <<'PY'
+import http.client
+import sys
+
+port = int(sys.argv[1])
+candidate = sys.argv[2]
+connection = http.client.HTTPConnection("127.0.0.1", port, timeout=1.0)
+try:
+ connection.request("GET", f"/{candidate}/manifest.json", headers={"Connection": "close"})
+ response = connection.getresponse()
+ if response.status != 200:
+ raise SystemExit(1)
+ response.read(64)
+finally:
+ connection.close()
+PY
+}
+
+resolve_native_repository() {
+ local client="${1:?client required}"
+ local device_host="${2:?device-visible host required}"
+ local port="${3:?port required}"
+
+ if [[ -n "${NIAKVIO_MANIFEST_URL:-}" ]]; then
+ NIAKVIO_RESOLVED_MANIFEST_URL="$NIAKVIO_MANIFEST_URL"
+ NIAKVIO_RESOLVED_ALLOW_LOCAL="${NIAKVIO_ALLOW_LOCAL_MANIFEST:-0}"
+ echo "FIELD_NATIVE_REPOSITORY_SOURCE client=$client mode=explicit local=$NIAKVIO_RESOLVED_ALLOW_LOCAL observational=true"
+ return 0
+ fi
+
+ # Prefer the exact SHA-pinned public repository whenever the selected manifest is
+ # a tracked unchanged root file. This is closest to what a real Nuvio user loads.
+ local pinned=false
+ if [[ "$TARGET_MANIFEST" != */* ]] \
+ && git -C "$NIAKVIO" ls-files --error-unmatch "$TARGET_MANIFEST" >/dev/null 2>&1 \
+ && git -C "$NIAKVIO" cat-file -e "$SOURCE_SHA:$TARGET_MANIFEST" 2>/dev/null \
+ && git -C "$NIAKVIO" diff --quiet "$SOURCE_SHA" -- "$TARGET_MANIFEST"; then
+ pinned=true
+ fi
+
+ if [[ "$pinned" == true ]]; then
+ NIAKVIO_RESOLVED_MANIFEST_URL="https://raw.githubusercontent.com/${SOURCE_REPOSITORY}/${SOURCE_SHA}/${TARGET_MANIFEST}"
+ NIAKVIO_RESOLVED_ALLOW_LOCAL="0"
+ echo "FIELD_NATIVE_REPOSITORY_SOURCE client=$client mode=pinned_github local=0 cache_key=${SOURCE_SHA} observational=true"
+ return 0
+ fi
+
+ # Generated/modified manifests must still resolve repository-relative provider
+ # filenames. Materialize manifest + every provider into a content-addressed local
+ # repository so persistent Nuvio caches cannot reuse stale JS.
+ local serve_root="${WORKSPACE}/native-candidate-repository-${client}"
+ local prepared_root="${serve_root}.prepared"
+ local server_log="${WORKSPACE}/native-candidate-repository-${client}.server.log"
+ rm -rf "$prepared_root" "$serve_root"
+ python3 "$NIAKVIO/scripts/prepare_native_candidate_repository.py" \
+ --manifest "$TARGET_MANIFEST" \
+ --serve-root "$prepared_root" || return $?
+
+ local content_key
+ content_key="$(python3 - "$prepared_root" <<'PY'
+from pathlib import Path
+import hashlib
+import sys
+
+root = Path(sys.argv[1]).resolve()
+h = hashlib.sha256()
+files = sorted((p for p in root.rglob('*') if p.is_file()), key=lambda p: p.relative_to(root).as_posix())
+if not files:
+ raise SystemExit('candidate repository contains no files')
+for path in files:
+ relative = path.relative_to(root).as_posix().encode('utf-8')
+ payload = path.read_bytes()
+ h.update(len(relative).to_bytes(4, 'big'))
+ h.update(relative)
+ h.update(len(payload).to_bytes(8, 'big'))
+ h.update(payload)
+print(h.hexdigest()[:32])
+PY
+)" || return $?
+ if [[ ! "$content_key" =~ ^[0-9a-f]{32}$ ]]; then
+ echo "FIELD_NATIVE_REPOSITORY_SOURCE_ERROR client=$client reason=invalid_content_key" >&2
+ rm -rf "$prepared_root"
+ return 2
+ fi
+
+ local candidate_dir="candidate-${content_key}"
+ mkdir -p "$serve_root"
+ mv "$prepared_root" "$serve_root/$candidate_dir"
+ NIAKVIO_LOCAL_REPOSITORY_ROOT="$serve_root"
+ NIAKVIO_LOCAL_REPOSITORY_KEY="$content_key"
+
+ local bind_host="0.0.0.0"
+ if [[ "$client" == "desktop" && ( "$device_host" == "127.0.0.1" || "$device_host" == "localhost" ) ]]; then
+ bind_host="127.0.0.1"
+ fi
+
+ rm -f "$server_log"
+ python3 -m http.server "$port" --bind "$bind_host" --directory "$serve_root" >"$server_log" 2>&1 &
+ NIAKVIO_LOCAL_REPOSITORY_PID=$!
+ NIAKVIO_LOCAL_REPOSITORY_LOG="$server_log"
+
+ local ready=0
+ local attempt
+ for attempt in $(seq 1 40); do
+ if ! kill -0 "$NIAKVIO_LOCAL_REPOSITORY_PID" 2>/dev/null; then
+ echo "FIELD_NATIVE_REPOSITORY_SOURCE_ERROR client=$client reason=local_server_exited cache_key=$content_key bind_host=$bind_host" >&2
+ cat "$server_log" >&2 2>/dev/null || true
+ rm -rf "$serve_root"
+ NIAKVIO_LOCAL_REPOSITORY_PID=""
+ return 2
+ fi
+ if probe_native_repository_loopback "$port" "$candidate_dir" >/dev/null 2>&1; then
+ ready=1
+ break
+ fi
+ sleep 0.2
+ done
+ if [[ "$ready" != "1" ]]; then
+ echo "FIELD_NATIVE_REPOSITORY_SOURCE_ERROR client=$client reason=local_server_not_ready_unprivileged cache_key=$content_key bind_host=$bind_host" >&2
+ cat "$server_log" >&2 2>/dev/null || true
+ kill "$NIAKVIO_LOCAL_REPOSITORY_PID" 2>/dev/null || true
+ rm -rf "$serve_root"
+ NIAKVIO_LOCAL_REPOSITORY_PID=""
+ return 2
+ fi
+
+ NIAKVIO_RESOLVED_MANIFEST_URL="http://${device_host}:${port}/${candidate_dir}/manifest.json"
+ NIAKVIO_RESOLVED_ALLOW_LOCAL="1"
+ echo "FIELD_NATIVE_REPOSITORY_SOURCE client=$client mode=local_candidate local=1 port=$port bind_host=$bind_host cache_key=$content_key observational=true privileged=false"
+}
+
+cleanup_native_repository() {
+ if [[ -n "${NIAKVIO_LOCAL_REPOSITORY_PID:-}" ]]; then
+ kill "$NIAKVIO_LOCAL_REPOSITORY_PID" 2>/dev/null || true
+ wait "$NIAKVIO_LOCAL_REPOSITORY_PID" 2>/dev/null || true
+ NIAKVIO_LOCAL_REPOSITORY_PID=""
+ fi
+ if [[ -n "${NIAKVIO_LOCAL_REPOSITORY_LOG:-}" ]]; then
+ rm -f "$NIAKVIO_LOCAL_REPOSITORY_LOG"
+ fi
+ if [[ -n "${NIAKVIO_LOCAL_REPOSITORY_ROOT:-}" ]]; then
+ rm -rf "$NIAKVIO_LOCAL_REPOSITORY_ROOT"
+ NIAKVIO_LOCAL_REPOSITORY_ROOT=""
+ fi
+}
diff --git a/scripts/resolve_nuvio_lab_heads.py b/scripts/resolve_nuvio_lab_heads.py
new file mode 100644
index 000000000..10fa2c352
--- /dev/null
+++ b/scripts/resolve_nuvio_lab_heads.py
@@ -0,0 +1,105 @@
+#!/usr/bin/env python3
+"""Resolve exact latest official Nuvio client revisions for native Labs.
+
+The upstream drift checker remains the single authority for contract/semantic audit.
+This helper only turns its report into fail-closed workflow outputs: Labs run the
+current official branch HEAD, while contract_ref and accepted_ref remain attached as
+audit context. An unresolved HEAD is never silently replaced by an older accepted ref.
+"""
+from __future__ import annotations
+
+import argparse
+import json
+from pathlib import Path
+from typing import Any
+
+CLIENT_OUTPUTS = {
+ "nuvio-tv": "tv",
+ "nuvio-mobile": "mobile",
+ "nuvio-desktop": "desktop",
+}
+
+
+def load(path: Path) -> dict[str, Any]:
+ value = json.loads(path.read_text(encoding="utf-8"))
+ if not isinstance(value, dict):
+ raise ValueError(f"{path}: expected JSON object")
+ return value
+
+
+def safe_sha(value: Any, label: str) -> str:
+ text = str(value or "").strip().lower()
+ if len(text) != 40 or any(ch not in "0123456789abcdef" for ch in text):
+ raise ValueError(f"{label}: latest official HEAD is unresolved ({text!r}); refusing stale fallback")
+ return text
+
+
+def clean(value: Any, limit: int = 128) -> str:
+ text = str(value or "").strip().replace("\n", " ").replace("\r", " ")
+ return text[:limit]
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--report", type=Path, required=True)
+ parser.add_argument("--clients", nargs="+", choices=sorted(CLIENT_OUTPUTS), required=True)
+ parser.add_argument("--github-output", type=Path)
+ parser.add_argument("--output", type=Path)
+ args = parser.parse_args()
+
+ report = load(args.report)
+ clients = report.get("clients") if isinstance(report.get("clients"), dict) else {}
+ outputs: dict[str, str] = {}
+ fingerprint_parts: list[str] = []
+ resolved: dict[str, Any] = {}
+
+ for client_id in args.clients:
+ row = clients.get(client_id)
+ if not isinstance(row, dict):
+ raise ValueError(f"{client_id}: missing from upstream drift report")
+ prefix = CLIENT_OUTPUTS[client_id]
+ head = safe_sha(row.get("current_head"), client_id)
+ accepted = clean(row.get("accepted_ref"), 40)
+ contract = clean(row.get("contract_ref") or row.get("verified_ref"), 40)
+ status = clean(row.get("status"), 96) or "unknown"
+ outputs[f"{prefix}_sha"] = head
+ outputs[f"{prefix}_accepted_ref"] = accepted
+ outputs[f"{prefix}_contract_ref"] = contract
+ outputs[f"{prefix}_drift_status"] = status
+ fingerprint_parts.append(f"{prefix}={head}")
+ resolved[client_id] = {
+ "head": head,
+ "acceptedRef": accepted or None,
+ "contractRef": contract or None,
+ "driftStatus": status,
+ "reviewRequired": bool(row.get("review_required")),
+ }
+
+ fingerprint = ";".join(fingerprint_parts)
+ outputs["runtime_fingerprint"] = fingerprint
+ payload = {
+ "schemaVersion": 1,
+ "policy": "latest-official-head-for-labs; accepted/contract refs retained for audit only; no stale fallback",
+ "runtimeFingerprint": fingerprint,
+ "clients": resolved,
+ }
+
+ if args.output:
+ args.output.parent.mkdir(parents=True, exist_ok=True)
+ args.output.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
+ if args.github_output:
+ with args.github_output.open("a", encoding="utf-8") as handle:
+ for key, value in outputs.items():
+ handle.write(f"{key}={value}\n")
+
+ for client_id, row in resolved.items():
+ print(
+ f"FIELD_NUVIO_LAB_HEAD client={client_id} head={row['head'][:12]} "
+ f"accepted={(row['acceptedRef'] or '-')[:12]} contract={(row['contractRef'] or '-')[:12]} "
+ f"status={row['driftStatus']}"
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/restage_native_corpus_client.py b/scripts/restage_native_corpus_client.py
index f1da20733..ebea6914c 100644
--- a/scripts/restage_native_corpus_client.py
+++ b/scripts/restage_native_corpus_client.py
@@ -3,17 +3,61 @@
from __future__ import annotations
import argparse
+import json
+import os
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts"))
import prepare_native_corpus_validation as corpus # noqa: E402
+import prepare_native_corpus_client as selected_manifest # noqa: E402
+import native_player_diagnostics_codegen as reader_diag # noqa: E402
+CORPUS_PATH = ROOT / ".github/triggers/nuvio-client-lab.json"
+DEFAULT_PR_PROVIDER_LIMIT = 4
-def staged_providers() -> list[dict]:
- providers = corpus.manifest_providers()
- return [{**provider, "asset": f"p{index:03d}.js"} for index, provider in enumerate(providers)]
+
+def _is_pull_request() -> bool:
+ return os.environ.get("GITHUB_EVENT_NAME", "").strip().lower() == "pull_request"
+
+
+def _pr_provider_limit() -> int:
+ raw = os.environ.get("NIAKVIO_PR_PROVIDER_LIMIT", str(DEFAULT_PR_PROVIDER_LIMIT)).strip()
+ try:
+ return max(1, min(int(raw), 12))
+ except ValueError:
+ return DEFAULT_PR_PROVIDER_LIMIT
+
+
+def _fixture_provider_ids(slug: str) -> list[str]:
+ data = json.loads(CORPUS_PATH.read_text(encoding="utf-8"))
+ for row in data.get("fixtures", []):
+ if not isinstance(row, dict) or str(row.get("slug") or "") != slug:
+ continue
+ providers = row.get("providers")
+ if not isinstance(providers, list):
+ break
+ return [str(value).strip() for value in providers if str(value).strip()]
+ raise SystemExit(f"unknown or malformed native corpus fixture: {slug}")
+
+
+def staged_providers(manifest_path: str, provider: str | None = None, fixture: str | None = None) -> list[dict]:
+ staged = selected_manifest.staged_manifest_providers(manifest_path)
+ if _is_pull_request() and not provider and fixture:
+ # The fixture list is an ordered canary contract. Preserve that order
+ # before applying the PR budget; filtering through a set and then keeping
+ # manifest order silently changes which providers the native reader tests.
+ wanted = _fixture_provider_ids(fixture)
+ by_id = {str(row.get("id") or "").strip().casefold(): row for row in staged}
+ filtered = [by_id[key] for value in wanted if (key := value.casefold()) in by_id]
+ if not filtered:
+ raise SystemExit(f"PR native corpus fixture {fixture} selected no providers from {manifest_path}")
+ return filtered[: _pr_provider_limit()]
+ try:
+ return reader_diag.filter_staged_providers(staged, provider)
+ except ValueError as error:
+ raise SystemExit(str(error)) from error
def collector_test(source: str, client: str) -> str:
@@ -33,10 +77,16 @@ def main() -> int:
parser.add_argument("target", choices=("desktop", "mobile", "tv"))
parser.add_argument("--fixture", required=True)
parser.add_argument("--workspace", required=True)
+ parser.add_argument("--provider", default="", help="optional exact provider id for a targeted human-style run")
+ parser.add_argument("--player-probes", type=int, default=1, help="number of returned streams played by the native reader (1-4)")
+ parser.add_argument("--manifest", default="manifest.json", help="same in-repository manifest used during initial preparation")
args = parser.parse_args()
fixture = corpus.fixture_by_slug(args.fixture)
- providers = staged_providers()
+ provider = args.provider.strip() or None
+ manifest_path = str(selected_manifest._manifest_path(args.manifest).relative_to(ROOT))
+ providers = staged_providers(manifest_path, provider, args.fixture)
workspace = Path(args.workspace).resolve()
+ probes = max(1, min(args.player_probes, 4))
if args.target == "desktop":
target = workspace / "nuvio-desktop/composeApp/src/desktopTest/kotlin/com/nuvio/app/features/plugins/NiakvioNativeCorpusDesktopTest.kt"
@@ -44,13 +94,31 @@ def main() -> int:
elif args.target == "mobile":
target = workspace / "nuvio-mobile/composeApp/src/androidDeviceTest/kotlin/com/nuvio/app/features/plugins/NiakvioNativeCorpusMobileTest.kt"
source = corpus.android_test(fixture, providers, "mobile")
+ source = reader_diag.augment_android_test(
+ source,
+ client="mobile",
+ expected_duration_minutes=fixture.get("expectedDurationMinutes"),
+ max_player_probes=probes,
+ )
else:
target = workspace / "nuvio-tv/app/src/androidTest/java/com/nuvio/tv/core/plugin/NiakvioNativeCorpusTvTest.kt"
source = corpus.android_test(fixture, providers, "tv")
+ source = reader_diag.augment_android_test(
+ source,
+ client="tv",
+ expected_duration_minutes=fixture.get("expectedDurationMinutes"),
+ max_player_probes=probes,
+ )
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(collector_test(source, args.target), encoding="utf-8")
- print(f"FIELD_NATIVE_CORPUS_RESTAGED_ISOLATED target={args.target} fixture={args.fixture} tmdb={fixture.get('tmdbId')}")
+ pr_bounded = _is_pull_request() and provider is None
+ print(
+ f"FIELD_NATIVE_CORPUS_RESTAGED_ISOLATED target={args.target} fixture={args.fixture} "
+ f"tmdb={fixture.get('tmdbId')} provider={provider or ('fixture' if pr_bounded else 'all')} "
+ f"providers={len(providers)} player_probes={probes} manifest={manifest_path} "
+ f"ci_mode={'pr-bounded' if pr_bounded else 'deep'} provider_limit={_pr_provider_limit() if pr_bounded else 0}"
+ )
return 0
diff --git a/scripts/run_native_corpus_desktop_suite.sh b/scripts/run_native_corpus_desktop_suite.sh
new file mode 100644
index 000000000..0b7229317
--- /dev/null
+++ b/scripts/run_native_corpus_desktop_suite.sh
@@ -0,0 +1,143 @@
+#!/usr/bin/env bash
+set -u
+
+if [[ "${GITHUB_EVENT_NAME:-}" = "pull_request" ]]; then
+ export NIAKVIO_PR_PROVIDER_LIMIT="${NIAKVIO_PR_PROVIDER_LIMIT:-8}"
+fi
+
+WORKSPACE="${GITHUB_WORKSPACE}"
+NIAKVIO="${WORKSPACE}/niakvio"
+DESKTOP_ROOT="${WORKSPACE}/nuvio-desktop"
+RESTAGE="${NIAKVIO}/scripts/restage_native_corpus_client.py"
+ANALYZER="${NIAKVIO}/scripts/analyze_native_corpus_collection.cjs"
+READER_GATE="${NIAKVIO}/scripts/gate_native_reader_result.cjs"
+COVERAGE_GATE="${NIAKVIO}/scripts/gate_native_reader_coverage.cjs"
+SMOKE_GATE="${NIAKVIO}/scripts/gate_native_player_reached.cjs"
+INSTRUMENTER="${NIAKVIO}/scripts/instrument_native_desktop_evidence.py"
+REPOSITORY_HTTP_INSTRUMENTER="${NIAKVIO}/scripts/instrument_native_repository_http_evidence.py"
+REQUEST_CONTRACT="${NIAKVIO}/scripts/augment_native_corpus_request_contract.py"
+PROVIDER_LOADING="${NIAKVIO}/scripts/augment_native_provider_loading.py"
+REPOSITORY_RESOLVER="${NIAKVIO}/scripts/resolve_native_repository.sh"
+PLAYER_AUGMENT="${NIAKVIO}/scripts/augment_native_desktop_player.py"
+FRONTEND_PHASES="${NIAKVIO}/scripts/complete_native_desktop_frontend_phases.py"
+TEST_SOURCE="${DESKTOP_ROOT}/composeApp/src/desktopTest/kotlin/com/nuvio/app/features/plugins/NiakvioNativeCorpusDesktopTest.kt"
+DEFAULT_FIXTURES=(sinners-2025 interstellar mon-ninja-et-moi-3 breaking-bad-s01e01 revenant-s01e01 jujutsu-kaisen-s01e01 mushoku-tensei-s01e01)
+TARGET_FIXTURE="${NIAKVIO_TARGET_FIXTURE:-}"
+TARGET_PROVIDER="${NIAKVIO_TARGET_PROVIDER:-all}"
+TARGET_MANIFEST="${NIAKVIO_TARGET_MANIFEST:-manifest.json}"
+PRIMARY_FIXTURE="${NIAKVIO_PRIMARY_FIXTURE:-sinners-2025}"
+PRIMARY_STREAM_SCOPE="${NIAKVIO_PRIMARY_STREAM_SCOPE:-all}"
+REGRESSION_STREAM_SCOPE="${NIAKVIO_REGRESSION_STREAM_SCOPE:-2}"
+REQUESTED_READER_SUCCESS="${NIAKVIO_REQUIRE_READER_SUCCESS:-0}"
+PLAYER_OUTCOME_GLOBAL_GATE="${NIAKVIO_NATIVE_PLAYER_OUTCOME_GLOBAL_GATE:-0}"
+REQUIRE_READER_SUCCESS=0
+if [[ "$PLAYER_OUTCOME_GLOBAL_GATE" = "1" && "$REQUESTED_READER_SUCCESS" = "1" ]]; then
+ REQUIRE_READER_SUCCESS=1
+fi
+SOURCE_SHA="${NIAKVIO_SOURCE_SHA:-$(git -C "$NIAKVIO" rev-parse HEAD)}"
+SOURCE_REPOSITORY="${GITHUB_REPOSITORY:-niakw/NiakVIO}"
+source "$REPOSITORY_RESOLVER"
+resolve_native_repository desktop 127.0.0.1 18767 || exit $?
+trap cleanup_native_repository EXIT
+MANIFEST_URL="$NIAKVIO_RESOLVED_MANIFEST_URL"
+ALLOW_LOCAL_MANIFEST="$NIAKVIO_RESOLVED_ALLOW_LOCAL"
+
+case "$(uname -s)" in
+ Darwin) HOST_OS="macos" ;;
+ MINGW*|MSYS*|CYGWIN*) HOST_OS="windows" ;;
+ *) echo "FIELD_NATIVE_DESKTOP_READER_UNSUPPORTED os=$(uname -s) reason=official_nuvio_desktop_player_is_stub" >&2; exit 96 ;;
+esac
+
+if [[ "$(id -u)" == "0" ]]; then
+ echo "FIELD_NATIVE_DESKTOP_READER_INFRA_ERROR os=$HOST_OS reason=root_execution_forbidden" >&2
+ exit 97
+fi
+
+if [[ -n "$TARGET_FIXTURE" && "$TARGET_FIXTURE" != "all" ]]; then
+ FIXTURES=("$TARGET_FIXTURE")
+else
+ FIXTURES=("${DEFAULT_FIXTURES[@]}")
+fi
+
+python3 "$INSTRUMENTER" "$DESKTOP_ROOT" || exit $?
+python3 "$REPOSITORY_HTTP_INSTRUMENTER" desktop "$DESKTOP_ROOT" || exit $?
+if [[ -n "${GITHUB_ENV:-}" ]]; then echo "NIAKVIO_BRAIN_NONBLOCKING=1" >> "$GITHUB_ENV"; fi
+
+SOFT_FAILURES=0
+
+echo "FIELD_NATIVE_CORPUS_DESKTOP_PROFILE os=$HOST_OS fixtures=${#FIXTURES[@]} provider=${TARGET_PROVIDER:-all} manifest=$TARGET_MANIFEST primary_stream_scope=$PRIMARY_STREAM_SCOPE regression_stream_scope=$REGRESSION_STREAM_SCOPE requested_reader_success=$REQUESTED_READER_SUCCESS require_reader_success=$REQUIRE_READER_SUCCESS player_outcome_global_gate=$PLAYER_OUTCOME_GLOBAL_GATE official_player=production_path official_repository_loading=true repository_http_evidence=true local_manifest=$ALLOW_LOCAL_MANIFEST observational=true privilege=ordinary-user smoke_gate=player_reached pr_provider_limit=${NIAKVIO_PR_PROVIDER_LIMIT:-default}"
+for fixture in "${FIXTURES[@]}"; do
+ STREAM_SCOPE="$REGRESSION_STREAM_SCOPE"
+ if [[ "$fixture" = "$PRIMARY_FIXTURE" ]]; then STREAM_SCOPE="$PRIMARY_STREAM_SCOPE"; fi
+ echo "===== DESKTOP NATIVE READER FIXTURE: $fixture ($HOST_OS) ====="
+
+ if [[ -n "$TARGET_PROVIDER" && "$TARGET_PROVIDER" != "all" && "$TARGET_PROVIDER" != "fixture" ]]; then
+ python3 "$RESTAGE" desktop --fixture "$fixture" --workspace "$WORKSPACE" --provider "$TARGET_PROVIDER" --manifest "$TARGET_MANIFEST" || { SOFT_FAILURES=$((SOFT_FAILURES+1)); continue; }
+ else
+ python3 "$RESTAGE" desktop --fixture "$fixture" --workspace "$WORKSPACE" --manifest "$TARGET_MANIFEST" || { SOFT_FAILURES=$((SOFT_FAILURES+1)); continue; }
+ fi
+ python3 "$REQUEST_CONTRACT" desktop --fixture "$fixture" --manifest "$TARGET_MANIFEST" --source "$TEST_SOURCE" || { SOFT_FAILURES=$((SOFT_FAILURES+1)); continue; }
+ if [[ "$ALLOW_LOCAL_MANIFEST" = "1" ]]; then
+ python3 "$PROVIDER_LOADING" desktop --manifest "$TARGET_MANIFEST" --manifest-url "$MANIFEST_URL" --source "$TEST_SOURCE" --platform "$HOST_OS" --allow-local-lab-url || { SOFT_FAILURES=$((SOFT_FAILURES+1)); continue; }
+ else
+ python3 "$PROVIDER_LOADING" desktop --manifest "$TARGET_MANIFEST" --manifest-url "$MANIFEST_URL" --source "$TEST_SOURCE" --platform "$HOST_OS" || { SOFT_FAILURES=$((SOFT_FAILURES+1)); continue; }
+ fi
+ EXPECTED_MINUTES="$(python3 - "$fixture" "$NIAKVIO/.github/triggers/nuvio-client-lab.json" <<'PY'
+import json, sys
+slug, path = sys.argv[1], sys.argv[2]
+data = json.load(open(path, encoding='utf-8'))
+for row in data.get('fixtures', []):
+ if row.get('slug') == slug:
+ print(int((row.get('fixture') or {}).get('expectedDurationMinutes') or 0))
+ break
+else:
+ raise SystemExit(f'fixture not found: {slug}')
+PY
+)" || { SOFT_FAILURES=$((SOFT_FAILURES+1)); continue; }
+ python3 "$PLAYER_AUGMENT" --source "$TEST_SOURCE" --expected-minutes "$EXPECTED_MINUTES" --streams "$STREAM_SCOPE" || { SOFT_FAILURES=$((SOFT_FAILURES+1)); continue; }
+ python3 "$FRONTEND_PHASES" "$TEST_SOURCE" || { SOFT_FAILURES=$((SOFT_FAILURES+1)); continue; }
+
+ BASE_LOG="${WORKSPACE}/desktop-native-corpus-${fixture}.log"
+ LOG="${WORKSPACE}/desktop-native-corpus-${HOST_OS}-${fixture}.log"
+ GRADLE_LOG="${WORKSPACE}/desktop-native-gradle-${HOST_OS}-${fixture}.log"
+ HTTP_LOG="${WORKSPACE}/desktop-native-http-evidence.log"
+ rm -f "$BASE_LOG" "$LOG" "$GRADLE_LOG" "$HTTP_LOG"
+ RUNTIME_STATUS=0
+ if [[ "$HOST_OS" = "windows" ]]; then
+ "$DESKTOP_ROOT/gradlew.bat" -p "$DESKTOP_ROOT" :composeApp:desktopTest --tests 'com.nuvio.app.features.plugins.NiakvioNativeCorpusDesktopTest' --console=plain 2>&1 | tee "$GRADLE_LOG"
+ RUNTIME_STATUS=${PIPESTATUS[0]}
+ else
+ "$DESKTOP_ROOT/gradlew" -p "$DESKTOP_ROOT" :composeApp:desktopTest --tests 'com.nuvio.app.features.plugins.NiakvioNativeCorpusDesktopTest' --console=plain 2>&1 | tee "$GRADLE_LOG"
+ RUNTIME_STATUS=${PIPESTATUS[0]}
+ fi
+
+ if [[ -s "$BASE_LOG" ]]; then cp "$BASE_LOG" "$LOG"; else : > "$LOG"; fi
+ if [[ -s "$HTTP_LOG" ]]; then cat "$HTTP_LOG" >> "$LOG"; fi
+ rm -f "$HTTP_LOG" "$GRADLE_LOG"
+ echo "FIELD_NATIVE_EVIDENCE_INSTRUMENTED client=desktop" >> "$LOG"
+ cat "$LOG" || true
+
+ ANALYSIS_STATUS=0
+ node "$ANALYZER" "$fixture" "$LOG" || ANALYSIS_STATUS=$?
+ COVERAGE_STATUS=0
+ node "$COVERAGE_GATE" --streams "$STREAM_SCOPE" "$LOG" || COVERAGE_STATUS=$?
+ OBSERVED_READER_STATUS=0
+ node "$READER_GATE" "$LOG" || OBSERVED_READER_STATUS=$?
+ if [[ "$RUNTIME_STATUS" -ne 0 || "$ANALYSIS_STATUS" -ne 0 || "$COVERAGE_STATUS" -ne 0 || "$OBSERVED_READER_STATUS" -ne 0 ]]; then
+ SOFT_FAILURES=$((SOFT_FAILURES+1))
+ fi
+ echo "FIELD_NATIVE_CORPUS_DESKTOP_STATUS os=$HOST_OS fixture=$fixture runtime=$RUNTIME_STATUS collection=$ANALYSIS_STATUS coverage=$COVERAGE_STATUS reader_observed=$OBSERVED_READER_STATUS blocking=false stream_scope=$STREAM_SCOPE"
+done
+
+for fixture in "${FIXTURES[@]}"; do
+ LOG="${WORKSPACE}/desktop-native-corpus-${HOST_OS}-${fixture}.log"
+ if [[ ! -s "$LOG" ]]; then
+ printf 'FIELD_NATIVE_SMOKE_DIAGNOSTIC_PLACEHOLDER client=desktop fixture=%s reason=no_route_log\n' "$fixture" > "$LOG"
+ fi
+done
+
+LOGS=("${WORKSPACE}"/desktop-native-corpus-${HOST_OS}-*.log)
+SMOKE_STATUS=0
+node "$SMOKE_GATE" "${LOGS[@]}" || SMOKE_STATUS=$?
+echo "FIELD_NATIVE_CORPUS_DESKTOP_SUITE_STATUS os=$HOST_OS status=$SMOKE_STATUS soft_failures=$SOFT_FAILURES fixtures=${#FIXTURES[@]} provider=${TARGET_PROVIDER:-all} manifest=$TARGET_MANIFEST gate=production_player_reached"
+exit "$SMOKE_STATUS"
diff --git a/scripts/run_native_corpus_mobile_suite.sh b/scripts/run_native_corpus_mobile_suite.sh
index 8321803a0..9621542b4 100644
--- a/scripts/run_native_corpus_mobile_suite.sh
+++ b/scripts/run_native_corpus_mobile_suite.sh
@@ -1,40 +1,158 @@
#!/usr/bin/env bash
set -u
+if [[ "${GITHUB_EVENT_NAME:-}" = "pull_request" ]]; then
+ export NIAKVIO_PR_PROVIDER_LIMIT="${NIAKVIO_PR_PROVIDER_LIMIT:-8}"
+fi
+
WORKSPACE="${GITHUB_WORKSPACE}"
NIAKVIO="${WORKSPACE}/niakvio"
MOBILE_ROOT="${WORKSPACE}/nuvio-mobile"
ANALYZER="${NIAKVIO}/scripts/analyze_native_corpus_collection.cjs"
+READER_GATE="${NIAKVIO}/scripts/gate_native_reader_result.cjs"
+COVERAGE_GATE="${NIAKVIO}/scripts/gate_native_reader_coverage.cjs"
+SMOKE_GATE="${NIAKVIO}/scripts/gate_native_player_reached.cjs"
RESTAGE="${NIAKVIO}/scripts/restage_native_corpus_client.py"
-FIXTURES=(interstellar mon-ninja-et-moi-3 breaking-bad-s01e01 revenant-s01e01 jujutsu-kaisen-s01e01 mushoku-tensei-s01e01)
-STATUS=0
+ACCEPTANCE_PREPARE="${NIAKVIO}/scripts/prepare_native_reader_acceptance.py"
+INSTRUMENTER="${NIAKVIO}/scripts/instrument_native_client_evidence.py"
+REPOSITORY_HTTP_INSTRUMENTER="${NIAKVIO}/scripts/instrument_native_repository_http_evidence.py"
+REQUEST_CONTRACT="${NIAKVIO}/scripts/augment_native_corpus_request_contract.py"
+PROVIDER_LOADING="${NIAKVIO}/scripts/augment_native_provider_loading.py"
+REPOSITORY_RESOLVER="${NIAKVIO}/scripts/resolve_native_repository.sh"
+LAB_TRANSPORT="${NIAKVIO}/scripts/configure_native_android_lab_transport.py"
+FRONTEND_CAPTURE="${NIAKVIO}/scripts/capture_native_device_frontend.sh"
+FRONTEND_WATCHER="${NIAKVIO}/scripts/watch_native_device_frontend.sh"
+EVIDENCE_ROOT="${WORKSPACE}/native-evidence/mobile"
+TEST_SOURCE="${MOBILE_ROOT}/composeApp/src/androidDeviceTest/kotlin/com/nuvio/app/features/plugins/NiakvioNativeCorpusMobileTest.kt"
+DEFAULT_FIXTURES=(sinners-2025 interstellar mon-ninja-et-moi-3 breaking-bad-s01e01 revenant-s01e01 jujutsu-kaisen-s01e01 mushoku-tensei-s01e01)
+TARGET_FIXTURE="${NIAKVIO_TARGET_FIXTURE:-}"
+TARGET_FIXTURES="${NIAKVIO_TARGET_FIXTURES:-}"
+TARGET_PROVIDER="${NIAKVIO_TARGET_PROVIDER:-}"
+TARGET_MANIFEST="${NIAKVIO_TARGET_MANIFEST:-manifest.json}"
+PLAYER_PROBES="${NIAKVIO_PLAYER_PROBES:-1}"
+REQUESTED_READER_SUCCESS="${NIAKVIO_REQUIRE_READER_SUCCESS:-0}"
+PLAYER_OUTCOME_GLOBAL_GATE="${NIAKVIO_NATIVE_PLAYER_OUTCOME_GLOBAL_GATE:-0}"
+REQUIRE_READER_SUCCESS=0
+if [[ "$PLAYER_OUTCOME_GLOBAL_GATE" = "1" && "$REQUESTED_READER_SUCCESS" = "1" ]]; then
+ REQUIRE_READER_SUCCESS=1
+fi
+READER_ACCEPTANCE="${NIAKVIO_READER_ACCEPTANCE:-0}"
+PRIMARY_FIXTURE="${NIAKVIO_PRIMARY_FIXTURE:-sinners-2025}"
+PRIMARY_STREAM_SCOPE="${NIAKVIO_PRIMARY_STREAM_SCOPE:-all}"
+REGRESSION_STREAM_SCOPE="${NIAKVIO_REGRESSION_STREAM_SCOPE:-2}"
+SOURCE_SHA="${NIAKVIO_SOURCE_SHA:-$(git -C "$NIAKVIO" rev-parse HEAD)}"
+SOURCE_REPOSITORY="${GITHUB_REPOSITORY:-niakw/NiakVIO}"
+source "$REPOSITORY_RESOLVER"
+resolve_native_repository mobile 10.0.2.2 18766 || exit $?
+trap cleanup_native_repository EXIT
+MANIFEST_URL="$NIAKVIO_RESOLVED_MANIFEST_URL"
+ALLOW_LOCAL_MANIFEST="$NIAKVIO_RESOLVED_ALLOW_LOCAL"
+PROVIDER_LOADING_URL_ARGS=()
+if [[ "$ALLOW_LOCAL_MANIFEST" = "1" ]]; then PROVIDER_LOADING_URL_ARGS+=(--allow-local-lab-url); fi
+CONFIGURED_ACCEPTANCE_PROVIDER_SCOPE="$(python3 - <<'PY' 2>/dev/null || true
+import json
+from pathlib import Path
+path = Path(__import__('os').environ['GITHUB_WORKSPACE']) / 'niakvio/.github/triggers/nuvio-client-lab.json'
+try:
+ data = json.loads(path.read_text(encoding='utf-8'))
+ print(str((data.get('native_reader_acceptance') or {}).get('provider_scope') or 'fixture'))
+except Exception:
+ print('fixture')
+PY
+)"
+CONFIGURED_ACCEPTANCE_PROVIDER_SCOPE="${CONFIGURED_ACCEPTANCE_PROVIDER_SCOPE:-fixture}"
+if [[ -n "$TARGET_FIXTURES" ]]; then
+ FIXTURES=()
+ for fixture in $TARGET_FIXTURES; do FIXTURES+=("$fixture"); done
+elif [[ -n "$TARGET_FIXTURE" && "$TARGET_FIXTURE" != "all" ]]; then
+ FIXTURES=("$TARGET_FIXTURE")
+else
+ FIXTURES=("${DEFAULT_FIXTURES[@]}")
+fi
+SOFT_FAILURES=0
+PROVIDER_ARGS=()
+if [[ -n "$TARGET_PROVIDER" && "$TARGET_PROVIDER" != "all" && "$TARGET_PROVIDER" != "fixture" ]]; then PROVIDER_ARGS=(--provider "$TARGET_PROVIDER"); fi
+
+python3 "$LAB_TRANSPORT" "$MOBILE_ROOT/composeApp/src/androidDeviceTest/AndroidManifest.xml" || exit $?
+python3 "$INSTRUMENTER" mobile "$MOBILE_ROOT" || exit $?
+python3 "$REPOSITORY_HTTP_INSTRUMENTER" mobile "$MOBILE_ROOT" || exit $?
+if [[ -n "${GITHUB_ENV:-}" ]]; then echo "NIAKVIO_BRAIN_NONBLOCKING=1" >> "$GITHUB_ENV"; fi
tasks=$("$MOBILE_ROOT/gradlew" -p "$MOBILE_ROOT" :composeApp:tasks --all -Pnuvio.android.distribution=full --console=plain) || exit $?
MOBILE_TASK=$(printf '%s\n' "$tasks" | awk 'tolower($1) ~ /connected.*device.*test/ {print $1; exit}')
-if [[ -z "${MOBILE_TASK:-}" ]]; then
- MOBILE_TASK=$(printf '%s\n' "$tasks" | awk 'tolower($1) ~ /device.*test/ && tolower($0) ~ /connected/ {print $1; exit}')
-fi
-if [[ -z "${MOBILE_TASK:-}" ]]; then
- echo "Unable to resolve NuvioMobile connected device-test task" >&2
- exit 97
+if [[ -z "${MOBILE_TASK:-}" ]]; then MOBILE_TASK=$(printf '%s\n' "$tasks" | awk 'tolower($1) ~ /device.*test/ && tolower($0) ~ /connected/ {print $1; exit}'); fi
+if [[ -z "${MOBILE_TASK:-}" ]]; then echo "Unable to resolve NuvioMobile connected device-test task" >&2; exit 97; fi
+
+"$MOBILE_ROOT/gradlew" -p "$MOBILE_ROOT" :androidApp:installFullDebug -Pnuvio.android.distribution=full --console=plain || exit $?
+if ! adb shell pm path com.nuviodebug.com >/dev/null 2>&1; then
+ echo "Official NuvioMobile debug application com.nuviodebug.com is not installed" >&2
+ exit 98
fi
+echo "FIELD_NATIVE_MOBILE_APP_INSTALLED package=com.nuviodebug.com variant=fullDebug"
+
+mkdir -p "$EVIDENCE_ROOT"
echo "Resolved NuvioMobile task once for corpus suite: $MOBILE_TASK"
+echo "FIELD_NATIVE_CORPUS_MOBILE_PROFILE fixtures=${#FIXTURES[@]} provider=${TARGET_PROVIDER:-all} configured_acceptance_provider_scope=$CONFIGURED_ACCEPTANCE_PROVIDER_SCOPE manifest=$TARGET_MANIFEST player_probes=$PLAYER_PROBES requested_reader_success=$REQUESTED_READER_SUCCESS require_reader_success=$REQUIRE_READER_SUCCESS player_outcome_global_gate=$PLAYER_OUTCOME_GLOBAL_GATE reader_acceptance=$READER_ACCEPTANCE primary_stream_scope=$PRIMARY_STREAM_SCOPE regression_stream_scope=$REGRESSION_STREAM_SCOPE reuse_avd=true reuse_gradle_daemon=true full_backend_evidence=true repository_http_evidence=true frontend_timeline=true official_repository_loading=true local_manifest=$ALLOW_LOCAL_MANIFEST smoke_gate=player_reached pr_provider_limit=${NIAKVIO_PR_PROVIDER_LIMIT:-default}"
for fixture in "${FIXTURES[@]}"; do
echo "===== MOBILE CORPUS FIXTURE: $fixture ====="
- python3 "$RESTAGE" mobile --fixture "$fixture" --workspace "$WORKSPACE" || { STATUS=1; continue; }
+ STREAM_SCOPE="$REGRESSION_STREAM_SCOPE"
+ if [[ "$fixture" = "$PRIMARY_FIXTURE" ]]; then STREAM_SCOPE="$PRIMARY_STREAM_SCOPE"; fi
+ if [[ "$READER_ACCEPTANCE" = "1" ]]; then
+ PROVIDER_SCOPE="$TARGET_PROVIDER"
+ if [[ -z "$PROVIDER_SCOPE" || "$PROVIDER_SCOPE" = "fixture" ]]; then PROVIDER_SCOPE="$CONFIGURED_ACCEPTANCE_PROVIDER_SCOPE"; fi
+ if [[ -z "$PROVIDER_SCOPE" ]]; then PROVIDER_SCOPE="fixture"; fi
+ python3 "$ACCEPTANCE_PREPARE" mobile --fixture "$fixture" --workspace "$WORKSPACE" --provider "$PROVIDER_SCOPE" --streams "$STREAM_SCOPE" --manifest "$TARGET_MANIFEST" || { SOFT_FAILURES=$((SOFT_FAILURES+1)); continue; }
+ else
+ python3 "$RESTAGE" mobile --fixture "$fixture" --workspace "$WORKSPACE" "${PROVIDER_ARGS[@]}" --player-probes "$PLAYER_PROBES" --manifest "$TARGET_MANIFEST" || { SOFT_FAILURES=$((SOFT_FAILURES+1)); continue; }
+ fi
+
+ python3 "$REQUEST_CONTRACT" mobile --fixture "$fixture" --manifest "$TARGET_MANIFEST" --source "$TEST_SOURCE" || { SOFT_FAILURES=$((SOFT_FAILURES+1)); continue; }
+ python3 "$PROVIDER_LOADING" mobile --manifest "$TARGET_MANIFEST" --manifest-url "$MANIFEST_URL" --source "$TEST_SOURCE" "${PROVIDER_LOADING_URL_ARGS[@]}" || { SOFT_FAILURES=$((SOFT_FAILURES+1)); continue; }
+
+ FRONT_DIR="${EVIDENCE_ROOT}/${fixture}"
+ FRONT_LOG="${WORKSPACE}/mobile-native-frontend-${fixture}.log"
+ mkdir -p "$FRONT_DIR"
+ rm -f "$FRONT_LOG"
adb logcat -c || true
+ bash "$FRONTEND_WATCHER" mobile "$FRONT_DIR" "$FRONTEND_CAPTURE" > "$FRONT_LOG" 2>&1 &
+ WATCH_PID=$!
+
RUNTIME_STATUS=0
- "$MOBILE_ROOT/gradlew" -p "$MOBILE_ROOT" ":composeApp:$MOBILE_TASK" \
- -Pnuvio.android.distribution=full --no-daemon --console=plain || RUNTIME_STATUS=$?
+ "$MOBILE_ROOT/gradlew" -p "$MOBILE_ROOT" ":composeApp:$MOBILE_TASK" -Pnuvio.android.distribution=full --console=plain || RUNTIME_STATUS=$?
+ sleep 1
+ kill "$WATCH_PID" 2>/dev/null || true
+ wait "$WATCH_PID" 2>/dev/null || true
+
LOG="${WORKSPACE}/mobile-native-corpus-${fixture}.log"
- adb logcat -d -s NiakvioCorpus:I '*:S' > "$LOG" || true
+ adb logcat -d -v brief -s NiakvioCorpus:I NiakvioEvidence:I '*:S' > "$LOG" || true
+ echo "FIELD_NATIVE_EVIDENCE_INSTRUMENTED client=mobile" >> "$LOG"
+ cat "$FRONT_LOG" >> "$LOG" 2>/dev/null || true
cat "$LOG" || true
+
ANALYSIS_STATUS=0
node "$ANALYZER" "$fixture" "$LOG" || ANALYSIS_STATUS=$?
- echo "FIELD_NATIVE_CORPUS_MOBILE_STATUS fixture=$fixture runtime=$RUNTIME_STATUS collection=$ANALYSIS_STATUS"
- if [[ "$RUNTIME_STATUS" -ne 0 || "$ANALYSIS_STATUS" -ne 0 ]]; then STATUS=1; fi
+ COVERAGE_STATUS=0
+ if [[ "$READER_ACCEPTANCE" = "1" ]]; then
+ node "$COVERAGE_GATE" --streams "$STREAM_SCOPE" "$LOG" || COVERAGE_STATUS=$?
+ fi
+ OBSERVED_READER_STATUS=0
+ node "$READER_GATE" "$LOG" || OBSERVED_READER_STATUS=$?
+ if [[ "$RUNTIME_STATUS" -ne 0 || "$ANALYSIS_STATUS" -ne 0 || "$COVERAGE_STATUS" -ne 0 || "$OBSERVED_READER_STATUS" -ne 0 ]]; then
+ SOFT_FAILURES=$((SOFT_FAILURES+1))
+ fi
+ echo "FIELD_NATIVE_CORPUS_MOBILE_STATUS fixture=$fixture runtime=$RUNTIME_STATUS collection=$ANALYSIS_STATUS coverage=$COVERAGE_STATUS reader_observed=$OBSERVED_READER_STATUS blocking=false stream_scope=$STREAM_SCOPE frontend_dir=$FRONT_DIR"
+done
+
+for fixture in "${FIXTURES[@]}"; do
+ LOG="${WORKSPACE}/mobile-native-corpus-${fixture}.log"
+ if [[ ! -s "$LOG" ]]; then
+ printf 'FIELD_NATIVE_SMOKE_DIAGNOSTIC_PLACEHOLDER client=mobile fixture=%s reason=no_route_log\n' "$fixture" > "$LOG"
+ fi
done
-echo "FIELD_NATIVE_CORPUS_MOBILE_SUITE_STATUS status=$STATUS fixtures=${#FIXTURES[@]} clients=1"
-exit "$STATUS"
+LOGS=("${WORKSPACE}"/mobile-native-corpus-*.log)
+SMOKE_STATUS=0
+node "$SMOKE_GATE" "${LOGS[@]}" || SMOKE_STATUS=$?
+echo "FIELD_NATIVE_CORPUS_MOBILE_SUITE_STATUS status=$SMOKE_STATUS soft_failures=$SOFT_FAILURES fixtures=${#FIXTURES[@]} clients=1 provider=${TARGET_PROVIDER:-all} configured_acceptance_provider_scope=$CONFIGURED_ACCEPTANCE_PROVIDER_SCOPE manifest=$TARGET_MANIFEST gate=production_player_reached evidence_root=$EVIDENCE_ROOT"
+exit "$SMOKE_STATUS"
diff --git a/scripts/run_native_corpus_tv_suite.sh b/scripts/run_native_corpus_tv_suite.sh
index add105ec7..4c24dfe14 100644
--- a/scripts/run_native_corpus_tv_suite.sh
+++ b/scripts/run_native_corpus_tv_suite.sh
@@ -1,28 +1,147 @@
#!/usr/bin/env bash
+# PR reader trigger: route-list execution stays inside this shell so one emulator boot is reused.
set -u
+if [[ "${GITHUB_EVENT_NAME:-}" = "pull_request" ]]; then
+ export NIAKVIO_PR_PROVIDER_LIMIT="${NIAKVIO_PR_PROVIDER_LIMIT:-8}"
+fi
+
WORKSPACE="${GITHUB_WORKSPACE}"
NIAKVIO="${WORKSPACE}/niakvio"
TV_ROOT="${WORKSPACE}/nuvio-tv"
ANALYZER="${NIAKVIO}/scripts/analyze_native_corpus_collection.cjs"
+READER_GATE="${NIAKVIO}/scripts/gate_native_reader_result.cjs"
+COVERAGE_GATE="${NIAKVIO}/scripts/gate_native_reader_coverage.cjs"
+SMOKE_GATE="${NIAKVIO}/scripts/gate_native_player_reached.cjs"
RESTAGE="${NIAKVIO}/scripts/restage_native_corpus_client.py"
-FIXTURES=(interstellar mon-ninja-et-moi-3 breaking-bad-s01e01 revenant-s01e01 jujutsu-kaisen-s01e01 mushoku-tensei-s01e01)
-STATUS=0
+ACCEPTANCE_PREPARE="${NIAKVIO}/scripts/prepare_native_reader_acceptance.py"
+INSTRUMENTER="${NIAKVIO}/scripts/instrument_native_client_evidence.py"
+REPOSITORY_HTTP_INSTRUMENTER="${NIAKVIO}/scripts/instrument_native_repository_http_evidence.py"
+REQUEST_CONTRACT="${NIAKVIO}/scripts/augment_native_corpus_request_contract.py"
+PROVIDER_LOADING="${NIAKVIO}/scripts/augment_native_provider_loading.py"
+REPOSITORY_RESOLVER="${NIAKVIO}/scripts/resolve_native_repository.sh"
+LAB_TRANSPORT="${NIAKVIO}/scripts/configure_native_android_lab_transport.py"
+FRONTEND_CAPTURE="${NIAKVIO}/scripts/capture_native_device_frontend.sh"
+FRONTEND_WATCHER="${NIAKVIO}/scripts/watch_native_device_frontend.sh"
+EVIDENCE_ROOT="${WORKSPACE}/native-evidence/tv"
+TEST_SOURCE="${TV_ROOT}/app/src/androidTest/java/com/nuvio/tv/core/plugin/NiakvioNativeCorpusTvTest.kt"
+DEFAULT_FIXTURES=(sinners-2025 interstellar mon-ninja-et-moi-3 breaking-bad-s01e01 revenant-s01e01 jujutsu-kaisen-s01e01 mushoku-tensei-s01e01)
+TARGET_FIXTURE="${NIAKVIO_TARGET_FIXTURE:-}"
+TARGET_FIXTURES="${NIAKVIO_TARGET_FIXTURES:-}"
+TARGET_PROVIDER="${NIAKVIO_TARGET_PROVIDER:-}"
+TARGET_MANIFEST="${NIAKVIO_TARGET_MANIFEST:-manifest.json}"
+PLAYER_PROBES="${NIAKVIO_PLAYER_PROBES:-1}"
+REQUESTED_READER_SUCCESS="${NIAKVIO_REQUIRE_READER_SUCCESS:-0}"
+PLAYER_OUTCOME_GLOBAL_GATE="${NIAKVIO_NATIVE_PLAYER_OUTCOME_GLOBAL_GATE:-0}"
+REQUIRE_READER_SUCCESS=0
+if [[ "$PLAYER_OUTCOME_GLOBAL_GATE" = "1" && "$REQUESTED_READER_SUCCESS" = "1" ]]; then
+ REQUIRE_READER_SUCCESS=1
+fi
+READER_ACCEPTANCE="${NIAKVIO_READER_ACCEPTANCE:-0}"
+PRIMARY_FIXTURE="${NIAKVIO_PRIMARY_FIXTURE:-sinners-2025}"
+PRIMARY_STREAM_SCOPE="${NIAKVIO_PRIMARY_STREAM_SCOPE:-all}"
+REGRESSION_STREAM_SCOPE="${NIAKVIO_REGRESSION_STREAM_SCOPE:-2}"
+SOURCE_SHA="${NIAKVIO_SOURCE_SHA:-$(git -C "$NIAKVIO" rev-parse HEAD)}"
+SOURCE_REPOSITORY="${GITHUB_REPOSITORY:-niakw/NiakVIO}"
+source "$REPOSITORY_RESOLVER"
+resolve_native_repository tv 10.0.2.2 18765 || exit $?
+trap cleanup_native_repository EXIT
+MANIFEST_URL="$NIAKVIO_RESOLVED_MANIFEST_URL"
+ALLOW_LOCAL_MANIFEST="$NIAKVIO_RESOLVED_ALLOW_LOCAL"
+PROVIDER_LOADING_URL_ARGS=()
+if [[ "$ALLOW_LOCAL_MANIFEST" = "1" ]]; then PROVIDER_LOADING_URL_ARGS+=(--allow-local-lab-url); fi
+CONFIGURED_ACCEPTANCE_PROVIDER_SCOPE="$(python3 - <<'PY' 2>/dev/null || true
+import json
+from pathlib import Path
+path = Path(__import__('os').environ['GITHUB_WORKSPACE']) / 'niakvio/.github/triggers/nuvio-client-lab.json'
+try:
+ data = json.loads(path.read_text(encoding='utf-8'))
+ print(str((data.get('native_reader_acceptance') or {}).get('provider_scope') or 'fixture'))
+except Exception:
+ print('fixture')
+PY
+)"
+CONFIGURED_ACCEPTANCE_PROVIDER_SCOPE="${CONFIGURED_ACCEPTANCE_PROVIDER_SCOPE:-fixture}"
+if [[ -n "$TARGET_FIXTURES" ]]; then
+ FIXTURES=()
+ for fixture in $TARGET_FIXTURES; do FIXTURES+=("$fixture"); done
+elif [[ -n "$TARGET_FIXTURE" && "$TARGET_FIXTURE" != "all" ]]; then
+ FIXTURES=("$TARGET_FIXTURE")
+else
+ FIXTURES=("${DEFAULT_FIXTURES[@]}")
+fi
+SOFT_FAILURES=0
+PROVIDER_ARGS=()
+if [[ -n "$TARGET_PROVIDER" && "$TARGET_PROVIDER" != "all" && "$TARGET_PROVIDER" != "fixture" ]]; then PROVIDER_ARGS=(--provider "$TARGET_PROVIDER"); fi
+
+mkdir -p "$EVIDENCE_ROOT"
+python3 "$LAB_TRANSPORT" "$TV_ROOT/app/src/androidTest/AndroidManifest.xml" || exit $?
+python3 "$INSTRUMENTER" tv "$TV_ROOT" || exit $?
+python3 "$REPOSITORY_HTTP_INSTRUMENTER" tv "$TV_ROOT" || exit $?
+if [[ -n "${GITHUB_ENV:-}" ]]; then echo "NIAKVIO_BRAIN_NONBLOCKING=1" >> "$GITHUB_ENV"; fi
+echo "FIELD_NATIVE_CORPUS_TV_PROFILE fixtures=${#FIXTURES[@]} provider=${TARGET_PROVIDER:-all} configured_acceptance_provider_scope=$CONFIGURED_ACCEPTANCE_PROVIDER_SCOPE manifest=$TARGET_MANIFEST player_probes=$PLAYER_PROBES requested_reader_success=$REQUESTED_READER_SUCCESS require_reader_success=$REQUIRE_READER_SUCCESS player_outcome_global_gate=$PLAYER_OUTCOME_GLOBAL_GATE reader_acceptance=$READER_ACCEPTANCE primary_stream_scope=$PRIMARY_STREAM_SCOPE regression_stream_scope=$REGRESSION_STREAM_SCOPE reuse_avd=true reuse_gradle_daemon=true full_backend_evidence=true repository_http_evidence=true frontend_timeline=true official_repository_loading=true local_manifest=$ALLOW_LOCAL_MANIFEST smoke_gate=player_reached pr_provider_limit=${NIAKVIO_PR_PROVIDER_LIMIT:-default}"
for fixture in "${FIXTURES[@]}"; do
echo "===== TV CORPUS FIXTURE: $fixture ====="
- python3 "$RESTAGE" tv --fixture "$fixture" --workspace "$WORKSPACE" || { STATUS=1; continue; }
+ STREAM_SCOPE="$REGRESSION_STREAM_SCOPE"
+ if [[ "$fixture" = "$PRIMARY_FIXTURE" ]]; then STREAM_SCOPE="$PRIMARY_STREAM_SCOPE"; fi
+ if [[ "$READER_ACCEPTANCE" = "1" ]]; then
+ PROVIDER_SCOPE="$TARGET_PROVIDER"
+ if [[ -z "$PROVIDER_SCOPE" || "$PROVIDER_SCOPE" = "fixture" ]]; then PROVIDER_SCOPE="$CONFIGURED_ACCEPTANCE_PROVIDER_SCOPE"; fi
+ if [[ -z "$PROVIDER_SCOPE" ]]; then PROVIDER_SCOPE="fixture"; fi
+ python3 "$ACCEPTANCE_PREPARE" tv --fixture "$fixture" --workspace "$WORKSPACE" --provider "$PROVIDER_SCOPE" --streams "$STREAM_SCOPE" --manifest "$TARGET_MANIFEST" || { SOFT_FAILURES=$((SOFT_FAILURES+1)); continue; }
+ else
+ python3 "$RESTAGE" tv --fixture "$fixture" --workspace "$WORKSPACE" "${PROVIDER_ARGS[@]}" --player-probes "$PLAYER_PROBES" --manifest "$TARGET_MANIFEST" || { SOFT_FAILURES=$((SOFT_FAILURES+1)); continue; }
+ fi
+
+ python3 "$REQUEST_CONTRACT" tv --fixture "$fixture" --manifest "$TARGET_MANIFEST" --source "$TEST_SOURCE" || { SOFT_FAILURES=$((SOFT_FAILURES+1)); continue; }
+ python3 "$PROVIDER_LOADING" tv --manifest "$TARGET_MANIFEST" --manifest-url "$MANIFEST_URL" --source "$TEST_SOURCE" "${PROVIDER_LOADING_URL_ARGS[@]}" || { SOFT_FAILURES=$((SOFT_FAILURES+1)); continue; }
+
+ FRONT_DIR="${EVIDENCE_ROOT}/${fixture}"
+ FRONT_LOG="${WORKSPACE}/tv-native-frontend-${fixture}.log"
+ GRADLE_LOG="${FRONT_DIR}/gradle.log"
+ mkdir -p "$FRONT_DIR"
+ rm -f "$FRONT_LOG" "$GRADLE_LOG"
adb logcat -c || true
+ bash "$FRONTEND_WATCHER" tv "$FRONT_DIR" "$FRONTEND_CAPTURE" > "$FRONT_LOG" 2>&1 &
+ WATCH_PID=$!
+
RUNTIME_STATUS=0
- "$TV_ROOT/gradlew" -p "$TV_ROOT" :app:connectedFullDebugAndroidTest --no-daemon --console=plain || RUNTIME_STATUS=$?
+ "$TV_ROOT/gradlew" -p "$TV_ROOT" :app:connectedFullDebugAndroidTest --console=plain 2>&1 | tee "$GRADLE_LOG"
+ RUNTIME_STATUS=${PIPESTATUS[0]}
+ sleep 1
+ kill "$WATCH_PID" 2>/dev/null || true
+ wait "$WATCH_PID" 2>/dev/null || true
+
LOG="${WORKSPACE}/tv-native-corpus-${fixture}.log"
- adb logcat -d -s NiakvioCorpus:I '*:S' > "$LOG" || true
+ adb logcat -d -v brief -s NiakvioCorpus:I NiakvioEvidence:I '*:S' > "$LOG" || true
+ echo "FIELD_NATIVE_EVIDENCE_INSTRUMENTED client=tv" >> "$LOG"
+ cat "$FRONT_LOG" >> "$LOG" 2>/dev/null || true
cat "$LOG" || true
+
ANALYSIS_STATUS=0
node "$ANALYZER" "$fixture" "$LOG" || ANALYSIS_STATUS=$?
- echo "FIELD_NATIVE_CORPUS_TV_STATUS fixture=$fixture runtime=$RUNTIME_STATUS collection=$ANALYSIS_STATUS"
- if [[ "$RUNTIME_STATUS" -ne 0 || "$ANALYSIS_STATUS" -ne 0 ]]; then STATUS=1; fi
+ COVERAGE_STATUS=0
+ if [[ "$READER_ACCEPTANCE" = "1" ]]; then
+ node "$COVERAGE_GATE" --streams "$STREAM_SCOPE" "$LOG" || COVERAGE_STATUS=$?
+ fi
+ OBSERVED_READER_STATUS=0
+ node "$READER_GATE" "$LOG" || OBSERVED_READER_STATUS=$?
+ if [[ "$RUNTIME_STATUS" -ne 0 || "$ANALYSIS_STATUS" -ne 0 || "$COVERAGE_STATUS" -ne 0 || "$OBSERVED_READER_STATUS" -ne 0 ]]; then
+ SOFT_FAILURES=$((SOFT_FAILURES+1))
+ fi
+ echo "FIELD_NATIVE_CORPUS_TV_STATUS fixture=$fixture runtime=$RUNTIME_STATUS collection=$ANALYSIS_STATUS coverage=$COVERAGE_STATUS reader_observed=$OBSERVED_READER_STATUS blocking=false stream_scope=$STREAM_SCOPE frontend_dir=$FRONT_DIR"
+done
+
+for fixture in "${FIXTURES[@]}"; do
+ LOG="${WORKSPACE}/tv-native-corpus-${fixture}.log"
+ if [[ ! -s "$LOG" ]]; then
+ printf 'FIELD_NATIVE_SMOKE_DIAGNOSTIC_PLACEHOLDER client=tv fixture=%s reason=no_route_log\n' "$fixture" > "$LOG"
+ fi
done
-echo "FIELD_NATIVE_CORPUS_TV_SUITE_STATUS status=$STATUS fixtures=${#FIXTURES[@]} clients=1"
-exit "$STATUS"
+LOGS=("${WORKSPACE}"/tv-native-corpus-*.log)
+SMOKE_STATUS=0
+node "$SMOKE_GATE" "${LOGS[@]}" || SMOKE_STATUS=$?
+echo "FIELD_NATIVE_CORPUS_TV_SUITE_STATUS status=$SMOKE_STATUS soft_failures=$SOFT_FAILURES fixtures=${#FIXTURES[@]} clients=1 provider=${TARGET_PROVIDER:-all} configured_acceptance_provider_scope=$CONFIGURED_ACCEPTANCE_PROVIDER_SCOPE manifest=$TARGET_MANIFEST gate=production_player_reached evidence_root=$EVIDENCE_ROOT"
+exit "$SMOKE_STATUS"
diff --git a/scripts/scope_native_reader_learning_runtime.py b/scripts/scope_native_reader_learning_runtime.py
new file mode 100644
index 000000000..20bf3a9b8
--- /dev/null
+++ b/scripts/scope_native_reader_learning_runtime.py
@@ -0,0 +1,203 @@
+#!/usr/bin/env python3
+"""Keep native-reader Brain memory useful without leaking it across client runtime drift.
+
+Reader repair outcomes are only valid control evidence for the exact official Nuvio
+client revisions on which they were observed. Historical entries are retained, but a
+repair sandbox receives only entries whose runtimeFingerprint exactly matches the
+current Labs runtime. Legacy unscoped entries are deliberately excluded.
+
+The `merge` mode delegates the existing outcome aggregation to
+merge_native_reader_repair_learning.py on a runtime-filtered view, then merges the
+new runtime-scoped entries back into the full historical state.
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import subprocess
+import tempfile
+from pathlib import Path
+from typing import Any
+
+ROOT = Path(__file__).resolve().parents[1]
+MERGER = ROOT / "scripts" / "merge_native_reader_repair_learning.py"
+
+
+def read_json(path: Path) -> dict[str, Any]:
+ value = json.loads(path.read_text(encoding="utf-8"))
+ if not isinstance(value, dict):
+ raise ValueError(f"{path}: expected JSON object")
+ return value
+
+
+def write_json(path: Path, value: dict[str, Any]) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
+
+
+def clean_fingerprint(value: Any) -> str:
+ text = str(value or "").strip()
+ lowered = text.casefold()
+ if not text:
+ raise ValueError("runtime fingerprint is required")
+ if len(text) > 700:
+ raise ValueError("runtime fingerprint is too long")
+ if "://" in text or any(token in lowered for token in ("authorization=", "cookie=", "token=", "secret=")):
+ raise ValueError("runtime fingerprint contains forbidden endpoint/credential material")
+ return text
+
+
+def memory(state: dict[str, Any]) -> dict[str, Any]:
+ value = state.get("nativeReaderRepairMemory")
+ return value if isinstance(value, dict) else {}
+
+
+def entry_fingerprint(row: Any) -> str:
+ if not isinstance(row, dict):
+ return ""
+ return str(row.get("runtimeFingerprint") or "").strip()
+
+
+def scoped_state(state: dict[str, Any], fingerprint: str) -> dict[str, Any]:
+ result = json.loads(json.dumps(state))
+ source_memory = memory(state)
+ source_entries = [row for row in source_memory.get("entries") or [] if isinstance(row, dict)]
+ selected = [dict(row) for row in source_entries if entry_fingerprint(row) == fingerprint]
+ scoped_memory = dict(source_memory)
+ scoped_memory["schemaVersion"] = max(2, int(scoped_memory.get("schemaVersion") or 0))
+ scoped_memory["runtimeAware"] = True
+ scoped_memory["entries"] = selected
+ scoped_memory["runtimeScope"] = {
+ "fingerprint": fingerprint,
+ "sourceEntryCount": len(source_entries),
+ "selectedEntryCount": len(selected),
+ "excludedEntryCount": len(source_entries) - len(selected),
+ "legacyUnscopedExcluded": sum(1 for row in source_entries if not entry_fingerprint(row)),
+ }
+ result["nativeReaderRepairMemory"] = scoped_memory
+ return result
+
+
+def combine_runtime_memory(
+ original: dict[str, Any], merged: dict[str, Any], fingerprint: str
+) -> dict[str, Any]:
+ result = json.loads(json.dumps(merged))
+ original_memory = memory(original)
+ merged_memory = memory(merged)
+
+ historical = [
+ dict(row)
+ for row in original_memory.get("entries") or []
+ if isinstance(row, dict) and entry_fingerprint(row) != fingerprint
+ ]
+ current = []
+ for raw in merged_memory.get("entries") or []:
+ if not isinstance(raw, dict):
+ continue
+ row = dict(raw)
+ row["runtimeFingerprint"] = fingerprint
+ current.append(row)
+
+ imported: list[str] = []
+ for source in (original_memory, merged_memory):
+ for raw in source.get("importedRunIds") or []:
+ value = str(raw or "").strip()
+ if value.isdigit() and value not in imported:
+ imported.append(value)
+
+ combined = dict(merged_memory)
+ combined["schemaVersion"] = max(2, int(combined.get("schemaVersion") or 0))
+ combined["runtimeAware"] = True
+ combined["entries"] = (current + historical)[:1200]
+ combined["importedRunIds"] = imported[-100:]
+ combined["runtimeScope"] = {
+ "fingerprint": fingerprint,
+ "currentRuntimeEntryCount": len(current),
+ "historicalOtherRuntimeEntryCount": len(historical),
+ "reusePolicy": "exact-runtime-fingerprint-only",
+ }
+ combined["skillStatsRuntimeFingerprint"] = fingerprint
+ result["nativeReaderRepairMemory"] = combined
+ result["privacy"] = (
+ "Native reader memory keeps sanitized provider/fixture/failure/skill ids and exact client revision "
+ "fingerprints only; historical runtime evidence is retained but never reused across client drift."
+ )
+ return result
+
+
+def run_filter(args: argparse.Namespace) -> int:
+ fingerprint = clean_fingerprint(args.runtime_fingerprint)
+ state = read_json(args.state)
+ scoped = scoped_state(state, fingerprint)
+ write_json(args.output, scoped)
+ scope = scoped["nativeReaderRepairMemory"]["runtimeScope"]
+ print(
+ "FIELD_NATIVE_READER_RUNTIME_SCOPE "
+ f"selected={scope['selectedEntryCount']} excluded={scope['excludedEntryCount']} "
+ f"legacy_excluded={scope['legacyUnscopedExcluded']}"
+ )
+ return 0
+
+
+def run_merge(args: argparse.Namespace) -> int:
+ fingerprint = clean_fingerprint(args.runtime_fingerprint)
+ original = read_json(args.state)
+ scoped = scoped_state(original, fingerprint)
+
+ with tempfile.TemporaryDirectory(prefix="niakvio-reader-runtime-") as raw:
+ temp = Path(raw)
+ scoped_path = temp / "scoped.json"
+ merged_path = temp / "merged.json"
+ write_json(scoped_path, scoped)
+ cmd = [
+ "python3", str(MERGER),
+ "--state", str(scoped_path),
+ "--previous-state", str(scoped_path),
+ "--comparison", str(args.comparison),
+ "--output", str(merged_path),
+ ]
+ if args.markdown_input and args.markdown_output:
+ cmd += [
+ "--markdown-input", str(args.markdown_input),
+ "--markdown-output", str(args.markdown_output),
+ ]
+ process = subprocess.run(cmd, cwd=ROOT, text=True, capture_output=True)
+ if process.returncode != 0:
+ raise RuntimeError(process.stdout + process.stderr)
+ merged = read_json(merged_path)
+
+ combined = combine_runtime_memory(original, merged, fingerprint)
+ write_json(args.output, combined)
+ print(
+ "FIELD_NATIVE_READER_RUNTIME_MERGE "
+ f"runtime_entries={combined['nativeReaderRepairMemory']['runtimeScope']['currentRuntimeEntryCount']} "
+ f"historical_entries={combined['nativeReaderRepairMemory']['runtimeScope']['historicalOtherRuntimeEntryCount']}"
+ )
+ return 0
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ sub = parser.add_subparsers(dest="command", required=True)
+
+ filter_parser = sub.add_parser("filter")
+ filter_parser.add_argument("--state", type=Path, required=True)
+ filter_parser.add_argument("--runtime-fingerprint", required=True)
+ filter_parser.add_argument("--output", type=Path, required=True)
+ filter_parser.set_defaults(handler=run_filter)
+
+ merge_parser = sub.add_parser("merge")
+ merge_parser.add_argument("--state", type=Path, required=True)
+ merge_parser.add_argument("--comparison", type=Path, required=True)
+ merge_parser.add_argument("--runtime-fingerprint", required=True)
+ merge_parser.add_argument("--output", type=Path, required=True)
+ merge_parser.add_argument("--markdown-input", type=Path)
+ merge_parser.add_argument("--markdown-output", type=Path)
+ merge_parser.set_defaults(handler=run_merge)
+
+ args = parser.parse_args()
+ return int(args.handler(args))
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/summarize_native_corpus_suite.cjs b/scripts/summarize_native_corpus_suite.cjs
index 5d51c72a0..1ffb56b2b 100644
--- a/scripts/summarize_native_corpus_suite.cjs
+++ b/scripts/summarize_native_corpus_suite.cjs
@@ -299,14 +299,32 @@ const summary = {
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, JSON.stringify(summary, null, 2) + '\n');
+
+// The base summarizer remains the single corpus aggregation path. Enrich that
+// exact artifact with sanitized native-reader evidence so the Brain sees the
+// same OS -> reader failures humans see, without persisting raw endpoints.
+const enrich = require('node:child_process').spawnSync(
+ process.execPath,
+ [path.join(__dirname, 'enrich_native_corpus_summary_with_player.cjs'), '--dir', inputDir, '--summary', outputPath],
+ { encoding: 'utf8' },
+);
+if (enrich.stdout) process.stdout.write(enrich.stdout);
+if (enrich.stderr) process.stderr.write(enrich.stderr);
+if (enrich.status !== 0) throw new Error(`native reader summary enrichment failed: ${enrich.status}`);
+Object.assign(summary, JSON.parse(fs.readFileSync(outputPath, 'utf8')));
+
console.log(`FIELD_NATIVE_CORPUS_SUITE_SUMMARY ${JSON.stringify({
providersObserved: summary.providersObserved,
executions: summary.executions,
contradictions: summary.contradictions,
transportExpectedEmbeds: summary.transportExpectedEmbeds,
transportFailures: summary.transportFailures,
+ nativeReaderObserved: summary.nativeReaderObserved || 0,
+ nativeReaderFailures: summary.nativeReaderFailures || 0,
+ readerFailureClasses: summary.readerFailureClasses || {},
runtimeErrors: summary.runtimeErrors,
repeatedPlatformGaps: repeatedPlatformGaps.length,
+ repeatedReaderFailures: summary.engineSignals?.repeatedReaderFailures?.length || 0,
systemicEmpty: systemicEmpty.length,
capabilitiesObserved: capabilityInventory.length,
})}`);
diff --git a/scripts/watch_native_device_frontend.sh b/scripts/watch_native_device_frontend.sh
new file mode 100644
index 000000000..99786ea18
--- /dev/null
+++ b/scripts/watch_native_device_frontend.sh
@@ -0,0 +1,51 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+CLIENT="${1:?client required}"
+OUT_DIR="${2:?output directory required}"
+CAPTURE="${3:?capture script required}"
+mkdir -p "$OUT_DIR"
+
+declare -A SEEN=()
+
+capture_once() {
+ local phase="$1"
+ if [[ -n "${SEEN[$phase]:-}" ]]; then return 0; fi
+ SEEN[$phase]=1
+ bash "$CAPTURE" "$CLIENT" "$phase" "$OUT_DIR" || {
+ echo "FIELD_NATIVE_FRONTEND_ERROR client=$CLIENT phase=$phase reason=capture_failed"
+ return 0
+ }
+}
+
+capture_http_terminal() {
+ local scope="$1"
+ # One canonical screenshot is enough. The completeness reader still accepts the
+ # historical *-http-response alias when consuming older artifacts, but creating
+ # both snapshots doubles synchronous ADB work and can backlog current logcat.
+ capture_once "${scope}-http-terminal"
+}
+
+# Watch only NiakVIO's structured/sanitized tags. Official client debug tags may
+# contain raw provider URLs; they are not needed because every phase marker below
+# is emitted explicitly through NiakvioCorpus/NiakvioEvidence.
+adb logcat -v brief -s NiakvioCorpus:I NiakvioEvidence:I '*:S' | while IFS= read -r line; do
+ case "$line" in
+ *FIELD_NATIVE_UI_LAUNCHED*) capture_once "ui-launched" ;;
+ *FIELD_NATIVE_REPOSITORY_LOAD_BEGIN*) capture_once "repository-load" ;;
+ *FIELD_NATIVE_REPOSITORY_HTTP_REQUEST*) capture_once "repository-http-request" ;;
+ *FIELD_NATIVE_REPOSITORY_HTTP_RESPONSE*|*FIELD_NATIVE_REPOSITORY_HTTP_ERROR*) capture_http_terminal "repository" ;;
+ *FIELD_NATIVE_REPOSITORY_LOAD_RESULT*) capture_once "repository-loaded" ;;
+ *FIELD_NATIVE_REPOSITORY_LOAD_ERROR*) capture_once "repository-load-error" ;;
+ *FIELD_NATIVE_PROVIDER_LOAD_RESULT*|*FIELD_NATIVE_PROVIDER_LOAD_ERROR*|*FIELD_NATIVE_PROVIDER_LOAD_SKIPPED*) capture_once "provider-load-state" ;;
+ *FIELD_NATIVE_CORPUS_BEGIN*) capture_once "corpus-begin" ;;
+ *FIELD_NATIVE_PROVIDER_BEGIN*) capture_once "provider-loading" ;;
+ *FIELD_NATIVE_HTTP_REQUEST*) capture_once "provider-http-request" ;;
+ *FIELD_NATIVE_HTTP_RESPONSE*|*FIELD_NATIVE_HTTP_ERROR*) capture_http_terminal "provider" ;;
+ *FIELD_NATIVE_RESULT*) capture_once "provider-result" ;;
+ *FIELD_NATIVE_PLAYER_BEGIN*) capture_once "player-start" ;;
+ *FIELD_NATIVE_PLAYER*) capture_once "player-result" ;;
+ *FIELD_NATIVE_ERROR*) capture_once "provider-error" ;;
+ *FIELD_NATIVE_CORPUS_END*) capture_once "corpus-end" ;;
+ esac
+done
diff --git a/tests/canonical_media_types_test.py b/tests/canonical_media_types_test.py
new file mode 100644
index 000000000..f26b34e65
--- /dev/null
+++ b/tests/canonical_media_types_test.py
@@ -0,0 +1,151 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+CANONICAL = {"movie", "tv", "anime"}
+
+
+def canonical_types(raw: object, label: str) -> tuple[str, ...]:
+ assert isinstance(raw, list) and raw, f"{label}: supportedTypes must be a non-empty list"
+ types = tuple(str(value).strip().lower() for value in raw)
+ assert len(types) == len(set(types)), f"{label}: repeated supportedTypes {types}"
+ invalid = [value for value in types if value not in CANONICAL]
+ assert not invalid, (
+ f"{label}: non-canonical media types {invalid}; canonical vocabulary is "
+ "movie|tv|anime and series/show/other belong only at client input boundaries"
+ )
+ return types
+
+
+def validate_manifest(path: Path) -> tuple[list[dict], int]:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ rows = data.get("scrapers", [])
+ assert isinstance(rows, list) and rows, f"{path}: no scrapers"
+ seen: set[str] = set()
+ anime = 0
+ out: list[dict] = []
+ for index, row in enumerate(rows):
+ assert isinstance(row, dict), f"{path}: scraper[{index}] must be an object"
+ provider_id = str(row.get("id") or "").strip()
+ assert provider_id, f"{path}: scraper[{index}] has no id"
+ key = provider_id.casefold()
+ assert key not in seen, f"{path}: duplicate provider id {provider_id!r}"
+ seen.add(key)
+ types = canonical_types(row.get("supportedTypes"), f"{path}:{provider_id}")
+ anime += int("anime" in types)
+ out.append({"id": provider_id, "key": key, "types": types, "row": row})
+ return out, anime
+
+
+def validate_catalog(path: Path) -> tuple[dict[str, dict], dict[str, list[str]]]:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ assert data.get("sourceOfTruth") is True, f"{path}: sourceOfTruth must stay true"
+ providers = data.get("providers", [])
+ assert isinstance(providers, list) and providers, f"{path}: providers must be non-empty"
+
+ by_canonical: dict[str, dict] = {}
+ scraper_ids: set[str] = set()
+ for index, entry in enumerate(providers):
+ assert isinstance(entry, dict), f"{path}: providers[{index}] must be an object"
+ canonical_id = str(entry.get("canonicalId") or "").strip()
+ assert canonical_id, f"{path}: providers[{index}] has no canonicalId"
+ canonical_key = canonical_id.casefold()
+ assert canonical_key not in by_canonical, f"{path}: duplicate canonicalId {canonical_id!r}"
+ scraper = entry.get("scraper")
+ assert isinstance(scraper, dict), f"{path}:{canonical_id}: scraper must be an object"
+ scraper_id = str(scraper.get("id") or "").strip()
+ assert scraper_id, f"{path}:{canonical_id}: scraper has no id"
+ scraper_key = scraper_id.casefold()
+ assert scraper_key not in scraper_ids, f"{path}: duplicate scraper id {scraper_id!r}"
+ scraper_ids.add(scraper_key)
+ types = canonical_types(scraper.get("supportedTypes"), f"{path}:{canonical_id}/{scraper_id}")
+ projections = entry.get("projections") or {}
+ assert isinstance(projections, dict), f"{path}:{canonical_id}: projections must be an object"
+ by_canonical[canonical_key] = {
+ "canonicalId": canonical_id,
+ "scraperId": scraper_id,
+ "types": types,
+ "projections": projections,
+ }
+
+ orders = data.get("manifestOrder") or {}
+ assert isinstance(orders, dict), f"{path}: manifestOrder must be an object"
+ normalized_orders: dict[str, list[str]] = {}
+ for projection in ("general", "vf"):
+ raw_order = orders.get(projection)
+ assert isinstance(raw_order, list), f"{path}: manifestOrder.{projection} must be a list"
+ order = [str(value).strip().casefold() for value in raw_order]
+ assert all(order), f"{path}: manifestOrder.{projection} contains an empty id"
+ assert len(order) == len(set(order)), f"{path}: manifestOrder.{projection} contains duplicate ids"
+ missing = [value for value in order if value not in by_canonical]
+ assert not missing, f"{path}: manifestOrder.{projection} references unknown canonical ids {missing}"
+
+ projected = {
+ key
+ for key, entry in by_canonical.items()
+ if bool(entry["projections"].get(projection))
+ }
+ assert set(order) == projected, (
+ f"{path}: manifestOrder.{projection} must exactly match providers projected to {projection}; "
+ f"missing_from_order={sorted(projected - set(order))} extra_in_order={sorted(set(order) - projected)}"
+ )
+ normalized_orders[projection] = order
+
+ return by_canonical, normalized_orders
+
+
+def assert_projection(
+ manifest_path: Path,
+ projection: str,
+ catalog: dict[str, dict],
+ orders: dict[str, list[str]],
+) -> tuple[int, int]:
+ manifest_rows, anime = validate_manifest(manifest_path)
+ order = orders[projection]
+ assert len(manifest_rows) == len(order), (
+ f"{manifest_path}: projection size drift {len(manifest_rows)} != catalog order {len(order)}"
+ )
+ expected_ids = [catalog[key]["scraperId"].casefold() for key in order]
+ actual_ids = [row["key"] for row in manifest_rows]
+ assert actual_ids == expected_ids, (
+ f"{manifest_path}: provider order/identity is not the deterministic {projection} projection of provider_catalog.json"
+ )
+ for row, canonical_key in zip(manifest_rows, order, strict=True):
+ expected_types = catalog[canonical_key]["types"]
+ assert row["types"] == expected_types, (
+ f"{manifest_path}:{row['id']}: supportedTypes drift from provider_catalog.json: "
+ f"{row['types']} != {expected_types}"
+ )
+ return len(manifest_rows), anime
+
+
+catalog, orders = validate_catalog(ROOT / "provider_catalog.json")
+canonical_count, canonical_anime = assert_projection(ROOT / "manifest.json", "general", catalog, orders)
+vf_count, vf_anime = assert_projection(ROOT / "vf/manifest.json", "vf", catalog, orders)
+
+assert canonical_count >= 80, canonical_count
+assert canonical_anime > 0, "general projection must retain anime providers"
+assert vf_count > 0, vf_count
+assert vf_anime > 0, "VF projection must retain its anime providers"
+
+corpus = json.loads((ROOT / ".github/triggers/nuvio-client-lab.json").read_text(encoding="utf-8"))
+fixtures = {
+ row["slug"]: row["fixture"]
+ for row in corpus.get("fixtures", [])
+ if isinstance(row, dict) and isinstance(row.get("fixture"), dict)
+}
+for slug in ("jujutsu-kaisen-s01e01", "mushoku-tensei-s01e01"):
+ fixture = fixtures[slug]
+ assert str(fixture.get("category") or "").lower() == "anime", (slug, fixture)
+ # A catalogue may surface episodic anime through tv/series. The device lab then
+ # probes anime and tv routes separately and labels undeclared routes as capability probes.
+ assert str(fixture.get("mediaType") or "").lower() in {"tv", "anime"}, (slug, fixture)
+
+print(
+ "canonical media type tests passed: "
+ f"catalog={len(catalog)} general={canonical_count} vf={vf_count} "
+ f"anime_general={canonical_anime} anime_vf={vf_anime} vocabulary=movie|tv|anime"
+)
diff --git a/tests/full_provider_portfolio_audit_test.py b/tests/full_provider_portfolio_audit_test.py
new file mode 100644
index 000000000..4a479af72
--- /dev/null
+++ b/tests/full_provider_portfolio_audit_test.py
@@ -0,0 +1,142 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import json
+from collections import Counter
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+MANIFEST = ROOT / "manifest.json"
+OVERRIDES = ROOT / "provider-overrides.json"
+
+manifest = json.loads(MANIFEST.read_text(encoding="utf-8"))
+overrides = json.loads(OVERRIDES.read_text(encoding="utf-8"))
+rows = manifest.get("scrapers") or []
+assert isinstance(rows, list) and rows, "manifest must contain provider scrapers"
+provider_patches = overrides.get("provider_patches") or {}
+assert isinstance(provider_patches, dict)
+
+hard: list[str] = []
+soft: list[str] = []
+ids: list[str] = []
+on_ids: list[str] = []
+off_ids: list[str] = []
+quarantined_ids: list[str] = []
+hls_without_common_repair: list[str] = []
+wrapper_counts: Counter[str] = Counter()
+
+
+def canonical_from_row(row: dict) -> str:
+ filename = str(row.get("filename") or "")
+ if filename.startswith("providers/"):
+ stem = Path(filename).name
+ if "--" in stem:
+ return stem.split("--", 1)[0].casefold()
+ return Path(stem).stem.casefold()
+ return str(row.get("id") or "").casefold()
+
+
+for index, row in enumerate(rows):
+ if not isinstance(row, dict):
+ hard.append(f"row[{index}]:not_object")
+ continue
+ provider_id = canonical_from_row(row)
+ display_id = str(row.get("id") or provider_id)
+ ids.append(provider_id)
+ enabled = row.get("enabled", True) is not False
+ (on_ids if enabled else off_ids).append(provider_id)
+
+ filename = str(row.get("filename") or "")
+ if not filename:
+ hard.append(f"{provider_id}:missing_filename")
+ continue
+ path = ROOT / filename
+ if not path.is_file():
+ hard.append(f"{provider_id}:missing_bundle:{filename}")
+ continue
+ try:
+ text = path.read_text(encoding="utf-8")
+ except UnicodeDecodeError:
+ hard.append(f"{provider_id}:bundle_not_utf8:{filename}")
+ continue
+
+ cfg = provider_patches.get(provider_id) or {}
+ if not isinstance(cfg, dict):
+ hard.append(f"{provider_id}:override_not_object")
+ cfg = {}
+ scripts = [str(v) for v in (cfg.get("patch_scripts") or [])]
+ capability = str(cfg.get("capability") or "").casefold()
+ manifest_override = cfg.get("manifest_overrides") or {}
+ explicitly_quarantined = (
+ capability == "quarantined"
+ or "scripts/provider_patches/quarantine_provider_v1.py" in scripts
+ or (isinstance(manifest_override, dict) and manifest_override.get("enabled") is False)
+ )
+ if explicitly_quarantined:
+ quarantined_ids.append(provider_id)
+ if enabled:
+ hard.append(f"{provider_id}:quarantined_but_enabled")
+ elif not enabled:
+ soft.append(f"{provider_id}:disabled_without_quarantine")
+
+ sanitizer = text.find("/* NUVIO_STREAM_OUTPUT_SANITIZER_V4:")
+ target = text.rfind("/* NUVIO_TV_TARGET_MEDIA_V3:")
+ sanitizer_count = text.count("/* NUVIO_STREAM_OUTPUT_SANITIZER_V4:")
+ target_count = text.count("/* NUVIO_TV_TARGET_MEDIA_V3:")
+ repair_count = text.count("/* NUVIO_STREAM_OUTPUT_HLS_HTML_REPAIR_V7 */")
+ strict_count = text.count("/* NUVIO_STREAM_OUTPUT_SANITIZER_ALL_URL_FAIL_CLOSED_V6 */")
+ hls_integrity_count = text.count("/* NUVIO_HLS_RUNTIME_INTEGRITY_V1:")
+
+ wrapper_counts["sanitizer"] += sanitizer_count
+ wrapper_counts["target_media"] += target_count
+ wrapper_counts["hls_repair"] += repair_count
+ wrapper_counts["strict_sanitizer"] += strict_count
+ wrapper_counts["hls_integrity"] += hls_integrity_count
+
+ if sanitizer_count > 1:
+ hard.append(f"{provider_id}:duplicate_sanitizer:{sanitizer_count}")
+ if target_count > 1:
+ hard.append(f"{provider_id}:duplicate_target_media:{target_count}")
+ if repair_count > 1:
+ hard.append(f"{provider_id}:duplicate_hls_repair:{repair_count}")
+ if strict_count > 1:
+ hard.append(f"{provider_id}:duplicate_strict_sanitizer:{strict_count}")
+ if repair_count and sanitizer < 0:
+ hard.append(f"{provider_id}:hls_repair_without_sanitizer")
+ if strict_count and not repair_count:
+ hard.append(f"{provider_id}:strict_sanitizer_without_generic_repair")
+ # Later textual wrappers are outer wrappers at runtime. If target-media is
+ # rewrapped after the sanitizer, its final URL bypasses terminal validation.
+ if sanitizer >= 0 and target >= 0 and sanitizer <= target:
+ hard.append(
+ f"{provider_id}:terminal_order_invalid:sanitizer={sanitizer}:target_media={target}"
+ )
+
+ formats = {str(v).casefold() for v in (row.get("formats") or [])}
+ hls_capable = "m3u8" in formats or "hls" in formats
+ has_common_hls_layer = repair_count > 0 or hls_integrity_count > 0
+ if hls_capable and not has_common_hls_layer:
+ hls_without_common_repair.append(provider_id)
+
+# Canonical names are derived from filenames because manifest IDs may differ in
+# case from provider-overrides keys. Filenames themselves must still be unique.
+duplicates = sorted(key for key, count in Counter(ids).items() if count > 1)
+if duplicates:
+ hard.append("duplicate_canonical_ids:" + ",".join(duplicates))
+
+print(
+ "full provider portfolio audit: "
+ f"total={len(rows)} on={len(on_ids)} off={len(off_ids)} "
+ f"quarantined={len(set(quarantined_ids))} hard={len(hard)} soft={len(soft)}"
+)
+print("portfolio OFF: " + (",".join(sorted(off_ids)) or "none"))
+print("portfolio quarantined: " + (",".join(sorted(set(quarantined_ids))) or "none"))
+print(
+ "portfolio HLS without common repair layer: "
+ + (",".join(sorted(hls_without_common_repair)) or "none")
+)
+print("portfolio wrapper totals: " + json.dumps(dict(wrapper_counts), sort_keys=True))
+if soft:
+ print("portfolio observations: " + " | ".join(sorted(soft)))
+if hard:
+ raise AssertionError("full provider portfolio audit failed: " + " | ".join(sorted(hard)))
diff --git a/tests/global_media_enrichment_direct_safety_test.py b/tests/global_media_enrichment_direct_safety_test.py
index aa1fcbb2c..2cca2d1a0 100644
--- a/tests/global_media_enrichment_direct_safety_test.py
+++ b/tests/global_media_enrichment_direct_safety_test.py
@@ -23,9 +23,14 @@
]};
'''
patched = module.apply(base, options={"preserve_original": True})
-assert "scoped-playback-context-v5-direct-safe" in patched
+# Lock the behavior, not an implementation revision string: direct nested rows
+# must keep their request context, opaque containers must be provable from final
+# response metadata, and unresolved player pages must not leak as playable rows.
assert "declaredDirect" in patched
+assert "metadataKind" in patched
+assert "content-disposition" in patched
assert "row.url&&typeof row.url===\"object\"&&row.url.headers" in patched
+assert "Unresolved player/download pages are not playable streams" in patched
runtime = patched + r'''
const fetches=[];
diff --git a/tests/global_playback_integrity_policy_test.py b/tests/global_playback_integrity_policy_test.py
index b56c0c129..ee558fdc3 100644
--- a/tests/global_playback_integrity_policy_test.py
+++ b/tests/global_playback_integrity_policy_test.py
@@ -56,6 +56,7 @@
# Staged scheduler contract: HLS integrity runs before enrichment and final safety.
+# The stable hls_master hook now composes final presentation after media safety.
original_apply_patch_script = module._apply_patch_script
captured_scheduler_paths = []
def capture_scheduler(text, provider_id, patch_script, options, profile_name):
@@ -85,14 +86,37 @@ def capture_scheduler(text, provider_id, patch_script, options, profile_name):
text = patched.decode("utf-8")
assert "NUVIO_HLS_MASTER_AUDIO_PRESERVER_V1" in text
assert "NUVIO_HLS_RUNTIME_INTEGRITY_V1" in text
+assert "NUVIO_GLOBAL_STREAM_PRESENTATION_V1" in text
+# Presentation must be the outermost/final provider output layer so it cannot
+# influence HLS validation or native playback semantics.
+assert text.rfind("NUVIO_GLOBAL_STREAM_PRESENTATION_V1") > text.rfind("NUVIO_HLS_MASTER_AUDIO_PRESERVER_V1")
+assert text.rfind("NUVIO_GLOBAL_STREAM_PRESENTATION_V1") > text.rfind("NUVIO_HLS_RUNTIME_INTEGRITY_V1")
paths = {str(row.get("path")) for row in records if isinstance(row, dict)}
assert "scripts/provider_patches/hls_master_audio_preserver_v1.py" in paths
assert "scripts/provider_patches/hls_runtime_integrity_v1.py" in paths
assert any(row.get("scope") == "global_playback_integrity" for row in records if isinstance(row, dict))
+# The complete discovery transform must be byte-idempotent. Stable output bytes
+# are required for content-addressed filenames, cache keys and future post-Brain
+# minification/purification: a second apply with no source change cannot create a
+# new provider artifact just because wrapper tail ordering is reconsidered.
+reapplied, reapplied_records = module.apply_overrides(
+ "future-provider-never-seen-before", patched, phase="discovery"
+)
+assert reapplied == patched
+assert reapplied.decode("utf-8").count("NUVIO_GLOBAL_STREAM_PRESENTATION_V1") == 1
+assert reapplied.decode("utf-8").count("NUVIO_GLOBAL_RUNTIME_MEDIA_SAFETY_V1") == 1
+assert reapplied.decode("utf-8").count("NUVIO_HLS_RUNTIME_INTEGRITY_V1") == 1
+assert not any(
+ row.get("type") == "replace"
+ for row in reapplied_records
+ if isinstance(row, dict)
+)
+
# Runtime repair phase must not inject discovery wrappers.
runtime, runtime_records = module.apply_overrides("future-provider-never-seen-before", future, phase="runtime")
assert b"NUVIO_HLS_RUNTIME_INTEGRITY_V1" not in runtime
+assert b"NUVIO_GLOBAL_STREAM_PRESENTATION_V1" not in runtime
assert not any(row.get("scope") == "global_playback_integrity" for row in runtime_records if isinstance(row, dict))
health_cfg = json.loads((ROOT / "health-config.json").read_text(encoding="utf-8"))
@@ -110,4 +134,4 @@ def capture_scheduler(text, provider_id, patch_script, options, profile_name):
):
assert marker in health_source, marker
-print("global playback integrity policy tests passed")
+print("global playback integrity + final stream presentation policy tests passed")
diff --git a/tests/global_provider_policy_test.py b/tests/global_provider_policy_test.py
index 56fd3908d..a8fe436b7 100644
--- a/tests/global_provider_policy_test.py
+++ b/tests/global_provider_policy_test.py
@@ -40,7 +40,7 @@
assert token in cat_source, token
for forbidden in ("Mon ninja et moi 3", "Interstellar", "Ternet Ninja 3"):
assert forbidden not in cat_source, forbidden
-for token in ("preserveOriginal", "m3u8|mpd|mp4|mkv|webm", "kindBytes", "add(row)"):
+for token in ("preserveOriginal", "m3u8|mpd|mp4|m4v|mkv|webm|ts", "kindBytes", "add(row)"):
assert token in media_source, token
# A configured HTML provider receives both global behaviours automatically.
diff --git a/tests/global_stream_presentation_test.py b/tests/global_stream_presentation_test.py
new file mode 100644
index 000000000..5d94bbb7d
--- /dev/null
+++ b/tests/global_stream_presentation_test.py
@@ -0,0 +1,114 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import importlib.util
+import json
+import subprocess
+import tempfile
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+PATCHES = ROOT / "scripts" / "provider_patches"
+
+
+def load(name: str):
+ path = PATCHES / name
+ spec = importlib.util.spec_from_file_location(name.replace(".", "_"), path)
+ assert spec and spec.loader
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+facts = load("purstream_stream_facts_v1.py")
+presentation = load("global_stream_presentation_v1.py")
+
+legacy = """module.exports={getStreams:async()=>[{name:'Purstream | 4k | Dual-Audio',title:'🎬 Interstellar - 2014\\n🔥 4k | 🔊 Dual-Audio\\n🎯 HLS • HEVC | 🎧 AAC • 169 min • BLU-RAY',size:'🎬 Interstellar - 2014\\n🔥 4k | 🔊 Dual-Audio\\n🎯 HLS • HEVC | 🎧 AAC • 169 min • BLU-RAY',description:'🎬 Interstellar - 2014\\n🔥 4k | 🔊 Dual-Audio\\n🎯 HLS • HEVC | 🎧 AAC • 169 min • BLU-RAY',url:'https://media.example/master.m3u8',quality:'',language:'',format:'m3u8',headers:{Referer:'https://purstream.example/'}}]};\n"""
+with_facts = facts.apply(legacy)
+assert "NUVIO_PURSTREAM_STREAM_FACTS_V1" in with_facts
+patched = presentation.apply(with_facts, context={"provider_id": "purstream"})
+assert "NUVIO_GLOBAL_STREAM_PRESENTATION_V1" in patched
+assert patched == presentation.apply(patched, context={"provider_id": "purstream"})
+
+
+def run(source: str, fetch_impl: str, call: str) -> object:
+ with tempfile.TemporaryDirectory() as raw:
+ root = Path(raw)
+ provider = root / "provider.cjs"
+ runner = root / "runner.cjs"
+ provider.write_text(source, encoding="utf-8")
+ runner.write_text(
+ "global.fetch=" + fetch_impl + ";\n"
+ "const p=require(" + json.dumps(str(provider)) + ");\n"
+ + call
+ + "\n",
+ encoding="utf-8",
+ )
+ result = subprocess.run(["node", str(runner)], text=True, capture_output=True, check=False, timeout=15)
+ assert result.returncode == 0, result.stdout + result.stderr
+ return json.loads(result.stdout.strip())
+
+
+tmdb_ok = r"""async function(url){
+ if(!String(url).includes('api.themoviedb.org')) throw new Error('presentation must not fetch playback URLs');
+ return {ok:true,status:200,json:async()=>({id:157336,title:'Interstellar',release_date:'2014-11-05',runtime:169,release_dates:{results:[{iso_3166_1:'FR',release_dates:[{certification:'-12'}]}]}})};
+}"""
+row = run(
+ patched,
+ tmdb_ok,
+ "p.getStreams({tmdbId:'157336',mediaType:'movie',title:'Interstellar',year:2014}).then(v=>console.log(JSON.stringify(v[0])))",
+)
+assert row["url"] == "https://media.example/master.m3u8"
+assert row["headers"] == {"Referer": "https://purstream.example/"}
+assert row["quality"] == "2160p", row
+assert row["language"] == "Dual Audio", row
+assert row["codec"] == "HEVC", row
+assert row["audio"] == "AAC", row
+assert row["duration"] == 169, row
+assert row["sourceType"] == "BLU-RAY", row
+assert row["ageRating"] == "-12", row
+assert row["title"] == "Purstream", row
+assert row["size"].startswith("【4K】 【BLU-RAY】 🌐 Dual Audio 🎞 HEVC"), row
+for token in ("【4K】", "【BLU-RAY】", "🌐 Dual Audio", "🎞 HEVC", "🔊 AAC", "⏱ 2h49", "🔞 -12", "Interstellar • 2014"):
+ assert token in row["description"], (token, row)
+assert "Unknown" not in row["description"]
+
+# Empty provider output must not start an optional TMDB request that can outlive
+# the provider route and make native evidence look incomplete.
+empty = "module.exports={getStreams:async()=>[]};\n"
+empty_patched = presentation.apply(empty, context={"provider_id": "movieshunt"})
+empty_result = run(
+ empty_patched,
+ "async function(){global.__tmdbCalls=(global.__tmdbCalls||0)+1;throw new Error('must not run')}",
+ "p.getStreams({tmdbId:'1233413',mediaType:'movie',title:'Sinners',year:2025}).then(v=>console.log(JSON.stringify({streams:v,calls:global.__tmdbCalls||0})))",
+)
+assert empty_result == {"streams": [], "calls": 0}, empty_result
+
+# Resolution alone never invents Blu-ray provenance.
+plain = "module.exports={getStreams:async()=>[{name:'Cineby',url:'https://media.example/video.mp4',quality:'1080p'}]};\n"
+plain_patched = presentation.apply(plain, context={"provider_id": "cineby"})
+plain_row = run(
+ plain_patched,
+ "async function(){throw new Error('tmdb offline')}",
+ "p.getStreams({mediaType:'movie',title:'Example',year:2026}).then(v=>console.log(JSON.stringify(v[0])))",
+)
+assert plain_row["quality"] == "1080p"
+assert "sourceType" not in plain_row
+assert "BLU-RAY" not in plain_row["description"]
+assert "【1080P】" in plain_row["description"]
+assert "Example • 2026" in plain_row["description"]
+
+# Optional TMDB failure must never drop or mutate playback material.
+unknown = "module.exports={getStreams:async()=>[{name:'Movix',url:'https://media.example/u.m3u8',description:'Unknown',headers:{Origin:'https://movix.example'}}]};\n"
+unknown_patched = presentation.apply(unknown, context={"provider_id": "movix"})
+unknown_row = run(
+ unknown_patched,
+ "async function(){throw new Error('offline')}",
+ "p.getStreams({tmdbId:'999',mediaType:'movie',title:'Fallback title',year:2026}).then(v=>console.log(JSON.stringify(v[0])))",
+)
+assert unknown_row["url"] == "https://media.example/u.m3u8"
+assert unknown_row["headers"] == {"Origin": "https://movix.example"}
+assert "Unknown" not in unknown_row["description"]
+assert "Fallback title • 2026" in unknown_row["description"]
+
+print("global stream presentation + Purstream factual contract tests passed")
diff --git a/tests/hls_playback_integrity_test.py b/tests/hls_playback_integrity_test.py
index 689a1b93b..952e70edd 100644
--- a/tests/hls_playback_integrity_test.py
+++ b/tests/hls_playback_integrity_test.py
@@ -44,7 +44,9 @@ def run_node(source: str) -> None:
'''
wrapped = integrity.apply(base_provider, {"timeout_ms": 2000, "max_children": 2})
assert "NUVIO_HLS_RUNTIME_INTEGRITY_V1" in wrapped
-assert "recovery-first-v3" in wrapped
+assert "recovery-first-v4-timer-safe" in wrapped
+assert 'typeof setTimeout==="function"' in wrapped
+assert 'typeof clearTimeout==="function"' in wrapped
assert integrity.apply(wrapped, {"timeout_ms": 2000, "max_children": 2}) == wrapped
# A syntactically valid HLS header without any variant/media structure is not
@@ -122,7 +124,7 @@ def run_node(source: str) -> None:
})
assert ordered.count("NUVIO_HLS_RUNTIME_INTEGRITY_V1:") == 1
assert ordered.rfind("NUVIO_HLS_RUNTIME_INTEGRITY_V1:") > ordered.rfind("streamzo #1")
-assert "final-output-order-v4" in ordered
+assert "final-output-order-v5-timer-safe" in ordered
run_node(r'''
const media="#EXTM3U\n#EXT-X-TARGETDURATION:6\n#EXTINF:6,\nseg.ts\n#EXT-X-ENDLIST\n";
globalThis.fetch=async function(url){var u=String(url);
diff --git a/tests/native_android_lab_transport_test.py b/tests/native_android_lab_transport_test.py
new file mode 100644
index 000000000..bf4cb7b83
--- /dev/null
+++ b/tests/native_android_lab_transport_test.py
@@ -0,0 +1,86 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import sys
+import tempfile
+import xml.etree.ElementTree as ET
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+SCRIPTS = ROOT / "scripts"
+sys.path.insert(0, str(SCRIPTS))
+
+from configure_native_android_lab_transport import ANDROID, validate_manifest # noqa: E402
+
+
+def application(path: Path):
+ root = ET.parse(path).getroot()
+ return next((child for child in root if child.tag == "application"), None)
+
+
+with tempfile.TemporaryDirectory() as raw:
+ temp = Path(raw)
+
+ # Missing test overlays are valid and must remain missing: the lab inherits the
+ # accepted application's production network policy instead of creating one.
+ tv = temp / "nuvio-tv/app/src/androidTest/AndroidManifest.xml"
+ validate_manifest(tv)
+ assert not tv.exists()
+
+ mobile = temp / "nuvio-mobile/composeApp/src/androidDeviceTest/AndroidManifest.xml"
+ mobile.parent.mkdir(parents=True, exist_ok=True)
+ mobile.write_text(
+ '''
+
+
+
+
+
+''',
+ encoding="utf-8",
+ )
+ before = mobile.read_text(encoding="utf-8")
+ validate_manifest(mobile)
+ assert mobile.read_text(encoding="utf-8") == before
+ app = application(mobile)
+ assert app is not None
+ assert app.get(f"{ANDROID}usesCleartextTraffic") is None
+ assert app.get(f"{ANDROID}networkSecurityConfig") is None
+
+ relaxed = temp / "nuvio-tv/app/src/androidTest/AndroidManifest.xml"
+ relaxed.parent.mkdir(parents=True, exist_ok=True)
+ relaxed.write_text(
+ '''
+
+\n''',
+ encoding="utf-8",
+ )
+ try:
+ validate_manifest(relaxed)
+ except RuntimeError as exc:
+ assert "usesCleartextTraffic=true" in str(exc)
+ else:
+ raise AssertionError("human UX lab must reject test-only cleartext relaxation")
+
+ production = temp / "nuvio-mobile/composeApp/src/main/AndroidManifest.xml"
+ production.parent.mkdir(parents=True, exist_ok=True)
+ production.write_text("\n", encoding="utf-8")
+ before = production.read_text(encoding="utf-8")
+ try:
+ validate_manifest(production)
+ except ValueError as exc:
+ assert "production manifest" in str(exc)
+ else:
+ raise AssertionError("production manifest must be rejected")
+ assert production.read_text(encoding="utf-8") == before
+
+mobile_suite = (SCRIPTS / "run_native_corpus_mobile_suite.sh").read_text(encoding="utf-8")
+tv_suite = (SCRIPTS / "run_native_corpus_tv_suite.sh").read_text(encoding="utf-8")
+hardener = (SCRIPTS / "harden_nuvio_mobile_device_test.py").read_text(encoding="utf-8")
+for suite in (mobile_suite, tv_suite):
+ assert "configure_native_android_lab_transport.py" in suite
+assert 'composeApp/src/androidDeviceTest/AndroidManifest.xml' in mobile_suite
+assert 'app/src/androidTest/AndroidManifest.xml' in tv_suite
+assert "from configure_native_android_lab_transport import configure_manifest" not in hardener
+
+print("native Android lab transport contract passed: production_policy=true modified=false")
diff --git a/tests/native_corpus_device_lab_test.py b/tests/native_corpus_device_lab_test.py
index 75fafe7d7..95359ef0a 100644
--- a/tests/native_corpus_device_lab_test.py
+++ b/tests/native_corpus_device_lab_test.py
@@ -5,11 +5,16 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
-WORKFLOW = ROOT / ".github/workflows/native-corpus-device-lab.yml"
-TARGETED_WORKFLOW = ROOT / ".github/workflows/native-corpus-device-targeted.yml"
+TARGETED_RUNTIME = ROOT / ".github/workflows/native-corpus-device-targeted.yml"
+ANDROID_READER = ROOT / ".github/workflows/native-android-route-reader.yml"
+DESKTOP_READER = ROOT / ".github/workflows/native-desktop-reader-acceptance.yml"
+READER_LEARNING_SYNC = ROOT / ".github/workflows/native-reader-learning-sync.yml"
+BRAIN_LEARNING = ROOT / ".github/workflows/brain-learning-lab.yml"
PREPARE_CORE = ROOT / "scripts/prepare_native_corpus_validation.py"
PREPARE_CLIENT = ROOT / "scripts/prepare_native_corpus_client.py"
RESTAGE_CLIENT = ROOT / "scripts/restage_native_corpus_client.py"
+READER_CODEGEN = ROOT / "scripts/native_player_diagnostics_codegen.py"
+READER_GATE = ROOT / "scripts/gate_native_reader_result.cjs"
MOBILE_SUITE = ROOT / "scripts/run_native_corpus_mobile_suite.sh"
TV_SUITE = ROOT / "scripts/run_native_corpus_tv_suite.sh"
COLLECTION_ANALYZER = ROOT / "scripts/analyze_native_corpus_collection.cjs"
@@ -17,11 +22,29 @@
CORPUS = ROOT / ".github/triggers/nuvio-client-lab.json"
MANIFEST = ROOT / "manifest.json"
-workflow = WORKFLOW.read_text(encoding="utf-8")
-targeted_workflow = TARGETED_WORKFLOW.read_text(encoding="utf-8")
+# Obsolete labs must stay removed. They used stale/mutable client refs, Linux
+# Desktop as reader evidence, duplicated device work, provider-specific staging,
+# or persisted overly broad runtime logs.
+for retired in (
+ ".github/workflows/native-corpus-device-lab.yml",
+ ".github/workflows/native-corpus-visual-sublab.yml",
+ ".github/workflows/permanent-android-real-client.yml",
+ ".github/workflows/permanent-real-client-labs.yml",
+ ".github/workflows/nuvio-client-lab.yml",
+ ".github/workflows/validate-desktop-runtime-compat.yml",
+):
+ assert not (ROOT / retired).exists(), retired
+
+targeted_runtime = TARGETED_RUNTIME.read_text(encoding="utf-8")
+android_reader = ANDROID_READER.read_text(encoding="utf-8")
+desktop_reader = DESKTOP_READER.read_text(encoding="utf-8")
+reader_learning = READER_LEARNING_SYNC.read_text(encoding="utf-8")
+brain_learning = BRAIN_LEARNING.read_text(encoding="utf-8")
prepare_core = PREPARE_CORE.read_text(encoding="utf-8")
prepare_client = PREPARE_CLIENT.read_text(encoding="utf-8")
restage_client = RESTAGE_CLIENT.read_text(encoding="utf-8")
+reader_codegen = READER_CODEGEN.read_text(encoding="utf-8")
+reader_gate = READER_GATE.read_text(encoding="utf-8")
mobile_suite = MOBILE_SUITE.read_text(encoding="utf-8")
tv_suite = TV_SUITE.read_text(encoding="utf-8")
collection_analyzer = COLLECTION_ANALYZER.read_text(encoding="utf-8")
@@ -29,7 +52,7 @@
corpus = json.loads(CORPUS.read_text(encoding="utf-8"))
manifest = json.loads(MANIFEST.read_text(encoding="utf-8"))
-expected_slugs = {
+legacy_slugs = {
"interstellar",
"mon-ninja-et-moi-3",
"breaking-bad-s01e01",
@@ -37,6 +60,7 @@
"jujutsu-kaisen-s01e01",
"mushoku-tensei-s01e01",
}
+expected_slugs = legacy_slugs | {"sinners-2025"}
actual_slugs = {
str(row.get("slug") or "")
for row in corpus.get("fixtures", [])
@@ -44,158 +68,178 @@
}
assert actual_slugs == expected_slugs, (actual_slugs, expected_slugs)
-for slug in sorted(expected_slugs):
- assert slug in workflow, (slug, "desktop workflow")
- assert slug in mobile_suite, (slug, "mobile suite")
- assert slug in tv_suite, (slug, "tv suite")
-
-for job in (
- "publication-gate:",
- "desktop-native-corpus:",
- "mobile-native-corpus:",
- "tv-native-corpus:",
- "native-corpus-engine-summary:",
+# Fast runtime retries are explicitly manual-only; they never consume runners
+# because a normal provider/script push happened.
+assert "workflow_dispatch:" in targeted_runtime
+assert "pull_request:" not in targeted_runtime
+assert "\n push:" not in targeted_runtime
+assert "run_native_corpus_tv_suite.sh" in targeted_runtime
+assert "run_native_corpus_mobile_suite.sh" in targeted_runtime
+assert "avd-v1-${{ runner.os }}-tv-api31-android-tv-x86-tv_1080p" in targeted_runtime
+assert "avd-v1-${{ runner.os }}-mobile-api35-google_apis-x86_64-pixel_2" in targeted_runtime
+
+# Canonical reader proof is one Android workflow: representative movie/TV/anime
+# catalogue routes, every manifest provider including inactive entries, every
+# returned stream, official TV/Mobile readers, then a bounded Brain retest. The
+# workflow declares exhaustive intent; pull-request staging may bound that intent
+# while trusted-main/manual runs keep the full path.
+for fixture in ("sinners-2025", "breaking-bad-s01e01", "jujutsu-kaisen-s01e01"):
+ assert fixture in android_reader, fixture
+for required in (
+ "pull_request:",
+ "push:",
+ ' - "providers/**"',
+ ' - "provider_catalog.json"',
+ ' - "provider-overrides.json"',
+ ' - "vf/manifest.json"',
+ "NuvioMedia/NuvioTV.git",
+ "NuvioMedia/NuvioMobile.git",
+ "NIAKVIO_TARGET_PROVIDER: all",
+ "NIAKVIO_PRIMARY_STREAM_SCOPE: all",
+ "NIAKVIO_REGRESSION_STREAM_SCOPE: all",
+ "--provider all --streams all",
+ 'NIAKVIO_REQUIRE_READER_SUCCESS: "1"',
+ "avd-v1-${{ runner.os }}-tv-api31-android-tv-x86-tv_1080p",
+ "avd-v1-${{ runner.os }}-mobile-api35-google_apis-x86_64-pixel_2",
+ "build_native_reader_brain_repair.py",
+ "compare_native_reader_brain_repair.py",
+ "--max-providers 24",
+ "native-reader-brain-repair-${{ github.run_id }}",
+ "native-tv-route-sinners-2025-${{ github.run_id }}",
):
- assert job in workflow, job
-assert "android-mobile-tv-native-corpus:" not in workflow
-assert workflow.count("runs-on: ubuntu-latest") == 5, workflow.count("runs-on: ubuntu-latest")
-assert "matrix:" not in workflow
-assert "strategy:" not in workflow
-
-# Each client owns its repository/job. Mobile and TV must never share a clone or emulator job.
-desktop_section = workflow.split(" desktop-native-corpus:", 1)[1].split(" mobile-native-corpus:", 1)[0]
-mobile_section = workflow.split(" mobile-native-corpus:", 1)[1].split(" tv-native-corpus:", 1)[0]
-tv_section = workflow.split(" tv-native-corpus:", 1)[1].split(" native-corpus-engine-summary:", 1)[0]
-assert "NuvioMedia/NuvioDesktop.git" in desktop_section
-assert "NuvioMedia/NuvioMobile.git" not in desktop_section
-assert "NuvioMedia/NuvioTV.git" not in desktop_section
-assert "NuvioMedia/NuvioMobile.git" in mobile_section
-assert "NuvioMedia/NuvioTV.git" not in mobile_section
-assert "NuvioMedia/NuvioDesktop.git" not in mobile_section
-assert "NuvioMedia/NuvioTV.git" in tv_section
-assert "NuvioMedia/NuvioMobile.git" not in tv_section
-assert "NuvioMedia/NuvioDesktop.git" not in tv_section
-# One cold-cache snapshot-generation invocation + one runtime invocation. Warm runs skip the first.
-assert mobile_section.count("ReactiveCircus/android-emulator-runner@") == 2
-assert tv_section.count("ReactiveCircus/android-emulator-runner@") == 2
-
-# Android profiles are cached as clean snapshots and reused without saving test state.
-for section, cache_key in (
- (mobile_section, "avd-v1-${{ runner.os }}-mobile-api35-google_apis-x86_64-pixel_2"),
- (tv_section, "avd-v1-${{ runner.os }}-tv-api31-android-tv-x86-tv_1080p"),
+ assert required in android_reader, required
+assert "playback-hotfix" not in android_reader
+
+# Desktop reader proof is native macOS/Windows only, never the Linux stub, and
+# direct provider/catalog/override changes must trigger fresh native proof.
+for required in (
+ "macos-15",
+ "windows-2022",
+ ' - "providers/**"',
+ ' - "provider_catalog.json"',
+ ' - "provider-overrides.json"',
+ ' - "vf/manifest.json"',
+ "NuvioMedia/NuvioDesktop.git",
+ ":composeApp:buildMacosPlayerBridge",
+ ":composeApp:buildWindowsPlayerBridge",
+ "Microsoft.Web.WebView2",
+ "NIAKVIO_TARGET_PROVIDER: all",
+ "NIAKVIO_PRIMARY_STREAM_SCOPE: all",
+ "NIAKVIO_REGRESSION_STREAM_SCOPE: all",
+ "native-evidence/desktop/**",
):
- assert "gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c" in section
- assert "actions/cache@caa296126883cff596d87d8935842f9db880ef25" in section
- assert "~/.android/avd/*" in section
- assert "~/.android/adb*" in section
- assert cache_key in section
- assert section.count("force-avd-creation: false") == 2
- assert "-no-snapshot-save" in section
-
-# TV corpus must be a genuine Android TV proof, not a phone AVD labelled as TV.
-assert "api-level: 31" in tv_section
-assert "target: android-tv" in tv_section
-assert "arch: x86" in tv_section
-assert "profile: tv_1080p" in tv_section
-assert "profile: pixel_2" not in tv_section
-
-# Targeted retries stay per-device, but TV is one job/one emulator for all six fixtures.
-assert "- tv\n - mobile\n - desktop\n - all" in targeted_workflow
-assert 'targets = [{"device": device, "fixture": "all"}]' in targeted_workflow
-assert '{"device": "tv", "fixture": "all"}' in targeted_workflow
-assert 'run_native_corpus_tv_suite.sh' in targeted_workflow
-assert 'run-tv-targeted.sh' not in targeted_workflow
-assert 'TV_CORPUS_FIXTURE' not in targeted_workflow
-targeted_tv = targeted_workflow.split("- name: Restore TV AVD snapshot", 1)[1]
-assert "api-level: 31" in targeted_tv
-assert "target: android-tv" in targeted_tv
-assert "arch: x86" in targeted_tv
-assert "profile: tv_1080p" in targeted_tv
-assert "avd-v1-${{ runner.os }}-tv-api31-android-tv-x86-tv_1080p" in targeted_tv
-assert "force-avd-creation: false" in targeted_tv
-assert "-no-snapshot-save" in targeted_tv
-assert "run_native_corpus_mobile_suite.sh" in targeted_workflow
-assert "avd-v1-${{ runner.os }}-mobile-api35-google_apis-x86_64-pixel_2" in targeted_workflow
+ assert required in desktop_reader, required
+assert "official_nuvio_desktop_player_is_stub" in (ROOT / "scripts/run_native_corpus_desktop_suite.sh").read_text(encoding="utf-8")
+# Reader learning imports exactly the trusted-main Android run that completed,
+# once. PR candidate evidence is diagnosable but cannot silently mutate persistent
+# Brain memory. Missing repair artifacts do not consume the run id, so rerunning
+# the same GitHub run remains learnable when it later produces complete evidence.
for required in (
- "prepare_native_corpus_client.py",
- "restage_native_corpus_client.py",
- "run_native_corpus_mobile_suite.sh",
- "run_native_corpus_tv_suite.sh",
- "analyze_native_corpus_collection.cjs",
- "summarize_native_corpus_suite.cjs",
- "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a",
- "actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c",
+ "Native Android route reader acceptance",
+ "github.event.workflow_run.id",
+ "github.event.workflow_run.event == 'push'",
+ "github.event.workflow_run.head_branch == 'main'",
+ "importedRunIds",
+ "native-reader-brain-repair-$RUN_ID",
+ "has_comparison=true",
+ "has_comparison=false",
+ "steps.source.outputs.repair_duplicate != 'true'",
+ "rm -rf reader-learning-input/acceptance",
+ "duplicate",
):
- assert required in workflow, required
-
-# A no-op workflow_run on another revision must not cancel a useful push corpus.
-assert "native-corpus-device-lab-${{ github.event_name }}-${{ github.sha }}" in workflow
-assert "workflow_run:" in workflow
-assert "Niakvio provider pipeline" in workflow
-assert "chore: publish validated ARCHI2 provider transaction" in workflow
-assert "github.event.workflow_run.conclusion" in workflow
-assert "sources.json" in workflow
-assert "accepted_ref" in workflow
-assert "target_sha" in workflow
-assert '.github/triggers/native-corpus-device-lab' in workflow
-
-# Provider/runtime anomalies are evidence. Only an incomplete corpus is an infrastructure failure.
-assert "FIELD_NATIVE_CORPUS_BEGIN" in prepare_core
-assert "FIELD_NATIVE_CORPUS_END" in prepare_core
-assert "FIELD_NATIVE_ERROR" in prepare_core
-for marker in (
- "no_readable_log",
- "missing_begin_marker",
- "missing_end:",
- "incomplete_provider_traversal:",
- "invalid_expected_provider_count:",
+ assert required in reader_learning, required
+assert "NiakVIO Brain learning lab" not in reader_learning
+assert "gh run list --workflow native-android-route-reader.yml" not in reader_learning
+assert "native-corpus-device-lab.yml" not in brain_learning
+assert "--native-summary" not in brain_learning
+assert "--provider-portfolio" not in brain_learning
+assert "nativeReaderRepairMemory" in brain_learning
+
+# Android AVDs remain warm/cached; test sessions do not save mutated snapshots.
+for workflow, cache_key in (
+ (android_reader, "avd-v1-${{ runner.os }}-tv-api31-android-tv-x86-tv_1080p"),
+ (android_reader, "avd-v1-${{ runner.os }}-mobile-api35-google_apis-x86_64-pixel_2"),
):
- assert marker in collection_analyzer, marker
-assert "process.exitCode = complete ? 0 : 2" in collection_analyzer
+ assert "actions/cache@caa296126883cff596d87d8935842f9db880ef25" in workflow
+ assert cache_key in workflow
+ assert "force-avd-creation: false" in workflow
+ assert "-no-snapshot-save" in workflow
-# Kotlin/JUnit assertTrue signatures differ. Preparation and every fixture restage must preserve them.
-desktop_old = 'assertTrue(errors.isEmpty(), "native provider runtime errors:'
-android_old = 'assertTrue("native provider runtime errors:'
-desktop_new = 'assertTrue(providers.isNotEmpty(), "native corpus provider list must not be empty")'
-android_new = 'assertTrue("native corpus provider list must not be empty", providers.isNotEmpty())'
+# Pull-request reader staging is explicitly bounded, but trusted main/manual
+# execution remains exhaustive because the bound is derived from the event name.
for source, label in ((prepare_client, "prepare"), (restage_client, "restage")):
- assert desktop_old in source, (label, "desktop source anchor")
- assert android_old in source, (label, "android source anchor")
- assert desktop_new in source, (label, "desktop replacement")
- assert android_new in source, (label, "android replacement")
-
-stageable = []
-seen = set()
-for row in manifest.get("scrapers", []):
- if not isinstance(row, dict):
- continue
- provider_id = str(row.get("id") or "").strip()
- filename = str(row.get("filename") or "").strip()
- key = provider_id.casefold()
- if not provider_id or not filename or key in seen:
- continue
- seen.add(key)
- stageable.append(provider_id)
-assert len(stageable) >= 80, len(stageable)
+ assert "--provider" in source, (label, "target provider")
+ assert "--player-probes" in source, (label, "target reader probe count")
+ assert "--manifest" in source, (label, "manifest selection")
+assert "raw.githubusercontent.com" in prepare_client
+assert "FIELD_NATIVE_CORPUS_STAGE_SELECTED" in prepare_client
+assert "GITHUB_EVENT_NAME" in restage_client
+assert "NIAKVIO_PR_PROVIDER_LIMIT" in restage_client
+assert "pr-bounded" in restage_client
+# Production Nuvio player is primary evidence; lightweight HTTP is secondary.
+# The lab must never construct a parallel ExoPlayer/data-source stack because that
+# can disagree with the actual TV/Mobile application and create a second test logic.
+for required in (
+ "Screen.Player.createRoute",
+ "NuvioNavHost",
+ "LastPlaybackDiagnostics",
+ "PlatformPlayerSurface(",
+ "PlayerPlaybackSnapshot",
+ "nuvio-tv-production",
+ "nuvio-mobile-production",
+ "FIELD_NATIVE_PLAYER",
+ "duration_identity",
+ "short_media",
+ "",
+):
+ assert required in reader_codegen, required
+for forbidden in (
+ "ExoPlayer.Builder(context)",
+ "PlayerPlaybackNetworking.createDataSourceFactory(context, headers)",
+ "PlatformPlaybackDataSourceFactory.create(",
+):
+ assert forbidden not in reader_codegen, forbidden
+assert reader_codegen.index("val reader = probeNativePlayer") < reader_codegen.index("val transport = probeTransport")
+for required in (
+ "missing_native_reader_evidence",
+ "FIELD_NATIVE_READER_GATE_FAILURE",
+ "readerFailureClass",
+ "process.exit(failures.length ? 1 : 0)",
+):
+ assert required in reader_gate, required
for required in (
"java.net.HttpURLConnection",
"probeTransport(row.url, row.headers)",
"#EXTM3U",
- "hlsDuration",
"FIELD_NATIVE_TRANSPORT",
- "media_hint64",
- "host64",
):
assert required in prepare_core, required
assert "url64=" not in prepare_core
assert "headers64=" not in prepare_core
+# Provider/runtime anomalies are evidence. Infrastructure fails only on an
+# incomplete corpus/evidence chain.
+for marker in (
+ "no_readable_log",
+ "missing_begin_marker",
+ "missing_end:",
+ "incomplete_provider_traversal:",
+ "invalid_expected_provider_count:",
+):
+ assert marker in collection_analyzer, marker
+assert "process.exitCode = complete ? 0 : 2" in collection_analyzer
+
for suite, client in ((mobile_suite, "MOBILE"), (tv_suite, "TV")):
assert 'for fixture in "${FIXTURES[@]}"' in suite, client
assert f"FIELD_NATIVE_CORPUS_{client}_STATUS" in suite, client
assert f"FIELD_NATIVE_CORPUS_{client}_SUITE_STATUS" in suite, client
+ assert "NIAKVIO_REQUIRE_READER_SUCCESS" in suite, client
+ assert "NIAKVIO_TARGET_MANIFEST" in suite, client
+ assert '--manifest "$TARGET_MANIFEST"' in suite, client
+ assert "gate_native_reader_result.cjs" in suite, client
+ assert "--no-daemon" not in suite, client
for required in (
"repeatedContradictions",
@@ -204,12 +248,28 @@
"repeatedPlatformGaps",
"systemicEmpty",
"providerRuntimeErrors",
+ "repeatedReaderFailures",
+ "nativeReaderFailures",
+ "readerFailureClasses",
"FIELD_NATIVE_ENGINE_SIGNAL",
):
assert required in summarizer, required
-assert "native-corpus-engine-summary" in workflow
+
+stageable = []
+seen = set()
+for row in manifest.get("scrapers", []):
+ if not isinstance(row, dict):
+ continue
+ provider_id = str(row.get("id") or "").strip()
+ filename = str(row.get("filename") or "").strip()
+ key = provider_id.casefold()
+ if not provider_id or not filename or key in seen:
+ continue
+ seen.add(key)
+ stageable.append(provider_id)
+assert len(stageable) >= 80, len(stageable)
print(
- "native corpus device lab coverage tests passed: "
- f"fixtures={len(expected_slugs)} stageable_providers={len(stageable)} clients=3 isolated_android_jobs=2 cached_avds=true real_tv=true targeted_single_boot=true collection_gate=true assertion_contract=true"
-)
+ "native device lab contract passed: "
+ f"fixtures={len(expected_slugs)} providers={len(stageable)} android_exhaustive=true desktop_native=true brain_retest=true cached_profiles=true targeted_manual=true reader_learning_idempotent=true trusted_main_learning=true missing_artifact_retriable=true pr_bounded=true production_player_only=true"
+)
\ No newline at end of file
diff --git a/tests/native_corpus_suite_summary_test.py b/tests/native_corpus_suite_summary_test.py
index f73cb998f..c5ad266ee 100644
--- a/tests/native_corpus_suite_summary_test.py
+++ b/tests/native_corpus_suite_summary_test.py
@@ -32,12 +32,22 @@ def row(client: str, fixture: str, provider: str, title: str, hint: str) -> str:
def transport(client: str, fixture: str, provider: str, seconds: float) -> str:
return (
- f"FIELD_NATIVE_TRANSPORT client={client} fixture={fixture} provider64={b64(provider)} "
+ f"FIELD_NATIVE_TRANSPORT client={client} fixture={fixture} provider64={b64(provider)} index=0 "
f"state=ok kind=hls status=200 content_type64={b64('application/vnd.apple.mpegurl')} "
f"extm3u=true duration_seconds={seconds} host64={b64('media.example')} media_hint64={b64('fixture-media')}"
)
+def player(client: str, fixture: str, provider: str, state: str, *, status: int = 0, stage: str = 'none', seconds: float = 0) -> str:
+ return (
+ f"FIELD_NATIVE_PLAYER client={client} fixture={fixture} provider64={b64(provider)} index=0 "
+ f"state={state} engine=media3 http_status={status} failure_stage={stage} duration_seconds={seconds} "
+ f"host64={b64('media.example')} error_class64={b64('PlaybackException' if state == 'error' else '')} "
+ f"error_code64={b64('ERROR_CODE_IO_BAD_HTTP_STATUS' if status else '')} "
+ f"exception_chain64={b64('InvalidResponseCodeException' if status else '')} response_header_names64={b64('content-type,date' if status else '')}"
+ )
+
+
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
desktop = []
@@ -65,6 +75,18 @@ def transport(client: str, fixture: str, provider: str, seconds: float) -> str:
transport("desktop", fixture, "topcartoons", 7 * 60),
]
+ # Reader evidence must survive the cross-device summary with the exact causal
+ # class while remaining sanitized. Two 403s become a repeated reader signal.
+ for fixture in ("sinners-2025", "interstellar"):
+ tv += [
+ result("tv", fixture, "MOVIESDRIVE", 1),
+ player("tv", fixture, "MOVIESDRIVE", "error", status=403, stage="http_access"),
+ ]
+ tv += [
+ result("tv", "sinners-2025", "PURSTREAM", 1),
+ player("tv", "sinners-2025", "PURSTREAM", "ready", seconds=137 * 60),
+ ]
+
(root / "desktop-native-corpus-synthetic.log").write_text("\n".join(desktop) + "\n")
(root / "tv-native-corpus-synthetic.log").write_text("\n".join(tv) + "\n")
output = root / "summary.json"
@@ -77,7 +99,7 @@ def transport(client: str, fixture: str, provider: str, seconds: float) -> str:
if proc.returncode != 0:
raise AssertionError(proc.stdout + "\n" + proc.stderr)
data = json.loads(output.read_text())
- assert data["schemaVersion"] >= 2, data
+ assert data["schemaVersion"] >= 3, data
signals = data["engineSignals"]
streamzo_gap = next(
row for row in signals["repeatedPlatformGaps"]
@@ -103,7 +125,20 @@ def transport(client: str, fixture: str, provider: str, seconds: float) -> str:
)
assert topcartoons["occurrences"] >= 2, topcartoons
assert data["contradictions"] >= 2, data
+
+ assert data["nativeReaderObserved"] == 3, data
+ assert data["nativeReaderHealthy"] == 1, data
+ assert data["nativeReaderFailures"] == 2, data
+ assert data["readerFailureClasses"]["playback_http_access"] == 2, data
+ moviesdrive = next(row for row in data["providerReaderFailures"] if row["provider"] == "moviesdrive")
+ assert moviesdrive["occurrences"] == 2, moviesdrive
+ repeated_reader = next(row for row in signals["repeatedReaderFailures"] if row["provider"] == "moviesdrive")
+ assert repeated_reader["failureClass"] == "playback_http_access", repeated_reader
+ assert repeated_reader["occurrences"] == 2, repeated_reader
+ assert data["readerPrivacy"].startswith("Sanitized only"), data["readerPrivacy"]
+
assert "FIELD_NATIVE_ENGINE_SIGNAL" in proc.stdout, proc.stdout
assert "FIELD_NATIVE_CAPABILITY_SIGNAL" in proc.stdout, proc.stdout
+ assert "FIELD_NATIVE_PLAYER_SUMMARY" in proc.stdout, proc.stdout
-print("native corpus suite summary tests passed")
+print("native corpus suite summary tests passed")
\ No newline at end of file
diff --git a/tests/native_evidence_codegen_pipeline_test.py b/tests/native_evidence_codegen_pipeline_test.py
new file mode 100644
index 000000000..728dc63d5
--- /dev/null
+++ b/tests/native_evidence_codegen_pipeline_test.py
@@ -0,0 +1,155 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import importlib.util
+import os
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+PINNED_MANIFEST_URL = (
+ "https://raw.githubusercontent.com/niakw/NiakVIO/"
+ "0123456789abcdef0123456789abcdef01234567/manifest.json"
+)
+
+
+def load(name: str, relative: str):
+ path = ROOT / relative
+ spec = importlib.util.spec_from_file_location(name, path)
+ assert spec and spec.loader
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+corpus = load("native_corpus_validation", "scripts/prepare_native_corpus_validation.py")
+reader = load("native_player_diag_codegen", "scripts/native_player_diagnostics_codegen.py")
+contract = load("native_request_contract", "scripts/augment_native_corpus_request_contract.py")
+provider_loading = load("native_provider_loading", "scripts/augment_native_provider_loading.py")
+desktop_player = load("native_desktop_player", "scripts/augment_native_desktop_player.py")
+
+manifest = ROOT / "manifest.json"
+providers = corpus.manifest_providers()
+assert len(providers) >= 80
+selected = []
+for wanted in ("movieshunt", "purstream", "anime-sama"):
+ row = next((p for p in providers if str(p["id"]).casefold() == wanted), None)
+ if row is not None:
+ selected.append({**row, "asset": f"p{len(selected):03d}.js"})
+assert len(selected) >= 2
+
+with tempfile.TemporaryDirectory() as tmp_raw:
+ tmp = Path(tmp_raw)
+
+ movie = corpus.fixture_by_slug("sinners-2025")
+ tv_source = reader.augment_android_test(
+ corpus.android_test(movie, selected, "tv"),
+ client="tv",
+ expected_duration_minutes=movie.get("expectedDurationMinutes"),
+ max_player_probes=4,
+ )
+ tv_path = tmp / "Tv.kt"
+ tv_path.write_text(tv_source, encoding="utf-8")
+ contract.augment(tv_path, "tv", "sinners-2025", manifest)
+ provider_loading.augment(tv_path, "tv", manifest, PINNED_MANIFEST_URL, "")
+ tv_out = tv_path.read_text(encoding="utf-8")
+ for required in (
+ "FIELD_NATIVE_UI_LAUNCHED client=tv",
+ "FIELD_NATIVE_REPOSITORY_LOAD_BEGIN client=tv",
+ "FIELD_NATIVE_PROVIDER_LOAD_RESULT client=tv",
+ "officialPluginManager.executeScraper(loadedScraper",
+ "FIELD_NATIVE_PLAYER_BEGIN client=tv",
+ "Screen.Player.createRoute",
+ "NuvioNavHost",
+ "LastPlaybackDiagnostics",
+ "nuvio-tv-production",
+ ):
+ assert required in tv_out, required
+ assert "ExoPlayer.Builder" not in tv_out
+ assert "PluginRuntime.executePlugin(" not in tv_out
+
+ anime = corpus.fixture_by_slug("jujutsu-kaisen-s01e01")
+ mobile_source = reader.augment_android_test(
+ corpus.android_test(anime, selected, "mobile"),
+ client="mobile",
+ expected_duration_minutes=anime.get("expectedDurationMinutes"),
+ max_player_probes=4,
+ )
+ mobile_path = tmp / "Mobile.kt"
+ mobile_path.write_text(mobile_source, encoding="utf-8")
+ contract.augment(mobile_path, "mobile", "jujutsu-kaisen-s01e01", manifest)
+ provider_loading.augment(mobile_path, "mobile", manifest, PINNED_MANIFEST_URL, "")
+ mobile_out = mobile_path.read_text(encoding="utf-8")
+ for required in (
+ 'listOf("anime", "tv").map',
+ '"capability_probe"',
+ "FIELD_NATIVE_REPOSITORY_LOAD_BEGIN client=mobile",
+ "PluginRepository.executeScraper(loadedScraper",
+ "PlatformPlayerSurface",
+ "sourceHeaders = headers.orEmpty()",
+ "nuvio-mobile-production",
+ "FIELD_NATIVE_PLAYER_BEGIN client=mobile",
+ ):
+ assert required in mobile_out, required
+ assert "ExoPlayer.Builder" not in mobile_out
+ assert "PluginRepository.clearLocalState()" not in mobile_out
+ assert "PluginRuntime.executePlugin(" not in mobile_out
+
+ original_event = os.environ.pop("GITHUB_EVENT_NAME", None)
+ try:
+ for platform in ("macos", "windows"):
+ desktop_path = tmp / f"Desktop-{platform}.kt"
+ desktop_path.write_text(corpus.desktop_test(anime, selected), encoding="utf-8")
+ contract.augment(desktop_path, "desktop", "jujutsu-kaisen-s01e01", manifest)
+ provider_loading.augment(desktop_path, "desktop", manifest, PINNED_MANIFEST_URL, platform)
+ desktop_player.augment(desktop_path, int(anime.get("expectedDurationMinutes") or 0), "all")
+ completed = subprocess.run(
+ [sys.executable, str(ROOT / "scripts/complete_native_desktop_frontend_phases.py"), str(desktop_path)],
+ cwd=ROOT,
+ text=True,
+ capture_output=True,
+ )
+ assert completed.returncode == 0, completed.stdout + completed.stderr
+ desktop_out = desktop_path.read_text(encoding="utf-8")
+ for required in (
+ "PluginRepository.executeScraper(loadedScraper",
+ "PlatformPlayerSurface(",
+ "probeDesktopProductionPlayer",
+ "engine=nuvio-production-desktop",
+ "sourceHeaders = headers.orEmpty()",
+ 'captureDesktopPhase("player-start"',
+ 'captureDesktopPhase("player-result"',
+ ):
+ assert required in desktop_out, f"{platform}:{required}"
+ assert "NativePlayerController(" not in desktop_out
+ assert "controller.attach(" not in desktop_out
+ assert "rows.take(" not in desktop_out
+ assert "25000L" in desktop_out
+ finally:
+ if original_event is not None:
+ os.environ["GITHUB_EVENT_NAME"] = original_event
+
+ original_event = os.environ.get("GITHUB_EVENT_NAME")
+ os.environ["GITHUB_EVENT_NAME"] = "pull_request"
+ os.environ["NIAKVIO_PR_STREAM_LIMIT"] = "2"
+ try:
+ pr_path = tmp / "Desktop-pr.kt"
+ pr_path.write_text(corpus.desktop_test(anime, selected), encoding="utf-8")
+ contract.augment(pr_path, "desktop", "jujutsu-kaisen-s01e01", manifest)
+ provider_loading.augment(pr_path, "desktop", manifest, PINNED_MANIFEST_URL, "windows")
+ desktop_player.augment(pr_path, int(anime.get("expectedDurationMinutes") or 0), "all")
+ pr_out = pr_path.read_text(encoding="utf-8")
+ assert "rows.take(2).forEachIndexed" in pr_out
+ assert "rows.take(1).forEachIndexed" not in pr_out
+ assert "12000L" in pr_out
+ assert "PlatformPlayerSurface(" in pr_out
+ finally:
+ os.environ.pop("NIAKVIO_PR_STREAM_LIMIT", None)
+ if original_event is None:
+ os.environ.pop("GITHUB_EVENT_NAME", None)
+ else:
+ os.environ["GITHUB_EVENT_NAME"] = original_event
+
+print("native evidence production-player codegen pipeline tests passed")
diff --git a/tests/native_evidence_completeness_test.cjs b/tests/native_evidence_completeness_test.cjs
new file mode 100644
index 000000000..c3be37c18
--- /dev/null
+++ b/tests/native_evidence_completeness_test.cjs
@@ -0,0 +1,67 @@
+'use strict';
+
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const { assessNativeEvidence } = require('../scripts/native_evidence_completeness.cjs');
+
+function writeLog(dir, name, lines) {
+ const file = path.join(dir, name);
+ fs.writeFileSync(file, `${lines.join('\n')}\n`);
+ return file;
+}
+
+function baseEvidence(routeLines, frontendLines) {
+ return [
+ 'FIELD_NATIVE_CORPUS_BEGIN client=tv fixture=sinners-2025 providers=1',
+ 'FIELD_NATIVE_EVIDENCE_INSTRUMENTED client=tv',
+ 'FIELD_NATIVE_REPOSITORY_LOAD_BEGIN client=tv fixture=sinners-2025 expected=1',
+ 'FIELD_NATIVE_REPOSITORY_CACHE_HIT client=tv fixture=sinners-2025',
+ 'FIELD_NATIVE_REPOSITORY_LOAD_RESULT client=tv fixture=sinners-2025',
+ 'FIELD_NATIVE_PROVIDER_LOAD_RESULT client=tv fixture=sinners-2025 provider=test',
+ ...routeLines,
+ 'FIELD_NATIVE_FRONTEND_CAPTURE client=tv fixture=sinners-2025 phase=ui-launched',
+ 'FIELD_NATIVE_FRONTEND_CAPTURE client=tv fixture=sinners-2025 phase=corpus-begin',
+ 'FIELD_NATIVE_FRONTEND_CAPTURE client=tv fixture=sinners-2025 phase=repository-load',
+ 'FIELD_NATIVE_FRONTEND_CAPTURE client=tv fixture=sinners-2025 phase=repository-loaded',
+ 'FIELD_NATIVE_FRONTEND_CAPTURE client=tv fixture=sinners-2025 phase=provider-load-state',
+ ...frontendLines,
+ 'FIELD_NATIVE_FRONTEND_CAPTURE client=tv fixture=sinners-2025 phase=corpus-end',
+ 'FIELD_NATIVE_CORPUS_END client=tv fixture=sinners-2025',
+ ];
+}
+
+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'niakvio-native-evidence-'));
+try {
+ const skippedOnly = writeLog(tmp, 'skipped-only.log', baseEvidence([
+ 'FIELD_NATIVE_PROVIDER_SKIPPED client=tv fixture=sinners-2025 provider=test reason=unsupported_type',
+ ], []));
+ const skippedAssessment = assessNativeEvidence([skippedOnly]);
+ assert.equal(skippedAssessment.complete, false, 'skipped-only corpus must never be complete');
+ assert.ok(
+ skippedAssessment.problems.includes('missing_provider_execution:tv:sinners-2025'),
+ `missing execution problem not reported: ${JSON.stringify(skippedAssessment.problems)}`,
+ );
+ assert.equal(skippedAssessment.stats.providerExecutions, 0);
+
+ const executedNoPlayer = writeLog(tmp, 'executed-no-player.log', baseEvidence([
+ 'FIELD_NATIVE_PROVIDER_BEGIN client=tv fixture=sinners-2025 provider=test request_type=movie',
+ 'FIELD_NATIVE_RESULT client=tv fixture=sinners-2025 provider=test request_type=movie returned=0',
+ ], [
+ 'FIELD_NATIVE_FRONTEND_CAPTURE client=tv fixture=sinners-2025 phase=provider-loading',
+ 'FIELD_NATIVE_FRONTEND_CAPTURE client=tv fixture=sinners-2025 phase=provider-result',
+ ]));
+ const executedAssessment = assessNativeEvidence([executedNoPlayer]);
+ assert.equal(
+ executedAssessment.complete,
+ true,
+ `real provider execution with zero returned streams remains valid evidence: ${JSON.stringify(executedAssessment.problems)}`,
+ );
+ assert.equal(executedAssessment.stats.providerExecutions, 1);
+ assert.equal(executedAssessment.stats.playerProbes, 0);
+
+ console.log('native evidence completeness execution-floor tests passed');
+} finally {
+ fs.rmSync(tmp, { recursive: true, force: true });
+}
diff --git a/tests/native_evidence_contract_test.py b/tests/native_evidence_contract_test.py
new file mode 100644
index 000000000..6930a5257
--- /dev/null
+++ b/tests/native_evidence_contract_test.py
@@ -0,0 +1,255 @@
+#!/usr/bin/env python3
+"""Architecture contract for native human-UX playback evidence.
+
+This test intentionally focuses on invariants that must never regress: official Nuvio
+repository/provider loading, production player entry points, observational evidence,
+ordinary OS privileges and fail-closed Brain learning. Detailed codegen behavior has
+its own dedicated tests.
+"""
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def text(path: str) -> str:
+ return (ROOT / path).read_text(encoding="utf-8")
+
+
+request_contract = text("scripts/augment_native_corpus_request_contract.py")
+provider_loading = text("scripts/augment_native_provider_loading.py")
+repository_http = text("scripts/instrument_native_repository_http_evidence.py")
+client_instrumenter = text("scripts/instrument_native_client_evidence.py")
+desktop_instrumenter = text("scripts/instrument_native_desktop_evidence.py")
+resolver = text("scripts/resolve_native_repository.sh")
+client_head_resolver = text("scripts/resolve_nuvio_lab_heads.py")
+reader_runtime_scope = text("scripts/scope_native_reader_learning_runtime.py")
+android_player = text("scripts/native_player_diagnostics_codegen.py")
+desktop_player = text("scripts/augment_native_desktop_player.py")
+android_transport = text("scripts/configure_native_android_lab_transport.py")
+tv_suite = text("scripts/run_native_corpus_tv_suite.sh")
+mobile_suite = text("scripts/run_native_corpus_mobile_suite.sh")
+desktop_suite = text("scripts/run_native_corpus_desktop_suite.sh")
+completeness = text("scripts/native_evidence_completeness.cjs")
+diagnosis = text("engine_v2/scripts/diagnose-native-reader.mjs")
+reader_gate = text("scripts/gate_native_reader_result.cjs")
+coverage_gate = text("scripts/gate_native_reader_coverage.cjs")
+player_reach_gate = text("scripts/gate_native_player_reached.cjs")
+android_workflow = text(".github/workflows/native-android-route-reader.yml")
+desktop_workflow = text(".github/workflows/native-desktop-reader-acceptance.yml")
+learning_sync = text(".github/workflows/native-reader-learning-sync.yml")
+
+# Canonical media route traversal remains explicit; capability probes are evidence,
+# never permission for a Lab-side provider/player rewrite.
+for required in (
+ 'CANONICAL = {"movie", "tv", "anime"}',
+ "ProviderRequestRoute",
+ 'listOf("anime", "tv").map',
+ '"capability_probe"',
+ "FIELD_NATIVE_PROVIDER_BEGIN",
+ "FIELD_NATIVE_PLAYER_BEGIN",
+ "request_type=$requestMediaType route_mode=$routeMode",
+):
+ assert required in request_contract, required
+
+# Real Nuvio repository/provider APIs are used. Profile/plugin state is warm and not
+# reset to manufacture another environment.
+for required in (
+ "manager.addRepository(repositoryManifestUrl)",
+ "officialPluginManager.executeScraper(loadedScraper",
+ "PluginRepository.initialize()",
+ "PluginRepository.addRepository(repositoryManifestUrl)",
+ "PluginRepository.executeScraper(loadedScraper",
+ "FIELD_NATIVE_REPOSITORY_LOAD_BEGIN",
+ "FIELD_NATIVE_PROVIDER_LOAD_RESULT",
+):
+ assert required in provider_loading, required
+assert "PluginRepository.clearLocalState()" not in provider_loading
+assert "PluginRuntime.executePlugin(" not in provider_loading
+
+# Production Nuvio source must not be patched merely to gain richer HTTP evidence.
+# Historical instrumentation entry points remain as audited no-op shims so stale
+# workflow references cannot silently reintroduce runtime mutation.
+for shim in (repository_http, client_instrumenter, desktop_instrumenter):
+ assert "disabled_by_human_ux_policy" in shim
+ assert "runtime_mutation=false" in shim
+ assert "audit_checkout" in shim
+ for forbidden in (
+ "write_text(",
+ "write_bytes(",
+ "addInterceptor",
+ "FIELD_NATIVE_HTTP_REQUEST client=",
+ "FIELD_NATIVE_REPOSITORY_HTTP_REQUEST client=",
+ ):
+ assert forbidden not in shim, forbidden
+
+# Staging may expose a generated content-addressed NiakVIO repository, but may never
+# elevate Nuvio or bypass the OS to reach it.
+for required in (
+ "content-addressed",
+ "candidate-${content_key}",
+ "manifest + every provider",
+ "local_server_not_ready_unprivileged",
+ "observational=true privileged=false",
+):
+ assert required in resolver, required
+for forbidden in ("sudo -n", "sudo ", "--add-opens", "ALL-UNNAMED"):
+ assert forbidden not in resolver, forbidden
+ assert forbidden not in desktop_suite, forbidden
+assert "root_execution_forbidden" in desktop_suite
+
+# Labs resolve the latest official client branch HEAD. Accepted/contract refs remain
+# audit context only; an unresolved HEAD may never silently fall back to stale code.
+for required in (
+ "latest official HEAD is unresolved",
+ "refusing stale fallback",
+ "current_head",
+ "accepted_ref",
+ "contract_ref",
+ "latest-official-head-for-labs",
+):
+ assert required in client_head_resolver, required
+for workflow in (android_workflow, desktop_workflow):
+ assert "check_nuvio_client_upstreams.py" in workflow
+ assert "resolve_nuvio_lab_heads.py" in workflow
+ assert "Checkout latest official" in workflow
+assert "get('accepted_ref') or '')" not in android_workflow
+assert "get('accepted_ref') or '')" not in desktop_workflow
+
+# Reader repair memory is historical but only influences the exact client revision
+# fingerprint that produced it. Legacy/unscoped memory is never recycled after drift.
+for required in (
+ "exact-runtime-fingerprint-only",
+ "legacyUnscopedExcluded",
+ "runtimeFingerprint",
+ "entry_fingerprint(row) == fingerprint",
+):
+ assert required in reader_runtime_scope, required
+assert "scope_native_reader_learning_runtime.py filter" in android_workflow
+assert "sinners-cross-client-brain.json" in android_workflow
+assert 'needs: [resolve, tv-route-reader, mobile-route-reader]' in android_workflow
+assert "scope_native_reader_learning_runtime.py merge" in learning_sync
+
+# Android may not gain a test-only transport capability.
+assert "validate_manifest" in android_transport
+assert "mode=production-policy" in android_transport
+assert "modified=false" in android_transport
+assert 'usesCleartextTraffic\", \"true\"' not in android_transport
+assert "android.permission.INTERNET" not in android_transport
+
+# Component diagnostics invoke production player entry points and may supplement
+# diagnosis, but the policy prevents them from being counted as human-UX acceptance.
+for required in (
+ "Screen.Player.createRoute",
+ "NuvioNavHost",
+ "LastPlaybackDiagnostics",
+ "PlatformPlayerSurface",
+ "nuvio-tv-production",
+ "nuvio-mobile-production",
+):
+ assert required in android_player, required
+for forbidden in (
+ "ExoPlayer.Builder",
+ "PlayerPlaybackNetworking.createDataSourceFactory",
+ "PlatformPlaybackDataSourceFactory.create",
+):
+ assert forbidden not in android_player, forbidden
+
+assert "PlatformPlayerSurface(" in desktop_player
+assert "probeDesktopProductionPlayer" in desktop_player
+assert "sourceHeaders = headers.orEmpty()" in desktop_player
+assert "DEFAULT_PR_STREAM_LIMIT = 2" in desktop_player
+assert "NIAKVIO_PR_STREAM_LIMIT" in desktop_player
+for forbidden in (
+ "NativePlayerController(",
+ "NativePlayerHost(",
+ "controller.attach(",
+ "decoderPriority = 1",
+ "nvidiaRtxSuperResolutionEnabled = false",
+):
+ assert forbidden not in desktop_player, forbidden
+
+# The production player is always the first media consumer in component diagnostics.
+# Transport diagnostics are post-observation only and cannot consume/expire a signed
+# URL before the production player attempts it.
+assert android_player.index("val reader = probeNativePlayer(row.url, row.headers, row.type") < android_player.index("val transport = probeTransport(row.url, row.headers)")
+assert desktop_player.index("val reader = probeDesktopProductionPlayer(row.url, row.headers, row.type") < desktop_player.index("val transport = probeTransport(row.url, row.headers)")
+
+# Suites may retain historical shim calls, but those calls are now explicit audited
+# no-ops. Repository/provider/player/frontend evidence comes from test-owned code,
+# official Nuvio behavior and external capture rather than runtime source patching.
+for suite, client in ((tv_suite, "tv"), (mobile_suite, "mobile")):
+ for required in (
+ "instrument_native_client_evidence.py",
+ "instrument_native_repository_http_evidence.py",
+ "augment_native_provider_loading.py",
+ "gate_native_reader_coverage.cjs",
+ "gate_native_reader_result.cjs",
+ "gate_native_player_reached.cjs",
+ "official_repository_loading=true",
+ f"FIELD_NATIVE_EVIDENCE_INSTRUMENTED client={client}",
+ ):
+ assert required in suite, (client, required)
+for required in (
+ "instrument_native_desktop_evidence.py",
+ "instrument_native_repository_http_evidence.py",
+ "augment_native_desktop_player.py",
+ "gate_native_reader_coverage.cjs",
+ "gate_native_reader_result.cjs",
+ "gate_native_player_reached.cjs",
+ "official_repository_loading=true",
+ "privilege=ordinary-user",
+):
+ assert required in desktop_suite, required
+
+# Smoke acceptance is allowed to keep ordinary provider/evidence/player outcomes as
+# diagnostics, but it must never claim success without reaching the production player.
+for required in (
+ "FIELD_NATIVE_PLAYER_BEGIN",
+ "player_never_reached",
+ "FIELD_NATIVE_PLAYER_REACH_GATE",
+):
+ assert required in player_reach_gate, required
+for suite in (tv_suite, mobile_suite, desktop_suite):
+ assert "soft_failures=" in suite
+ assert "gate=production_player_reached" in suite
+ assert "NIAKVIO_BRAIN_NONBLOCKING=1" in suite
+
+# Coverage is fail-closed and PR component proof checks two returned streams; deep/
+# manual diagnostic paths remain exhaustive. This is not human-UX acceptance by itself.
+assert "NIAKVIO_PR_STREAM_LIMIT" in coverage_gate
+assert "DEFAULT_PR_STREAM_LIMIT = 2" in coverage_gate
+assert "streamCoverageSatisfied" in coverage_gate
+assert "return observed >= expected && observed <= returned;" in coverage_gate
+assert "return observed === expected;" in coverage_gate
+for workflow in (android_workflow, desktop_workflow):
+ assert "NIAKVIO_PRIMARY_STREAM_SCOPE: all" in workflow
+assert "NIAKVIO_TARGET_PROVIDER: all" in android_workflow
+assert "NIAKVIO_TARGET_PROVIDER: all" in desktop_workflow
+
+# Evidence completeness and Brain learning remain fail-closed outside the smoke. The
+# native-reader smoke may suppress the diagnostic process exit only; it does not turn
+# incomplete evidence into usable learning or permit provider mutation.
+for required in (
+ "missing_repository_load:",
+ "provider_route_terminal:",
+ "player_terminal:",
+ "http_terminal:",
+ "missing_frontend_phase:",
+):
+ assert required in completeness, required
+for required in (
+ "evidenceComplete",
+ "evidenceUsable: evidence.complete",
+ "learningAllowed: providerLearningAllowed",
+ "repairPlanningAllowed: providerLearningAllowed",
+ "providerMutationRequiresCrossClientConsensus: true",
+ "clientRuntimeFailureLearningAllowed: false",
+ "providerLoadJsMutationAllowed: false",
+ "const nonblockingSmoke = process.env.NIAKVIO_BRAIN_NONBLOCKING === '1'",
+ "if (!evidence.complete && !nonblockingSmoke) process.exitCode = 2",
+ "nonblockingSmoke,",
+):
+ assert required in diagnosis, required
+assert "FIELD_NATIVE_CAPABILITY_PROBE_REJECTED" in reader_gate
+
+print("full native human UX evidence contract tests passed")
diff --git a/tests/native_human_ux_policy_lock_test.py b/tests/native_human_ux_policy_lock_test.py
new file mode 100644
index 000000000..c2d0c77cd
--- /dev/null
+++ b/tests/native_human_ux_policy_lock_test.py
@@ -0,0 +1,104 @@
+#!/usr/bin/env python3
+"""Lock native reader harness behavior and its persistent profile/blocker memory."""
+import json
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+POLICY = json.loads((ROOT / "automation/native-human-ux-policy.json").read_text(encoding="utf-8"))
+
+assert POLICY["version"] >= 4
+assert POLICY["mode"] == "human-ux-observation-only"
+
+harness = POLICY["harness_change_control"]
+assert harness["default"] == "locked-do-not-modify"
+assert harness["change_policy"] == "only-when-faithful-observation-is-blocked"
+for requirement in (
+ "consult persistent_profiles and job_blocker_memory",
+ "identify the exact harness blocker",
+ "show that the blocker prevents observation rather than merely producing a bad playback result",
+ "show that the proposed change cannot make Nuvio, the player, networking or the OS more permissive",
+):
+ assert requirement in harness["required_before_change"], requirement
+for forbidden_reason in (
+ "make CI green",
+ "increase playback success rate",
+ "work around a Nuvio player failure",
+ "work around an OS or emulator playback/network limitation",
+ "re-diagnose a resolved blocker without a changed signature or invalidated profile",
+):
+ assert forbidden_reason in harness["forbidden_reasons"], forbidden_reason
+
+profiles = POLICY["persistent_profiles"]
+assert profiles["schema_version"] == 1
+assert profiles["reuse_rule"] == "reuse-before-rediagnosis"
+for profile_id in ("common", "tv", "mobile", "desktop"):
+ assert profiles[profile_id]["status"] == "validated-and-reusable", profile_id
+common = profiles["common"]
+assert common["production_player_first"] is True
+assert common["transport_probe_after_player_only"] is True
+assert common["preserve_warm_profile_plugin_cache_state"] is True
+assert common["nuvio_runtime_source_read_only"] is True
+assert common["os_network_security_policy_read_only"] is True
+assert common["component_probes_are_diagnostic_only"] is True
+assert common["human_ux_acceptance_requires_real_ui_path"] is True
+assert "a stale regression-test assertion" in common["not_invalidated_by"]
+assert "stream playback failure" in common["not_invalidated_by"]
+assert profiles["tv"]["known_entry"] == "native_client_test_bootstrap.enable_tv_tests"
+assert profiles["mobile"]["known_entry"] == "native_client_test_bootstrap.enable_mobile_device_tests"
+assert profiles["mobile"]["launcher_package"] == "com.nuviodebug.com"
+assert profiles["desktop"]["execution_policy"] == "ordinary-user"
+
+blockers = POLICY["job_blocker_memory"]
+assert blockers["schema_version"] == 1
+assert blockers["consult_before_harness_change"] is True
+entries = {row["id"]: row for row in blockers["entries"]}
+for blocker_id in (
+ "stale-repository-http-instrumentation-contract",
+ "stale-tv-bootstrap-wrapper-contract",
+ "stale-tv-bootstrap-alias-contract",
+ "reader-run-cancellation-churn",
+ "repository-http-evidence-gap-after-instrumentation-disable",
+ "actions-log-blob-not-ready",
+):
+ assert blocker_id in entries, blocker_id
+for blocker_id in (
+ "stale-repository-http-instrumentation-contract",
+ "stale-tv-bootstrap-wrapper-contract",
+ "stale-tv-bootstrap-alias-contract",
+):
+ assert entries[blocker_id]["status"] == "resolved", blocker_id
+ assert entries[blocker_id]["never_repeat"], blocker_id
+assert entries["repository-http-evidence-gap-after-instrumentation-disable"]["status"] == "watch"
+assert "never restore Nuvio runtime HTTP instrumentation" in entries["repository-http-evidence-gap-after-instrumentation-disable"]["next_action"]
+assert entries["reader-run-cancellation-churn"]["status"] == "operational-guard"
+assert "freeze the head until evidence is collected" in entries["reader-run-cancellation-churn"]["resolution"]
+
+human_path = POLICY["human_ux_acceptance_path"]
+for step in (
+ "launch the official Nuvio client",
+ "reach the real Nuvio stream-selection surface",
+ "select the returned stream through the real Nuvio UI",
+ "let the real Nuvio player attempt playback before any media transport diagnostic",
+ "record first-frame success or the visible/native player failure",
+):
+ assert step in human_path, step
+
+classes = POLICY["evidence_classes"]
+assert classes["human_ux_proof"]["can_gate_acceptance"] is True
+assert classes["human_ux_proof"]["requires_real_ui_path"] is True
+assert classes["human_ux_proof"]["requires_production_player"] is True
+assert classes["human_ux_proof"]["may_patch_nuvio_runtime"] is False
+assert classes["component_probe"]["can_gate_acceptance"] is False
+assert classes["component_probe"]["role"] == "diagnostic-only"
+
+for forbidden_behavior in (
+ "count a direct player/provider component probe as human-UX acceptance",
+ "retouch the harness merely because playback or player tests are failing",
+):
+ assert forbidden_behavior in POLICY["forbidden_behaviors"], forbidden_behavior
+
+change_control = POLICY["change_control"]
+assert change_control["persistent_profiles_are_source_of_truth"] is True
+assert change_control["job_blocker_memory_is_source_of_truth"] is True
+assert change_control["default_on_ambiguity"] == "fail-closed"
+print("native human UX harness + persistent profile/blocker memory tests passed")
diff --git a/tests/native_lab_observational_purity_test.py b/tests/native_lab_observational_purity_test.py
new file mode 100644
index 000000000..d79b49e9d
--- /dev/null
+++ b/tests/native_lab_observational_purity_test.py
@@ -0,0 +1,228 @@
+#!/usr/bin/env python3
+"""Fail closed if native reader labs make playback easier than real Nuvio.
+
+The machine-readable policy is automation/native-human-ux-policy.json. Tests here
+protect both sides of the contract: the policy cannot silently become permissive,
+and the implementation cannot silently stop consuming it.
+"""
+import json
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+POLICY_PATH = ROOT / "automation/native-human-ux-policy.json"
+policy = json.loads(POLICY_PATH.read_text(encoding="utf-8"))
+
+android = (ROOT / "scripts/native_player_diagnostics_codegen.py").read_text(encoding="utf-8")
+desktop = (ROOT / "scripts/augment_native_desktop_player.py").read_text(encoding="utf-8")
+resolver = (ROOT / "scripts/resolve_native_repository.sh").read_text(encoding="utf-8")
+desktop_suite = (ROOT / "scripts/run_native_corpus_desktop_suite.sh").read_text(encoding="utf-8")
+android_transport = (ROOT / "scripts/configure_native_android_lab_transport.py").read_text(encoding="utf-8")
+mobile_hardener = (ROOT / "scripts/harden_nuvio_mobile_device_test.py").read_text(encoding="utf-8")
+bootstrap = (ROOT / "scripts/native_client_test_bootstrap.py").read_text(encoding="utf-8")
+checkout_audit = (ROOT / "scripts/audit_native_client_checkout.py").read_text(encoding="utf-8")
+reader_acceptance = (ROOT / "scripts/prepare_native_reader_acceptance.py").read_text(encoding="utf-8")
+client_prepare = (ROOT / "scripts/prepare_native_corpus_client.py").read_text(encoding="utf-8")
+corpus = (ROOT / "scripts/prepare_native_corpus_validation.py").read_text(encoding="utf-8")
+request_contract = (ROOT / "scripts/augment_native_corpus_request_contract.py").read_text(encoding="utf-8")
+provider_loading = (ROOT / "scripts/augment_native_provider_loading.py").read_text(encoding="utf-8")
+
+# The policy itself is intentionally strict and fail-closed. Relaxing any of these
+# requires an explicit policy diff instead of an incidental helper change.
+assert policy["version"] >= 5
+assert policy["mode"] == "human-ux-observation-only"
+control = policy["change_control"]
+assert control["policy_file_is_source_of_truth"] is True
+assert control["audit_must_read_this_file"] is True
+assert control["policy_changes_require_canonical_gate"] is True
+assert control["policy_changes_require_explicit_diff_review"] is True
+assert control["default_on_ambiguity"] == "fail-closed"
+for required_external in (
+ "NuvioMobile",
+ "NuvioTV",
+ "NuvioDesktop",
+ "Android emulator and Android OS",
+ "macOS",
+ "Windows",
+):
+ assert required_external in policy["ownership"]["external_read_only"]
+for forbidden in (
+ "android.permission.INTERNET",
+ "usesCleartextTraffic",
+ "networkSecurityConfig",
+ "PlayerPlaybackNetworking",
+ "PlatformPlaybackDataSourceFactory",
+ "ExoPlayer.Builder",
+ "NativePlayerController(",
+ "PluginRepository.clearLocalState",
+ "FIELD_NATIVE_HTTP_REQUEST client=",
+ "FIELD_NATIVE_REPOSITORY_HTTP_REQUEST client=",
+):
+ assert forbidden in policy["forbidden_checkout_tokens"], forbidden
+for required_rule in (
+ "patch Nuvio source code for logging or instrumentation",
+ "patch Nuvio repository/network loaders to inject evidence interceptors",
+ "patch OS permissions, security policy or runtime privileges",
+):
+ assert required_rule in policy["forbidden_behaviors"], required_rule
+
+# Audit implementation must consume the policy file rather than duplicate an
+# independent allow-list that can drift from it.
+assert 'POLICY_PATH = ROOT / "automation/native-human-ux-policy.json"' in checkout_audit
+assert "json.loads(POLICY_PATH.read_text" in checkout_audit
+assert 'POLICY["allowed_checkout_changes"]' in checkout_audit
+assert 'POLICY["allowed_gradle_additions"]' in checkout_audit
+assert 'POLICY["forbidden_checkout_tokens"]' in checkout_audit
+assert "native human-UX lab mutated runtime-owned path" in checkout_audit
+assert "native human-UX lab introduced forbidden runtime mutation" in checkout_audit
+assert "default_on_ambiguity" in checkout_audit
+assert "fail-closed" in checkout_audit
+
+# Android must use Nuvio's production entries, not a lab-created ExoPlayer.
+for required in (
+ "Screen.Player.createRoute",
+ "NuvioNavHost",
+ "LastPlaybackDiagnostics",
+ "PlatformPlayerSurface",
+ "nuvio-tv-production",
+ "nuvio-mobile-production",
+):
+ assert required in android, required
+for forbidden in (
+ "ExoPlayer.Builder",
+ "PlayerPlaybackNetworking.createDataSourceFactory",
+ "PlatformPlaybackDataSourceFactory.create",
+ "setDefaultRequestProperties",
+ 'playbackHeaders["Range"]',
+ 'playbackHeaders["Referer"]',
+ 'playbackHeaders["Origin"]',
+):
+ assert forbidden not in android, forbidden
+
+# Desktop also uses the actual production player surface and lets Nuvio choose its
+# own decoder/settings. Direct NativePlayerController construction is forbidden.
+assert "PlatformPlayerSurface(" in desktop
+assert "sourceHeaders = headers.orEmpty()" in desktop
+for forbidden in (
+ "NativePlayerController(",
+ "NativePlayerHost(",
+ "decoderPriority = 1",
+ "nvidiaRtxSuperResolutionEnabled = false",
+ "controller.attach(",
+):
+ assert forbidden not in desktop, forbidden
+
+# Production player first, independent diagnostic network probe second.
+android_reader = android.index("val reader = probeNativePlayer(row.url, row.headers, row.type")
+android_transport_call = android.index("val transport = probeTransport(row.url, row.headers)")
+assert android_reader < android_transport_call
+desktop_reader = desktop.index("val reader = probeDesktopProductionPlayer(row.url, row.headers, row.type")
+desktop_transport_call = desktop.index("val transport = probeTransport(row.url, row.headers)")
+assert desktop_reader < desktop_transport_call
+
+# OS/process policy must never be relaxed for a green result.
+for text, label in ((resolver, "resolver"), (desktop_suite, "desktop-suite")):
+ for forbidden in ("sudo -n", "sudo ", "--add-opens", "ALL-UNNAMED", "privileged_client=1"):
+ assert forbidden not in text, f"{label}:{forbidden}"
+assert "root_execution_forbidden" in desktop_suite
+assert "local_server_not_ready_unprivileged" in resolver
+
+# Android transport helper is a validator only: no cleartext/network-security/permission grant.
+assert "validate_manifest" in android_transport
+assert "modified=false" in android_transport
+assert '.set(f"{ANDROID}usesCleartextTraffic"' not in android_transport
+assert "android.permission.INTERNET" not in android_transport
+
+# Mobile has one persisted behavior-neutral exception: the ephemeral device-test APK
+# may select one duplicate libc++ runtime so instrumentation can be packaged. This
+# must never spread into application/player/network/runtime behavior.
+assert 'pickFirsts.add("lib/*/libc++_shared.so")' in mobile_hardener
+assert "composeApp/build.gradle.kts" in mobile_hardener
+assert "device-test" in mobile_hardener
+assert "instrumentation APK" in mobile_hardener
+assert 'pickFirsts.add("lib/*/libc++_shared.so")' in policy["allowed_gradle_additions"]["mobile"]
+assert any(
+ row.get("id") == "mobile-device-test-libcxx-packaging-conflict" and row.get("status") == "resolved"
+ for row in policy["job_blocker_memory"]["entries"]
+)
+for forbidden in (
+ "configure_manifest",
+ "sentry-android-gradle",
+ "io.sentry.android.gradle",
+ "tools:replace",
+ "usesCleartextTraffic",
+ "networkSecurityConfig",
+ "android.permission.INTERNET",
+ "PlayerPlaybackNetworking",
+ "PlatformPlaybackDataSourceFactory",
+ "ExoPlayer.Builder",
+ "NativePlayerController(",
+ "setDefaultRequestProperties",
+ 'setRequestProperty("Referer"',
+ 'setRequestProperty("Origin"',
+ 'setRequestProperty("User-Agent"',
+):
+ assert forbidden not in mobile_hardener, f"mobile-hardener:{forbidden}"
+
+# The only supported Android checkout edits are test plumbing. The shared bootstrap
+# must never manufacture a production network/player/runtime capability.
+for required in (
+ "enable_mobile_device_tests",
+ "enable_tv_tests",
+ "withDeviceTest {",
+ "androidDeviceTest by getting",
+ "testInstrumentationRunner",
+ "androidTestImplementation",
+ "runtime_mutation=false",
+):
+ assert required in bootstrap, required
+for forbidden in (
+ "AndroidManifest.xml",
+ "android.permission.INTERNET",
+ "usesCleartextTraffic",
+ "networkSecurityConfig",
+ "cleartextTrafficPermitted",
+ "PlayerPlaybackNetworking",
+ "PlatformPlaybackDataSourceFactory",
+ "ExoPlayer.Builder",
+ "setDefaultRequestProperties",
+ "PluginRepository.clearLocalState",
+):
+ assert forbidden not in bootstrap, f"bootstrap:{forbidden}"
+
+# Every active preparation entry point uses the shared test-only bootstrap and then
+# audits the actual Nuvio checkout before Gradle/player execution.
+for text, label in ((reader_acceptance, "reader-acceptance"), (client_prepare, "client-prepare")):
+ assert "from native_client_test_bootstrap import" in text, label
+ assert "audit_checkout(" in text, label
+ assert "corpus.enable_mobile_device_tests" not in text, label
+assert "enable_mobile_device_tests(repo)" in reader_acceptance
+assert "enable_mobile_device_tests(mobile)" in client_prepare
+assert "enable_tv_tests(repo)" in reader_acceptance
+assert "enable_tv_tests(tv)" in client_prepare
+
+# No playback-row rewriting/ranking inside the Lab preparation path.
+for text, label in (
+ (corpus, "corpus"),
+ (request_contract, "request-contract"),
+ (provider_loading, "provider-loading"),
+):
+ for forbidden in (
+ "row.headers =",
+ "row.url =",
+ "rows.sorted",
+ "rows.sortBy",
+ "rows.sortWith",
+ "copy(url =",
+ "copy(headers =",
+ "repairStream",
+ "rewriteStream",
+ ):
+ assert forbidden not in text, f"{label}:{forbidden}"
+
+# Official repository/provider state remains warm; no test-only reset to manufacture
+# a different user profile or hide cache-related playback behavior.
+assert "PluginRepository.clearLocalState()" not in provider_loading
+assert "officialPluginManager.executeScraper(loadedScraper" in provider_loading
+assert "PluginRepository.executeScraper(loadedScraper" in provider_loading
+
+print("native human UX observational-purity policy and implementation tests passed")
diff --git a/tests/native_media_type_capability_test.py b/tests/native_media_type_capability_test.py
new file mode 100644
index 000000000..7f128409d
--- /dev/null
+++ b/tests/native_media_type_capability_test.py
@@ -0,0 +1,144 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import base64
+import json
+import subprocess
+import tempfile
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def b64(value: str) -> str:
+ return base64.urlsafe_b64encode(value.encode()).decode().rstrip("=")
+
+
+def player(provider: str, route: str, mode: str, state: str, *, status: int = 0, stage: str = "none") -> str:
+ return (
+ f"FIELD_NATIVE_PLAYER client=tv fixture=jujutsu-kaisen-s01e01 provider64={b64(provider)} "
+ f"request_type={route} route_mode={mode} index=0 state={state} engine=media3 "
+ f"http_status={status} failure_stage={stage} duration_seconds={1440 if state == 'ready' else 0} "
+ f"host64={b64('media.example')} error_class64={b64('PlaybackException' if state == 'error' else '')} "
+ f"error_code64={b64('ERROR_CODE_IO_BAD_HTTP_STATUS' if state == 'error' else '')} "
+ f"exception_chain64={b64('InvalidResponseCodeException' if state == 'error' else '')} "
+ f"response_header_names64={b64('content-type')} load_bytes=0 load_duration_ms=0 media_data_type=-1 track_type=-1"
+ )
+
+
+def evidence_lines(*, probe_state: str) -> list[str]:
+ provider = "anime-sama" # catalog/manifest: movie + anime, intentionally no tv
+ phases = [
+ "ui-launched",
+ "repository-load",
+ "repository-http-request",
+ "repository-http-response",
+ "repository-loaded",
+ "provider-load-state",
+ "corpus-begin",
+ "provider-loading",
+ "provider-result",
+ "player-start",
+ "player-result",
+ "corpus-end",
+ ]
+ lines = ["FIELD_NATIVE_EVIDENCE_INSTRUMENTED client=tv"]
+ lines.extend(
+ f"FIELD_NATIVE_FRONTEND_CAPTURE client=tv phase={phase} screenshot=x.png bytes=100"
+ for phase in phases
+ )
+ lines.extend([
+ "FIELD_NATIVE_REPOSITORY_LOAD_BEGIN client=tv fixture=jujutsu-kaisen-s01e01 manifest_host=raw.githubusercontent.com expected=1",
+ "FIELD_NATIVE_REPOSITORY_HTTP_REQUEST client=tv kind=manifest method=GET endpoint=https://raw.githubusercontent.com/niakw/NiakVIO/sha/manifest.json request_header_names=accept",
+ "FIELD_NATIVE_REPOSITORY_HTTP_RESPONSE client=tv kind=manifest method=GET endpoint=https://raw.githubusercontent.com/niakw/NiakVIO/sha/manifest.json status=200 duration_ms=20 response_header_names=content-type source=network",
+ "FIELD_NATIVE_REPOSITORY_LOAD_RESULT client=tv fixture=jujutsu-kaisen-s01e01 expected=1 loaded=1",
+ f"FIELD_NATIVE_PROVIDER_LOAD_RESULT client=tv fixture=jujutsu-kaisen-s01e01 provider64={b64(provider)} manifest_enabled=true runtime_enabled=true supported_types64={b64('anime,movie')} metadata_match=true",
+ "FIELD_NATIVE_CORPUS_BEGIN client=tv fixture=jujutsu-kaisen-s01e01 providers=1",
+ f"FIELD_NATIVE_PROVIDER_BEGIN client=tv fixture=jujutsu-kaisen-s01e01 provider64={b64(provider)} enabled=true request_type=anime route_mode=declared",
+ f"FIELD_NATIVE_RESULT client=tv fixture=jujutsu-kaisen-s01e01 provider64={b64(provider)} request_type=anime route_mode=declared enabled=true duration_ms=10 count=1",
+ f"FIELD_NATIVE_ROW client=tv fixture=jujutsu-kaisen-s01e01 provider64={b64(provider)} request_type=anime route_mode=declared index=0 title64={b64('Jujutsu Kaisen')} name64={b64('Episode 1')} quality64={b64('1080p')} language64={b64('ja')} type64={b64('hls')} host64={b64('media.example')} media_hint64={b64('Jujutsu Kaisen Episode 1')}",
+ f"FIELD_NATIVE_PLAYER_BEGIN client=tv fixture=jujutsu-kaisen-s01e01 provider64={b64(provider)} request_type=anime route_mode=declared index=0",
+ player(provider, "anime", "declared", "ready"),
+ f"FIELD_NATIVE_PROVIDER_BEGIN client=tv fixture=jujutsu-kaisen-s01e01 provider64={b64(provider)} enabled=true request_type=tv route_mode=capability_probe",
+ f"FIELD_NATIVE_RESULT client=tv fixture=jujutsu-kaisen-s01e01 provider64={b64(provider)} request_type=tv route_mode=capability_probe enabled=true duration_ms=10 count=1",
+ f"FIELD_NATIVE_ROW client=tv fixture=jujutsu-kaisen-s01e01 provider64={b64(provider)} request_type=tv route_mode=capability_probe index=0 title64={b64('Jujutsu Kaisen')} name64={b64('Episode 1')} quality64={b64('1080p')} language64={b64('ja')} type64={b64('hls')} host64={b64('media.example')} media_hint64={b64('Jujutsu Kaisen Episode 1')}",
+ f"FIELD_NATIVE_PLAYER_BEGIN client=tv fixture=jujutsu-kaisen-s01e01 provider64={b64(provider)} request_type=tv route_mode=capability_probe index=0",
+ player(provider, "tv", "capability_probe", probe_state, status=403 if probe_state == "error" else 0, stage="http_access" if probe_state == "error" else "none"),
+ "FIELD_NATIVE_CORPUS_END client=tv fixture=jujutsu-kaisen-s01e01 errors=0",
+ ])
+ return lines
+
+
+def run(*args: str) -> subprocess.CompletedProcess[str]:
+ return subprocess.run(args, cwd=ROOT, text=True, capture_output=True)
+
+
+with tempfile.TemporaryDirectory() as tmp_raw:
+ tmp = Path(tmp_raw)
+
+ healthy_log = tmp / "healthy.log"
+ healthy_log.write_text("\n".join(evidence_lines(probe_state="ready")) + "\n", encoding="utf-8")
+
+ gate = run("node", "scripts/gate_native_reader_result.cjs", str(healthy_log))
+ assert gate.returncode == 0, gate.stdout + gate.stderr
+ assert "capability_probe_healthy=1" in gate.stdout
+
+ cap_out = tmp / "capability.json"
+ cap = run(
+ "node", "scripts/analyze_native_media_type_capabilities.cjs",
+ "jujutsu-kaisen-s01e01", "--output", str(cap_out), str(healthy_log),
+ )
+ assert cap.returncode == 0, cap.stdout + cap.stderr
+ data = json.loads(cap_out.read_text(encoding="utf-8"))
+ assert data["evidenceComplete"] is True
+ assert data["provenCapabilities"] == 1, data
+ proposal = data["proposals"][0]
+ assert proposal["provider"] == "anime-sama", proposal
+ assert proposal["addType"] == "tv", proposal
+ assert proposal["requiresCrossDeviceConfirmation"] is True
+
+ brain_cap_out = tmp / "brain-capability.json"
+ brain_cap = run(
+ "node", "engine_v2/scripts/diagnose-native-media-capabilities.mjs",
+ "--output", str(brain_cap_out), str(healthy_log),
+ )
+ assert brain_cap.returncode == 0, brain_cap.stdout + brain_cap.stderr
+ brain_cap_data = json.loads(brain_cap_out.read_text(encoding="utf-8"))
+ assert len(brain_cap_data["proposals"]) == 1
+ assert brain_cap_data["policy"]["productionManifestMutationAllowed"] is False
+
+ failed_log = tmp / "failed.log"
+ failed_log.write_text("\n".join(evidence_lines(probe_state="error")) + "\n", encoding="utf-8")
+
+ # The undeclared tv probe fails, but the provider's declared anime contract is healthy.
+ # Strict reader validation must therefore stay green.
+ failed_gate = run("node", "scripts/gate_native_reader_result.cjs", str(failed_log))
+ assert failed_gate.returncode == 0, failed_gate.stdout + failed_gate.stderr
+ assert "failures=0" in failed_gate.stdout
+ assert "capability_probe_failures=1" in failed_gate.stdout
+ assert "FIELD_NATIVE_CAPABILITY_PROBE_REJECTED" in failed_gate.stdout
+
+ failed_cap_out = tmp / "failed-capability.json"
+ failed_cap = run(
+ "node", "scripts/analyze_native_media_type_capabilities.cjs",
+ "jujutsu-kaisen-s01e01", "--output", str(failed_cap_out), str(failed_log),
+ )
+ assert failed_cap.returncode == 0, failed_cap.stdout + failed_cap.stderr
+ failed_data = json.loads(failed_cap_out.read_text(encoding="utf-8"))
+ assert failed_data["provenCapabilities"] == 0
+ assert failed_data["proposals"] == []
+ assert any("reader_failure" in reason for reason in failed_data["outcomes"][0]["reasons"])
+
+ # Repair Brain sees the probe failure as discovery evidence only: no provider repair.
+ repair_out = tmp / "repair-brain.json"
+ repair = run(
+ "node", "engine_v2/scripts/diagnose-native-reader.mjs",
+ "--output", str(repair_out), str(failed_log),
+ )
+ assert repair.returncode == 0, repair.stdout + repair.stderr
+ repair_data = json.loads(repair_out.read_text(encoding="utf-8"))
+ assert repair_data["readerFailures"] == 0, repair_data
+ assert repair_data["capabilityProbeFailures"] == 1, repair_data
+ assert repair_data["plans"] == [], repair_data
+
+print("native media type capability tests passed")
diff --git a/tests/native_player_diagnostics.test.cjs b/tests/native_player_diagnostics.test.cjs
new file mode 100755
index 000000000..321de0bb5
--- /dev/null
+++ b/tests/native_player_diagnostics.test.cjs
@@ -0,0 +1,47 @@
+#!/usr/bin/env node
+'use strict';
+const assert = require('node:assert/strict');
+const {
+ readerFailureClass,
+ readerFailureDomain,
+ providerMutationEligible,
+ readerSignature,
+ isReaderFailure,
+} = require('../scripts/native_player_diagnostics.cjs');
+
+assert.equal(readerFailureClass({ state: 'ready' }), 'healthy');
+assert.equal(readerFailureClass({ state: 'ended' }), 'healthy');
+assert.equal(readerFailureClass({ state: 'short_media', failureStage: 'duration_identity' }), 'short_media');
+assert.equal(readerFailureClass({ state: 'error', failureStage: 'http_access', httpStatus: 403 }), 'playback_http_access');
+assert.equal(readerFailureClass({ state: 'error', httpStatus: 404 }), 'playback_http_gone');
+assert.equal(readerFailureClass({ state: 'error', httpStatus: 429 }), 'playback_rate_limited');
+assert.equal(readerFailureClass({ state: 'timeout' }), 'playback_timeout');
+assert.equal(readerFailureClass({ state: 'error', failureStage: 'parser' }), 'playback_parser');
+assert.equal(readerFailureClass({ state: 'error', failureStage: 'decoder' }), 'playback_decoder');
+assert.equal(isReaderFailure({ state: 'ready' }), false);
+assert.equal(isReaderFailure({ state: 'error', httpStatus: 403 }), true);
+
+assert.equal(readerFailureDomain({ state: 'error', failureStage: 'http_access', httpStatus: 403 }), 'provider_stream');
+assert.equal(providerMutationEligible({ state: 'error', failureStage: 'parser' }), true);
+assert.equal(readerFailureDomain({ state: 'error', failureStage: 'decoder' }), 'client_runtime');
+assert.equal(providerMutationEligible({ state: 'error', failureStage: 'decoder' }), false);
+assert.equal(readerFailureDomain({
+ state: 'error',
+ failureStage: 'player',
+ exceptionChain: 'Unable_to_make_field_transient_volatile_java.awt.peer.ComponentPeer_java.awt.Component.peer_accessible:_module_java.desktop_does_not_"opens_java.awt"_to_unnamed',
+}), 'client_runtime');
+assert.equal(providerMutationEligible({
+ state: 'error',
+ failureStage: 'http_access',
+ httpStatus: 403,
+ exceptionChain: 'java.lang.reflect.InaccessibleObjectException module java.desktop does not "opens java.awt"',
+}), false);
+
+const legacySignature = readerSignature({ state: 'error', failureStage: 'http_access', httpStatus: 403, errorCode: 'ERROR_CODE_IO_BAD_HTTP_STATUS', host: 'zipdisk.example' });
+assert.equal(legacySignature, 'playback_http_access:403:ERROR_CODE_IO_BAD_HTTP_STATUS:zipdisk.example');
+const tvSignature = readerSignature({ state: 'error', failureStage: 'http_access', httpStatus: 403, errorCode: 'ERROR_CODE_IO_BAD_HTTP_STATUS', host: 'zipdisk.example', requestType: 'tv' });
+const animeSignature = readerSignature({ state: 'error', failureStage: 'http_access', httpStatus: 403, errorCode: 'ERROR_CODE_IO_BAD_HTTP_STATUS', host: 'zipdisk.example', requestType: 'anime' });
+assert.equal(tvSignature, 'tv:playback_http_access:403:ERROR_CODE_IO_BAD_HTTP_STATUS:zipdisk.example');
+assert.equal(animeSignature, 'anime:playback_http_access:403:ERROR_CODE_IO_BAD_HTTP_STATUS:zipdisk.example');
+assert.notEqual(tvSignature, animeSignature);
+console.log('native player diagnostics taxonomy tests passed');
diff --git a/tests/native_player_diagnostics_codegen_test.py b/tests/native_player_diagnostics_codegen_test.py
new file mode 100644
index 000000000..7d4989826
--- /dev/null
+++ b/tests/native_player_diagnostics_codegen_test.py
@@ -0,0 +1,66 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import importlib.util
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+path = ROOT / "scripts/native_player_diagnostics_codegen.py"
+spec = importlib.util.spec_from_file_location("native_player_diagnostics_codegen", path)
+assert spec and spec.loader
+mod = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(mod)
+
+
+def source(client: str) -> str:
+ return f'''package example\n\nimport android.util.Log\nimport androidx.test.platform.app.InstrumentationRegistry\nimport org.junit.Test\n\nclass Sample {{\n private fun b64(v: Any?) = ""\n private fun hostOnly(v: String) = ""\n private fun probeTransport(url: String, headers: Map?) = TODO()\n\n @Test\n fun run() {{\n val fixtureSlug = "sinners"\n val provider = object {{ val id = "MOVIESDRIVE" }}\n val rows = emptyList()\n rows.firstOrNull()?.let {{ row ->\n val probe = probeTransport(row.url, row.headers)\n emit("FIELD_NATIVE_TRANSPORT client={client} fixture=$fixtureSlug provider64=${{b64(provider.id)}} state=${{probe.state}} kind=${{probe.kind}} status=${{probe.status}} content_type64=${{b64(probe.contentType)}} extm3u=${{probe.extm3u}} duration_seconds=${{probe.durationSeconds ?: 0.0}} host64=${{b64(probe.host)}} media_hint64=${{b64(probe.mediaHint)}}")\n }}\n }}\n private fun emit(v: String) {{}}\n}}\n'''
+
+
+tv = mod.augment_android_test(source("tv"), client="tv", expected_duration_minutes=137, max_player_probes=3)
+assert "com.nuvio.tv.MainActivity" in tv
+assert "NuvioNavHost" in tv
+assert "Screen.Player.createRoute" in tv
+assert "LastPlaybackDiagnostics" in tv
+assert "PlayerSettingsDataStore" in tv
+assert "nuvio-tv-production" in tv
+assert "rows.take(3)" in tv
+assert "probeNativePlayer(row.url, row.headers, row.type, 137)" in tv
+assert "ExoPlayer.Builder" not in tv
+assert "PlayerPlaybackNetworking.createDataSourceFactory" not in tv
+assert "FIELD_NATIVE_PLAYER_BEGIN client=tv" in tv
+assert "entry=nuvio-production-player" in tv
+
+# Production Nuvio receives the provider output. Any sanitation/normalization after
+# that belongs to Nuvio itself and is deliberately not reimplemented by the lab.
+assert "headers = headers" in tv
+assert 'playbackHeaders["Range"]' not in tv
+assert "--add-opens" not in tv
+assert "usesCleartextTraffic" not in tv
+
+reader_call = tv.index("val reader = probeNativePlayer(row.url, row.headers, row.type, 137)")
+transport_call = tv.index("val transport = probeTransport(row.url, row.headers)")
+assert reader_call < transport_call
+
+mobile = mod.augment_android_test(source("mobile"), client="mobile", expected_duration_minutes=137, max_player_probes=2)
+assert "com.nuvio.app.MainActivity" in mobile
+assert "PlatformPlayerSurface" in mobile
+assert "nuvio-mobile-production" in mobile
+assert "rows.take(2)" in mobile
+assert "probeNativePlayer(row.url, row.headers, row.type, 137)" in mobile
+assert "ExoPlayer.Builder" not in mobile
+assert "PlatformPlaybackDataSourceFactory.create" not in mobile
+assert "sourceHeaders = headers.orEmpty()" in mobile
+assert "Auto mode intentionally emits null" in mobile
+assert mobile.index("val reader = probeNativePlayer(row.url, row.headers, row.type, 137)") < mobile.index("val transport = probeTransport(row.url, row.headers)")
+
+rows = [{"id": "A"}, {"id": "MOVIESDRIVE"}, {"id": "B"}]
+assert [row["id"] for row in mod.filter_staged_providers(rows, "moviesdrive")] == ["MOVIESDRIVE"]
+assert mod.filter_staged_providers(rows, "") == rows
+try:
+ mod.filter_staged_providers(rows, "missing")
+except ValueError:
+ pass
+else:
+ raise AssertionError("unknown targeted provider must fail closed")
+
+print("native player diagnostics codegen tests passed")
diff --git a/tests/native_pr_timeout_contract_test.py b/tests/native_pr_timeout_contract_test.py
new file mode 100644
index 000000000..06c25a919
--- /dev/null
+++ b/tests/native_pr_timeout_contract_test.py
@@ -0,0 +1,23 @@
+#!/usr/bin/env python3
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+android = (ROOT / ".github/workflows/native-android-route-reader.yml").read_text(encoding="utf-8")
+desktop = (ROOT / ".github/workflows/native-desktop-reader-acceptance.yml").read_text(encoding="utf-8")
+
+android_reader_timeout = "timeout-minutes: ${{ github.event_name == 'pull_request' && 55 || 180 }}"
+android_brain_timeout = "timeout-minutes: ${{ github.event_name == 'pull_request' && 55 || 150 }}"
+desktop_reader_timeout = "timeout-minutes: ${{ github.event_name == 'pull_request' && 55 || 240 }}"
+
+assert android.count(android_reader_timeout) == 2, "TV and Mobile PR jobs must each be capped at 55 minutes"
+assert android.count(android_brain_timeout) == 1, "Brain PR sandbox must be capped at 55 minutes"
+assert desktop.count(desktop_reader_timeout) == 1, "Desktop PR reader matrix must be capped at 55 minutes"
+
+# Exhaustive/deep budgets remain available outside PRs through the right-hand
+# branch of each event-aware expression; artifact persistence must remain fail-safe.
+for workflow, deep_values in ((android, ("180", "150")), (desktop, ("240",))):
+ for value in deep_values:
+ assert f"|| {value} }}" in workflow, f"deep native budget {value} minutes was lost"
+ assert "if: always()" in workflow, "native evidence artifacts/diagnosis must survive failures"
+
+print("native PR timeout contract tests passed")
diff --git a/tests/native_reader_backlog_test.py b/tests/native_reader_backlog_test.py
new file mode 100644
index 000000000..ddb4219db
--- /dev/null
+++ b/tests/native_reader_backlog_test.py
@@ -0,0 +1,221 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import json
+import subprocess
+import tempfile
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+MERGE = ROOT / "scripts/merge_native_reader_backlog.py"
+SYNC = ROOT / ".github/workflows/native-reader-learning-sync.yml"
+CANONICAL = ROOT / ".github/workflows/canonical-media-types.yml"
+
+
+def backlog_of(state: dict) -> dict:
+ memory = state.get("nativeReaderRepairMemory") if isinstance(state.get("nativeReaderRepairMemory"), dict) else {}
+ backlog = memory.get("readerBacklog") if isinstance(memory.get("readerBacklog"), dict) else {}
+ return backlog
+
+
+def run_merge(
+ state: Path,
+ root: Path,
+ run_id: str,
+ output: Path,
+ md_in: Path,
+ md_out: Path,
+ previous: Path | None = None,
+) -> dict:
+ command = [
+ "python3", str(MERGE),
+ "--state", str(state),
+ "--diagnostics-root", str(root),
+ "--run-id", run_id,
+ "--output", str(output),
+ "--markdown-input", str(md_in),
+ "--markdown-output", str(md_out),
+ ]
+ if previous is not None:
+ command.extend(["--previous-state", str(previous)])
+ run = subprocess.run(command, cwd=ROOT, text=True, capture_output=True)
+ assert run.returncode == 0, run.stdout + run.stderr
+ assert "FIELD_NATIVE_READER_BACKLOG" in run.stdout, run.stdout
+ return json.loads(output.read_text(encoding="utf-8"))
+
+
+sync_text = SYNC.read_text(encoding="utf-8")
+canonical_text = CANONICAL.read_text(encoding="utf-8")
+assert "Native Android route reader acceptance" in sync_text
+assert "Native Desktop reader acceptance" in sync_text
+assert "github.event.workflow_run.event == 'push'" in sync_text
+assert "github.event.workflow_run.head_branch == 'main'" in sync_text
+assert "merge_native_reader_backlog.py" in sync_text
+assert "--previous-state reader-learning-input/latest.json" in sync_text
+assert "--state reader-learning-output/latest.json" in sync_text
+assert "--output reader-learning-output/latest.json" in sync_text
+assert "native-reader-backlog.json" not in sync_text
+assert "native-reader-backlog.md" not in sync_text
+assert "--diagnostics-root reader-learning-input/diagnostics" in sync_text
+assert 'gh run download "$RUN_ID" --pattern' in sync_text
+assert "brain-learning/proposals" in sync_text
+assert "find reader-learning-input/diagnostics -type f -name '*brain.json'" in sync_text
+assert "merge_native_reader_backlog.py" in canonical_text
+assert "tests/native_reader_backlog_test.py" in canonical_text
+assert "Validate automatic native reader bug backlog" in canonical_text
+
+with tempfile.TemporaryDirectory(dir=ROOT) as tmp_raw:
+ tmp = Path(tmp_raw)
+ state = tmp / "state.json"
+ state.write_text(json.dumps({
+ "publicationAllowed": False,
+ "productionWritesAllowed": False,
+ "proposals": [],
+ "nativeReaderRepairMemory": {"schemaVersion": 1, "entries": [], "importedRunIds": []},
+ }), encoding="utf-8")
+ md = tmp / "latest.md"
+ md.write_text("# NiakVIO Brain Learning\n", encoding="utf-8")
+ evidence = tmp / "evidence"
+ evidence.mkdir()
+ diag = evidence / "tv-route-sinners-2025-brain.json"
+
+ first_diag = {
+ "generatedAt": "2026-08-19T17:00:00Z",
+ "evidenceComplete": True,
+ "readerObserved": 3,
+ "readerFailures": 2,
+ "providerLoadObservedFailures": 1,
+ "observations": [
+ {
+ "provider": "moviesdrive", "client": "tv", "fixture": "sinners-2025",
+ "requestType": "movie", "routeMode": "declared", "failureClass": "playback_http_access",
+ },
+ {
+ "provider": "videasy", "client": "tv", "fixture": "sinners-2025",
+ "requestType": "movie", "routeMode": "declared", "failureClass": "healthy",
+ },
+ {
+ "provider": "anime-sama", "client": "tv", "fixture": "jujutsu-kaisen-s01e01",
+ "requestType": "tv", "routeMode": "capability_probe", "failureClass": "playback_parser",
+ },
+ ],
+ "providerLoadIssues": [
+ {
+ "provider": "goated", "client": "tv", "fixture": "sinners-2025",
+ "failureClass": "provider_repository_load_error", "layer": "repository",
+ "reason": "https://unsafe.example/signed?token=secret",
+ }
+ ],
+ "plans": [
+ {
+ "provider": "moviesdrive", "client": "tv", "fixture": "sinners-2025",
+ "requestType": "movie", "failureClass": "playback_http_access",
+ "hypotheses": [{"id": "replay-native-request-context"}],
+ }
+ ],
+ "providerOutcomes": [],
+ }
+ diag.write_text(json.dumps(first_diag), encoding="utf-8")
+
+ first_out = tmp / "first.json"
+ first_md = tmp / "first.md"
+ first = run_merge(state, evidence, "100", first_out, md, first_md)
+ backlog = backlog_of(first)
+ assert backlog["openCount"] == 2, backlog
+ assert backlog["resolvedCount"] == 0, backlog
+ assert backlog["externalCandidateOpenCount"] == 1, backlog
+ assert backlog["importedRunIds"] == ["100"], backlog
+ assert len(backlog["importedEvidenceIds"]) == 1, backlog
+ assert all(row["providerId"] != "anime-sama" for row in backlog["entries"]), backlog["entries"]
+
+ movie = next(row for row in backlog["entries"] if row["providerId"] == "moviesdrive")
+ assert movie["failureClass"] == "playback_http_access"
+ assert movie["scope"] == "external_or_context"
+ assert movie["externalCandidate"] is True
+ assert movie["providerJsMutationAllowed"] is False
+ assert movie["hypotheses"] == ["replay-native-request-context"]
+
+ load = next(row for row in backlog["entries"] if row["providerId"] == "goated")
+ assert load["requestType"] == "repository"
+ assert load["providerJsMutationAllowed"] is False
+ assert "http" not in json.dumps(load).lower(), load
+
+ # Exact same diagnosis artifact is idempotent.
+ duplicate_out = tmp / "duplicate.json"
+ duplicate_md = tmp / "duplicate.md"
+ duplicate = run_merge(first_out, evidence, "100", duplicate_out, first_md, duplicate_md)
+ duplicate_backlog = backlog_of(duplicate)
+ assert duplicate_backlog["skippedDuplicateThisRun"] == 1, duplicate_backlog
+ assert next(row for row in duplicate_backlog["entries"] if row["providerId"] == "moviesdrive")["occurrences"] == 1
+
+ # Simulate a repair-memory merger rebuilding nativeReaderRepairMemory without
+ # carrying readerBacklog. --previous-state must recover it before new evidence.
+ rebuilt = tmp / "rebuilt.json"
+ rebuilt_payload = json.loads(duplicate_out.read_text(encoding="utf-8"))
+ rebuilt_payload["nativeReaderRepairMemory"] = {
+ "schemaVersion": 1,
+ "entries": [{"providerId": "moviesdrive", "fixture": "sinners-2025", "failureClass": "playback_http_access", "skill": "x"}],
+ "importedRunIds": ["100"],
+ }
+ rebuilt.write_text(json.dumps(rebuilt_payload), encoding="utf-8")
+
+ second_diag = {
+ "generatedAt": "2026-08-19T17:30:00Z",
+ "evidenceComplete": True,
+ "readerObserved": 3,
+ "readerFailures": 1,
+ "providerLoadObservedFailures": 0,
+ "observations": [
+ {
+ "provider": "moviesdrive", "client": "tv", "fixture": "sinners-2025",
+ "requestType": "movie", "routeMode": "declared", "failureClass": "healthy",
+ },
+ {
+ "provider": "goated", "client": "tv", "fixture": "sinners-2025",
+ "requestType": "movie", "routeMode": "declared", "failureClass": "healthy",
+ },
+ {
+ "provider": "videasy", "client": "tv", "fixture": "sinners-2025",
+ "requestType": "movie", "routeMode": "declared", "failureClass": "playback_parser",
+ },
+ ],
+ "providerLoadIssues": [],
+ "plans": [],
+ "providerOutcomes": [],
+ }
+ diag.write_text(json.dumps(second_diag), encoding="utf-8")
+ second_out = tmp / "second.json"
+ second_md = tmp / "second.md"
+ second = run_merge(rebuilt, evidence, "100", second_out, duplicate_md, second_md, previous=duplicate_out)
+ second_backlog = backlog_of(second)
+ assert second["nativeReaderRepairMemory"]["entries"][0]["skill"] == "x"
+ assert second_backlog["importedRunIds"] == ["100"], second_backlog
+ assert len(second_backlog["importedEvidenceIds"]) == 2, second_backlog
+ assert second_backlog["openCount"] == 1, second_backlog
+ assert second_backlog["resolvedCount"] == 2, second_backlog
+ assert next(row for row in second_backlog["entries"] if row["providerId"] == "moviesdrive")["status"] == "resolved"
+ assert next(row for row in second_backlog["entries"] if row["providerId"] == "goated")["status"] == "resolved"
+ parser = next(row for row in second_backlog["entries"] if row["providerId"] == "videasy" and row["failureClass"] == "playback_parser")
+ assert parser["status"] == "open" and parser["providerJsMutationAllowed"] is True, parser
+ assert "Native reader bug backlog" in second_md.read_text(encoding="utf-8")
+
+ # Incomplete evidence is fail-closed and does not consume run/evidence IDs.
+ diag.write_text(json.dumps({
+ "generatedAt": "2026-08-19T18:00:00Z",
+ "evidenceComplete": False,
+ "evidenceProblems": ["missing_player_end"],
+ "observations": [{
+ "provider": "videasy", "client": "tv", "fixture": "sinners-2025",
+ "requestType": "movie", "routeMode": "declared", "failureClass": "healthy",
+ }],
+ }), encoding="utf-8")
+ incomplete_out = tmp / "incomplete.json"
+ incomplete_md = tmp / "incomplete.md"
+ incomplete = run_merge(second_out, evidence, "101", incomplete_out, second_md, incomplete_md, previous=second_out)
+ incomplete_backlog = backlog_of(incomplete)
+ assert incomplete_backlog["openCount"] == 1, incomplete_backlog
+ assert incomplete_backlog["importedRunIds"] == ["100"], incomplete_backlog
+ assert incomplete_backlog["skippedIncompleteThisRun"] == 1, incomplete_backlog
+ assert len(incomplete_backlog["importedEvidenceIds"]) == 2, incomplete_backlog
+
+print("native reader automatic backlog lifecycle, persistence and workflow topology tests passed")
diff --git a/tests/native_reader_brain_repair_test.py b/tests/native_reader_brain_repair_test.py
new file mode 100644
index 000000000..693348647
--- /dev/null
+++ b/tests/native_reader_brain_repair_test.py
@@ -0,0 +1,161 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import json
+import subprocess
+import tempfile
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+BUILDER = ROOT / "scripts/build_native_reader_brain_repair.py"
+COMPARE = ROOT / "scripts/compare_native_reader_brain_repair.py"
+
+
+def run_builder(diagnosis: Path, repair_dir: Path) -> dict:
+ built = subprocess.run([
+ "python3", str(BUILDER),
+ "--diagnosis", str(diagnosis),
+ "--manifest", str(ROOT / "manifest.json"),
+ "--output-dir", str(repair_dir),
+ "--fixture", "sinners-2025",
+ "--max-providers", "2",
+ ], cwd=ROOT, text=True, capture_output=True)
+ assert built.returncode == 0, built.stdout + built.stderr
+ return json.loads((repair_dir / "repair-report.json").read_text(encoding="utf-8"))
+
+
+with tempfile.TemporaryDirectory(dir=ROOT) as tmp_raw:
+ tmp = Path(tmp_raw)
+
+ # A single native client failure is compatibility evidence only. It must not
+ # become a global provider mutation candidate.
+ single = tmp / "single-client.json"
+ single.write_text(json.dumps({
+ "schemaVersion": 5,
+ "brainVersion": 4,
+ "readerFailures": 1,
+ "plans": [{
+ "provider": "moviesdrive",
+ "client": "tv",
+ "fixture": "sinners-2025",
+ "requestType": "movie",
+ "routeMode": "declared",
+ "failureClass": "playback_http_access",
+ "action": "probe-targeted-repair",
+ "hypotheses": [{"id": "replay-native-request-context"}],
+ }],
+ "observations": [{
+ "provider": "moviesdrive", "client": "tv", "fixture": "sinners-2025",
+ "requestType": "movie", "routeMode": "declared", "index": 0,
+ "failureClass": "playback_http_access", "state": "error",
+ }],
+ }), encoding="utf-8")
+ single_report = run_builder(single, tmp / "single-repair")
+ assert single_report["proposalCount"] == 0, single_report
+ assert single_report["compatibilityOnlyCount"] == 1, single_report
+ assert single_report["skipped"][0]["reason"] == "insufficient_cross_client_confirmation"
+ assert single_report["policy"]["singleClientFailureIsCompatibilityEvidenceOnly"] is True
+
+ # Even two failing clients cannot justify a global mutation when a peer client
+ # already proves the same provider/route/fixture playable.
+ healthy_peer = tmp / "healthy-peer.json"
+ healthy_peer.write_text(json.dumps({
+ "schemaVersion": 5,
+ "brainVersion": 4,
+ "readerFailures": 2,
+ "plans": [
+ {
+ "provider": "moviesdrive", "client": "mobile", "fixture": "sinners-2025",
+ "requestType": "movie", "routeMode": "declared",
+ "failureClass": "playback_http_access", "action": "probe-targeted-repair",
+ "hypotheses": [{"id": "replay-native-request-context"}],
+ },
+ {
+ "provider": "moviesdrive", "client": "desktop", "fixture": "sinners-2025",
+ "requestType": "movie", "routeMode": "declared",
+ "failureClass": "playback_http_access", "action": "probe-targeted-repair",
+ "hypotheses": [{"id": "replay-native-request-context"}],
+ },
+ ],
+ "observations": [
+ {"provider": "moviesdrive", "client": "mobile", "fixture": "sinners-2025", "requestType": "movie", "routeMode": "declared", "failureClass": "playback_http_access", "state": "error"},
+ {"provider": "moviesdrive", "client": "desktop", "fixture": "sinners-2025", "requestType": "movie", "routeMode": "declared", "failureClass": "playback_http_access", "state": "error"},
+ {"provider": "moviesdrive", "client": "tv", "fixture": "sinners-2025", "requestType": "movie", "routeMode": "declared", "failureClass": "healthy", "state": "ready"},
+ ],
+ }), encoding="utf-8")
+ peer_report = run_builder(healthy_peer, tmp / "peer-repair")
+ assert peer_report["proposalCount"] == 0, peer_report
+ assert peer_report["skipped"][0]["reason"] == "client_specific_failure_has_healthy_peer"
+ assert peer_report["skipped"][0]["healthyClients"] == ["tv"]
+
+ # A provider mutation sandbox becomes eligible only after independent client
+ # families corroborate the same failure and no healthy peer contradicts it.
+ diagnosis = tmp / "cross-client.json"
+ diagnosis.write_text(json.dumps({
+ "schemaVersion": 5,
+ "brainVersion": 4,
+ "readerFailures": 2,
+ "plans": [
+ {
+ "provider": "moviesdrive", "client": "tv", "fixture": "sinners-2025",
+ "requestType": "movie", "routeMode": "declared",
+ "failureClass": "playback_http_access", "action": "probe-targeted-repair",
+ "hypotheses": [
+ {"id": "replay-native-request-context"},
+ {"id": "refresh-access-bound-media"},
+ ],
+ },
+ {
+ "provider": "moviesdrive", "client": "mobile", "fixture": "sinners-2025",
+ "requestType": "movie", "routeMode": "declared",
+ "failureClass": "playback_http_access", "action": "probe-targeted-repair",
+ "hypotheses": [{"id": "replay-native-request-context"}],
+ },
+ ],
+ "observations": [
+ {"provider": "moviesdrive", "client": "tv", "fixture": "sinners-2025", "requestType": "movie", "routeMode": "declared", "index": 0, "failureClass": "playback_http_access", "state": "error"},
+ {"provider": "moviesdrive", "client": "mobile", "fixture": "sinners-2025", "requestType": "movie", "routeMode": "declared", "index": 0, "failureClass": "playback_http_access", "state": "error"},
+ ],
+ }), encoding="utf-8")
+ repair_dir = tmp / "repair"
+ report = run_builder(diagnosis, repair_dir)
+ assert report["proposalCount"] == 1, report
+ proposal = report["proposals"][0]
+ assert proposal["provider"] == "moviesdrive"
+ assert proposal["failingClients"] == ["mobile", "tv"]
+ assert proposal["crossClientConfirmed"] is True
+ assert proposal["skills"] == ["global_media_enrichment_v1"]
+ assert proposal["requiresFreshNativeReaderProof"] is True
+ candidate = ROOT / proposal["candidateFile"]
+ assert candidate.is_file(), proposal
+ text = candidate.read_text(encoding="utf-8")
+ assert "scoped-playback-context-v6-direct-safe-opaque-media" in text
+ serialized_report = json.dumps(report).lower()
+ assert "http://" not in serialized_report and "https://" not in serialized_report, report
+
+ after = tmp / "after.json"
+ after.write_text(json.dumps({
+ "schemaVersion": 5,
+ "brainVersion": 4,
+ "readerFailures": 0,
+ "observations": [
+ {"provider": "moviesdrive", "client": "tv", "fixture": "sinners-2025", "requestType": "movie", "index": 0, "failureClass": "healthy", "state": "ready"},
+ {"provider": "moviesdrive", "client": "mobile", "fixture": "sinners-2025", "requestType": "movie", "index": 0, "failureClass": "healthy", "state": "ready"},
+ ],
+ }), encoding="utf-8")
+ compared = tmp / "compare.json"
+ comparison = subprocess.run([
+ "python3", str(COMPARE),
+ "--before", str(diagnosis),
+ "--after", str(after),
+ "--repair-report", str(repair_dir / "repair-report.json"),
+ "--fixture", "sinners-2025",
+ "--output", str(compared),
+ ], cwd=ROOT, text=True, capture_output=True)
+ assert comparison.returncode == 0, comparison.stdout + comparison.stderr
+ result = json.loads(compared.read_text(encoding="utf-8"))
+ assert result["acceptedCount"] == 1, result
+ assert result["acceptedProviders"] == ["moviesdrive"], result
+ assert result["policy"]["freshNativeReaderProofRequired"] is True
+
+print("native reader Brain repair cross-client safety tests passed")
diff --git a/tests/native_reader_exhaustive_acceptance_test.py b/tests/native_reader_exhaustive_acceptance_test.py
new file mode 100644
index 000000000..0dc82c70f
--- /dev/null
+++ b/tests/native_reader_exhaustive_acceptance_test.py
@@ -0,0 +1,127 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import importlib.util
+import json
+import os
+import subprocess
+import tempfile
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+module_path = ROOT / "scripts/prepare_native_reader_acceptance.py"
+spec = importlib.util.spec_from_file_location("prepare_native_reader_acceptance", module_path)
+mod = importlib.util.module_from_spec(spec)
+assert spec.loader is not None
+spec.loader.exec_module(mod)
+
+
+def source(client: str) -> str:
+ return f'''package example\n\nimport android.util.Log\nimport androidx.test.platform.app.InstrumentationRegistry\nimport org.junit.Test\n\nclass Sample {{\n private fun b64(v: Any?) = ""\n private fun hostOnly(v: String) = ""\n private fun probeTransport(url: String, headers: Map?) = TODO()\n\n @Test\n fun run() {{\n val fixtureSlug = "sinners-2025"\n val provider = object {{ val id = "MOVIESDRIVE" }}\n val rows = emptyList()\n rows.take(3).forEachIndexed {{ index, row ->\n emit("FIELD_NATIVE_ROW client={client} fixture=$fixtureSlug provider64=${{b64(provider.id)}} index=$index")\n }}\n rows.firstOrNull()?.let {{ row ->\n val probe = probeTransport(row.url, row.headers)\n emit("FIELD_NATIVE_TRANSPORT client={client} fixture=$fixtureSlug provider64=${{b64(provider.id)}} state=${{probe.state}} kind=${{probe.kind}} status=${{probe.status}} content_type64=${{b64(probe.contentType)}} extm3u=${{probe.extm3u}} duration_seconds=${{probe.durationSeconds ?: 0.0}} host64=${{b64(probe.host)}} media_hint64=${{b64(probe.mediaHint)}}")\n }}\n }}\n private fun emit(v: String) {{}}\n}}\n'''
+
+
+out = mod.reader_source(source("tv"), "tv", 137, "all")
+assert "rows.take(" not in out
+assert out.count("rows.forEachIndexed") >= 2
+# The acceptance generator must exercise Nuvio's production player path. A
+# test-only PlayerPlaybackNetworking/ExoPlayer stack would create a second reader
+# implementation and can disagree with the real application.
+for required in ("Screen.Player.createRoute", "NuvioNavHost", "LastPlaybackDiagnostics"):
+ assert required in out, required
+assert "PlayerPlaybackNetworking.createDataSourceFactory(context, headers)" not in out
+assert "ExoPlayer.Builder(context)" not in out
+assert out.index("val reader = probeNativePlayer") < out.index("val transport = probeTransport")
+
+sampled = mod.reader_source(source("tv"), "tv", 137, 2)
+assert "rows.take(2).forEachIndexed" in sampled
+assert "rows.take(3).forEachIndexed" in sampled
+
+# Curated fixture scope remains available for a manual/targeted diagnosis. Its
+# ordering is deliberate: the PR-bounded lab consumes the first canaries exactly
+# in this order, while deep acceptance below still traverses the whole manifest.
+selected_fixture = mod.select_providers("manifest.json", "sinners-2025", "fixture")
+fixture_ids = [str(row["id"]).casefold() for row in selected_fixture]
+assert fixture_ids == [
+ "cineby",
+ "movieshunt",
+ "videasy",
+ "vixsrc",
+ "moviesdrive",
+ "moviesmod",
+ "4khdhub",
+], fixture_ids
+assert "4khdhubnew" not in fixture_ids
+
+# Acceptance scope is intentionally exhaustive and includes inactive providers.
+selected_all = mod.select_providers("manifest.json", "sinners-2025", "all")
+manifest_all = mod.client_prepare.manifest_providers("manifest.json")
+all_ids = [str(row["id"]).casefold() for row in selected_all]
+assert len(selected_all) == len(manifest_all) >= 90, (len(selected_all), len(manifest_all))
+assert len(set(all_ids)) == len(all_ids)
+assert any(not bool(row.get("enabled")) for row in selected_all), "inactive providers must stay in reader lab scope"
+assert any(bool(row.get("enabled")) for row in selected_all), "active providers must stay in reader lab scope"
+
+config = json.loads((ROOT / ".github/triggers/nuvio-client-lab.json").read_text(encoding="utf-8"))
+acceptance = config["native_reader_acceptance"]
+assert acceptance["provider_scope"] == "all"
+assert acceptance["include_disabled"] is True
+assert acceptance["publication_requires_fresh_reader_proof"] is True
+for suite in ("scripts/run_native_corpus_tv_suite.sh", "scripts/run_native_corpus_mobile_suite.sh"):
+ text = (ROOT / suite).read_text(encoding="utf-8")
+ assert "CONFIGURED_ACCEPTANCE_PROVIDER_SCOPE" in text
+ assert 'if [[ -z "$PROVIDER_SCOPE" || "$PROVIDER_SCOPE" = "all" ]]; then PROVIDER_SCOPE="fixture"; fi' not in text
+
+b64 = lambda value: __import__("base64").urlsafe_b64encode(value.encode()).decode().rstrip("=")
+with tempfile.TemporaryDirectory() as tmp:
+ log = Path(tmp) / "reader.log"
+ lines = [
+ "FIELD_NATIVE_CORPUS_BEGIN client=tv fixture=sinners-2025 title64=x providers=1",
+ f"FIELD_NATIVE_RESULT client=tv fixture=sinners-2025 provider64={b64('MOVIESDRIVE')} enabled=true duration_ms=1 count=9",
+ ]
+ for index in range(3):
+ lines.append(f"FIELD_NATIVE_PLAYER client=tv fixture=sinners-2025 provider64={b64('MOVIESDRIVE')} index={index} state=error")
+ log.write_text("\n".join(lines) + "\n", encoding="utf-8")
+
+ # Explicitly isolate both modes. GitHub's provider pipeline itself runs as a
+ # pull_request, so inheriting its environment here would make the synthetic
+ # deep assertion silently exercise the PR floor instead.
+ deep_env = dict(os.environ, GITHUB_EVENT_NAME="push")
+ pr_env = dict(os.environ, GITHUB_EVENT_NAME="pull_request")
+
+ failed = subprocess.run(
+ ["node", str(ROOT / "scripts/gate_native_reader_coverage.cjs"), "--streams", "all", str(log)],
+ cwd=ROOT, text=True, capture_output=True, env=deep_env,
+ )
+ assert failed.returncode == 1, failed.stdout + failed.stderr
+ assert "returned=9" in failed.stdout and "played=3" in failed.stdout
+ assert "ci_mode=deep" in failed.stdout
+
+ # PR acceptance has exactly one bounded canonical coverage floor: two native
+ # reads for a route when at least two streams exist. Deep/manual remains all.
+ pr_bounded = subprocess.run(
+ ["node", str(ROOT / "scripts/gate_native_reader_coverage.cjs"), "--streams", "all", str(log)],
+ cwd=ROOT, text=True, capture_output=True, env=pr_env,
+ )
+ assert pr_bounded.returncode == 0, pr_bounded.stdout + pr_bounded.stderr
+ assert "ci_mode=pr-bounded" in pr_bounded.stdout
+ assert "pr_stream_limit=2" in pr_bounded.stdout
+ assert "expected_played=2 played=3" in pr_bounded.stdout
+
+ sampled_pass = subprocess.run(
+ ["node", str(ROOT / "scripts/gate_native_reader_coverage.cjs"), "--streams", "3", str(log)],
+ cwd=ROOT, text=True, capture_output=True, env=deep_env,
+ )
+ assert sampled_pass.returncode == 0, sampled_pass.stdout + sampled_pass.stderr
+ assert "expected_played=3 played=3" in sampled_pass.stdout
+
+ for index in range(3, 9):
+ lines.append(f"FIELD_NATIVE_PLAYER client=tv fixture=sinners-2025 provider64={b64('MOVIESDRIVE')} index={index} state=error")
+ log.write_text("\n".join(lines) + "\n", encoding="utf-8")
+ passed = subprocess.run(
+ ["node", str(ROOT / "scripts/gate_native_reader_coverage.cjs"), "--streams", "all", str(log)],
+ cwd=ROOT, text=True, capture_output=True, env=deep_env,
+ )
+ assert passed.returncode == 0, passed.stdout + passed.stderr
+ assert "expected_played=9 played=9" in passed.stdout
+
+print("sampled/exhaustive native reader acceptance tests passed: pr_floor=2 deep=all production_player=true")
diff --git a/tests/native_reader_gate_test.py b/tests/native_reader_gate_test.py
new file mode 100644
index 000000000..24ed88b64
--- /dev/null
+++ b/tests/native_reader_gate_test.py
@@ -0,0 +1,54 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import base64
+import subprocess
+import tempfile
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+GATE = ROOT / 'scripts/gate_native_reader_result.cjs'
+
+
+def b64(value: str) -> str:
+ return base64.urlsafe_b64encode(value.encode()).decode().rstrip('=')
+
+
+def player(state: str, status: int = 0, stage: str = 'none', duration: float = 0.0, index: int = 0) -> str:
+ return (
+ 'FIELD_NATIVE_PLAYER client=tv fixture=sinners-2025 '
+ f'provider64={b64("MOVIESDRIVE")} index={index} state={state} engine=media3 '
+ f'http_status={status} failure_stage={stage} duration_seconds={duration} '
+ f'host64={b64("media.example")} error_class64={b64("PlaybackException" if state == "error" else "")} '
+ f'error_code64={b64("ERROR_CODE_IO_BAD_HTTP_STATUS" if status else "")} '
+ f'exception_chain64={b64("InvalidResponseCodeException" if status else "")} '
+ f'response_header_names64={b64("content-type,date" if status else "")}'
+ )
+
+
+def run(lines: list[str]):
+ with tempfile.TemporaryDirectory() as tmp:
+ log = Path(tmp) / 'tv-native-corpus-sinners-2025.log'
+ log.write_text('\n'.join(lines) + '\n', encoding='utf-8')
+ return subprocess.run(['node', str(GATE), str(log)], cwd=ROOT, text=True, capture_output=True)
+
+
+ok = run([player('ready', duration=137 * 60, index=0), player('ended', duration=137 * 60, index=1)])
+assert ok.returncode == 0, (ok.stdout, ok.stderr)
+assert 'state=passed' in ok.stdout
+
+for line, expected in (
+ (player('error', status=403, stage='http_access'), 'playback_http_access'),
+ (player('short_media', stage='duration_identity', duration=20), 'short_media'),
+ (player('timeout', stage='timeout'), 'playback_timeout'),
+):
+ failed = run([player('ready', duration=137 * 60), line])
+ assert failed.returncode == 1, (failed.stdout, failed.stderr)
+ assert expected in failed.stdout, failed.stdout
+ assert 'state=failed' in failed.stdout
+
+missing = run(['FIELD_NATIVE_RESULT client=tv fixture=sinners-2025 provider64=' + b64('MOVIESDRIVE') + ' enabled=true duration_ms=10 count=3'])
+assert missing.returncode == 2, (missing.stdout, missing.stderr)
+assert 'missing_native_reader_evidence' in missing.stderr
+
+print('native reader strict gate tests passed')
diff --git a/tests/native_reader_learning_memory_test.py b/tests/native_reader_learning_memory_test.py
new file mode 100644
index 000000000..00b2462cc
--- /dev/null
+++ b/tests/native_reader_learning_memory_test.py
@@ -0,0 +1,132 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import json
+import subprocess
+import tempfile
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+MERGE = ROOT / "scripts/merge_native_reader_repair_learning.py"
+BUILD = ROOT / "scripts/build_native_reader_brain_repair.py"
+
+with tempfile.TemporaryDirectory(dir=ROOT) as tmp_raw:
+ tmp = Path(tmp_raw)
+ base = tmp / "base.json"
+ base.write_text(json.dumps({
+ "publicationAllowed": False,
+ "productionWritesAllowed": False,
+ "proposals": [],
+ "nativeReaderRepairMemory": {
+ "importedRunIds": ["111", "222", "111", "invalid", "https://unsafe.example/333"],
+ "entries": [],
+ },
+ }), encoding="utf-8")
+ comparison = tmp / "comparison.json"
+ rejected = {
+ "fixture": "sinners-2025",
+ "acceptedCount": 0,
+ "rejectedCount": 1,
+ "inconclusiveCount": 0,
+ "accepted": [],
+ "inconclusive": [],
+ "rejected": [{
+ "provider": "moviesdrive",
+ "fixture": "sinners-2025",
+ "failureClasses": ["playback_http_access"],
+ "hypotheses": ["replay-native-request-context"],
+ "skills": ["global_media_enrichment_v1"],
+ "reason": "reader_failure_persisted_or_changed",
+ }],
+ }
+ comparison.write_text(json.dumps(rejected), encoding="utf-8")
+
+ first = tmp / "first.json"
+ second = tmp / "second.json"
+ for previous, output in ((base, first), (first, second)):
+ run = subprocess.run([
+ "python3", str(MERGE),
+ "--state", str(previous),
+ "--previous-state", str(previous),
+ "--comparison", str(comparison),
+ "--output", str(output),
+ ], cwd=ROOT, text=True, capture_output=True)
+ assert run.returncode == 0, run.stdout + run.stderr
+ merged = json.loads(output.read_text(encoding="utf-8"))
+ assert merged["nativeReaderRepairMemory"]["importedRunIds"] == ["111", "222"], merged["nativeReaderRepairMemory"]
+
+ learned = json.loads(second.read_text(encoding="utf-8"))
+ entries = learned["nativeReaderRepairMemory"]["entries"]
+ assert len(entries) == 1, entries
+ entry = entries[0]
+ assert entry["failures"] == 2 and entry["consecutiveFailures"] == 2, entry
+ assert any(row.get("type") == "avoid_native_reader_skill" for row in learned["proposals"]), learned["proposals"]
+ assert "http://" not in second.read_text(encoding="utf-8").lower()
+ assert "https://" not in second.read_text(encoding="utf-8").lower()
+
+ # The repair Brain now requires cross-client confirmation before a global
+ # provider mutation is even eligible. Exercise negative memory *after* that
+ # guard by presenting the same declared-route failure from TV and Mobile.
+ diagnosis = tmp / "diagnosis.json"
+ diagnosis.write_text(json.dumps({
+ "brainVersion": 4,
+ "readerFailures": 2,
+ "plans": [
+ {
+ "provider": "moviesdrive",
+ "fixture": "sinners-2025",
+ "requestType": "movie",
+ "client": "tv",
+ "failureClass": "playback_http_access",
+ "action": "probe-targeted-repair",
+ "hypotheses": [{"id": "replay-native-request-context"}],
+ },
+ {
+ "provider": "moviesdrive",
+ "fixture": "sinners-2025",
+ "requestType": "movie",
+ "client": "mobile",
+ "failureClass": "playback_http_access",
+ "action": "probe-targeted-repair",
+ "hypotheses": [{"id": "replay-native-request-context"}],
+ },
+ ],
+ }), encoding="utf-8")
+ repair_dir = tmp / "repair-suppressed"
+ build = subprocess.run([
+ "python3", str(BUILD),
+ "--diagnosis", str(diagnosis),
+ "--manifest", str(ROOT / "manifest.json"),
+ "--learning-state", str(second),
+ "--output-dir", str(repair_dir),
+ "--fixture", "sinners-2025",
+ ], cwd=ROOT, text=True, capture_output=True)
+ assert build.returncode == 0, build.stdout + build.stderr
+ report = json.loads((repair_dir / "repair-report.json").read_text(encoding="utf-8"))
+ assert report["learningApplied"] is True
+ assert report["proposalCount"] == 0, report
+ # If this were still blocked by client compatibility, compatibilityOnlyCount
+ # would be non-zero. Zero proves the target passed cross-client eligibility;
+ # the explicit skip reason then proves negative memory suppressed the skill.
+ assert report["compatibilityOnlyCount"] == 0, report
+ assert report["skipped"][0]["reason"] == "all_compatible_reader_skills_suppressed_by_negative_memory", report
+
+ accepted = dict(rejected)
+ accepted.update({"acceptedCount": 1, "rejectedCount": 0, "accepted": rejected["rejected"], "rejected": []})
+ accepted["accepted"][0] = dict(accepted["accepted"][0], reason="fresh_native_reader_proof_green")
+ comparison.write_text(json.dumps(accepted), encoding="utf-8")
+ recovered = tmp / "recovered.json"
+ run = subprocess.run([
+ "python3", str(MERGE),
+ "--state", str(second),
+ "--previous-state", str(second),
+ "--comparison", str(comparison),
+ "--output", str(recovered),
+ ], cwd=ROOT, text=True, capture_output=True)
+ assert run.returncode == 0, run.stdout + run.stderr
+ recovered_state = json.loads(recovered.read_text(encoding="utf-8"))
+ recovered_entry = recovered_state["nativeReaderRepairMemory"]["entries"][0]
+ assert recovered_entry["successes"] == 1 and recovered_entry["consecutiveFailures"] == 0, recovered_entry
+ assert recovered_state["nativeReaderRepairMemory"]["importedRunIds"] == ["111", "222"]
+
+print("native reader learning memory and imported-run history tests passed")
diff --git a/tests/native_reader_runtime_bootstrap_test.py b/tests/native_reader_runtime_bootstrap_test.py
new file mode 100644
index 000000000..da179ae6e
--- /dev/null
+++ b/tests/native_reader_runtime_bootstrap_test.py
@@ -0,0 +1,157 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import sys
+import tempfile
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+SCRIPTS = ROOT / "scripts"
+sys.path.insert(0, str(SCRIPTS))
+
+from augment_native_corpus_request_contract import kotlin_map # noqa: E402
+from augment_native_provider_loading import insert_imports, platform_set_literal, repository_helpers, tv_helpers # noqa: E402
+from native_player_diagnostics_codegen import TV_IMPORTS # noqa: E402
+from nuvio_tv_test_bootstrap import enable_tv_tests # noqa: E402
+
+request_contract = (SCRIPTS / "augment_native_corpus_request_contract.py").read_text(encoding="utf-8")
+provider_loading = (SCRIPTS / "augment_native_provider_loading.py").read_text(encoding="utf-8")
+mobile_suite = (SCRIPTS / "run_native_corpus_mobile_suite.sh").read_text(encoding="utf-8")
+desktop_suite = (SCRIPTS / "run_native_corpus_desktop_suite.sh").read_text(encoding="utf-8")
+desktop_player = (SCRIPTS / "augment_native_desktop_player.py").read_text(encoding="utf-8")
+desktop_workflow = (ROOT / ".github/workflows/native-desktop-reader-acceptance.yml").read_text(encoding="utf-8")
+tv_bootstrap = (SCRIPTS / "nuvio_tv_test_bootstrap.py").read_text(encoding="utf-8")
+shared_bootstrap = (SCRIPTS / "native_client_test_bootstrap.py").read_text(encoding="utf-8")
+reader_acceptance = (SCRIPTS / "prepare_native_reader_acceptance.py").read_text(encoding="utf-8")
+corpus_client = (SCRIPTS / "prepare_native_corpus_client.py").read_text(encoding="utf-8")
+
+# NuvioTV's androidTest compiler must not infer nested map Pair types through
+# Kotlin's generic infix `to`; the real run previously failed before the corpus
+# with "Cannot infer type for type parameter T".
+generated_map = kotlin_map({"provider-a": ["movie", "tv"], "provider-b": ["anime"]})
+assert "mapOf>" in generated_map
+assert "Pair>" in generated_map
+assert " to setOf" not in generated_map
+assert "Pair>" in request_contract
+
+# The same compiler-family failure can happen in any generated empty generic.
+# Keep both the literal and owning property explicitly typed so TV/Mobile/Desktop
+# cannot regress when a platform happens to have zero excluded providers.
+assert platform_set_literal([]) == "emptySet()"
+assert platform_set_literal(["provider-a"]) == 'setOf("provider-a")'
+tv_empty = tv_helpers("https://raw.githubusercontent.com/niakw/NiakVIO/0123456789012345678901234567890123456789/manifest.json", [])
+mobile_empty = repository_helpers("mobile", "https://raw.githubusercontent.com/niakw/NiakVIO/0123456789012345678901234567890123456789/manifest.json", [])
+assert "private val platformExcludedProviders: Set = emptySet()" in tv_empty
+assert "private val platformExcludedProviders: Set = emptySet()" in mobile_empty
+assert 'return "emptySet()"' not in provider_loading
+assert "private val platformExcludedProviders =" not in provider_loading
+assert "emptyMap()" in provider_loading
+assert "emptyMap()" in provider_loading
+
+# Player and repository augmentation are two layers of one generated diagnostic TV
+# test. Repository loading owns their shared Hilt/Flow imports; the player layer must
+# never emit a second copy (the official Kotlin compiler rejects duplicates).
+layered_tv_imports = insert_imports(
+ "import androidx.test.platform.app.InstrumentationRegistry\n" + TV_IMPORTS,
+ "tv",
+)
+for shared_import in (
+ "import dagger.hilt.EntryPoint\n",
+ "import dagger.hilt.InstallIn\n",
+ "import dagger.hilt.android.EntryPointAccessors\n",
+ "import dagger.hilt.components.SingletonComponent\n",
+ "import kotlinx.coroutines.flow.first\n",
+):
+ assert shared_import not in TV_IMPORTS, shared_import
+ assert layered_tv_imports.count(shared_import) == 1, shared_import
+
+# The historical TV bootstrap entry point is now only a compatibility wrapper. The
+# actual implementation is centralized in native_client_test_bootstrap.py so policy
+# audits have one place to police diagnostic test plumbing. Never restore runtime or
+# network mutation logic to this wrapper just to satisfy this test.
+assert 'from native_client_test_bootstrap import enable_tv_tests' in tv_bootstrap
+assert '__all__ = ["enable_tv_tests"]' in tv_bootstrap
+for forbidden in (
+ "write_text(",
+ "defaultConfig",
+ "AndroidManifest.xml",
+ "android.permission.INTERNET",
+ "usesCleartextTraffic",
+ "networkSecurityConfig",
+ "ExoPlayer.Builder",
+):
+ assert forbidden not in tv_bootstrap, f"tv-wrapper:{forbidden}"
+
+# Diagnostic instrumentation bootstrap remains structural/version-agnostic and may
+# add only test plumbing. It must not manufacture production playback capabilities.
+assert 'versionName = "0.8.4-beta"' not in shared_bootstrap
+assert "defaultConfig" in shared_bootstrap
+assert "testInstrumentationRunner" in shared_bootstrap
+assert "runtime_mutation=false" in shared_bootstrap
+for forbidden in (
+ "AndroidManifest.xml",
+ "android.permission.INTERNET",
+ "usesCleartextTraffic",
+ "networkSecurityConfig",
+ "cleartextTrafficPermitted",
+ "PlayerPlaybackNetworking",
+ "PlatformPlaybackDataSourceFactory",
+ "ExoPlayer.Builder",
+ "setDefaultRequestProperties",
+):
+ assert forbidden not in shared_bootstrap, f"shared-bootstrap:{forbidden}"
+assert "enable_tv_tests(repo)" in reader_acceptance
+assert "enable_tv_tests(tv)" in corpus_client
+assert "corpus.enable_tv_tests" not in reader_acceptance
+assert "corpus.enable_tv_tests" not in corpus_client
+with tempfile.TemporaryDirectory(prefix="niakvio-tv-bootstrap-") as tmp:
+ repo = Path(tmp)
+ build = repo / "app/build.gradle.kts"
+ build.parent.mkdir(parents=True)
+ build.write_text(
+ '''android {\n defaultConfig {\n applicationId = "com.nuvio.tv"\n versionCode = 9999\n versionName = "9.99.0-future"\n }\n buildTypes {\n debug {\n signingConfig = signingConfigs.getByName("release")\n }\n }\n}\n''',
+ encoding="utf-8",
+ )
+ enable_tv_tests(repo)
+ first = build.read_text(encoding="utf-8")
+ enable_tv_tests(repo)
+ second = build.read_text(encoding="utf-8")
+ assert second == first
+ assert first.count('testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"') == 1
+ assert first.count('androidTestImplementation("androidx.test.ext:junit:1.3.0")') == 1
+ assert first.count('androidTestImplementation("androidx.test:runner:1.7.0")') == 1
+ assert 'signingConfigs.getByName("debug")' in first
+ assert 'versionName = "9.99.0-future"' in first
+
+# Mobile device diagnostics execute from composeApp, but the official launcher is the
+# androidApp fullDebug APK whose debug application id is com.nuviodebug.com.
+assert 'packageName: String = "com.nuviodebug.com"' in request_contract
+assert ":androidApp:installFullDebug" in mobile_suite
+assert "adb shell pm path com.nuviodebug.com" in mobile_suite
+assert "FIELD_NATIVE_MOBILE_APP_INSTALLED" in mobile_suite
+
+# Desktop diagnostic evidence must run under the same ordinary JVM/module policy and
+# composition-local contract as the real NuvioDesktop UI. A direct component probe is
+# diagnostic-only; it must not relax JVM privileges or count as human-UX acceptance.
+for forbidden in (
+ "--add-opens=java.desktop/java.awt.peer=ALL-UNNAMED",
+ "--add-opens=java.desktop/sun.awt=ALL-UNNAMED",
+ "MACOS_JAVA_OPENS",
+ "JAVA_TOOL_OPTIONS",
+):
+ assert forbidden not in desktop_workflow, forbidden
+assert "No test-only --add-opens/JVM privilege relaxation" in desktop_workflow
+assert "root_execution_forbidden" in desktop_suite
+assert "privilege=ordinary-user" in desktop_suite
+assert "import com.nuvio.app.core.ui.NuvioTheme" in desktop_player
+assert "NuvioTheme {" in desktop_player
+assert "desktopThrowableChain" in desktop_player
+assert "exception_chain64=${{b64(reader.exceptionChain)}}" in desktop_player
+
+print(
+ "native reader runtime bootstrap contract passed: "
+ "tv_explicit_pairs=true generated_empty_generics_typed=true tv_single_owner_hilt_imports=true "
+ "tv_wrapper_non_mutating=true tv_version_agnostic_diagnostic_bootstrap=true "
+ "mobile_real_app=true desktop_ordinary_jvm_policy=true "
+ "desktop_production_theme=true desktop_unwrapped_errors=true"
+)
diff --git a/tests/native_reader_runtime_scope_test.py b/tests/native_reader_runtime_scope_test.py
new file mode 100644
index 000000000..403eb4da1
--- /dev/null
+++ b/tests/native_reader_runtime_scope_test.py
@@ -0,0 +1,91 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import json
+import subprocess
+import tempfile
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+SCOPE = ROOT / "scripts/scope_native_reader_learning_runtime.py"
+
+FP_OLD = "tv=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;mobile=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
+FP_NEW = "tv=cccccccccccccccccccccccccccccccccccccccc;mobile=dddddddddddddddddddddddddddddddddddddddd"
+
+with tempfile.TemporaryDirectory(dir=ROOT) as tmp_raw:
+ tmp = Path(tmp_raw)
+ state = tmp / "state.json"
+ state.write_text(json.dumps({
+ "publicationAllowed": False,
+ "productionWritesAllowed": False,
+ "proposals": [],
+ "nativeReaderRepairMemory": {
+ "schemaVersion": 2,
+ "entries": [
+ {
+ "providerId": "moviesdrive", "fixture": "sinners-2025",
+ "failureClass": "playback_http_access", "skill": "global_media_enrichment_v1",
+ "failures": 2, "successes": 0, "attempts": 2, "consecutiveFailures": 2,
+ "runtimeFingerprint": FP_OLD,
+ },
+ {
+ "providerId": "cineby", "fixture": "sinners-2025",
+ "failureClass": "decoder_error", "skill": "global_media_enrichment_v1",
+ "failures": 1, "successes": 0, "attempts": 1, "consecutiveFailures": 1,
+ },
+ ],
+ },
+ }), encoding="utf-8")
+
+ scoped = tmp / "scoped.json"
+ run = subprocess.run([
+ "python3", str(SCOPE), "filter",
+ "--state", str(state),
+ "--runtime-fingerprint", FP_NEW,
+ "--output", str(scoped),
+ ], cwd=ROOT, text=True, capture_output=True)
+ assert run.returncode == 0, run.stdout + run.stderr
+ filtered = json.loads(scoped.read_text(encoding="utf-8"))
+ memory = filtered["nativeReaderRepairMemory"]
+ assert memory["entries"] == [], memory
+ assert memory["runtimeScope"]["excludedEntryCount"] == 2
+ assert memory["runtimeScope"]["legacyUnscopedExcluded"] == 1
+
+ comparison = tmp / "comparison.json"
+ comparison.write_text(json.dumps({
+ "runtimeFingerprint": FP_NEW,
+ "fixture": "sinners-2025",
+ "acceptedCount": 0,
+ "rejectedCount": 1,
+ "inconclusiveCount": 0,
+ "accepted": [],
+ "inconclusive": [],
+ "rejected": [{
+ "provider": "moviesdrive",
+ "fixture": "sinners-2025",
+ "failureClasses": ["playback_http_access"],
+ "skills": ["global_media_enrichment_v1"],
+ "reason": "reader_failure_persisted_or_changed",
+ }],
+ }), encoding="utf-8")
+ merged = tmp / "merged.json"
+ run = subprocess.run([
+ "python3", str(SCOPE), "merge",
+ "--state", str(state),
+ "--comparison", str(comparison),
+ "--runtime-fingerprint", FP_NEW,
+ "--output", str(merged),
+ ], cwd=ROOT, text=True, capture_output=True)
+ assert run.returncode == 0, run.stdout + run.stderr
+ learned = json.loads(merged.read_text(encoding="utf-8"))
+ entries = learned["nativeReaderRepairMemory"]["entries"]
+ new_rows = [row for row in entries if row.get("runtimeFingerprint") == FP_NEW]
+ old_rows = [row for row in entries if row.get("runtimeFingerprint") == FP_OLD]
+ legacy_rows = [row for row in entries if not row.get("runtimeFingerprint")]
+ assert len(new_rows) == 1, entries
+ assert new_rows[0]["failures"] == 1, new_rows[0]
+ assert len(old_rows) == 1 and old_rows[0]["failures"] == 2, entries
+ assert len(legacy_rows) == 1, entries
+ assert learned["nativeReaderRepairMemory"]["runtimeScope"]["reusePolicy"] == "exact-runtime-fingerprint-only"
+
+print("native reader runtime-scoped Brain memory tests passed")
diff --git a/tests/native_repository_cache_contract_test.py b/tests/native_repository_cache_contract_test.py
new file mode 100644
index 000000000..bacc6892a
--- /dev/null
+++ b/tests/native_repository_cache_contract_test.py
@@ -0,0 +1,99 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import json
+import os
+import re
+import shutil
+import subprocess
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+TMP = ROOT / ".native-repository-cache-contract"
+MANIFEST = TMP / "manifest.json"
+RESOLVER = ROOT / "scripts/resolve_native_repository.sh"
+
+
+def resolve_cache_key() -> tuple[str, str]:
+ script = f'''
+set -euo pipefail
+WORKSPACE="{TMP / 'workspace'}"
+NIAKVIO="{ROOT}"
+TARGET_MANIFEST="{MANIFEST.relative_to(ROOT).as_posix()}"
+SOURCE_SHA="$(git -C "$NIAKVIO" rev-parse HEAD)"
+SOURCE_REPOSITORY="niakw/NiakVIO"
+source "{RESOLVER}"
+resolve_native_repository desktop 127.0.0.1 18979
+printf 'RESOLVED_URL=%s\n' "$NIAKVIO_RESOLVED_MANIFEST_URL"
+printf 'RESOLVED_KEY=%s\n' "$NIAKVIO_LOCAL_REPOSITORY_KEY"
+cleanup_native_repository
+'''
+ env = os.environ.copy()
+ # A runner-level proxy must never intercept the loopback readiness probe.
+ # This reproduces the failure mode seen on GitHub-hosted macOS runners.
+ env.update(
+ {
+ "HTTP_PROXY": "http://127.0.0.1:9",
+ "HTTPS_PROXY": "http://127.0.0.1:9",
+ "ALL_PROXY": "http://127.0.0.1:9",
+ "NO_PROXY": "",
+ "no_proxy": "",
+ }
+ )
+ run = subprocess.run(
+ ["bash", "-lc", script],
+ cwd=ROOT,
+ text=True,
+ capture_output=True,
+ env=env,
+ )
+ assert run.returncode == 0, run.stdout + run.stderr
+ url = re.search(r"^RESOLVED_URL=(.+)$", run.stdout, re.MULTILINE)
+ key = re.search(r"^RESOLVED_KEY=([0-9a-f]{32})$", run.stdout, re.MULTILINE)
+ assert url, run.stdout
+ assert key, run.stdout
+ assert f"/candidate-{key.group(1)}/manifest.json" in url.group(1)
+ assert "mode=local_candidate" in run.stdout
+ return url.group(1), key.group(1)
+
+
+try:
+ shutil.rmtree(TMP, ignore_errors=True)
+ TMP.mkdir(parents=True)
+ source_manifest = json.loads((ROOT / "manifest.json").read_text(encoding="utf-8"))
+ source_rows = [row for row in source_manifest.get("scrapers", []) if isinstance(row, dict) and row.get("filename")]
+ assert source_rows
+
+ # One valid provider is enough to prove the cache-addressing contract. The
+ # filename remains repository-relative, exactly as Nuvio resolves it.
+ candidate = {
+ "name": "NiakVIO cache contract A",
+ "version": "1",
+ "scrapers": [source_rows[0]],
+ }
+ MANIFEST.write_text(json.dumps(candidate, indent=2) + "\n", encoding="utf-8")
+ url_a1, key_a1 = resolve_cache_key()
+ url_a2, key_a2 = resolve_cache_key()
+ assert key_a1 == key_a2, "identical candidate must retain the same cache key"
+ assert url_a1 == url_a2, "identical candidate must retain the same repository URL"
+
+ # Changing only manifest bytes must invalidate the persistent provider cache.
+ candidate["name"] = "NiakVIO cache contract B"
+ MANIFEST.write_text(json.dumps(candidate, indent=2) + "\n", encoding="utf-8")
+ url_b, key_b = resolve_cache_key()
+ assert key_b != key_a1, "changed candidate must get a new content-addressed cache key"
+ assert url_b != url_a1, "changed candidate must get a new repository URL"
+
+ resolver_text = RESOLVER.read_text(encoding="utf-8")
+ for required in (
+ "content-addressed",
+ "candidate-${content_key}",
+ "manifest + every provider",
+ "NIAKVIO_LOCAL_REPOSITORY_KEY",
+ 'http.client.HTTPConnection("127.0.0.1", port, timeout=1.0)',
+ ):
+ assert required in resolver_text, required
+finally:
+ shutil.rmtree(TMP, ignore_errors=True)
+
+print("native repository cache contract tests passed")
diff --git a/tests/native_repository_http_instrumentation_test.py b/tests/native_repository_http_instrumentation_test.py
new file mode 100644
index 000000000..d77171498
--- /dev/null
+++ b/tests/native_repository_http_instrumentation_test.py
@@ -0,0 +1,95 @@
+#!/usr/bin/env python3
+"""Regression guard: native runtime instrumentation entry points stay non-mutating."""
+from __future__ import annotations
+
+import json
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+POLICY = json.loads((ROOT / "automation/native-human-ux-policy.json").read_text(encoding="utf-8"))
+CLIENT = ROOT / "scripts/instrument_native_client_evidence.py"
+REPOSITORY = ROOT / "scripts/instrument_native_repository_http_evidence.py"
+DESKTOP = ROOT / "scripts/instrument_native_desktop_evidence.py"
+
+assert "patch Nuvio source code for logging or instrumentation" in POLICY["forbidden_behaviors"]
+assert "patch Nuvio repository/network loaders to inject evidence interceptors" in POLICY["forbidden_behaviors"]
+
+for path in (CLIENT, REPOSITORY, DESKTOP):
+ text = path.read_text(encoding="utf-8")
+ assert "disabled_by_human_ux_policy" in text, path.name
+ assert "runtime_mutation=false" in text, path.name
+ for forbidden in (
+ "write_text(",
+ "write_bytes(",
+ "replace_once(",
+ "addInterceptor",
+ "PluginRuntime.kt",
+ "FetchBridge.kt",
+ "AndroidManifest.xml",
+ "android.permission.INTERNET",
+ "usesCleartextTraffic",
+ "networkSecurityConfig",
+ ):
+ assert forbidden not in text, f"{path.name}:{forbidden}"
+
+
+def init_repo(path: Path) -> None:
+ subprocess.run(["git", "init", "-q", str(path)], check=True)
+ marker = path / "production-runtime.txt"
+ marker.write_text("official runtime\n", encoding="utf-8")
+ subprocess.run(["git", "-C", str(path), "add", "production-runtime.txt"], check=True)
+ subprocess.run(
+ [
+ "git",
+ "-C",
+ str(path),
+ "-c",
+ "user.email=native-lab@example.invalid",
+ "-c",
+ "user.name=Native Lab",
+ "commit",
+ "-qm",
+ "baseline",
+ ],
+ check=True,
+ )
+
+
+def assert_clean(path: Path) -> None:
+ status = subprocess.run(
+ ["git", "-C", str(path), "status", "--porcelain=v1", "--untracked-files=all"],
+ check=True,
+ text=True,
+ capture_output=True,
+ ).stdout
+ assert status.strip() == "", status
+
+
+with tempfile.TemporaryDirectory() as raw:
+ root = Path(raw)
+ tv = root / "tv"
+ mobile = root / "mobile"
+ desktop = root / "desktop"
+ for repo in (tv, mobile, desktop):
+ repo.mkdir()
+ init_repo(repo)
+
+ calls = (
+ ([sys.executable, str(CLIENT), "tv", str(tv)], tv, "client=tv"),
+ ([sys.executable, str(CLIENT), "mobile", str(mobile)], mobile, "client=mobile"),
+ ([sys.executable, str(REPOSITORY), "tv", str(tv)], tv, "client=tv"),
+ ([sys.executable, str(REPOSITORY), "mobile", str(mobile)], mobile, "client=mobile"),
+ ([sys.executable, str(REPOSITORY), "desktop", str(desktop)], desktop, "client=desktop"),
+ ([sys.executable, str(DESKTOP), str(desktop)], desktop, "client=desktop"),
+ )
+ for command, repo, marker in calls:
+ completed = subprocess.run(command, cwd=ROOT, text=True, capture_output=True)
+ assert completed.returncode == 0, completed.stdout + completed.stderr
+ assert "disabled_by_human_ux_policy" in completed.stdout
+ assert marker in completed.stdout
+ assert_clean(repo)
+
+print("native runtime instrumentation shims are policy-locked and non-mutating")
diff --git a/tests/nuvio_client_lab.test.cjs b/tests/nuvio_client_lab.test.cjs
index 21f4b200e..86a328518 100755
--- a/tests/nuvio_client_lab.test.cjs
+++ b/tests/nuvio_client_lab.test.cjs
@@ -23,29 +23,15 @@ const {
const repositoryRoot = path.resolve(__dirname, '..');
const packageJson = JSON.parse(fs.readFileSync(path.join(repositoryRoot, 'package.json'), 'utf8'));
-const labWorkflow = fs.readFileSync(path.join(repositoryRoot, '.github/workflows/nuvio-client-lab.yml'), 'utf8');
const labTrigger = JSON.parse(fs.readFileSync(path.join(repositoryRoot, '.github/triggers/nuvio-client-lab.json'), 'utf8'));
const npmTestLifecycle = `${packageJson.scripts.pretest || ''} ${packageJson.scripts.test || ''}`;
assert.match(npmTestLifecycle, /node tests\/nuvio_client_lab\.test\.cjs/);
assert.match(packageJson.scripts.posttest || '', /python3 scripts\/validate_release_integrity\.py/);
-for (const requiredPath of [
- 'manifest.json',
- 'vf/manifest.json',
- 'provider-overrides.json',
- 'providers/**',
- 'scripts/nuvio_client_lab.cjs',
- 'scripts/provider_worker.cjs',
- 'scripts/provider_patches/**',
- 'tests/nuvio_client_lab.test.cjs',
-]) {
- assert.equal(labWorkflow.includes(`- "${requiredPath}"`), true, `lab workflow must watch ${requiredPath}`);
-}
-assert.match(labWorkflow, /python3 scripts\/validate_release_integrity\.py/);
-assert.doesNotMatch(labWorkflow, /lab\/nuvio-client-matrix/);
+assert.equal(fs.existsSync(path.join(repositoryRoot, '.github/workflows/nuvio-client-lab.yml')), false, 'mutable-client transport workflow is retired; official native readers own CI evidence');
assert.equal(labTrigger.policy.blocking, false);
assert.equal(labTrigger.policy.require_identity_match, true);
assert.equal(labTrigger.policy.block_identity_contradictions, true);
-assert.equal(labTrigger.fixtures.length, 6);
+assert.equal(labTrigger.fixtures.length, 7);
assert.equal(labTrigger.fixtures.every((row) => Number(row.fixture.expectedDurationMinutes) > 0), true);
const manifest = {
@@ -176,4 +162,4 @@ try {
fs.rmSync(tmp, { recursive: true, force: true });
}
-console.log('nuvio client lab tests passed');
+console.log('nuvio client transport unit tests passed');
diff --git a/tests/nuvio_client_upstream_drift_guard_test.py b/tests/nuvio_client_upstream_drift_guard_test.py
index 092cda513..dae9516f3 100644
--- a/tests/nuvio_client_upstream_drift_guard_test.py
+++ b/tests/nuvio_client_upstream_drift_guard_test.py
@@ -59,7 +59,7 @@ def run_case(head: str, comparison: dict | None, state: dict | None = None) -> d
old_compare = module.compare
try:
module.current_head = lambda repository, branch: head
- module.compare = lambda repository, base, current: comparison or {}
+ module.compare = lambda repository, base, current, patch_rules=None: comparison or {}
return module.inspect_client("client", sample(), state or {})
finally:
module.current_head = old_head
@@ -73,6 +73,12 @@ def main() -> int:
assert module.semantic_hits("+val item: StreamItem\n", ["StreamItem"]) == ["StreamItem"]
assert module.semantic_hits("+selectedSubtitleId = null\n", ["StreamItem"]) == []
+ source = SCRIPT.read_text(encoding="utf-8")
+ assert '"--clients"' in source
+ assert "ThreadPoolExecutor" in source
+ assert "parallel-git-ls-remote-plus-targeted-partial-tree-diff" in source
+ assert "patch_files = [name for name in files if path_matches(name, patch_rules)]" in source
+
# The provider-to-screen contract must be guarded on every accepted client.
# Mobile/Desktop already guard their complete features/streams surfaces; TV
# must also treat its stream-selection UI as a hard contract because
@@ -206,13 +212,14 @@ def main() -> int:
"unrelated_changed_files": ["README.md"],
}
}
- config = {"clients": {"client": sample()}}
+ config = {"clients": {"client": sample(), "unselected": sample()}}
advanced = module.apply_safe_state(payload, config, result, "2026-08-09T00:00:00+00:00")
assert advanced == ["client"]
saved = payload["nuvio_client_compatibility"]["clients"]["client"]
assert saved["contract_ref"] == "a" * 40
assert saved["accepted_ref"] == "c" * 40
assert saved["acceptance"] == "automatic-contract-safe-advance"
+ assert "unselected" not in payload["nuvio_client_compatibility"]["clients"]
print("Nuvio client upstream drift guard tests passed")
return 0
diff --git a/tests/nuvio_lab_head_resolver_test.py b/tests/nuvio_lab_head_resolver_test.py
new file mode 100644
index 000000000..4acb0f381
--- /dev/null
+++ b/tests/nuvio_lab_head_resolver_test.py
@@ -0,0 +1,79 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import json
+import subprocess
+import tempfile
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+RESOLVER = ROOT / "scripts/resolve_nuvio_lab_heads.py"
+
+TV_HEAD = "a" * 40
+MOBILE_HEAD = "b" * 40
+OLD = "c" * 40
+CONTRACT = "d" * 40
+
+with tempfile.TemporaryDirectory(dir=ROOT) as raw:
+ tmp = Path(raw)
+ report = tmp / "report.json"
+ report.write_text(json.dumps({
+ "clients": {
+ "nuvio-tv": {
+ "current_head": TV_HEAD,
+ "accepted_ref": OLD,
+ "contract_ref": CONTRACT,
+ "status": "contract_review_required",
+ "review_required": True,
+ },
+ "nuvio-mobile": {
+ "current_head": MOBILE_HEAD,
+ "accepted_ref": OLD,
+ "contract_ref": CONTRACT,
+ "status": "safe_advance",
+ "review_required": False,
+ },
+ }
+ }), encoding="utf-8")
+ gh_output = tmp / "github-output.txt"
+ audit = tmp / "audit.json"
+ run = subprocess.run([
+ "python3", str(RESOLVER),
+ "--report", str(report),
+ "--clients", "nuvio-tv", "nuvio-mobile",
+ "--github-output", str(gh_output),
+ "--output", str(audit),
+ ], cwd=ROOT, text=True, capture_output=True)
+ assert run.returncode == 0, run.stdout + run.stderr
+ outputs = dict(line.split("=", 1) for line in gh_output.read_text(encoding="utf-8").splitlines())
+ assert outputs["tv_sha"] == TV_HEAD
+ assert outputs["mobile_sha"] == MOBILE_HEAD
+ assert outputs["tv_accepted_ref"] == OLD
+ assert outputs["tv_contract_ref"] == CONTRACT
+ assert outputs["tv_drift_status"] == "contract_review_required"
+ assert outputs["runtime_fingerprint"] == f"tv={TV_HEAD};mobile={MOBILE_HEAD}"
+ payload = json.loads(audit.read_text(encoding="utf-8"))
+ assert payload["clients"]["nuvio-tv"]["head"] == TV_HEAD
+ assert "no stale fallback" in payload["policy"]
+
+ broken = tmp / "broken.json"
+ broken.write_text(json.dumps({
+ "clients": {
+ "nuvio-tv": {
+ "current_head": "",
+ "accepted_ref": OLD,
+ "contract_ref": CONTRACT,
+ "status": "verification_inconclusive",
+ }
+ }
+ }), encoding="utf-8")
+ refused = subprocess.run([
+ "python3", str(RESOLVER),
+ "--report", str(broken),
+ "--clients", "nuvio-tv",
+ "--github-output", str(tmp / "should-not-exist.txt"),
+ ], cwd=ROOT, text=True, capture_output=True)
+ assert refused.returncode != 0
+ assert "refusing stale fallback" in (refused.stdout + refused.stderr)
+
+print("Nuvio latest-HEAD Lab resolver tests passed")
diff --git a/tests/nuvio_tv_target_media_playback_context_test.py b/tests/nuvio_tv_target_media_playback_context_test.py
index 48fafd0cd..b69f45fc8 100644
--- a/tests/nuvio_tv_target_media_playback_context_test.py
+++ b/tests/nuvio_tv_target_media_playback_context_test.py
@@ -39,6 +39,7 @@ def main() -> int:
patched = module.apply(source, options=options)
assert module.V4_MARKER in patched
assert module.V5_MARKER in patched
+ assert module.HLS_PROOF_MARKER in patched
assert module.FETCH_COMPAT_MARKER in patched
assert module.PROTOCOL_RELATIVE_MARKER in patched
assert module.apply(patched, options=options) == patched
@@ -52,9 +53,13 @@ def main() -> int:
ok:true,status:200,url,
headers:{get:function(name){name=String(name).toLowerCase();if(name==='content-type')return contentType||'text/html';if(name==='set-cookie')return setCookie||null;return null;}},
text:async function(){return body;},
+ arrayBuffer:async function(){const b=Buffer.from(body,'utf8');return b.buffer.slice(b.byteOffset,b.byteOffset+b.byteLength);},
json:async function(){try{return JSON.parse(body)}catch(_){return null;}}
};
}
+// Intentionally do not inject TextDecoder into the VM. QuickJS-style runtimes
+// can take decode()'s byte fallback, which exposes a UTF-8 BOM as the mojibake
+// sequence . The target-media resolver must still recognize the HLS structure.
const ctx={console,URL,setTimeout,clearTimeout,module:{exports:{}},exports:{}};ctx.globalThis=ctx;
ctx.fetch=async function(url,opt){
const headers=Object.assign({},opt&&opt.headers||{});requests.push({url,headers});
@@ -65,13 +70,15 @@ def main() -> int:
return response(url,'','text/html','play=xyz; Domain=.example.com; Path=/; Secure');
}
if(url==='https://cdn.example.com/master.m3u8'){
- return response(url,'#EXTM3U\n#EXTINF:600,\nseg.ts\n#EXTINF:600,\nseg2.ts\n','application/vnd.apple.mpegurl',null);
+ // Deliberately omit #EXTM3U. Structural HLS proof must preserve the row so
+ // the outer HLS-integrity/sanitizer layer can normalize the final manifest.
+ return response(url,'\uFEFF #EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:600\n#EXTINF:600,\nseg.ts\n#EXTINF:600,\nseg2.ts\n','application/vnd.apple.mpegurl',null);
}
- return {ok:false,status:404,url,headers:{get:()=> 'text/plain'},text:async()=>''};
+ return {ok:false,status:404,url,headers:{get:()=> 'text/plain'},text:async()=>'',arrayBuffer:async()=>new ArrayBuffer(0)};
};
vm.createContext(ctx);vm.runInContext(code,ctx);
(async()=>{
- const rows=await ctx.module.exports.getStreams('123','movie');
+ const rows=await ctx.module.exports.getStreams('123','tv',1,1);
console.log(JSON.stringify({rows,requests}));
})().catch(e=>{console.error(e&&e.stack||e);process.exit(1)});
'''
@@ -103,7 +110,7 @@ def main() -> int:
assert req["https://cdn.example.com/master.m3u8"]["Origin"] == "https://player.example.com"
assert req["https://cdn.example.com/master.m3u8"]["Cookie"] in ("sid=abc; play=xyz", "play=xyz; sid=abc")
- print("NuvioTV target-media playback-context tests passed")
+ print("NuvioTV target-media playback-context and malformed-HLS handoff tests passed")
return 0
diff --git a/tests/opaque_native_media_enrichment_test.py b/tests/opaque_native_media_enrichment_test.py
new file mode 100644
index 000000000..3ea770563
--- /dev/null
+++ b/tests/opaque_native_media_enrichment_test.py
@@ -0,0 +1,87 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import importlib.util
+import json
+import subprocess
+import tempfile
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+PATCH = ROOT / "scripts/provider_patches/global_media_enrichment_v1.py"
+spec = importlib.util.spec_from_file_location("global_media_enrichment_v1", PATCH)
+assert spec and spec.loader
+module = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(module)
+
+base = r'''
+globalThis.getStreams = async function(){
+ return [
+ {name:"Opaque Hub", url:"https://hub.example/download?id=1", headers:{"User-Agent":"NUVIO-UA"}},
+ {name:"Already Direct", url:"https://cdn.example/movie.mkv", type:"mkv"}
+ ];
+};
+'''
+patched = module.apply(base, options={"default_user_agent": "FALLBACK-UA"})
+assert "scoped-playback-context-v6-direct-safe-opaque-media" in patched
+assert "content-disposition" in patched
+assert "metadataKind" in patched
+assert "declaredDirect" in patched
+
+runtime = patched + r'''
+let binaryBodyReads = 0;
+let fetches = [];
+globalThis.fetch = async function(url, init){
+ fetches.push(String(url));
+ if (String(url).startsWith("https://hub.example/download")) {
+ return {
+ ok: true,
+ status: 206,
+ url: "https://files.example/token/opaque-123",
+ headers: {
+ get(name){
+ const key = String(name).toLowerCase();
+ if (key === "content-type") return "video/x-matroska";
+ if (key === "content-disposition") return "attachment; filename=Movie.2026.1080p.mkv";
+ return "";
+ }
+ },
+ async text(){ binaryBodyReads++; throw new Error("opaque binary body must not be consumed"); }
+ };
+ }
+ throw new Error("unexpected fetch: " + url);
+};
+(async()=>{
+ const rows = await globalThis.getStreams({tmdbId:"123", mediaType:"movie"});
+ console.log(JSON.stringify({rows, binaryBodyReads, fetches}));
+})().catch(err=>{console.error(err);process.exit(2)});
+'''
+
+with tempfile.NamedTemporaryFile("w", suffix=".cjs", delete=False, encoding="utf-8") as handle:
+ handle.write(runtime)
+ filename = handle.name
+try:
+ result = subprocess.run(["node", filename], capture_output=True, text=True, timeout=20)
+finally:
+ Path(filename).unlink(missing_ok=True)
+
+assert result.returncode == 0, result.stderr
+payload = json.loads(result.stdout.strip().splitlines()[-1])
+rows = payload["rows"]
+assert payload["binaryBodyReads"] == 0, payload
+assert payload["fetches"] == ["https://hub.example/download?id=1"], payload
+assert len(rows) == 2, rows
+
+resolved = rows[0]
+assert resolved["url"] == "https://files.example/token/opaque-123", resolved
+assert resolved["type"] == "mkv", resolved
+assert resolved["isDirect"] is True, resolved
+assert resolved["headers"]["User-Agent"] == "NUVIO-UA", resolved
+assert resolved["headers"]["Referer"] == "https://hub.example/download?id=1", resolved
+assert resolved["headers"]["Origin"] == "https://hub.example", resolved
+assert all(row["url"] != "https://hub.example/download?id=1" for row in rows), rows
+
+direct = rows[1]
+assert direct["url"] == "https://cdn.example/movie.mkv", direct
+
+print("opaque native media enrichment tests passed")
diff --git a/tests/playback_context_compat_test.py b/tests/playback_context_compat_test.py
index c9d38831f..2d341d181 100644
--- a/tests/playback_context_compat_test.py
+++ b/tests/playback_context_compat_test.py
@@ -85,7 +85,7 @@ def test_embed_cookie_and_header_inheritance(root: Path) -> None:
base = 'module.exports={getStreams:async()=>[{title:"x",url:"https://player.example.com/watch"}]};\n'
patched = enrichment.apply(base)
assert patched == enrichment.apply(patched)
- assert "scoped-playback-context-v4" in patched
+ assert "scoped-playback-context-v6-direct-safe-opaque-media" in patched
assert 'typeof r.arrayBuffer==="function"' in patched
assert 'typeof r.text==="function"' in patched
diff --git a/tests/stream_output_sanitizer_direct_normalization_test.py b/tests/stream_output_sanitizer_direct_normalization_test.py
index fa4bb7e8a..16068f62c 100644
--- a/tests/stream_output_sanitizer_direct_normalization_test.py
+++ b/tests/stream_output_sanitizer_direct_normalization_test.py
@@ -19,9 +19,12 @@
def main() -> int:
source = r'''
module.exports={getStreams:async function(){return [
- {name:"opaque-mp4",url:"https://media.example/token-mp4"},
+ {name:"opaque-mp4",url:"https://media.example/token-mp4",headers:{"X-Legacy":"kept"}},
{name:"opaque-hls",url:"https://media.example/token-hls"},
+ {name:"missing-header-hls",url:"https://media.example/token-hls-missing"},
{name:"opaque-mkv",url:"https://media.example/token-mkv"},
+ {name:"html-embed",url:"https://media.example/embed/abc"},
+ {name:"json-resolver",url:"https://media.example/api-resolver"},
{name:"html-error",url:"https://media.example/not-media"}
]}};
'''
@@ -30,18 +33,19 @@ def main() -> int:
options={
"probe_direct_media": True,
"probe_all_urls": True,
- "max_probes": 4,
+ "max_probes": 8,
"probe_timeout_ms": 2000,
"min_vod_duration_seconds": 0,
},
)
assert '"implementationVersion":7' in patched
+ assert "NUVIO_STREAM_OUTPUT_HLS_HTML_REPAIR_V7" in patched
runner = r'''
const vm=require('vm');
const source=process.argv[2];
function makeResponse(url,type,bytes,disposition){
- let used=false;
+ let offset=0;
return {
ok:true,status:200,url,
headers:{get:(key)=>{
@@ -51,7 +55,13 @@ def main() -> int:
return null;
}},
body:{getReader:()=>({
- read:async()=>used?{done:true,value:undefined}:(used=true,{done:false,value:new Uint8Array(bytes)}),
+ read:async()=>{
+ if(offset>=bytes.length)return {done:true,value:undefined};
+ const end=Math.min(offset+7,bytes.length);
+ const value=new Uint8Array(bytes.slice(offset,end));
+ offset=end;
+ return {done:false,value};
+ },
cancel:async()=>{}
})}
};
@@ -60,16 +70,22 @@ def main() -> int:
const responses={
'https://media.example/token-mp4':()=>makeResponse('https://cdn.example/final/opaque','video/mp4',[0,0,0,24,102,116,121,112,1,2,3,4]),
'https://media.example/token-hls':()=>makeResponse('https://cdn.example/final/hls-opaque','text/plain',enc('#EXTM3U\n#EXT-X-TARGETDURATION:6\n#EXTINF:6,\nseg.ts\n#EXT-X-ENDLIST\n')),
+ 'https://media.example/token-hls-missing':()=>makeResponse('https://cdn.example/final/no-header','text/plain',enc('#EXT-X-TARGETDURATION:6\n#EXTINF:6,\nsegment-a.ts\n#EXT-X-ENDLIST\n')),
'https://media.example/token-mkv':()=>makeResponse('https://cdn.example/final/download','application/octet-stream',[0x1a,0x45,0xdf,0xa3,1,2,3,4],'attachment; filename="movie.mkv"'),
+ 'https://media.example/embed/abc':()=>makeResponse('https://media.example/embed/abc','text/html',enc('')),
+ 'https://media.example/nested/master.m3u8':()=>makeResponse('https://media.example/nested/master.m3u8','application/vnd.apple.mpegurl',enc('#EXTM3U\n#EXT-X-TARGETDURATION:5\n#EXTINF:5,\nseg.ts\n#EXT-X-ENDLIST\n')),
+ 'https://media.example/api-resolver':()=>makeResponse('https://media.example/api-resolver','application/json',enc('{"file":"https://cdn.example/resolved/movie.mp4"}')),
+ 'https://cdn.example/resolved/movie.mp4':()=>makeResponse('https://cdn.example/resolved/movie.mp4','video/mp4',[0,0,0,24,102,116,121,112,5,6,7,8]),
'https://media.example/not-media':()=>makeResponse('https://media.example/not-media','text/html',enc('error'))
};
-const sandbox={module:{exports:{}},exports:{},URL,AbortController,setTimeout,clearTimeout,Uint8Array,
- fetch:async(url)=>{const factory=responses[String(url)];if(!factory)throw new Error('unexpected '+url);return factory();}
+const calls=[];
+const sandbox={module:{exports:{}},exports:{},URL,AbortController,setTimeout,clearTimeout,Uint8Array,encodeURIComponent,
+ fetch:async(url,init)=>{calls.push({url:String(url),headers:init&&init.headers||{}});const factory=responses[String(url)];if(!factory)throw new Error('unexpected '+url);return factory();}
};
sandbox.globalThis=sandbox;
vm.runInNewContext(source,sandbox,{timeout:5000});
sandbox.module.exports.getStreams({tmdbId:'1',mediaType:'movie'})
- .then(rows=>console.log(JSON.stringify(rows)))
+ .then(rows=>console.log(JSON.stringify({rows,calls})))
.catch(error=>{console.error(error);process.exit(1)});
'''
@@ -83,15 +99,41 @@ def main() -> int:
timeout=20,
)
assert process.returncode == 0, process.stderr
- rows = json.loads(process.stdout.strip())
- assert len(rows) == 3, rows
+ payload = json.loads(process.stdout.strip())
+ rows = payload["rows"]
+ calls = payload["calls"]
+ assert len(rows) == 6, rows
by_name = {row["name"]: row for row in rows}
+
assert by_name["opaque-mp4"]["url"] == "https://cdn.example/final/opaque"
assert by_name["opaque-mp4"]["isDirect"] is True
+ assert by_name["opaque-mp4"]["behaviorHints"]["proxyHeaders"]["request"]["X-Legacy"] == "kept"
+
assert by_name["opaque-hls"]["url"] == "https://cdn.example/final/hls-opaque"
assert by_name["opaque-hls"]["isDirect"] is True
+
+ repaired = by_name["missing-header-hls"]
+ assert repaired["url"].startswith("data:application/vnd.apple.mpegurl;charset=utf-8,%23EXTM3U%0A"), repaired
+ assert repaired["type"] == "hls"
+ assert repaired["isDirect"] is True
+ assert "https%3A%2F%2Fcdn.example%2Ffinal%2Fsegment-a.ts" in repaired["url"], repaired["url"]
+
assert by_name["opaque-mkv"]["url"] == "https://cdn.example/final/download"
assert by_name["opaque-mkv"]["isDirect"] is True
+
+ embed = by_name["html-embed"]
+ assert embed["url"] == "https://media.example/nested/master.m3u8", embed
+ embed_headers = embed["behaviorHints"]["proxyHeaders"]["request"]
+ assert embed_headers["Referer"] == "https://media.example/embed/abc"
+ assert embed_headers["Origin"] == "https://media.example"
+ nested_call = next(call for call in calls if call["url"] == "https://media.example/nested/master.m3u8")
+ assert nested_call["headers"]["Referer"] == "https://media.example/embed/abc"
+ assert nested_call["headers"]["Origin"] == "https://media.example"
+
+ resolved = by_name["json-resolver"]
+ assert resolved["url"] == "https://cdn.example/resolved/movie.mp4", resolved
+ assert resolved["isDirect"] is True
+
assert "html-error" not in by_name
print("stream output sanitizer direct normalization tests passed")
diff --git a/tests/stream_output_sanitizer_fail_closed_test.py b/tests/stream_output_sanitizer_fail_closed_test.py
index 8b9143274..d6cec854b 100644
--- a/tests/stream_output_sanitizer_fail_closed_test.py
+++ b/tests/stream_output_sanitizer_fail_closed_test.py
@@ -13,6 +13,14 @@
spec.loader.exec_module(module)
+def sanitizer_region(text: str) -> str:
+ start = text.find(module.SANITIZER_PREFIX)
+ assert start >= 0
+ call = text.find(module.SANITIZER_CALL, start)
+ assert call >= 0
+ return text[start:call]
+
+
def main() -> int:
source = "module.exports={getStreams:async function(){return [];}};\n"
options = {
@@ -36,6 +44,7 @@ def main() -> int:
assert '"maxProbes":20' in first
assert module.NEW in first
assert module.OLD not in first
+ assert module.PROBE_ALIAS in sanitizer_region(first)
for path in options["blocked_path_patterns"]:
assert f'"{path}"' in first
@@ -49,6 +58,29 @@ def main() -> int:
assert module.apply(changed, options=changed_options) == changed
assert changed.count(module.MARKER) == 1
assert module.NEW in changed
+ assert module.PROBE_ALIAS in sanitizer_region(changed)
+
+ # Durable/LKG reapplication can append a freshly force-rewrapped target-media
+ # adapter after an already-materialized sanitizer. Reapplying V6 must move
+ # the sanitizer IIFE after that target-media layer so final media is probed.
+ stale_order = first.rstrip() + "\n/* NUVIO_TV_TARGET_MEDIA_V3:fixture */\n"
+ relocated = module.apply(stale_order, options=options)
+ sanitizer_pos = relocated.find(module.SANITIZER_PREFIX)
+ target_pos = relocated.rfind("/* NUVIO_TV_TARGET_MEDIA_V3:fixture */")
+ assert target_pos >= 0 and sanitizer_pos > target_pos, (target_pos, sanitizer_pos)
+ assert relocated.count(module.SANITIZER_PREFIX) == 1
+ assert relocated.count(module.MARKER) == 1
+ assert module.PROBE_ALIAS in sanitizer_region(relocated)
+ assert module.apply(relocated, options=options) == relocated
+
+ # Reproduce the Coflix collision: another provider wrapper may already define
+ # the exact V5 compatibility alias text. Whole-file detection must not make
+ # the terminal sanitizer omit its own lexical alias.
+ collision_source = module.PROBE_ALIAS + source
+ collision = module.apply(collision_source, options=options)
+ assert collision.count(module.PROBE_ALIAS) >= 2
+ assert module.PROBE_ALIAS in sanitizer_region(collision)
+ assert module.apply(collision, options=options) == collision
print("fail-closed all-URL stream sanitizer tests passed")
return 0
diff --git a/tests/targeted_manifest_staging_test.py b/tests/targeted_manifest_staging_test.py
new file mode 100644
index 000000000..8ae97ee7a
--- /dev/null
+++ b/tests/targeted_manifest_staging_test.py
@@ -0,0 +1,53 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import importlib.util
+import json
+import tempfile
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+module_path = ROOT / 'scripts/prepare_native_corpus_client.py'
+spec = importlib.util.spec_from_file_location('prepare_native_corpus_client', module_path)
+mod = importlib.util.module_from_spec(spec)
+assert spec.loader is not None
+spec.loader.exec_module(mod)
+
+canonical = mod.manifest_providers('manifest.json')
+canonical_manifest = json.loads((ROOT / 'manifest.json').read_text(encoding='utf-8'))
+assert len(canonical) >= 90, len(canonical)
+assert len({str(row['id']).casefold() for row in canonical}) == len(canonical)
+for row in canonical:
+ assert row['source'].is_file(), row['id']
+ assert row['manifest'] == 'manifest.json', row['id']
+
+# Any alternate manifest used by a repair/lab must be an in-repository projection
+# that points to locally present provider bundles. This keeps the staging contract
+# generic without retaining a historical playback-hotfix repository.
+with tempfile.TemporaryDirectory(dir=ROOT, prefix='.native-manifest-test-') as tmp:
+ temp_root = Path(tmp)
+ selected = canonical_manifest.get('scrapers', [])[:2]
+ assert len(selected) == 2
+ alternate = temp_root / 'manifest.json'
+ alternate.write_text(json.dumps({'name': 'Native staging fixture', 'scrapers': selected}), encoding='utf-8')
+ alternate_rel = alternate.relative_to(ROOT)
+
+ rows = mod.manifest_providers(alternate_rel)
+ assert [str(row['id']).casefold() for row in rows] == [str(row['id']).casefold() for row in selected]
+ assert all(row['source'].is_file() for row in rows)
+
+ destination = temp_root / 'assets'
+ staged = mod.stage_manifest_providers(destination, alternate_rel)
+ assert [row['id'] for row in staged] == [row['id'] for row in rows]
+ for row in staged:
+ copied = destination / row['asset']
+ assert copied.read_bytes() == row['source'].read_bytes(), row['id']
+
+try:
+ mod.manifest_providers('../outside.json')
+except SystemExit as error:
+ assert 'must live inside the repository' in str(error)
+else:
+ raise AssertionError('manifest traversal must fail closed')
+
+print('generic in-repository manifest staging tests passed')
diff --git a/tests/tv_provider_hardening_test.py b/tests/tv_provider_hardening_test.py
index f61e909fb..9089d7e76 100644
--- a/tests/tv_provider_hardening_test.py
+++ b/tests/tv_provider_hardening_test.py
@@ -15,6 +15,7 @@
patches = overrides['provider_patches']
purstream_identity = 'scripts/provider_patches/purstream_tv_identity_v3.py'
+purstream_identity_impl = 'scripts/provider_patches/purstream_tv_identity_impl_v3.py'
papa_anime = 'scripts/provider_patches/papadustream_anime_tv_v1.py'
playable_first = 'scripts/provider_patches/nuvio_tv_playable_first_v1.py'
streamzo_identity = 'scripts/provider_patches/streamzo_source_identity_v3.py'
@@ -27,8 +28,16 @@
assert float(pur_opts.get('duration_tolerance')) <= 0.35
assert int(pur_opts.get('max_probes')) <= 3
pur_source = (ROOT / purstream_identity).read_text(encoding='utf-8')
-assert '#EXTINF' in pur_source and 'durationTolerance' in pur_source
-assert 'episodic' in pur_source and 'series' in pur_source and 'anime' in pur_source
+# Purstream is intentionally composed now: factual metadata projection first,
+# then the existing episodic duration-identity guard. Keep checking the public
+# hook wiring and the concrete identity implementation rather than requiring the
+# wrapper itself to contain the old monolithic JavaScript shim.
+assert 'purstream_stream_facts_v1.py' in pur_source
+assert 'purstream_tv_identity_impl_v3.py' in pur_source
+assert '_FACTS.apply' in pur_source and '_IDENTITY.apply' in pur_source
+pur_identity_source = (ROOT / purstream_identity_impl).read_text(encoding='utf-8')
+assert '#EXTINF' in pur_identity_source and 'durationTolerance' in pur_identity_source
+assert 'episodic' in pur_identity_source and 'series' in pur_identity_source and 'anime' in pur_identity_source
assert papa_anime in patches['papadustream'].get('patch_scripts', [])
assert patches['papadustream']['published_types'] == ['movie', 'tv', 'anime']
diff --git a/tests/vf_movie_recovery.test.cjs b/tests/vf_movie_recovery.test.cjs
index 7bdfba075..115c2a4e6 100755
--- a/tests/vf_movie_recovery.test.cjs
+++ b/tests/vf_movie_recovery.test.cjs
@@ -13,6 +13,7 @@ const entries = Object.fromEntries(manifest.scrapers.map((row) => [String(row.id
const externalPlayer = 'https://player.example/embed/movie';
const provenHls = 'https://media.example/hls/fixture/master.m3u8';
const tvFixture = { id: '94605', title: 'Arcane', year: 2021, slug: 'arcane', season: 1, episode: 1 };
+const fetchTrace = [];
function configuredSite(id, fallback) {
const patch = overrides?.provider_patches?.[id] || {};
@@ -58,8 +59,9 @@ function hlsBody() {
'#EXT-X-ENDLIST\n';
}
-global.fetch = async (input) => {
+async function fixtureFetch(input) {
const raw = typeof input === 'string' ? input : input.url;
+ fetchTrace.push(raw);
const url = new URL(raw);
if (url.hostname === 'api.themoviedb.org') return tmdbResponse(raw);
if (url.hostname === 'movix.fun' && url.pathname === '/') return new Response('', { status: 200, headers: { 'content-type': 'text/html' } });
@@ -79,14 +81,28 @@ global.fetch = async (input) => {
return new Response(dleSearch(url.origin, fixture), { status: 200, headers: { 'content-type': 'text/html' } });
}
return new Response('not found', { status: 404, headers: { 'content-type': 'text/plain' } });
-};
+}
+
+function resetProviderGlobal() {
+ // Official Nuvio runtimes isolate provider execution contexts. This Node
+ // regression harness must do the same: provider-local fetch/domain wrappers
+ // must never leak from Movix into Coflix (or any neighboring provider).
+ global.fetch = fixtureFetch;
+ try { delete global.getStreams; } catch {}
+ for (const key of Object.keys(globalThis)) {
+ if (/^__nuvio/i.test(key)) {
+ try { delete globalThis[key]; } catch {}
+ }
+ }
+}
function assertSafeRows(id, fixtureId, rows, kind) {
- assert(Array.isArray(rows) && rows.length > 0, `${id}/${fixtureId}: ${kind} recovery produced no player`);
- assert(rows.every((row) => typeof row.url === 'string' && /^https?:\/\//.test(row.url)), `${id}/${fixtureId}: invalid ${kind} player URL`);
- assert(rows.every((row) => !/fstream\.top/i.test(row.url)), `${id}/${fixtureId}: known fake short player escaped filtering`);
- assert(rows.every((row) => !/\/troll\/master\.m3u8(?:[?#]|$)/i.test(row.url)), `${id}/${fixtureId}: known troll fallback escaped filtering`);
- assert(rows.every((row) => !/(?:^|\.)(?:snap\.com|snapchat\.com|ctfassets\.net|sc-cdn\.net)$/i.test(new URL(row.url).hostname)), `${id}/${fixtureId}: unrelated advertising media escaped filtering`);
+ const trace = JSON.stringify(fetchTrace.slice(-24));
+ assert(Array.isArray(rows) && rows.length > 0, `${id}/${fixtureId}: ${kind} recovery produced no player; fetchTrace=${trace}`);
+ assert(rows.every((row) => typeof row.url === 'string' && /^https?:\/\//.test(row.url)), `${id}/${fixtureId}: invalid ${kind} player URL; fetchTrace=${trace}`);
+ assert(rows.every((row) => !/fstream\.top/i.test(row.url)), `${id}/${fixtureId}: known fake short player escaped filtering; fetchTrace=${trace}`);
+ assert(rows.every((row) => !/\/troll\/master\.m3u8(?:[?#]|$)/i.test(row.url)), `${id}/${fixtureId}: known troll fallback escaped filtering; fetchTrace=${trace}`);
+ assert(rows.every((row) => !/(?:^|\.)(?:snap\.com|snapchat\.com|ctfassets\.net|sc-cdn\.net)$/i.test(new URL(row.url).hostname)), `${id}/${fixtureId}: unrelated advertising media escaped filtering; fetchTrace=${trace}`);
}
(async () => {
@@ -95,16 +111,14 @@ function assertSafeRows(id, fixtureId, rows, kind) {
assert(entry, `missing manifest entry: ${id}`);
const patch = overrides?.provider_patches?.[id] || {};
const configuredQuarantine = patch.capability === 'quarantined' && patch?.manifest_overrides?.enabled === false;
- // Migration compatibility only: a publication-scoped legacy inert bundle
- // may be the current LKG before the Quick promoter has a chance to recover
- // a fresh sibling. Final publication rules no longer create global inert
- // bundles from one fixture contradiction.
const legacyPublicationQuarantine = entry.enabled === false && /--nuvio-audit-quarantine--/.test(String(entry.filename || ''));
const quarantined = configuredQuarantine || legacyPublicationQuarantine;
const providerPath = path.resolve(__dirname, '..', entry.filename);
+ resetProviderGlobal();
delete require.cache[require.resolve(providerPath)];
const provider = require(providerPath);
for (const fixture of fixtures) {
+ fetchTrace.length = 0;
const rows = await provider.getStreams(fixture.id, 'movie', null, null, {});
if (quarantined) {
assert.deepEqual(rows, [], `${id}/${fixture.id}: quarantined movie provider returned content`);
@@ -113,6 +127,7 @@ function assertSafeRows(id, fixtureId, rows, kind) {
assertSafeRows(id, fixture.id, rows, 'movie');
}
if (id === 'flemmix') continue;
+ fetchTrace.length = 0;
const tvRows = await provider.getStreams(tvFixture.id, 'tv', tvFixture.season, tvFixture.episode, {});
if (quarantined) {
assert.deepEqual(tvRows, [], `${id}/${tvFixture.id}: quarantined TV provider returned content`);
@@ -120,5 +135,6 @@ function assertSafeRows(id, fixtureId, rows, kind) {
}
assertSafeRows(id, tvFixture.id, tvRows, 'TV');
}
- console.log('VF catalogue recovery tests passed with current routes, content-proven HLS, scoped publication quarantine migration, and configured safety quarantines');
+ resetProviderGlobal();
+ console.log('VF catalogue recovery tests passed with isolated provider runtimes, current routes, content-proven HLS, scoped publication quarantine migration, and configured safety quarantines');
})().catch((error) => { console.error(error); process.exit(1); });