| import { Sys as sys } from 'gpaccore' | |
| import {XMLHttpRequest} from 'xhr' | |
| globalThis.sys = sys; | |
| let server_ports=0; | |
| let do_quit = false; | |
| let force_quit = false; | |
| let all_requests = []; | |
| const MANI_DASH = 1; | |
| const MANI_HLS = 2; | |
| const HLS_MASTER = 1; | |
| const HLS_VARIANT = 2; | |
| const LIVE_EDGE_MAX_DIST = 5; | |
| const DEACTIVATE_TIMEOUT_MS = 5000; | |
| //safety on how long we will make the client wait for the MABR file to be available | |
| //the overall timeout depends on repair parameters | |
| const MABR_TIMEOUT_SAFETY = 200; | |
| //timeout before MABR self-deactivates if no request on a PID | |
| const MABR_PID_DEACTIVATION_TIMEOUT = 10000; | |
| const CACHE_TYPE_HTTP = 0; | |
| const CACHE_TYPE_MOD = 1; | |
| const CACHE_TYPE_MABR = 2; | |
| const DEFAULT_MABR_UNLOAD_SEC = 4; | |
| const DEFAULT_KEEPALIVE_SEC = 4; | |
| const DEFAULT_ACTIVATE_CLIENTS = 1; | |
| const DEFAULT_MCACHE = false; | |
| const DEFAULT_REPAIR = 'false'; // 'false', 'true' or 'auto' | |
| const DEFAULT_TIMESHIFT = 10; | |
| const DEFAULT_GCACHE = false; | |
| const DEFAULT_CHECKIP = false; | |
| //metadata | |
| filter.set_name("mediaserver"); | |
| filter.set_class_hint(GF_FS_CLASS_NETWORK_IO); | |
| filter.set_desc("Media Server"); | |
| filter.set_version("1.0"); | |
| filter.set_author("GPAC team - (c) Telecom Paris 2024 - license LGPL v2"); | |
| let filter_help = `This filter is an HTTP server and proxy for GET and HEAD requests supporting Multicast ABR sources. | |
| This filter does not produce PIDs, it attaches to HTTP output filter. | |
| If no HTTP output filter is specified in the session, the filter will create one. | |
| The file \`.gpac_auth\`, if present in current working directory, will be used for authentication unless \`--rdirs\` is set. | |
| If more options need to be specified for the HTTP output filter, they can be passed as local options or using global options: | |
| EX gpac mediaserver:cors=on --user_agent=MyUser | |
| Although not recommended, a server may be specified explicitly: | |
| EX gpac mediaserver httpout:OPTS | |
| In this case, the first \`httpout\` filter created will be used. | |
| Default request handling of \`httpout\` filter through read / write directories is disabled. | |
| # Services Configuration | |
| The service configuration is set using [-scfg](). It shall be a JSON file containing a single array of services. | |
| Each service is a JSON object with one or more of the following properties: | |
| - id: (string, default null) Service identifier used for logs | |
| - active: (boolean, default true) service is ignored if false | |
| - http: (string, default null) URL of remote service to proxy (either resource name or server path) | |
| - gcache: (boolean, default ${DEFAULT_GCACHE}) use gpac local disk cache when fetching media from HTTP for this service | |
| - local: (string, default null) local mount point of this service | |
| - keepalive: (number, default ${DEFAULT_KEEPALIVE_SEC}) remove the service if no request received for the indicated delay in seconds (0 force service to stay in memory forever) | |
| - mabr: (string, default null) address of multicast ABR source for this service | |
| - timeshift: (number, default ${DEFAULT_TIMESHIFT}) time in seconds a cached file remains in memory | |
| - unload: (number, default ${DEFAULT_MABR_UNLOAD_SEC}) multicast unload policy | |
| - activate: (number, default ${DEFAULT_ACTIVATE_CLIENTS}) multicast activation policy | |
| - mcache: (boolean, default ${DEFAULT_MCACHE}) cache manifest files | |
| - repair: (string, default ${DEFAULT_REPAIR}) enable unicast repair in MABR stack | |
| - false: disable repair | |
| - true: enable repair using source URL or repair servers indicated in MABR | |
| - auto: enable repair only from repair servers indicated in MABR | |
| - check_ip: (boolean, , default ${DEFAULT_CHECKIP}) monitor IP address and port rather than connection when tracking active clients | |
| - noproxy: (boolean) disable proxy for service when local mount point is set. Default is \`true\` if both \`local\` and \`http\` are set, \`false\` otherwise | |
| - sources: (array, default null) list of sources objects for file-only services. Each source has the following property: | |
| - name: name as used in resource path, | |
| - url: local or remote URL to use for this resource. | |
| - js: (string, default null) built-in or custom request resolver | |
| Any JSON object with the property \`comment\` set will be ignored. | |
| Not all properties are used for each type of service and other properties can be defined by custom request resolvers. | |
| # Proxy versus Server | |
| All services using \`http\` option can be exposed by the server without exposing the origin URL, rather than being proxied. To enable this, the \`local\` service configuration option must be set to: | |
| - the exposed server path, in which case manifest names are not rewritten | |
| - or the exposed manifest path, in which case manifest names are rewritten, but only one manifest can be exposed (does not work with dual MPD and M3U8 services) | |
| EX { "http": "https://test.com/live/dash/live.mpd", "local": "/service1/"} | |
| The server will translate any request \`/service1/foo/bar.ext\` into \`https://test.com/live/dash/foo/bar.ext\`. | |
| EX { "http": "https://test.com/live/dash/live.mpd", "local": "/service1/manifest.mpd"} | |
| The server will translate: | |
| - request \`/service1/manifest.mpd\` into \`https://test.com/live/dash/live.mpd\` | |
| - any request \`/service1/foo/bar.ext\` into \`https://test.com/live/dash/foo/bar.ext\` | |
| Note: The URL must point to a self-contained subdirectory of the remote site. Any URLs outside this directory will either fail or be resolved as absolute path on the remote site. | |
| When \`local\` is not set, these services are always acting as proxies for the \`http\` URL. | |
| When \`noproxy\` is explicitly set to false for the services with both \`http\` and \`local\`, the remote URL will be available as a proxy service as well. | |
| # HTTP Proxy and Relay | |
| The server can act as a proxy for HTTP requests, either for any requests or by domain or resource name. | |
| __Service configuration parameters used :__ \`http\` (mandatory), \`gcache\`, \`local\`. | |
| Configuration for activating proxy for a specific network path: | |
| EX { "http": "https://test.com/video/"} | |
| Configuration for activating proxy for any network path: | |
| EX { "http": "*"} | |
| Configuration for a relay on a given path: | |
| EX { "http": "https://test.com/some/path/to/video/", "local": "/myvids/"} | |
| This will resolve any request \`http://localhost/myvids/*\` to \`https://test.com/some/path/to/video/*\` | |
| Note: The requests are never cached in memory in this mode, but can be cached on disk if \`gcache\` is set. | |
| # HTTP Streaming Cache | |
| The server can act as a cache for live HTTP streaming sessions. The live edge can be cached in memory for a given duration. | |
| __Service configuration parameters used :__ \`http\` ( mandatory), \`timeshift\`, \`mcache\`, \`gcache\`, \`keepalive\` and \`local\`. | |
| Configuration for proxying while caching a live HTTP streaming service: | |
| EX { "http": "https://test.com/dash/live.mpd", "timeshift": 30 } | |
| Configuration for relay caching a live HTTP streaming service: | |
| EX { "http": "https://test.com/dash/live.mpd", "timeshift": 30, "local": "/myservice/test.mpd"} | |
| The \`local\` service configuration option can be set to: | |
| - the exposed server path, in which case manifest names are not rewritten | |
| - or the exposed manifest path, in which case manifest names are rewritten, but only one manifest can be exposed (does not work with dual MPD and M3U8 services) | |
| # Multicast ABR Gateway | |
| The server can be configured to use a multicast ABR source for an HTTP streaming service, without any HTTP source. | |
| __Service configuration parameters used :__ \`mabr\` (mandatory), \`local\` (mandatory), \`timeshift\` and \`keepalive\`. | |
| The multicast source can be DVB-MABR (e.g. \`mabr://235.0.0.1:1234/\`), ATSC3.0 (e.g. \`atsc://\`) or ROUTE (e.g. \`route://235.0.0.1:1234/\`). | |
| - If the multicast is replayed from a file, netcap ID shall be set in this multicast URL (e.g. \`:NCID=N\`). | |
| - If a specific IP interface is used, it can also be set in multicast URL (e.g. \`:ifce=IP\`). | |
| For example, with \`local\` set to \`/service/live.mpd\` with \`mabr\` set, the server will expose the multicast service as \`http://localhost/service/live.mpd\`. | |
| The manifest name can be omitted, in which case the exact manifest name used in the broadcast shall be used (and known to the client). | |
| Configuration for exposing a MABR session: | |
| EX { "mabr": "mabr://234.0.0.1:1234", "local": "/service1", "timeshift": 30 } | |
| # Multicast ABR Gateway with HTTP cache | |
| The server can be configured to use a multicast source as an alternate data source of a given HTTP streaming service. | |
| __Service configuration parameters used :__ \`http\` (mandatory), \`mabr\` (mandatory), \`local\`, \`timeshift\`, \`repair\`, \`gcache\`, \`mcache\`, \`unload\`, \`activate\`, \`keepalive\` and \`js\`. | |
| The multicast service can be dynamically loaded at run-time using the \`unload\` service configuration option: | |
| - if 0, the multicast is started when loading the server and never ended, | |
| - otherwise, the multicast is started dynamically and ended \`unload\` seconds after last deactivation. | |
| The qualities in the multicast service can be dynamically activated or deactivated using the \`activate\` service configuration option: | |
| - if 0, multicast streams are never deactivated, | |
| - otherwise, a multicast representation is activated only if at least \`activate\` clients are consuming it, and deactivated otherwise. | |
| The multicast service can use repair options of the MABR stack using \`repair\` service configuration option: | |
| - if false, the file will not be sent until completely received (this increases latency), | |
| - otherwise, file data will be pushed as soon as available in order (after reception or repair). | |
| The number of active clients on a given quality is computed using the client connection state: any disconnect/reconnect from a client for the same quality will trigger a deactivate+activate sequence. | |
| If \`check_ip\` is set to true, the remote IP address+port are used instead of the connection. This however assumes that each client has a unique IP/port which may not always be true (NATs). | |
| If \`timeshift\` is 0 for the service, multicast segments will be trashed as soon as not in use (potentially before the client request). | |
| Note: Manifest files coming from multicast are currently never cached. | |
| Configuration for caching a live HTTP streaming service with MABR backup: | |
| EX { "http": "https://test.com/dash/live.mpd", "mabr": "mabr://234.0.0.1:1234", "timeshift": 30} | |
| For such services, the custom HTTP header \`X-From-MABR\` is defined: | |
| - for client request, a value of \`no\` will disable MABR cache for this request; if absent or value is \`yes\`, MABR cache will be used if available | |
| - for client response, a value of \`yes\` indicates the content comes from the MABR cache; if absent or value is \`no\` or \`off-edge\`, the content comes from HTTP (\`off-edge\` indicates a request outside of the timeshift buffer) | |
| The dedicated root endpoint \`/stats\` returns, the response content type's count (\`yes\`, \`no\`, \`off-edge\`) served by the gateway. | |
| The \`js\` option can be set to a JS module exporting the following functions: | |
| - init : (mandatory) The function is called once at the start of the server. Parameters: | |
| - scfg: the service configuration object | |
| - return value: must be true if configuration and initialization are successful, false otherwise. | |
| - service_activation : (optional) The function is called when the service is activated or deactivated. Parameters: | |
| - do_activate (boolean): if true, service is being loaded otherwise it is being unloaded | |
| - return value: none | |
| - quality_activation : (optional) The function is called when the given quality is to be activated or deactivated. If not present, (de)activation always happens. Parameters (in order): | |
| - do_activate (boolean): if true, quality is being activated otherwise it is being deactivated | |
| - service_id (integer): ID of the service as announced in the multicast | |
| - period_id (string): ID of the DASH Period, ignored (empty) for HLS | |
| - adaptationSet_ID (integer): ID of the DASH AdaptationSet, ignored (-1) for HLS | |
| - representation_ID (string): ID of the DASH representation or name of the HLS variant playlist | |
| - return value: shall be true if activation/deactivation shall proceed and false if activation/deactivation shall be canceled. | |
| - get_mcast_address : (optional) The function is called when the service is activated. Parameters: | |
| - service_url (string): URL of service for which the multicast adress is queried | |
| - return value: shall be the multicast address to use for the service or null if no multicast is used (active multicast wil then be deactivated). | |
| # File Services | |
| A file system directory can be exposed as a service. | |
| __Service configuration parameters used :__ \`local\` (mandatory), \`sources\` (mandatory), \`gcache\` and \`keepalive\`. | |
| The \`local\` service configuration option must be set to the desired service path, and the \`sources\` service configuration option must one or more valid sources. | |
| Each source is either a file, a directory or a remote URL. | |
| Configuration for exposing a directory: | |
| EX { "local": "/dserv/", "sources": [ { "name": "foo/", "url": "my_dir/" } ] } | |
| This service will expose the content of directory \`my_dir/*\` as \`http://localhost/dserv/foo/*\`. | |
| In this mode, file serving is handled directly by httpout filter and no memory caching is used. | |
| If the source is a remote HTTP one, the \`gcache\` option will indicate if GPAC local cache shall be used. | |
| # Module development | |
| A JS module can be specified using the \`js\` option in the service configuration. The module export functions are: | |
| ## init (mandatory) | |
| The function is called once at the start of the server | |
| Parameter: the service configuration object | |
| return value must be true if configuration and initialization are successful, false otherwise | |
| ## resolve (mandatory) | |
| Parameter: an HTTP request object from GPAC | |
| The function returns an array of two values \`[result, delay]\`: | |
| - result: null if error, a resolved string indicating either a local file or the reply body, or an object | |
| - delay: if true, the reply is being delayed by the module for later processing | |
| When an object is returned, the request is handled by the JS module in charge of sending the reply and reading the data. The object shall have the following properties: | |
| - read: same semantics as the request read method | |
| - on_close: optional function called when the request is closed | |
| # Built-in modules | |
| `; | |
| filter.set_arg({ name: "scfg", desc: "service configuration file", type: GF_PROP_STRING} ); | |
| filter.set_arg({ name: "quit", desc: "exit server once last service has been deactivated", type: GF_PROP_BOOL, def: "false"} ); | |
| let services_defs = []; | |
| let all_services = []; | |
| function do_log(level, msg) | |
| { | |
| if (arguments.length==3) print(level, `${arguments[2]}: ${msg}`); | |
| else print(level, msg); | |
| } | |
| function setup_representation(rep) | |
| { | |
| rep.mabr_active = false; | |
| rep.mabr_deactivate_timeout = 0; | |
| rep.nb_active = 0; | |
| rep.first_mabr_tune = 0; | |
| rep.seg_id = null; | |
| rep.seg_dur = 0; | |
| rep.hls_seq_start = 0; | |
| rep.live_edge = false; | |
| rep.radical = null; | |
| rep.suffix = null; | |
| rep.last_edge_compute = 0; | |
| if (rep.template) { | |
| let idx = rep.template.indexOf('$'); | |
| rep.radical = rep.template.substring(0, idx); | |
| let remain = rep.template.substring(idx+1); | |
| idx = remain.indexOf('$'); | |
| rep.suffix = remain.substring(idx+1); | |
| } | |
| } | |
| function update_manifest(manifest_ab, target_url, mabr_service, cache_file) | |
| { | |
| let mani = sys.mpd_parse(manifest_ab); | |
| if (!mani) return; | |
| let hls_active_rep = null; | |
| let service = mabr_service ? mabr_service : all_services.find(s => { | |
| if (target_url.indexOf(s.url)>=0) return true; | |
| if (!s.manifest || !s.manifest.m3u8) return false; | |
| if (!s.manifest) return false; | |
| let period = s.manifest.periods[0]; | |
| //for HLS, locate rep for this variant URL | |
| let rep = period.reps.find(e => { | |
| if (!e.xlink) return false; | |
| //absolute URLs | |
| if (e.xlink == target_url) return true; | |
| let idx = target_url.indexOf(e.xlink); | |
| if (idx<0) return false; | |
| if (target_url.startsWith(s.base_url)) return true; | |
| return false; | |
| }); | |
| if (rep) { | |
| hls_active_rep = rep; | |
| return true; | |
| } | |
| return false; | |
| }); | |
| if (!service) { | |
| if (mani.m3u8 == HLS_VARIANT) { | |
| do_log(GF_LOG_ERROR, `Failed to locate service for HLS variant ${target_url}`); | |
| return; | |
| } | |
| service = create_service(target_url, true, null); | |
| } | |
| //HLS master playlist | |
| if (mani.m3u8 == HLS_MASTER) { | |
| //we assume master playlist is static | |
| if (service.manifest) return; | |
| let period = mani.periods[0]; | |
| period.reps = period.reps.sort((a, b) => a.bandwidth - b.bandwidth); | |
| for (let r=0; r<period.reps.length; r++) { | |
| setup_representation( period.reps[r] ); | |
| } | |
| service.manifest = mani; | |
| return; | |
| } | |
| //HLS variant playlist | |
| if (mani.m3u8 == HLS_VARIANT) { | |
| if (!service.manifest) return; | |
| let period = service.manifest.periods[0]; | |
| if (!hls_active_rep) { | |
| do_log(GF_LOG_ERROR, `Service ${service.id} cannot find rep for target ${target_url}`); | |
| return; | |
| } | |
| hls_active_rep.segments = mani.segments; | |
| hls_active_rep.hls_seq_start = mani.seq_start; | |
| hls_active_rep.live_seg_num = mani.live_seg_num; | |
| service.manifest.live = mani.live; | |
| if (cache_file) { | |
| cache_file.manifest_min_update = mani.min_update; | |
| } | |
| do_log(GF_LOG_DEBUG, `Service ${service.id} updated HLS manifest`); // + JSON.stringify(service.manifest)); | |
| return; | |
| } | |
| for (let p=0; p<mani.periods.length; p++) { | |
| let period = mani.periods[p]; | |
| period.reps = period.reps.sort((a, b) => a.bandwidth - b.bandwidth); | |
| for (let r=0; r<period.reps.length; r++) { | |
| setup_representation( period.reps[r] ); | |
| } | |
| } | |
| let updated = false; | |
| if (!service.manifest) { | |
| service.manifest = mani; | |
| } else { | |
| updated = true; | |
| service.manifest.live_utc = mani.live_utc; | |
| //update existing manifest | |
| //remove old periods | |
| for (let p=0; p<service.manifest.periods.length; p++) { | |
| let period = service.manifest.periods[p]; | |
| let new_p = mani.periods.find(e => e.id == period.id); | |
| if (new_p) continue; | |
| service.manifest.periods.splice(p); | |
| p--; | |
| continue; | |
| } | |
| //push new periods | |
| for (let p=0; p<mani.periods.length; p++) { | |
| let rep_inserted = false; | |
| let period = mani.periods[p]; | |
| let old_p = service.manifest.periods.find(e => e.id == period.id); | |
| if (!old_p) { | |
| service.manifest.periods.push(period); | |
| continue; | |
| } | |
| //same period, update reps | |
| for (let r=0; r<period.reps.length; r++) { | |
| let rep = period.reps[r]; | |
| let old_r = old_p.reps.find(e => (e.ID == rep.ID || e.bandwidth == rep.bandwidth)); | |
| //rep injection is not prohibited, rep removal is | |
| if (!old_r) { | |
| setup_representation( rep); | |
| old_p.reps.push(rep); | |
| rep_inserted = true; | |
| continue; | |
| } | |
| //same rep, update segments and live edge info | |
| old_p.bandwidth = rep.bandwidth; | |
| old_r.segments = rep.segments; | |
| old_r.live_seg_num = rep.live_seg_num; | |
| } | |
| if (rep_inserted) { | |
| old_p.reps = old_p.reps.sort((a, b) => a.bandwidth - b.bandwidth); | |
| } | |
| } | |
| } | |
| if (cache_file) { | |
| cache_file.manifest_min_update = mani.min_update; | |
| } | |
| do_log(GF_LOG_DEBUG, `Service ${service.id} manifest ${ (updated ? 'updated' : 'received')} for ${target_url}`); // + ': ' + JSON.stringify(service.manifest)); | |
| } | |
| function locate_service(target_url) | |
| { | |
| let service = null; | |
| for (let i=0; i<all_services.length; i++) { | |
| service = all_services[i]; | |
| if (service.url == target_url) break; | |
| if (! service.manifest) { | |
| service=null; | |
| continue; | |
| } | |
| //todo, deal with base urls ? | |
| let base_url = service.base_url; | |
| if (target_url.startsWith(base_url)) break; | |
| service=null; | |
| } | |
| return service; | |
| } | |
| function locate_service_quality(target_url, in_service) | |
| { | |
| let service = in_service; | |
| let with_base_url = null; | |
| if (!service) { | |
| for (let i=0; i<all_services.length; i++) { | |
| service = all_services[i]; | |
| if (service.url == target_url) break; | |
| if (! service.manifest) { | |
| service=null; | |
| continue; | |
| } | |
| //todo, deal with base urls ? | |
| let base_url = service.base_url; | |
| if (target_url.startsWith(base_url)) break; | |
| service=null; | |
| } | |
| if (!service) return [null,null,null]; | |
| } | |
| if (!service.manifest) return [service, null,null]; | |
| if (!with_base_url) with_base_url = service.base_url; | |
| //extract seg name | |
| let seg_name = target_url.substring(with_base_url.length); | |
| let period = null; | |
| let rep = null; | |
| let now = sys.get_utc(); | |
| //find segment | |
| for (let p=0; p<service.manifest.periods.length; p++) { | |
| period = service.manifest.periods[p]; | |
| for (let r=0; r<period.reps.length; r++) { | |
| rep = period.reps[r]; | |
| rep.seg_id = null; | |
| rep.seg_dur = 0; | |
| rep.live_edge = false; | |
| rep.seg_num = 0; | |
| if (rep.xlink && (seg_name.indexOf(rep.xlink)>=0)) { | |
| rep=null; | |
| continue; | |
| } | |
| if (rep.radical && ! seg_name.startsWith(rep.radical)) { | |
| rep=null; | |
| continue; | |
| } | |
| //m3u8 or SegmentTimeline | |
| if (rep.segments.length) { | |
| let found_rep = false; | |
| for (let si=0; si<rep.segments.length; si++) { | |
| let s_idx = rep.segments.length - 1 - si; | |
| let seg = rep.segments[s_idx]; | |
| if (seg.url == seg_name) { | |
| rep.seg_dur = seg.duration * 1000 / rep.timescale; | |
| if (rep.radical) { | |
| let num_time = seg_name.substring(rep.radical.length); | |
| let idx = num_time.indexOf(rep.suffix); | |
| if (idx>=0) { | |
| rep.seg_id = num_time.substring(0, idx); | |
| } | |
| } else if (service.manifest.m3u8) { | |
| rep.seg_id = '' + (rep.hls_seq_start + s_idx); | |
| } | |
| if (si<=LIVE_EDGE_MAX_DIST) { | |
| rep.live_edge = true; | |
| } | |
| found_rep = true; | |
| break; | |
| } | |
| } | |
| if (found_rep) break; | |
| } | |
| if (rep.template) { | |
| let num_time = seg_name.substring(rep.radical.length); | |
| let idx = num_time.indexOf(rep.suffix); | |
| if (idx>=0) { | |
| rep.seg_id = num_time.substring(0, idx); | |
| } | |
| if (!rep.seg_dur) | |
| rep.seg_dur = rep.duration * 1000 / rep.timescale; | |
| //compute live edge time divide then multiply to avoid overflow | |
| if (rep.last_edge_compute + rep.seg_dur < now) { | |
| let live_edge_num = now; | |
| live_edge_num -= service.manifest.ast; | |
| live_edge_num -= period.start; | |
| live_edge_num /= 1000; | |
| live_edge_num /= rep.duration; | |
| live_edge_num *= rep.timescale; | |
| live_edge_num = Math.ceil(live_edge_num) + 1; | |
| rep.live_seg_num = live_edge_num; | |
| rep.last_edge_compute = now; | |
| } | |
| //todo in live, figure out if we are on the live edge | |
| if (rep.live_seg_num && rep.seg_id) { | |
| rep.seg_num = parseInt(rep.seg_id); | |
| let seg_num = rep.seg_num - rep.live_seg_num; | |
| if (Math.abs(seg_num) <= LIVE_EDGE_MAX_DIST) { | |
| rep.live_edge = true; | |
| } | |
| } | |
| break; | |
| } | |
| rep = null; | |
| } | |
| if (rep) { | |
| rep.period_id = period.ID; | |
| break; | |
| } | |
| period = null; | |
| } | |
| do_log(GF_LOG_DEBUG, `Located service ${service.id} for URL ${seg_name}${(rep ? (' rep: ' + rep.ID ) : '' )}`); | |
| return [service, rep, period]; | |
| } | |
| function cat_buffer(dst_ab, src_ab) | |
| { | |
| let tmp = new Uint8Array(dst_ab ? dst_ab.byteLength + src_ab.byteLength : src_ab.byteLength); | |
| if (dst_ab) | |
| tmp.set(new Uint8Array(dst_ab), 0); | |
| tmp.set(new Uint8Array(src_ab), dst_ab ? dst_ab.byteLength : 0); | |
| return tmp.buffer; | |
| } | |
| globalThis.cat_buffer = cat_buffer; | |
| function get_mediaserver_stats(){ | |
| return all_services.reduce((r,s) => { | |
| r[s.id] = s.stats; | |
| return r; | |
| }, {}); | |
| } | |
| globalThis.get_mediaserver_stats = get_mediaserver_stats; | |
| //custom HTTPout request handler | |
| let httpout = {}; | |
| httpout.on_request = (req) => | |
| { | |
| //use pre-authentication done by server | |
| if (req.auth_code!=200) { | |
| req.reply = req.auth_code; | |
| req.send(); | |
| return; | |
| } | |
| //not yet supported | |
| if ((req.method !== 'GET') && (req.method !== 'HEAD')) { | |
| req.reply = 405; | |
| req.send('Method not allowed by server'); | |
| return; | |
| } | |
| do_log(GF_LOG_DEBUG, `Got request ${req.method} for ${req.url}`); | |
| if (req.url.startsWith('/stats')){ | |
| req.reply = 200; | |
| req.body = JSON.stringify(get_mediaserver_stats()); | |
| req.send(); | |
| return; | |
| } | |
| //setup request | |
| req.target_url = null; | |
| req.is_head = (req.method === 'HEAD'); | |
| req.start_time = sys.clock_ms(); | |
| req.bytes_in_req = 0; | |
| req.cache_file = null; | |
| req.ab_offset = 0; | |
| req.ab_queue = null; | |
| req.ab_total_bytes = 0; | |
| req.data = null; | |
| req.waiting_mabr = 0; | |
| req.xhr_done = false; | |
| req.activate_rep_timeout = null; | |
| req.xhr = null; | |
| req.br_start=0; | |
| req.br_end=0; | |
| req.from_cache=false; | |
| req.no_cache=false; | |
| req.do_cache = false; | |
| req.jsmod=null; | |
| req.up_rate=0; | |
| req.service = null; | |
| all_requests.push(req); | |
| req.get_down_rate = function() { | |
| let bitrate = this.bytes_in_req * 8; // /1000 for kbits but we then need to divide by time/1000 for secs | |
| let time = sys.clock_ms() - this.start_time; | |
| if (!time) time=1; | |
| return [Math.floor(1000*bitrate/time), time]; | |
| } | |
| req.get_up_rate = function() { | |
| return this.up_rate; | |
| } | |
| req.on_done = function() { | |
| let bitrate, time; | |
| [bitrate, time] = this.get_down_rate(); | |
| bitrate = Math.floor(bitrate/1000); | |
| let orig = 'HTTP' | |
| if (this.from_cache) { | |
| orig = 'local cache'; | |
| } else if (this.cache_file) { | |
| orig = ((this.cache_file.cache_type==CACHE_TYPE_MABR) ? 'MABR' : 'HTTP') +' cache'; | |
| } else if (this.jsmod) { | |
| orig = 'JS module'; | |
| } | |
| do_log(GF_LOG_INFO, `Done sending ${this.url} (${this.reply}) from ${orig} in ${time} ms at ${bitrate} kbps (${this.bytes_in_req} bytes)`); | |
| }; | |
| //used when reading from JS module | |
| req._read_from_mod = function(ab) { | |
| let res = this.jsmod.read(ab); | |
| if (res==0) { | |
| this.on_done(); | |
| return 0; | |
| } | |
| if (res<0) return res; | |
| this.bytes_in_req += res; | |
| return res; | |
| } | |
| //used when reading from XHR without cache | |
| req._read_from_queue = function(ab) { | |
| if (!this.ab_queue.length) { | |
| if (this.xhr_done) { | |
| this.on_done(); | |
| if (this.activate_rep_timeout) this.activate_rep_timeout.update(); | |
| return 0; | |
| } | |
| return -1; | |
| } | |
| let q_ab=this.ab_queue[0]; | |
| let to_read = q_ab.byteLength - this.ab_offset; | |
| if (to_read > ab.byteLength) to_read = ab.byteLength; | |
| new Uint8Array(ab, 0, to_read).set(new Uint8Array(q_ab, this.ab_offset, to_read) ); | |
| this.ab_offset += to_read; | |
| if (this.ab_offset == q_ab.byteLength) { | |
| this.ab_offset = 0; | |
| this.ab_queue.shift(); | |
| } | |
| this.bytes_in_req += to_read; | |
| return to_read; | |
| } | |
| //used when reading from MABR service or cached file | |
| req._read_from_buffer = function(ab) { | |
| if (this.is_head || !this.cache_file) return 0; | |
| let src_ab = this.cache_file.data; | |
| if (src_ab.byteLength <= this.ab_offset) { | |
| if (this.cache_file.done) { | |
| this.on_done(); | |
| this.cache_file.release(this); | |
| this.cache_file = null; | |
| if (this.activate_rep_timeout) this.activate_rep_timeout.update(); | |
| return 0; | |
| } | |
| //waiting for data | |
| return -1; | |
| } | |
| let to_read = src_ab.byteLength - this.ab_offset; | |
| if (to_read > ab.byteLength) to_read = ab.byteLength; | |
| new Uint8Array(ab, 0, to_read).set(new Uint8Array(src_ab, this.ab_offset, to_read) ); | |
| this.ab_offset += to_read; | |
| this.bytes_in_req += to_read; | |
| return to_read; | |
| }; | |
| req._read_abort = function(ab) { | |
| return 0; | |
| }; | |
| //end of session | |
| req.close = function(code) { | |
| if (code<0) | |
| do_log(GF_LOG_DEBUG, `Client session ${this.url} is closed: ${sys.error_string(code)}`); | |
| let ridx = all_requests.indexOf(this); | |
| if (ridx>=0) all_requests.splice(ridx, 1); | |
| if (do_quit && !all_requests.length && !all_services.length) force_quit = true; | |
| if (this.jsmod && typeof this.jsmod.on_close == 'function') this.jsmod.on_close(); | |
| this.jsmod = null; | |
| this.ab_queue = null; | |
| if (this.cache_file) this.cache_file.release(this); | |
| if (this.activate_rep_timeout) this.activate_rep_timeout.update(); | |
| else if (req.service && req.service.keepalive && !req.service.mabr) { | |
| let timeout = (code<GF_OK) ? 100 : 1000*req.service.keepalive; | |
| req.service.unload_timeout = sys.clock_ms() + timeout; | |
| } | |
| //we have an XHR, abort it if needed and move to XHR cache for later reuse | |
| if (this.xhr) { | |
| if (this.xhr && (this.xhr.readyState < 4)) this.xhr.abort(); | |
| if (this.service) { | |
| this.xhr.last_used = sys.clock_ms(); | |
| this.service.xhr_cache.push(this.xhr); | |
| this.xhr = null; | |
| } | |
| } | |
| }; | |
| req.set_cache_file = function(file) { | |
| if (this.cache_file) return; | |
| this.cache_file = file; | |
| file.nb_users ++; | |
| this.read = this._read_from_buffer; | |
| if (file.xhr_status) { | |
| this.flush_request(); | |
| } else { | |
| if (!file.pending_reqs) file.pending_reqs = []; | |
| file.pending_reqs.push(this); | |
| } | |
| if (file.pid) file.pid.deactivate_timeout = 0; | |
| }; | |
| req.flush_request = function() { | |
| this.reply = this.cache_file.xhr_status; | |
| this.headers_out.push( { "name" : 'Content-Type', "value": this.cache_file.mime} ); | |
| if (this.cache_file.headers) | |
| this.cache_file.headers.forEach( hdr => this.headers_out.push(hdr) ); | |
| if (this.cache_file.range) | |
| this.headers_out.push( { "name" : 'Content-Range', "value": this.cache_file.range} ); | |
| if (this.cache_file.aborted) { | |
| } else if (this.cache_file.done) { | |
| this.headers_out.push( { "name" : 'Content-Length', "value": this.cache_file.data.byteLength} ); | |
| } else { | |
| this.headers_out.push( { "name" : 'Transfer-Encoding', "value": 'chunked'} ); | |
| } | |
| if (this.cache_file.cache_type==CACHE_TYPE_MABR) { | |
| this.service.update_mabr_stats('yes'); | |
| this.headers_out.push( { "name" : 'X-From-MABR', "value": 'yes'} ); | |
| } | |
| else if (this.service && this.service.mabr) { | |
| const val = this.live_edge ? 'no' : 'off-edge'; | |
| this.service.update_mabr_stats(val); | |
| this.headers_out.push( { "name" : 'X-From-MABR', "value": val} ); | |
| } | |
| this.send(); | |
| }; | |
| req.fetch_unicast = function() { | |
| //already setup through set_cache_file - typically happens when N requests are pending on MABR file that is later cancelled | |
| //if source can still be cached, a cache file will be created and assigned to all pending requests for the same url | |
| if (this.cache_file) return; | |
| //MABR without origin server | |
| if (this.service && !this.service.url && !this.service.mabr_repair_url) { | |
| do_log(GF_LOG_DEBUG, `No URL for base repair, cannot fetch ${this.target_url}`); | |
| this.reply = 404; | |
| this.send(); | |
| return; | |
| } | |
| this.ab_queue = []; | |
| this.manifest_ab = null; | |
| this.read = this._read_from_queue; | |
| //DASH/HLS rep, create a cache file for media | |
| if (active_rep && !this.no_cache) { | |
| this.cache_file = this.service.create_cache_file(this.target_url, "video/mp4", this.br_start, this.br_end, CACHE_TYPE_HTTP); | |
| this.cache_file.nb_users ++; | |
| this.read = this._read_from_buffer; | |
| } | |
| //DASH/HLS manifest, create a cache file for media | |
| else if (this.service && this.manifest_type && this.service.mani_cache) { | |
| this.cache_file = this.service.create_cache_file(this.target_url,(this.manifest_type==MANI_DASH) ? "application/dash+xml" : "application/vnd.apple", this.br_start, this.br_end, CACHE_TYPE_HTTP); | |
| this.cache_file.nb_users ++; | |
| this.read = this._read_from_buffer; | |
| } | |
| //MABR without origin server but repair URL signaled from mabr, update target url | |
| if (this.service && !this.service.url) { | |
| this.target_url = sys.url_cat(this.service.mabr_repair_url, this.target_url); | |
| } | |
| this.xhr = null; | |
| if (this.service && this.service.xhr_cache.length) { | |
| this.xhr = this.service.xhr_cache.shift(); | |
| } | |
| if (!this.xhr) | |
| this.xhr = new XMLHttpRequest(); | |
| this.xhr.last_used = sys.clock_ms(); | |
| let do_cache = false; | |
| if (!this.cache_file) { | |
| do_cache = req.do_cache; | |
| } | |
| this.xhr.cache = do_cache ? "normal" : "none"; | |
| this.xhr.responseType = "push"; | |
| this.xhr.onprogress = function (evt) { | |
| if (!evt) return; | |
| if (evt.buffer.byteLength) { | |
| let new_bytes = evt.buffer.slice(0); | |
| req.ab_queue.push(new_bytes); | |
| req.ab_total_bytes += new_bytes.byteLength; | |
| req.up_rate = evt.bps; | |
| //gather manifest | |
| if (req.manifest_type) { | |
| req.manifest_ab = cat_buffer(req.manifest_ab, new_bytes); | |
| } | |
| //gather cache (can be media or manifest) | |
| if (req.cache_file) { | |
| req.cache_file.data = cat_buffer(req.cache_file.data, new_bytes); | |
| } | |
| let client_rate, req_time; | |
| [client_rate, req_time] = req.get_down_rate(); | |
| let buf_size = req.ab_total_bytes - req.bytes_in_req; | |
| //do_log(GF_LOG_DEBUG, `Up rate ${evt.bps} bps - down rate ${client_rate} bps ${buf_size} queued bytes`); | |
| if (buf_size > 1000000) return 10000; | |
| else if (buf_size > 200000) return client_rate; | |
| return 0; | |
| } | |
| }; | |
| this.xhr.onerror = function() { | |
| do_log(GF_LOG_ERROR, `Request failed for ${req.target_url}`); | |
| if (req.reply) return; | |
| req.reply = 502; | |
| req.send(); | |
| req.read = req._read_abort; | |
| if (req.cache_file) { | |
| req.cache_file.xhr_status = req.reply; | |
| req.cache_file.aborted = true; | |
| req.cache_file.done = true; | |
| //flush all pending requests that were waiting for XHR status | |
| if (req.cache_file.pending_reqs) { | |
| req.cache_file.pending_reqs.forEach(r => r.flush_request() ); | |
| req.cache_file.pending_reqs = null; | |
| } | |
| } | |
| }; | |
| this.xhr.onreadystatechange = function() { | |
| if (this.readyState == 4) { | |
| do_log(GF_LOG_DEBUG, `${req.target_url} received`); | |
| if (req.manifest_type) { | |
| update_manifest(req.manifest_ab, req.target_url, req.service, req.cache_file); | |
| req.manifest_ab = null; | |
| } | |
| //cache can be set for manifests or media | |
| if (req.cache_file) { | |
| do_log(GF_LOG_INFO, `${req.target_url} closing cache file`); | |
| req.cache_file.done = true; | |
| //purge delay applies on received file | |
| req.cache_file.received = sys.clock_ms(); | |
| } | |
| req.xhr_done = true; | |
| //and move to XHR cache | |
| if (req.service) { | |
| req.xhr.last_used = sys.clock_ms(); | |
| req.service.xhr_cache.push(req.xhr); | |
| req.xhr = null; | |
| } | |
| return; | |
| } | |
| if (this.readyState != 2) return; | |
| let content_range = null; | |
| if (req.cache_file) req.cache_file.headers = []; | |
| //we got our headers | |
| let headers = this.getAllResponseHeaders().split('\r\n'); | |
| headers.forEach(n => { | |
| let h = n.split(': '); | |
| let hdr = { "name" : h[0], "value": h[1]}; | |
| //filter headers | |
| let h_name = h[0].toLowerCase(); | |
| if (h_name === 'upgrade') return; | |
| if (h_name === ':status') return; | |
| if (h_name === 'content-range') content_range = h[1]; | |
| req.headers_out.push(hdr); | |
| if (req.cache_file) { | |
| //cache some headers | |
| if ((h_name !== 'content-length') | |
| && (h_name !== 'transfer-encoding') | |
| && (h_name !== 'server') | |
| && (h_name !== 'date') | |
| ) { | |
| req.cache_file.headers.push(hdr); | |
| } | |
| } | |
| if (h_name == 'content-type') { | |
| if ((h[1].indexOf('application/vnd.apple')>=0) ||Â (h[1].indexOf('x-mpegurl')>=0)) { | |
| req.manifest_type = MANI_HLS; | |
| } | |
| else if (h[1].indexOf('dash+xml')>=0) { | |
| req.manifest_type = MANI_DASH; | |
| } | |
| if (req.cache_file) req.cache_file.mime = h[1]; | |
| } | |
| }); | |
| do_log(GF_LOG_DEBUG, `Sending reply to ${req.url} code ${req.xhr.status}`); | |
| req.reply = req.xhr.status; | |
| if (req.service && req.service.mabr) { | |
| const val = req.live_edge ? 'no' : 'off-edge'; | |
| req.service.update_mabr_stats(val); | |
| req.headers_out.push( { "name" : 'X-From-MABR', "value": val} ); | |
| } | |
| req.send(); | |
| if (req.cache_file) { | |
| req.cache_file.xhr_status = req.reply; | |
| if (req.reply == 206) req.cache_file.range = content_range; | |
| //prune if error | |
| if (req.reply>=400) { | |
| req.cache_file.headers = null; | |
| req.cache_file.aborted = true; | |
| req.xhr_done = true; | |
| } | |
| //flush all pending requests that were waiting for XHR status | |
| if (req.cache_file.pending_reqs) { | |
| req.cache_file.pending_reqs.forEach(r => r.flush_request() ); | |
| req.cache_file.pending_reqs = null; | |
| } | |
| } | |
| }; | |
| this.xhr.open(this.method, this.target_url); | |
| this.xhr.send(); | |
| }; | |
| //preprocess headers to locate host and extract range | |
| let disable_mabr_cache = false; | |
| let host = null; | |
| let referer = null; | |
| req.headers_in.forEach(h => { | |
| let hlwr = h.name.toLowerCase(); | |
| if (hlwr == "host") host = h.value; | |
| if (hlwr == "referer") referer = h.value; | |
| else if (hlwr == ":authority") host = h.value; | |
| else if ((hlwr == "x-from-mabr") && (h.value.toLowerCase() == 'no')) disable_mabr_cache = true; | |
| else if (hlwr == "range") { | |
| let hdr_range = h.value.split('='); | |
| let range = hdr_range[1]; | |
| if (range.indexOf(',')>=0) { | |
| req.no_cache = true; | |
| } else { | |
| let idx = range.indexOf('-'); | |
| req.br_start = parseInt(range.substring(0, idx)); | |
| req.br_end = parseInt(range.substring(idx+1)); | |
| //only cache closed ranges | |
| if (!idx ||Â !req.br_end) req.no_cache = true; | |
| } | |
| } | |
| }); | |
| let check_any_allowed = true; | |
| //support http://myhost/http://url - not really standard | |
| if (!req.service && | |
| (req.url.startsWith('/http://') | |
| || req.url.startsWith('/http%3A%2F%2F') | |
| || req.url.startsWith('/https://') | |
| || req.url.startsWith('/https%3A%2F%2F') | |
| )) { | |
| req.target_url = req.url.substring(1); | |
| if ((target_url.indexOf("127.0.0.1")>=0) || (target_url.indexOf("localhost")>=0)) { | |
| req.reply = 405; | |
| req.send('Must use Host for localhost proxying'); | |
| return; | |
| } | |
| } | |
| //look in mount points | |
| if (!req.target_url) { | |
| let host = null; | |
| let full_name = true; | |
| let service_def = services_defs.find( s => (s.local && (req.url.indexOf(s.local)==0) ) ); | |
| if (!service_def) { | |
| service_def = services_defs.find( s => (s.local_base && (req.url.indexOf(s.local_base)==0) ) ); | |
| full_name = false; | |
| } | |
| //special case for MABR services, check we are loaded | |
| if (service_def && service_def.mabr) { | |
| req.service = locate_service(req.url); | |
| //if http source, only start once we fetch the manifest, otherwise start now | |
| if (!req.service && !service_def.http) | |
| req.service = create_service(null, true, service_def); | |
| req.target_url = req.url; | |
| //fall through | |
| } | |
| //for other services, format target url | |
| if (service_def && !req.service) { | |
| check_any_allowed = false; | |
| req.do_cache = service_def.gcache; | |
| if (service_def.http) { | |
| if (full_name) { | |
| req.target_url = req.url.replace(service_def.local, service_def.http); | |
| } else { | |
| req.target_url = req.url.replace(service_def.local_base, service_def.http_proto + '://' + service_def.http_host + service_def.http_path); | |
| } | |
| } else { | |
| req.target_url = req.url; | |
| } | |
| } | |
| if (!service_def && referer) { | |
| let src = referer.split('://'); | |
| src = src[(src.length>1) ? 1 : 0]; | |
| src = src.split('/'); | |
| src.shift(); | |
| src = '/'+src.join('/'); | |
| service_def = services_defs.find( s => (s.local_base && (src.indexOf(s.local_base)==0) ) ); | |
| if (service_def) { | |
| check_any_allowed = false; | |
| req.do_cache = service_def.gcache; | |
| req.target_url = service_def.http_proto + '://' + service_def.http_host + req.url; | |
| } | |
| } | |
| //local dir or custom JS | |
| if (service_def && !service_def.http && !service_def.mabr) { | |
| //custom JS handler | |
| if (service_def.js_mod) { | |
| let resolved, delayed; | |
| [resolved, delayed ] = service_def.js_mod.resolve(req); | |
| if (!resolved) { | |
| if (!req.reply) { | |
| do_log(GF_LOG_DEBUG, `Invalid JS handler for ${req.url}`); | |
| req.reply = 404; | |
| req.send(); | |
| } | |
| return; | |
| } | |
| if (delayed) return; | |
| if (typeof resolved == 'string') { | |
| if (req.from_cache) { | |
| req.read = req._read_from_buffer; | |
| req.target_url = resolved; | |
| } else if (resolved.indexOf('http')>=0) { | |
| req.target_url = resolved; | |
| } else if (typeof resolved == 'string') { | |
| req.body = resolved; | |
| req.send(); | |
| return; | |
| } | |
| } else if (typeof resolved == 'object') { | |
| req.jsmod = resolved; | |
| if (typeof resolved.read == 'function') { | |
| req.read = req._read_from_mod; | |
| return; | |
| } | |
| //otherwise we read from cache and module will populate it | |
| req.read = req._read_from_buffer; | |
| req.from_cache = true; | |
| } else { | |
| req.reply = 501; | |
| req.send('Invalid module resolution'); | |
| return; | |
| } | |
| } | |
| //custom mount point | |
| else if (service_def.sources.length && service_def.local_base) { | |
| let my_url = req.url.replace(service_def.local_base, ''); | |
| let e = service_def.sources.find( a => (a.name == my_url) ); | |
| if (!e) e = service_def.sources.find( a => my_url.startsWith(a.name) ); | |
| if (!e) { | |
| do_log(GF_LOG_DEBUG, `Invalid service definition for ${req.url}`); | |
| req.reply = 404; | |
| req.send(); | |
| return; | |
| } | |
| if (e.url.startsWith('http')) { | |
| req.target_url = e.url; | |
| } else if (e.url.indexOf('://')>=0) { | |
| req.reply = 501; | |
| req.send('Protocol not supported'); | |
| return; | |
| } else { | |
| my_url = my_url.replace(e.name, e.url); | |
| if (sys.file_exists(my_url)) { | |
| req.body = my_url; | |
| req.send(); | |
| return; | |
| } else { | |
| do_log(GF_LOG_DEBUG, `Invalid local path for ${req.url}`); | |
| req.reply = 404; | |
| req.send(); | |
| return; | |
| } | |
| } | |
| } | |
| } | |
| if (req.target_url && !req.from_cache && check_any_allowed) { | |
| //rewrite request url | |
| req.url = req.target_url.substring(req.target_url.indexOf('/', 8)); | |
| } | |
| } | |
| //running as real proxy, check host | |
| if (!req.target_url) { | |
| if (!host) { | |
| req.reply = 405; | |
| req.send('Invalid host name'); | |
| return; | |
| } | |
| let hport = req.tls ? 443 : 80; | |
| let splith = host.split(':'); | |
| if (splith.length>1) { | |
| hport = parseInt(splith[splith.length-1]); | |
| } | |
| if ((host.indexOf("127.0.0.1")>=0) || (host.indexOf("localhost")>=0)) { | |
| let e = server_ports.find( e => e == hport); | |
| if (e) { | |
| req.reply = 405; | |
| req.send('Invalid host pointing to this proxy'); | |
| return; | |
| } | |
| } | |
| let use_tls=false; | |
| if (hport!==443) { | |
| let url_no_proto = host + req.url; | |
| let service = all_services.find( s => (url_no_proto.indexOf(s.base_url_no_proto)>=0) ); | |
| if (service && service.force_tls) use_tls = true; | |
| } else { | |
| use_tls = true; | |
| } | |
| //remove default ports in host | |
| if (use_tls && (hport == 443)) host = host.replace(":443", ""); | |
| else if (!use_tls && (hport == 80)) host = host.replace(":80", ""); | |
| req.target_url = (use_tls ? 'https://' : 'http://') + host + req.url; | |
| } | |
| if (! req.target_url) { | |
| do_log(GF_LOG_DEBUG, `No service found for ${req.url}`); | |
| req.reply = 404; | |
| req.send('No such service'); | |
| return; | |
| } | |
| do_log(GF_LOG_INFO, `Processing request ${req.method} for ${req.url}`); | |
| do_log(GF_LOG_DEBUG, `Resolved request URL ${req.target_url}`); | |
| let url_lwr = req.target_url.toLowerCase(); | |
| if (url_lwr.indexOf('.m3u8')>=0) req.manifest_type = MANI_HLS; | |
| else if (url_lwr.indexOf('.mpd')>=0) req.manifest_type = MANI_DASH; | |
| else req.manifest_type = 0; | |
| //locate service | |
| let period, active_rep; | |
| [req.service, active_rep, period] = locate_service_quality(req.target_url, req.service); | |
| let purge_delay = req.service ? req.service.purge_delay : 0; | |
| if (!purge_delay) req.no_cache = true; | |
| //example of how to force highest bandwidth - this assumes segment alignment across all reps and bitstream switching | |
| //not working for HLS as we would need to extract the segment name from the target variant playlist which we may not have | |
| if (0 && active_rep && !req.service.m3u8) { | |
| let reps = period.reps.filter(r => (active_rep.as_idx== r.as_idx) && (r!==active_rep) ); | |
| if (reps.length) { | |
| let force_rep = reps[ reps.length-1]; | |
| let idx = req.target_url.indexOf(active_rep.radical); | |
| let new_url = req.target_url.substring(0, idx) + force_rep.radical + active_rep.seg_id + active_rep.suffix; | |
| req.target_url = new_url; | |
| force_rep.seg_id = active_rep.seg_id; | |
| force_rep.seg_dur = active_rep.seg_dur; | |
| active_rep = force_rep; | |
| } | |
| } | |
| if (!req.service && check_any_allowed) { | |
| //pure proxy, check allowed domains | |
| let service_def = services_defs.find( s => (!s.noproxy && (host === s.http_host) && req.url.startsWith(s.http_path)) ); | |
| if (!service_def) service_def = services_defs.find( s => (s.http === '*') ); | |
| if (!service_def) { | |
| do_log(GF_LOG_ERROR, `Proxying of ${req.url} not allowed`); | |
| req.reply = 404; | |
| req.send('Requested service is not exposed by server'); | |
| return; | |
| } | |
| req.do_cache = service_def.gcache; | |
| //little opt: create mabr service for dash before sending the request, so that we can signal X-From-MABR=no in the answer | |
| if (req.manifest_type && service_def.mabr) { | |
| req.service = create_service(req.target_url, true, null); | |
| } | |
| } | |
| if (req.service) { | |
| req.do_cache = req.service.gcache; | |
| //deactivate timeout, will be reactivated when removing request | |
| req.service.unload_timeout = 0; | |
| if (req.manifest_type && !req.service.mani_cache && !req.from_cache) req.no_cache = true; | |
| //check mcast | |
| if (req.service.dyn_mabr) { | |
| req.service.check_dyn_mabr(); | |
| } | |
| } | |
| //look in mem cache | |
| if (req.service && !req.no_cache) { | |
| let f = req.service.get_file(req.from_cache ? req.target_url : req.url, req.br_start, req.br_end); | |
| //only cache manifest if not too old - we use more than the min update due to transfer times in mabr | |
| if (f && !f.sticky && (f.cache_type == CACHE_TYPE_MABR) && req.manifest_type && (req.service.url || req.service.mabr_repair_url)) { | |
| if (f.manifest_min_update && (f.received + 3*f.manifest_min_update/2 < sys.clock_ms())) { | |
| //trash | |
| req.service.mem_cache.splice(req.service.mem_cache.indexOf(f) , 1); | |
| do_log(GF_LOG_WARNING, `Cached manifest ${req.url} too old by ${sys.clock_ms() - f.received - f.manifest_min_update} ms - removing and refreshing (${f.received} - ${f.url})`); | |
| f = null; | |
| } | |
| } | |
| if (f) { | |
| do_log(GF_LOG_INFO, `File ${req.url} present in cache ${ f.done ? '(completed)' : '(transfer in progress)'} (${f.received} - ${f.url})`); | |
| req.service.mark_active_rep(active_rep, req); | |
| return req.set_cache_file(f); | |
| } | |
| } | |
| if (!req.service) { | |
| if (req.target_url.startsWith('http')<0) { | |
| req.reply = 404; | |
| req.send('invalid path'); | |
| return; | |
| } | |
| do_log(GF_LOG_INFO, `No associated service configured, going for HTTP`); | |
| req.fetch_unicast(); | |
| return; | |
| } | |
| //load MABR service if present | |
| if (req.service.mabr && !req.service.mabr_loaded && !disable_mabr_cache) { | |
| req.service.load_mabr(); | |
| } | |
| //multicast service only | |
| if (req.service.mabr && !req.service.url && !active_rep && !disable_mabr_cache) { | |
| req.waiting_mabr_start = sys.clock_ms(); | |
| req.waiting_mabr = req.waiting_mabr_start + MABR_TIMEOUT_SAFETY; | |
| req.service.pending_reqs.push(req); | |
| return; | |
| } | |
| if (req.from_cache) { | |
| req.service.pending_reqs.push(req); | |
| return; | |
| } | |
| if (active_rep) { | |
| //mark rep active - this will trigger mcast joining so do this before checking mcast | |
| req.service.mark_active_rep(active_rep, req); | |
| if (disable_mabr_cache) { | |
| do_log(GF_LOG_INFO, `Rep ${active_rep.ID} seg ${req.url} disbaled MABR for this request, going for HTTP`); | |
| } | |
| else if (req.service && req.service.mabr_failure) { | |
| do_log(GF_LOG_INFO, `Rep ${active_rep.ID} seg ${req.url} MABR setup failed, going for HTTP`); | |
| } | |
| //check if we have mabr active | |
| else if (active_rep.live_edge && active_rep.mabr_active) { | |
| if (req.service && req.service.last_mabr_active && (req.service.last_mabr_active + 3*active_rep.seg_dur/2 < req.start_time)) { | |
| do_log(GF_LOG_WARNING, `Rep ${active_rep.ID} MABR is down since ${req.start_time - req.service.last_mabr_active} ms, going for HTTP`); | |
| } | |
| else if (active_rep.first_mabr_tune) { | |
| do_log(GF_LOG_INFO, `Rep ${active_rep.ID} seg ${req.url} first segment request and no low-latency, going for HTTP`); | |
| active_rep.first_mabr_tune = 0; | |
| } else { | |
| //set a timeout for the request in case MABR fails | |
| req.waiting_mabr_start = sys.clock_ms(); | |
| //we use 3 x segment duration since we don't know if the source was low-latency or not so in worst case we have: | |
| //- one seg_dur delay for multicaster to send the file | |
| //- one seg_dur delay for the file to be received | |
| //- repair delay | |
| req.waiting_mabr = req.waiting_mabr_start + 3*active_rep.seg_dur + MABR_TIMEOUT_SAFETY; | |
| do_log(GF_LOG_INFO, `Waiting for file ${req.url} from MABR (ID ${req.service.mabr_service_id}) - will wait max ${req.waiting_mabr-req.waiting_mabr_start} ms for seg dur ${active_rep.seg_dur}`); | |
| req.service.pending_reqs.push(req); | |
| return; | |
| } | |
| } else if (active_rep.mabr_active) { | |
| do_log(GF_LOG_INFO, `Rep ${active_rep.ID} seg ${req.url} num ${active_rep.seg_num} is not on live edge ${active_rep.live_seg_num}, going for HTTP`); | |
| } else if (req.service.mabr_service_id) { | |
| do_log(GF_LOG_INFO, `Rep ${active_rep.ID} seg ${req.url} has no active multicast, going for HTTP`); | |
| } else if (req.service.mabr) { | |
| do_log(GF_LOG_INFO, `Rep ${active_rep.ID} seg ${req.url} waiting for multicast bootstrap, going for HTTP`); | |
| } | |
| } | |
| //fetch from unicast | |
| req.fetch_unicast(); | |
| } | |
| function create_service(http_url, force_mcast_activate, forced_sdesc) | |
| { | |
| //mabr service is null by default | |
| let s = {}; | |
| s.url = http_url; | |
| s.mabr = null; | |
| s.local = null; | |
| s.mabr_loaded = false; | |
| s.mabr_failure = false; | |
| s.mem_cache = []; | |
| s.id = all_services.length+1; | |
| s.mabr_service_id = 0; | |
| s.source = null; | |
| s.sink = null; | |
| s.pending_reqs=[]; | |
| s.active_reps_timeouts=[]; | |
| s.nb_mabr_active = 0; | |
| s.mabr_unload_timeout = 0; | |
| s.dyn_mabr = false; | |
| s.last_mabr_active = 0; | |
| s.unload_timeout = 0; | |
| s.purge_delay = 0; | |
| s.mani_cache = false; | |
| s.gcache = false; | |
| s.check_ip = DEFAULT_CHECKIP; | |
| s.keepalive = DEFAULT_KEEPALIVE_SEC; | |
| s.xhr_cache = []; | |
| s.pending_deactivate = []; | |
| s.mabr_repair_url = null; | |
| if (http_url && http_url.toLowerCase().startsWith('https://')) s.force_tls = true; | |
| else s.force_tls = false; | |
| s.hdlr = null; | |
| s.manifest = null; | |
| //do we have a mcast service for this ? | |
| let serv_cfg = forced_sdesc; | |
| if (!forced_sdesc) { | |
| let url_noport = http_url.replace(/([^\/\:]+):\/\/([^\/]+):([0-9]+)\//, "$1://$2/"); | |
| serv_cfg = services_defs.find(e => e.http == url_noport); | |
| if (!serv_cfg) serv_cfg = services_defs.find(e => e.http == http_url); | |
| } | |
| if (serv_cfg) { | |
| do_log(GF_LOG_DEBUG, `Service ${forced_sdesc ? forced_sdesc.local_base : http_url} has custom config${ (serv_cfg.mabr ? ' and MABR' : '')}`); | |
| s.mabr = serv_cfg.mabr; | |
| s.unload = serv_cfg.unload; | |
| s.mabr_min_active = serv_cfg.activate; | |
| s.purge_delay = 1000 * serv_cfg.timeshift; | |
| s.mani_cache = serv_cfg.mcache; | |
| s.repair = serv_cfg.repair; | |
| s.local = serv_cfg.local; | |
| s.keepalive = serv_cfg.keepalive; | |
| s.gcache = serv_cfg.gcache; | |
| s.check_ip = serv_cfg.check_ip; | |
| if (serv_cfg.id) s.id = serv_cfg.id; | |
| s.dyn_mabr = serv_cfg.dyn_mabr; | |
| } | |
| if (!http_url) { | |
| s.base_url = serv_cfg.local_base; | |
| s.base_url_no_proto = serv_cfg.local_base; | |
| } else { | |
| //remove cgi and remove resource name | |
| let str = http_url.split('?'); | |
| str = str[0].split('/') | |
| str.pop(); | |
| s.base_url = str.join('/') + '/'; | |
| //remove protocol scheme | |
| s.base_url_no_proto = s.base_url.split('://')[1]; | |
| } | |
| s.get_file = function(url, br_start, br_end) | |
| { | |
| let file = null; | |
| for (let i=0; i<this.mem_cache.length; i++) { | |
| let f = this.mem_cache[i]; | |
| if (f.aborted) continue; | |
| if (f.url.indexOf(url)>=0) {} | |
| //in case the whole server path was not sent in mabr | |
| else if (f.cache_type && (url.indexOf(f.url)>=0)) {} | |
| else continue; | |
| //only deal with exact byte ranges - we could optimize to allow sub-ranges | |
| if (f.br_start==br_start && f.br_end==br_end) return f; | |
| } | |
| return null; | |
| }; | |
| s.unqueue_pending_request = function(url, from_mabr) | |
| { | |
| let ext=null; | |
| let pending = this.pending_reqs.find (r =>{ | |
| if (url.indexOf(r.url)>=0) return true; | |
| //in case the whole server path was not sent in mabr | |
| if (from_mabr && (r.url.indexOf(url)>=0) ) return true; | |
| if (r.from_cache && (r.url.indexOf(url)>=0) ) return true; | |
| return false; | |
| }); | |
| //if no match and direct multicast, locate manifests by extension | |
| if (!pending && from_mabr && !s.url) { | |
| let ext = url.split('.').pop().toLowerCase(); | |
| if ((ext==="mpd") || (ext==="m3u8")) { | |
| pending = this.pending_reqs.find (r => { | |
| if (r.url.indexOf(ext)>=0) return true; | |
| return false; | |
| }); | |
| } | |
| } | |
| if (pending) | |
| this.pending_reqs.splice(this.pending_reqs.indexOf(pending), 1); | |
| return pending; | |
| }; | |
| s.create_cache_file = function(url, mime, br_start, br_end, cache_type=CACHE_TYPE_MOD, pid=null) | |
| { | |
| let file = {}; | |
| file.url = url; | |
| file.mime = mime || 'video/mp4'; | |
| file.received = sys.clock_ms(); | |
| file.data = new ArrayBuffer(); | |
| file.data_tmp = null; | |
| file.data_tmp_done = false; | |
| this.mem_cache.push(file); | |
| file.done = false; | |
| file.aborted = false; | |
| file.xhr_status = 0; | |
| file.pending_reqs = null; | |
| file.headers = null; | |
| file.nb_users = 0; | |
| file.sticky = 0; | |
| file.br_start = br_start; | |
| file.br_end = br_end; | |
| file.cache_type = cache_type; | |
| file.range = null; | |
| file.pid = pid; | |
| file.manifest_min_update = 0; | |
| if (cache_type) { | |
| file.xhr_status = 200; | |
| } | |
| file.release = function(req=null) { | |
| this.nb_users --; | |
| if (!this.nb_users && this.data_tmp) { | |
| this.data = this.data_tmp; | |
| this.data_tmp = null; | |
| this.done = this.data_tmp_done; | |
| this.data_tmp_done = false; | |
| } | |
| //special case for files created for out of date cached manifests, revert cache type to MABR | |
| if (req && !this.nb_users && req.service && req.manifest_type && req.service.mani_cache) { | |
| this.cache_type = CACHE_TYPE_MABR; | |
| this.xhr_status = 200; | |
| } | |
| }; | |
| //get any pending request(s) for this file | |
| while (true) { | |
| //we use req.url , eg server path, and not target_url which includes server name | |
| let pending = this.unqueue_pending_request(url, (cache_type==CACHE_TYPE_MABR)); | |
| if (!pending) break; | |
| pending.waiting_mabr = 0; | |
| pending.set_cache_file(file); | |
| let ellapsed = pending.waiting_mabr_start ? (sys.clock_ms() - pending.waiting_mabr_start) : 0; | |
| do_log(GF_LOG_DEBUG, `Found pending request (${url} ${pending.target_url}) for ${file.url}, canceling timeout (${ellapsed} ms since request)`); | |
| if (pid) pid.deactivate_timeout=0; | |
| } | |
| do_log(GF_LOG_DEBUG, `Start ${(cache_type==CACHE_TYPE_MABR) ? 'MABR ' : ''}reception of ${file.url} mime ${file.mime} - ${this.mem_cache.length} files in service cache`); | |
| return file; | |
| }; | |
| s.mark_active_rep = function(rep, request) | |
| { | |
| if (!rep || !request) return; | |
| let use_same_conn = false; | |
| let connection_id = s.check_ip ? (request.ip + ':'+request.port) : request.netid; | |
| let idx = s.pending_deactivate.indexOf(rep); | |
| if (idx>=0) s.pending_deactivate.splice(idx, 1); | |
| //mark number of active requests for this quality | |
| if (!rep.nb_active) { | |
| do_log(GF_LOG_INFO, `Service ${this.id} Rep ${rep.ID} is now active`); | |
| if (!this.repair) | |
| rep.first_mabr_tune = 1; | |
| } else { | |
| //check if we have active requests on the same connection to be deactivated. If so, extend timeout of last found | |
| //and do not change nb_active | |
| //this avoids activate/deactivate on same connection which would trigger spurious mcast joins/leaves | |
| let same_conn = this.active_reps_timeouts.filter(e => e.rep.ID == rep.ID && e.cid == connection_id); | |
| if (same_conn.length) { | |
| let rep_to = same_conn[same_conn.length-1]; | |
| rep_to.update(); | |
| do_log(GF_LOG_DEBUG, `Service ${this.id} same connection used for same quality ${rep.ID}, extending inactive timeout`); | |
| use_same_conn = true; | |
| } | |
| } | |
| if (!use_same_conn) { | |
| rep.nb_active++; | |
| request.activate_rep_timeout = {"rep": rep, "timeout": 0, "cid": connection_id, "mabr_canceled": false}; | |
| request.activate_rep_timeout.update = function() { | |
| //timeout for active quality is segment duration + safety | |
| this.timeout = sys.clock_ms() + this.rep.seg_dur + DEACTIVATE_TIMEOUT_MS; | |
| }; | |
| request.activate_rep_timeout.update(); | |
| this.active_reps_timeouts.push( request.activate_rep_timeout ); | |
| } | |
| //check if we need to activate multicast | |
| if (this.mabr_loaded && !rep.mabr_active | |
| && (!this.mabr_min_active || (rep.nb_active>=this.mabr_min_active)) | |
| ) { | |
| this.activate_mabr(rep, true); | |
| } | |
| }; | |
| s.activate_mabr = function(rep, do_activate) | |
| { | |
| if (!this.mabr_service_id || !this.source || !rep) return; | |
| if (serv_cfg && serv_cfg.js_mod && (serv_cfg.js_mod.quality_activation != null)) { | |
| let req_ok = serv_cfg.js_mod.quality_activation(do_activate, this.mabr_service_id, rep.period_id, rep.AS_ID, rep.ID); | |
| if (!req_ok) return; | |
| } | |
| if (do_activate) { | |
| this.nb_mabr_active++; | |
| this.mabr_unload_timeout = 0; | |
| do_log(GF_LOG_INFO, `Service ${this.id} MABR activated for Rep ${rep.ID} - active in service ${this.nb_mabr_active}`); | |
| } else if (this.nb_mabr_active) { | |
| this.nb_mabr_active--; | |
| do_log(GF_LOG_INFO, `Service ${this.id} MABR deactivated for Rep ${rep.ID} - active in service ${this.nb_mabr_active}`); | |
| if (!this.nb_mabr_active && this.unload) { | |
| this.mabr_unload_timeout = sys.clock_ms() + 1000*this.unload; | |
| } | |
| //no deactivation of multicast channels | |
| if (this.mabr_min_active==0) { | |
| rep.mabr_active = true; | |
| return; | |
| } | |
| } | |
| let evt = new FilterEvent(GF_FEVT_DASH_QUALITY_SELECT); | |
| evt.service_id = this.mabr_service_id; | |
| evt.period_id = rep.period_id; | |
| evt.as_id = rep.AS_ID; | |
| evt.rep_id = rep.ID; | |
| evt.select = do_activate ? 0 : 1; | |
| rep.mabr_active = do_activate; | |
| session.fire_event(evt, this.source, false, true); | |
| }; | |
| s.load_mabr = function() | |
| { | |
| if (!this.mabr || this.mabr_loaded) return; | |
| //load routein | |
| let args = 'src=' + this.mabr; | |
| if (this.mabr.indexOf('gpac:')<0) args += ':gpac'; | |
| //routein config: | |
| // - no-cache mode to fetch files directly | |
| // - single pid per TSI to have files for a given quality on a single PID | |
| // - keep alive to disable timeout when multicast is down | |
| // - require source ID in case we use a capture file, we could get the first PID before returning from the add_filter function | |
| args += ':gcache=0:stsi:ka:RSID'; | |
| // - multicast is dynamically enabled/disabled, start with all services tuned but disabled | |
| // only do this if HTTP mirror - otherwise, activate everything to make sure we fetch the manifests and init segments | |
| if (this.url && this.mabr_min_active>0) args += ':tunein=-3'; | |
| //add repair option last | |
| if (!this.repair) { // false (default) - disable unicast repair | |
| args += ':repair=strict'; | |
| } else { | |
| args += ':repair=full'; | |
| if (this.repair == 1 && this.url) { | |
| args += '::repair_urls='+this.url; | |
| } | |
| } | |
| this.source = session.add_filter(args); | |
| if (!this.source) { | |
| do_log(GF_LOG_ERROR, `Service ${this.id} Failed to load MABR demux for ${this.mabr}`); | |
| this.mabr_failure = true; | |
| return; | |
| } | |
| this.source.require_source_id(); | |
| this.mabr_loaded = true; | |
| this.nb_mabr_active = 0; | |
| this.mabr_unload_timeout = 0; | |
| this.last_mabr_active = 0; | |
| //create custom sink accepting files | |
| this.sink = session.new_filter("RouteSink"+this.id); | |
| this.sink.set_cap({id: "StreamType", value: "File", in: true} ); | |
| this.sink.max_pids=-1; | |
| this.sink.pids=[]; | |
| this.sink.on_setup_error = function(srcf, err) { | |
| do_log(GF_LOG_WARNING, `Service ${s.id} MABR setup error ${sys.error_string(err)}`); | |
| s.mabr_failure = true; | |
| if (s.sink) session.remove_filter(s.sink); | |
| if (s.source) session.remove_filter(s.source); | |
| s.sink = null; | |
| s.source = null; | |
| }; | |
| this.sink.watch_setup_failure(this.source); | |
| this.sink.configure_pid = function(pid) | |
| { | |
| //reconfigure (new file), get previous file and mar as done | |
| if (this.pids.indexOf(pid)>=0) { | |
| //get previous file | |
| if (pid.url) { | |
| let file = s.mem_cache.find(f => f.url===pid.url); | |
| if (file && !file.done) { | |
| do_log(GF_LOG_INFO, `${pid.url} closing cache file as new file from MABR starts ${pid.get_prop('URL')}`); | |
| file.done = true; | |
| } | |
| } | |
| } else { | |
| //initial declaration | |
| this.pids.push(pid); | |
| //send play | |
| let evt = new FilterEvent(GF_FEVT_PLAY); | |
| evt.start_range = 0.0; | |
| pid.send_event(evt); | |
| //no repair, we must dispatch full files | |
| if (!s.repair) pid.framing = true; | |
| //auto-deactivate unless unload is set | |
| if (s.mabr_min_active) { | |
| pid.deactivate_timeout = MABR_PID_DEACTIVATION_TIMEOUT + sys.clock_ms(); | |
| } | |
| else | |
| pid.deactivate_timeout = 0; | |
| } | |
| if (!s.mabr_service_id) { | |
| s.mabr_service_id = pid.get_prop('ServiceID'); | |
| do_log(GF_LOG_INFO, `MABR configured for service ${s.mabr_service_id}`); | |
| } | |
| s.last_mabr_active = sys.clock_ms(); | |
| //get new URL for this pid - can be null when service is just being announced | |
| pid.url = pid.get_prop('URL'); | |
| if (pid.url && (pid.url.charAt(0) != '/')) pid.url = '/' + pid.url; | |
| pid.mime = pid.get_prop('MIMEType'); | |
| pid.corrupted = false; | |
| let is_manifest=false; | |
| if (pid.url) { | |
| let purl = pid.url.toLowerCase(); | |
| //do not cache HLS/DASH manifests | |
| if ((purl.indexOf('.m3u8')>=0)|| (purl.indexOf('.mpd')>=0)) is_manifest = true; | |
| else is_manifest = false; | |
| } | |
| pid.do_skip = false; | |
| pid.is_manifest = false; | |
| if (is_manifest) { | |
| pid.is_manifest = true; | |
| //we want full files for the manifest | |
| pid.framing = true; | |
| if ((s.url !== null) && !s.mani_cache) | |
| pid.do_skip = true; | |
| pid.deactivate_timeout = 0; | |
| } | |
| if (!s.url) { | |
| let urls = pid.get_prop('MABRBaseURLs'); | |
| s.mabr_repair_url = urls ? urls[0] : null; | |
| } | |
| }; | |
| this.push_to_cache = function (pid, pck) { | |
| let file = this.mem_cache.find(f => f.url===pid.url); | |
| let corrupted = pck.corrupted ? 1 : 0; | |
| if (corrupted && pck.get_prop('PartialRepair')) { | |
| corrupted = 2; | |
| } | |
| //file corrupted and no repair, move to HTTP | |
| if (!s.repair && pck.corrupted) { | |
| let log_done = false; | |
| //cache file shall never be created at this point since we only aggregate full files when no repair | |
| if (file) { | |
| do_log(GF_LOG_ERROR, `Service ${this.id} receiving corrupted MABR packet and cache file was already setup, bug in code !`); | |
| file.data.byteLength = 0; | |
| } | |
| //get any pending request(s) for this file | |
| while (true) { | |
| let pending = this.unqueue_pending_request(pid.url, true); | |
| if (!pending) break; | |
| //signal mabr was canceled | |
| pending.waiting_mabr = 0; | |
| if (pending.activate_rep_timeout) { | |
| pending.activate_rep_timeout.update(); | |
| pending.activate_rep_timeout.mabr_canceled = true; | |
| } | |
| if (!log_done) { | |
| do_log(GF_LOG_INFO, `Corrupted MABR packet for ${pid.url} (valid container ${corrupted==2}), switching to HTTP`); | |
| log_done = true; | |
| } | |
| //the first one will create a cache file if possible, associated it to the other pending requests and potentially removing them | |
| pending.fetch_unicast(); | |
| } | |
| if (!log_done) { | |
| do_log(GF_LOG_INFO, `Corrupted MABR packet for ${pid.url} (valid container ${corrupted==2}), removing`); | |
| } | |
| return; | |
| } else if (corrupted) { | |
| do_log(GF_LOG_WARNING, `MABR Repair failed for ${pid.url} (valid container ${corrupted==2}), broken data sent to client`); | |
| } | |
| if (!file) { | |
| file = this.create_cache_file(pid.url, pid.mime, 0, 0, CACHE_TYPE_MABR, pid); | |
| } | |
| //cache file exists, it was created from a pending request or an old request | |
| else if (pck.start) { | |
| //update to a file being sent, we need to create a temp buffer | |
| //which will be reassigned once no more users are on the file | |
| if (file.nb_users) { | |
| file.data_tmp = new ArrayBuffer(); | |
| file.data_tmp_done = false; | |
| } else { | |
| file.data = new ArrayBuffer(); | |
| file.data_tmp = null; | |
| file.data_tmp_done = false; | |
| file.done = false; | |
| } | |
| } | |
| do_log(GF_LOG_DEBUG, `Service ${this.id} receiving MABR packet for ${pid.url} size ${pck.size} end ${pck.end}`); | |
| //reagregate packet | |
| if (pck.size) { | |
| if (file.data_tmp) | |
| file.data_tmp = cat_buffer(file.data_tmp, pck.data); | |
| else | |
| file.data = cat_buffer(file.data, pck.data); | |
| } | |
| if (pck.start && pck.end) { | |
| do_log(GF_LOG_INFO, `Service ${this.id} got MABR file ${pid.url} in one packet`); | |
| } | |
| else if (pck.start) { | |
| do_log(GF_LOG_INFO, `Service ${this.id} start receiving MABR file ${pid.url}`); | |
| } | |
| else if (pck.end) { | |
| do_log(GF_LOG_INFO, `Service ${this.id} received MABR file ${pid.url}`); | |
| } | |
| if (pck.end) { | |
| if (file.data_tmp) { | |
| if (!file.nb_users) { | |
| file.data = file.data_tmp; | |
| file.data_tmp = null; | |
| file.data_tmp_done = false; | |
| file.done = true; | |
| } else { | |
| file.data_tmp_done = true; | |
| } | |
| } else { | |
| file.done = true; | |
| } | |
| file.received = sys.clock_ms(); | |
| if (pid.is_manifest) { | |
| update_manifest(file.data_tmp ? file.data_tmp : file.data, file.url, s, file); | |
| } | |
| } | |
| }; | |
| this.sink.process = function(pid) | |
| { | |
| let has_pck=false; | |
| this.pids.forEach(function(pid) { | |
| while (1) { | |
| let pck = pid.get_packet(); | |
| if (!pck) break; | |
| if (!pid.do_skip && !pid.corrupted) | |
| s.push_to_cache(pid, pck); | |
| pid.drop_packet(); | |
| has_pck=true; | |
| } | |
| if (pid.deactivate_timeout && (pid.deactivate_timeout<sys.clock_ms())) { | |
| print(GF_LOG_INFO, `PID ${pid.url} without active requests - deactivating multicast`); | |
| let evt = new FilterEvent(GF_FEVT_DASH_QUALITY_SELECT); | |
| evt.service_id = s.mabr_service_id; | |
| evt.period_id = pid.get_prop('Period'); | |
| evt.as_id = pid.get_prop('ASID'); | |
| evt.rep_id = pid.get_prop('Representation'); | |
| evt.select = 1; | |
| session.fire_event(evt, s.source, false, true); | |
| pid.deactivate_timeout = 0; | |
| } | |
| }); | |
| if (has_pck) s.last_mabr_active = sys.clock_ms(); | |
| }; | |
| //don't use dash client, we just need to get the files | |
| this.sink.set_source(this.source); | |
| do_log(GF_LOG_INFO, `Service ${this.id} loaded MABR for ${this.mabr}${this.repair ? ' with repair' : ''}`); | |
| if (serv_cfg && serv_cfg.js_mod && typeof serv_cfg.js_mod.service_activation === 'function') { | |
| serv_cfg.js_mod.service_activation(true); | |
| } | |
| }; | |
| s.unload_mabr = function() { | |
| if (this.source) { | |
| do_log(GF_LOG_INFO, `Service ${this.id} stopping MABR`); | |
| if (this.sink) session.remove_filter(this.sink); | |
| if (this.source) session.remove_filter(this.source); | |
| this.sink = null; | |
| this.source = null; | |
| this.mabr_loaded = false; | |
| this.mabr_failure = false; | |
| if (serv_cfg && serv_cfg.js_mod && typeof serv_cfg.js_mod.service_activation === 'function') { | |
| serv_cfg.js_mod.service_activation(false); | |
| } | |
| //cancel all pending requests on MABR files | |
| this.check_pending_requests(sys.clock_ms(), true); | |
| //clear all cache files from the multicast | |
| this.mem_cache.forEach(f => { | |
| if (f.cache_type == CACHE_TYPE_MABR) { | |
| f.done = true; | |
| f.aborted = true; | |
| } | |
| }); | |
| //abort all active reps | |
| for (let i=0; i<this.active_reps_timeouts.length; i++) { | |
| let o = this.active_reps_timeouts[i]; | |
| let active_rep = o.rep; | |
| this.active_reps_timeouts.splice(i, 1); | |
| i--; | |
| active_rep.mabr_active=0; | |
| } | |
| this.mabr_service_id = 0; | |
| } | |
| }; | |
| s.check_dyn_mabr = function() { | |
| let mabr_old = this.mabr; | |
| this.mabr = serv_cfg.js_mod.get_mcast_address(serv_cfg.http); | |
| if (this.mabr == mabr_old) return; | |
| if (this.mabr_loaded) this.unload_mabr(); | |
| if (!this.mabr) return; | |
| this.load_mabr(); | |
| }; | |
| s.check_pending_requests = function(now, force_deactivate) { | |
| //check timeout on pending reqs waiting for MABR | |
| for (let i=0; i<this.pending_reqs.length; i++) { | |
| let req = this.pending_reqs[i]; | |
| if (!force_deactivate) { | |
| if (!req.waiting_mabr || (req.waiting_mabr>now)) continue; | |
| } | |
| if (! this.url && !this.mabr_repair_url) { | |
| do_log(GF_LOG_DEBUG, `MABR timeout for ${req.url} after ${now - req.waiting_mabr_start} ms`); | |
| this.pending_reqs.splice(i, 1); | |
| i--; | |
| req.reply = 404; | |
| req.send(); | |
| continue; | |
| } | |
| do_log(GF_LOG_DEBUG, `MABR timeout for ${req.url} after ${now - req.waiting_mabr_start} ms - fetching from HTTP at ` + req.target_url); | |
| this.pending_reqs.splice(this.pending_reqs.indexOf(req), 1); | |
| i--; | |
| if (req.activate_rep_timeout) { | |
| req.activate_rep_timeout.update(); | |
| req.activate_rep_timeout.mabr_canceled = true; | |
| if (req.cache_file) req.cache_file.cache_type = CACHE_TYPE_HTTP | |
| } | |
| req.waiting_mabr = 0; | |
| //the first one will create a cache file if possible, associated it to the other pending requests and potentially removing them | |
| let prev_len = this.pending_reqs.length; | |
| req.fetch_unicast(); | |
| //if some pending were removed, restart the loop | |
| if (prev_len != this.pending_reqs.length) { | |
| i = -1; | |
| } | |
| } | |
| }; | |
| all_services.push(s); | |
| do_log(GF_LOG_INFO, `Created Service ID ${s.id} for ${s.url ? s.url : serv_cfg.local_base}${(s.mabr || s.dyn_mabr) ? ' with MABR' : ''}`); | |
| //and load if requested - we force mcast activation when an access to the mpd is first detected | |
| if (force_mcast_activate && s.mabr) | |
| s.load_mabr(); | |
| s.stats = { | |
| "X-From-MABR": { | |
| "yes": 0, | |
| "no": 0, | |
| "off-edge": 0 | |
| } | |
| } | |
| s.update_mabr_stats = function(x_from_mabr){ | |
| if(this.stats["X-From-MABR"][x_from_mabr] != undefined){ | |
| this.stats["X-From-MABR"][x_from_mabr]++ | |
| } else { | |
| do_log(GF_LOG_INFO, `update_mabr_stats ${this.stats} `); | |
| } | |
| } | |
| return s; | |
| } | |
| globalThis.all_services = all_services; | |
| globalThis.create_service = create_service; | |
| let script_args=null; | |
| function do_init() | |
| { | |
| if (http_out_f == null) { | |
| let rdir = (sys.file_exists('.gpac_auth')) ? '.gpac_auth' : "gmem"; | |
| sys.args.forEach(a => { | |
| if (a.indexOf('rdirs=')>=0) rdir = null; | |
| }); | |
| if (script_args && script_args.indexOf('rdirs=')>=0) rdir = null; | |
| let http_args = "httpout"; | |
| if (rdir) http_args += ":rdirs="+rdir; | |
| if (script_args) http_args += script_args; | |
| try { | |
| let http = session.add_filter(http_args); | |
| server_ports = http.get_arg("port"); | |
| do_log(GF_LOG_INFO, `Starting HTTP server on port ${server_ports}`); | |
| } catch (e) { | |
| do_log(GF_LOG_ERROR, `Failed to start HTTP server`); | |
| sys.exit(1); | |
| } | |
| } else { | |
| server_ports = http_out_f.get_arg("port"); | |
| do_log(GF_LOG_INFO, `Attached server running on port ${server_ports}`); | |
| } | |
| //preload services | |
| services_defs.forEach(sd => { | |
| if (sd.unload) return; | |
| if (sd.dyn_mabr) return; | |
| let s = create_service(sd.http, false, sd); | |
| if (!s) { | |
| do_log(GF_LOG_ERROR, `Failed to create service ${sd.http}`); | |
| } else { | |
| s.load_mabr(); | |
| } | |
| }); | |
| session.post_task( () => { | |
| let do_gc = false; | |
| let now = sys.clock_ms(); | |
| for (let i=0; i<all_services.length; i++) { | |
| let s = all_services[i]; | |
| //cleanup mem cache | |
| for (let i=0; i<s.mem_cache.length; i++) { | |
| let f = s.mem_cache[i]; | |
| if (f.nb_users || f.sticky) continue; | |
| if (!f.aborted && (f.received + s.purge_delay >= now)) continue; | |
| s.mem_cache.splice(i, 1); | |
| i--; | |
| do_log(GF_LOG_DEBUG, `Removing ${f.url} from cache - remain ${s.mem_cache.length}`); | |
| } | |
| //check timeout on pending reqs waiting for MABR | |
| s.check_pending_requests(now, false); | |
| //check reps that could be deactivated after a MABR timeout | |
| for (let i=0; i<s.pending_deactivate.length; i++) { | |
| let rep = s.pending_deactivate[i]; | |
| if (rep.mabr_deactivate_timeout < now) continue; | |
| s.pending_deactivate.splice(i, 1); | |
| i--; | |
| do_log(GF_LOG_INFO, `Service ${s.id} Rep ${rep.ID} no longer on multicast`); | |
| s.activate_mabr(rep, false); | |
| } | |
| //check timeout on active reps | |
| for (let i=0; i<s.active_reps_timeouts.length; i++) { | |
| let o = s.active_reps_timeouts[i]; | |
| if (o.timeout > now) break; | |
| let active_rep = o.rep; | |
| let mabr_canceled = o.mabr_canceled; | |
| s.active_reps_timeouts.splice(i, 1); | |
| i--; | |
| active_rep.nb_active--; | |
| if (!active_rep.nb_active) { | |
| do_log(GF_LOG_INFO, `Service ${s.id} Rep ${active_rep.ID} is now inactive`); | |
| if (s.keepalive) | |
| s.unload_timeout = sys.clock_ms() + 1000*s.keepalive; | |
| } else { | |
| do_log(GF_LOG_DEBUG, `Service ${s.id} Rep ${active_rep.ID} is still active (active clients ${active_rep.nb_active})`); | |
| } | |
| if (active_rep.mabr_active && s.mabr_min_active && (active_rep.nb_active<s.mabr_min_active) ) { | |
| if (!mabr_canceled) { | |
| s.activate_mabr(active_rep, false); | |
| } else { | |
| active_rep.mabr_deactivate_timeout = sys.clock_ms() + 2000; | |
| s.pending_deactivate.push(active_rep); | |
| } | |
| } | |
| } | |
| //cleanup XHRs | |
| for (let i=0; i<s.xhr_cache.length; i++) { | |
| let xhr = s.xhr_cache[i]; | |
| if (xhr.last_used + 10000 >= now) continue; | |
| s.xhr_cache.splice(i, 1); | |
| i--; | |
| do_gc = true; | |
| } | |
| //check timeout on MABR service deactivation | |
| if (s.mabr_unload_timeout && (s.mabr_unload_timeout < now)) { | |
| s.mabr_unload_timeout = 0; | |
| if (!s.nb_mabr_active) { | |
| s.unload_mabr(); | |
| do_gc = true; | |
| } | |
| } | |
| //check service unload | |
| if (s.unload_timeout && (s.unload_timeout < now)) { | |
| s.unload_mabr(); | |
| do_log(GF_LOG_INFO, `Service ${s.id} unloading`); | |
| all_services.splice(i, 1); | |
| if (typeof s.on_close == 'function') s.on_close(); | |
| if (!all_services.length && do_quit) { | |
| print("No more services and quit requested - exiting"); | |
| session.remove_filter(http_out_f); | |
| return false; | |
| } | |
| i--; | |
| do_gc = true; | |
| } | |
| } | |
| if (force_quit) { | |
| print("No more requests and quit requested - exiting"); | |
| session.remove_filter(http_out_f); | |
| return false; | |
| } | |
| if (do_gc) sys.gc(); | |
| if (session.last_task) return false; | |
| //perform cleanup every 100ms | |
| return 100; | |
| }, "mediaserver_cleanup"); | |
| } | |
| let http_out_f = null; | |
| //catch filter creation | |
| session.set_new_filter_fun( (f) => { | |
| //bind our custom http logic | |
| if (!http_out_f && (f.name == "httpout")) { | |
| http_out_f = f; | |
| f.bind(httpout); | |
| } | |
| }); | |
| let mods_pending=0; | |
| function get_base_url(url) | |
| { | |
| let str = url.split('/'); | |
| str.pop(); | |
| return str.join('/') + '/'; | |
| } | |
| filter.initialize = function() { | |
| let gpac_help = sys.get_opt("temp", "gpac-help"); | |
| let gpac_doc = (sys.get_opt("temp", "gendoc") == "yes") ? true : false; | |
| if (gpac_help || gpac_doc) { | |
| filter._help = filter_help; | |
| filter.set_help(filter._help); | |
| let scripts = sys.enum_directory(filter.jspath, "*.js").sort((a, b) => a.name.localeCompare(b.name)); | |
| scripts.forEach(s => { | |
| if (s.name == "init.js") return; | |
| import(s.name).then(obj => { | |
| filter._help += '## ' + obj.description + '\nModule is loaded when using `js='+s.name.replace('.js', '')+'`\n\n' + obj.help + '\n'; | |
| filter.set_help(filter._help); | |
| }).catch(err => {}); | |
| }); | |
| return GF_OK; | |
| } | |
| do_quit = filter.quit; | |
| script_args = filter.src_args; | |
| if (script_args) { | |
| let local_args = ["quit", "services"]; | |
| script_args = script_args.split(':'); | |
| local_args.forEach(a => { | |
| let e = script_args.find(e => e.startsWith(a)); | |
| if (e) script_args.splice(script_args.indexOf(e), 1); | |
| }); | |
| script_args = script_args.join(':'); | |
| } else { | |
| script_args=""; | |
| } | |
| if (!filter.scfg || !filter.scfg.length) { | |
| //todo: we should allow to specify a default service description file in ~/.gpac | |
| do_log(GF_LOG_WARNING, "No service description provided, configuring as proxy for any URL"); | |
| services_defs = [ {'http': '*', 'unload': true} ]; | |
| } else { | |
| try { | |
| let ab = sys.load_file(filter.scfg, true); | |
| services_defs = JSON.parse(ab); | |
| if (!Array.isArray(services_defs)) throw "Invalid JSON, expecting array"; | |
| services_defs = services_defs.filter(e => ((typeof e.active === 'undefined') || e.active) && typeof e.comment === 'undefined' ); | |
| services_defs.forEach(sd => { | |
| //check options are valid | |
| if (typeof sd.active == 'undefined') sd.active = true; | |
| else if (typeof sd.active != 'boolean') sd.active = false; | |
| if (typeof sd.http == 'undefined') sd.http = null; | |
| else if (typeof sd.http != 'string') throw "Missing or invalid http property, expecting string got "+typeof sd.http; | |
| if (typeof sd.mabr == 'undefined') sd.mabr = null; | |
| else if (typeof sd.mabr != 'string') throw "Missing or invalid mabr property, expecting string got "+typeof sd.mabr; | |
| if (typeof sd.local == 'undefined') sd.local = null; | |
| else if (typeof sd.local != 'string') throw "Missing or invalid local property, expecting string got "+typeof sd.local; | |
| if (typeof sd.unload != 'number') sd.unload = DEFAULT_MABR_UNLOAD_SEC; | |
| else if (sd.unload < 0) throw "Invalid unload property, expecting positive number"; | |
| if (typeof sd.activate != 'number') sd.activate = DEFAULT_ACTIVATE_CLIENTS; | |
| else if (sd.activate < 0) throw "Invalid activate property, expecting positive number"; | |
| if (typeof sd.timeshift != 'number') sd.timeshift = DEFAULT_TIMESHIFT; | |
| else if (sd.timeshift < 0) throw "Invalid timeshift property, expecting positive number"; | |
| if (typeof sd.mcache != 'boolean') sd.mcache = DEFAULT_MCACHE; | |
| if (typeof sd.repair == 'boolean') { | |
| sd.repair = sd.repair ? 1 : 0; | |
| } else { | |
| if (typeof sd.repair != 'string') sd.repair = DEFAULT_REPAIR; | |
| if (sd.repair === 'true') sd.repair = 1; | |
| else if (sd.repair === 'auto') sd.repair = 2; | |
| else sd.repair = 0; | |
| } | |
| if (typeof sd.gcache != 'boolean') sd.gcache = DEFAULT_GCACHE; | |
| if (typeof sd.keepalive != 'number') sd.keepalive = DEFAULT_KEEPALIVE_SEC; | |
| else if (sd.keepalive < 0) throw "Invalid keepalive property, expecting positive number"; | |
| if (typeof sd.check_ip != 'boolean') sd.check_ip = DEFAULT_CHECKIP; | |
| if (typeof sd.id == 'undefined') sd.id = null; | |
| if (typeof sd.sources == 'undefined') sd.sources = []; | |
| else if (!Array.isArray(sd.sources)) { | |
| throw "Invalid sources property, expecting array"; | |
| } else { | |
| sd.sources.forEach(a => { | |
| if (typeof a.name != 'string') throw "Invalid name in source element, expecting string"; | |
| if (typeof a.url != 'string') throw "Invalid url in source element, expecting string"; | |
| }); | |
| } | |
| if (typeof sd.js == 'undefined') sd.js = null; | |
| else if (typeof sd.js != 'string') throw "Missing or invalid js property, expecting string got "+typeof sd.local; | |
| if (typeof sd.noproxy == 'undefined') sd.noproxy = (sd.http && sd.local) ? true : false; | |
| else if (typeof sd.noproxy != 'boolean') throw "Missing or invalid noproxy property, expecting boolean got "+typeof sd.noproxy; | |
| if (!sd.http && !sd.local) throw "At least one of `http` or `local` shall be specified"; | |
| //safety checks | |
| if (!sd.unload) sd.keepalive = 0; | |
| if (sd.unload>sd.keepalive) sd.keepalive = sd.unload; | |
| //extract proto, host and path | |
| if (sd.http) { | |
| //remove default ports | |
| let url_lwr = sd.http.toLowerCase(); | |
| if (url_lwr.startsWith('https://')) { | |
| sd.http = sd.http.replace(":443/", "/"); | |
| } | |
| if (url_lwr.startsWith('http://')) { | |
| sd.http = sd.http.replace(":80/", "/"); | |
| } | |
| let idx = sd.http.indexOf('://'); | |
| sd.http_proto = sd.http.substring(0, idx); | |
| let str = sd.http.substring(idx+3).split('/'); | |
| sd.http_host = str[0]; | |
| str.shift(); | |
| str.pop(); | |
| if(str.length) { | |
| sd.http_path = '/'+str.join('/') + '/'; | |
| } else { | |
| sd.http_path = '/'; | |
| } | |
| } else { | |
| sd.http_proto = null; | |
| sd.http_host = null; | |
| sd.http_path = null; | |
| } | |
| //if local is a file, get base path | |
| sd.local_base = null; | |
| if (sd.local) { | |
| sd.local = sd.local.split('?')[0]; | |
| if (!sd.local.length) sd.local = null; | |
| else if (sd.local.charAt(sd.local.length-1) != '/') { | |
| let str = sd.local.split('/') | |
| str.pop(); | |
| sd.local_base = str.join('/') + '/'; | |
| } else { | |
| sd.local_base = sd.local; | |
| sd.local = null; | |
| } | |
| } | |
| //load JS module if present | |
| sd.dyn_mabr = false; | |
| }); | |
| //remove all inactive services | |
| services_defs = services_defs.filter(e => e.active ); | |
| //check for duplicates | |
| services_defs.forEach(sd => { | |
| if (!sd.active) return; | |
| let same_orig = services_defs.filter( e => (e.active && e.http === sd.http)); | |
| if (same_orig.length > 1) { | |
| throw `Multiple active services with same origin ${sd.http} not allowed`; | |
| } | |
| }); | |
| //check for JS modules | |
| services_defs.forEach(sd => { | |
| if (sd.js) { | |
| mods_pending++; | |
| let script_src = sys.url_cat(filter.scfg, sd.js); | |
| //local path | |
| if (sys.file_exists(script_src)) {} | |
| else if (sys.file_exists(sd.js+'.js')) { | |
| script_src = sd.js+'.js'; | |
| } | |
| //path in our filter directory | |
| else { | |
| script_src = filter.jspath + sd.js + '.js'; | |
| if (!sys.file_exists(script_src)) throw "File " + sd.js + " does not exist"; | |
| } | |
| import(script_src).then(obj_mod => { | |
| if (typeof obj_mod.init !== 'function') throw "Invalid module, missing `init` function"; | |
| if (typeof obj_mod.get_mcast_address === 'function') { | |
| sd.dyn_mabr = true; | |
| sd.mabr = null; | |
| } | |
| if (sd.mabr || sd.dyn_mabr) { | |
| if (typeof obj_mod.quality_activation !== 'function') obj_mod.quality_activation = null; | |
| } else { | |
| if (typeof obj_mod.resolve !== 'function') throw "Invalid module, missing `resolve` function"; | |
| } | |
| mods_pending--; | |
| sd.logname = sd.js; | |
| sd.log = function(level, msg) { | |
| do_log(level, msg, this.logname); | |
| }; | |
| obj_mod.init(sd); | |
| sd.js_mod = obj_mod; | |
| }).catch(e => { | |
| do_log(GF_LOG_ERROR, `Failed to load service script ${sd.js}: ${e}`); | |
| sys.exit(1); | |
| }); | |
| } else { | |
| sd.js_mod = null; | |
| } | |
| }); | |
| } catch (e) { | |
| do_log(GF_LOG_ERROR, `Failed to load services configuration ${filter.scfg}: ${e}`); | |
| sys.exit(1); | |
| } | |
| } | |
| //after this, filter object is no longer available in JS since we don't set caps or setup filter.process | |
| session.post_task( () => { | |
| if (mods_pending) return 100; | |
| do_init(); | |
| return false; | |
| }, "init", 200); | |
| }; |