|
1 | | -"""Reject Purstream episodic HLS whose real duration contradicts its metadata. |
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Compose Purstream factual metadata projection with TV identity validation. |
2 | 3 |
|
3 | | -Purstream can return a technically valid HLS with the requested title/episode in |
4 | | -its display metadata while the media itself belongs to another work. For TV, |
5 | | -series and anime requests, rows that advertise a duration are therefore checked |
6 | | -against the media playlist #EXTINF total. A large mismatch is fail-closed. |
7 | | -Movies and rows without an advertised duration are left unchanged. |
| 4 | +Purstream's provider adapter first exposes language/quality/codec/audio/duration |
| 5 | +as explicit stream facts. The existing episodic duration-identity guard then |
| 6 | +validates those rows. Final user-facing formatting remains entirely owned by |
| 7 | +the repository-wide Core presentation layer. |
8 | 8 | """ |
9 | 9 | from __future__ import annotations |
10 | 10 |
|
11 | | -import hashlib |
12 | | -import json |
| 11 | +import importlib.util |
| 12 | +from pathlib import Path |
13 | 13 | from typing import Any |
14 | 14 |
|
15 | | -MARKER = "NUVIO_PURSTREAM_TV_IDENTITY_V3" |
16 | | - |
17 | | - |
18 | | -def apply(source: str, options: dict[str, Any] | None = None, **_kwargs: Any) -> str: |
19 | | - cfg = dict(options or {}) |
20 | | - tolerance = max(0.15, min(float(cfg.get("duration_tolerance", 0.35)), 0.50)) |
21 | | - timeout_ms = max(2000, min(int(cfg.get("timeout_ms", 7000)), 12000)) |
22 | | - max_probes = max(1, min(int(cfg.get("max_probes", 3)), 5)) |
23 | | - payload = { |
24 | | - "durationTolerance": tolerance, |
25 | | - "timeoutMs": timeout_ms, |
26 | | - "maxProbes": max_probes, |
27 | | - } |
28 | | - serialized = json.dumps(payload, separators=(",", ":")) |
29 | | - marker = f"{MARKER}:{hashlib.sha256(serialized.encode()).hexdigest()[:12]}" |
30 | | - if marker in source: |
31 | | - return source |
32 | | - |
33 | | - shim = r''' |
34 | | -/* MARKER_PLACEHOLDER */ |
35 | | -;(function(g,c){"use strict"; |
36 | | -function txt(v){return String(v==null?"":v)} |
37 | | -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}} |
38 | | -function episodic(t){return t==="tv"||t==="series"||t==="anime"} |
39 | | -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} |
40 | | -function hls(row){return /\.m3u8(?:[?#]|$)/i.test(txt(row&&row.url))||txt(row&&row.type).toLowerCase()==="hls"||txt(row&&row.format).toLowerCase()==="m3u8"} |
41 | | -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} |
42 | | -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)}} |
43 | | -function child(master,base){var lines=txt(master).split(/\r?\n/);for(var i=0;i<lines.length;i++){if(!/^#EXT-X-STREAM-INF:/i.test(lines[i].trim()))continue;for(var j=i+1;j<lines.length;j++){var v=lines[j].trim();if(!v)continue;if(v[0]==="#")continue;try{return new URL(v,base).toString()}catch(_e){return ""}}}return ""} |
44 | | -function duration(media){var total=0,count=0,re=/^#EXTINF:([0-9.]+)/i;txt(media).split(/\r?\n/).forEach(function(line){var m=re.exec(line.trim());if(m){total+=Number(m[1])||0;count++}});return count?total:0} |
45 | | -async function prove(row,seconds){var first=await get(txt(row.url),headers(row));if(!first||!/^#EXTM3U/i.test(first.body))return false;var media=first,variant=child(first.body,first.url);if(variant){media=await get(variant,headers(row));if(!media||!/^#EXTM3U/i.test(media.body))return false}var actual=duration(media.body);if(!actual)return false;var ratio=actual/seconds;return ratio>=(1-c.durationTolerance)&&ratio<=(1+c.durationTolerance)} |
46 | | -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} |
47 | | -function rebuild(v,slot,list){if(slot.key===null)return list;var out=Object.assign({},v);out[slot.key]=list;return out} |
48 | | -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<slot.list.length;i++){var row=slot.list[i],seconds=expected(row);if(seconds&&hls(row)){if(probes>=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} |
49 | | -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){} |
50 | | -})(typeof globalThis!=="undefined"?globalThis:this,CONFIG_PLACEHOLDER); |
51 | | -'''.replace("MARKER_PLACEHOLDER", marker).replace("CONFIG_PLACEHOLDER", serialized) |
52 | | - return source.rstrip() + "\n" + shim.lstrip() |
| 15 | +HERE = Path(__file__).resolve().parent |
| 16 | + |
| 17 | + |
| 18 | +def _load(filename: str, module_name: str): |
| 19 | + path = HERE / filename |
| 20 | + spec = importlib.util.spec_from_file_location(module_name, path) |
| 21 | + if spec is None or spec.loader is None: |
| 22 | + raise RuntimeError(f"cannot load Purstream layer: {path}") |
| 23 | + module = importlib.util.module_from_spec(spec) |
| 24 | + spec.loader.exec_module(module) |
| 25 | + return module |
| 26 | + |
| 27 | + |
| 28 | +_FACTS = _load("purstream_stream_facts_v1.py", "nuvio_purstream_stream_facts_v1") |
| 29 | +_IDENTITY = _load("purstream_tv_identity_impl_v3.py", "nuvio_purstream_tv_identity_impl_v3") |
| 30 | +MARKER = _IDENTITY.MARKER |
| 31 | + |
| 32 | + |
| 33 | +def apply(source: str, options: dict[str, Any] | None = None, **kwargs: Any) -> str: |
| 34 | + with_facts = _FACTS.apply(source, options=options, **kwargs) |
| 35 | + return _IDENTITY.apply(with_facts, options=options, **kwargs) |
0 commit comments