├── .dockerignore ├── .github └── workflows │ ├── nodejs.yml │ └── publish.yml ├── .gitignore ├── .prettierrc ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── cli.js ├── docker-compose.yml ├── example.js ├── examples └── local-grafana-dashboards │ ├── demo-grafana-dashboard.json │ ├── grafana-docker-compose.yaml │ ├── how-to-setup-local-grafana.md │ ├── image.png │ └── prometheus.yml ├── index.ts ├── package-lock.json ├── package.json ├── spec ├── src │ └── HLSMonitor.spec.ts ├── support │ └── jasmine.json └── util │ └── testvectors.ts ├── src ├── HLSMonitor.ts ├── HLSMonitorService.ts └── ManifestLoader.ts └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | matrix: 11 | node-version: [18.x, 20.x] 12 | 13 | steps: 14 | - uses: actions/checkout@v1 15 | - name: Use Node.js ${{ matrix.node-version }} 16 | uses: actions/setup-node@v1 17 | with: 18 | node-version: ${{ matrix.node-version }} 19 | - name: npm install, build, and test 20 | run: | 21 | npm ci 22 | npm run build --if-present 23 | npm test 24 | env: 25 | CI: true 26 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v1 13 | - uses: actions/setup-node@v1 14 | with: 15 | node-version: 14 16 | registry-url: https://registry.npmjs.org/ 17 | - run: | 18 | npm ci 19 | npm run build 20 | npm publish --access public 21 | env: 22 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | test.ts 3 | dev 4 | dist/ 5 | *.swp 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": false, 3 | "tabWidth": 2, 4 | "trailingComma": "es5", 5 | "printWidth": 180} -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Submitting Issues 2 | 3 | We use GitHub issues to track public bugs. If you are submitting a bug, please provide the contents of the HLS manifests (master and media manifests) to make it easier to reproduce the issue and update unit tests. 4 | 5 | # Contributing Code 6 | 7 | We follow the [GitHub Flow](https://guides.github.com/introduction/flow/index.html) so all contributions happen through pull requests. We actively welcome your pull requests: 8 | 9 | 1. Fork the repo and create your branch from master. 10 | 2. If you've added code that should be tested, add tests. 11 | 3. If you've changed APIs, update the documentation. 12 | 4. Ensure the test suite passes. 13 | 5. Issue that pull request! 14 | 15 | Use 2 spaces for indentation rather than tabs. Thank you. 16 | 17 | When submit code changes your submissions are understood to be under the same MIT License that covers the project. Feel free to contact Eyevinn Technology if that's a concern. 18 | 19 | # Code of Conduct 20 | 21 | ## Our Pledge 22 | 23 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 24 | 25 | ## Our Standards 26 | 27 | Examples of behavior that contributes to creating a positive environment include: 28 | 29 | - Using welcoming and inclusive language 30 | - Being respectful of differing viewpoints and experiences 31 | - Gracefully accepting constructive criticism 32 | - Focusing on what is best for the community 33 | - Showing empathy towards other community members 34 | 35 | Examples of unacceptable behavior by participants include: 36 | 37 | - The use of sexualized language or imagery and unwelcome sexual attention or advances 38 | - Trolling, insulting/derogatory comments, and personal or political attacks 39 | - Public or private harassment 40 | - Publishing others' private information, such as a physical or electronic address, without explicit permission 41 | - Other conduct which could reasonably be considered inappropriate in a professional setting 42 | 43 | ## Our Responsibilities 44 | 45 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 46 | 47 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 48 | 49 | ## Scope 50 | 51 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 52 | 53 | ## Enforcement 54 | 55 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 56 | 57 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 58 | 59 | ## Attribution 60 | 61 | This Code of Conduct is adapted from the Contributor Covenant, version 1.4, available at http://contributor-covenant.org/version/1/4 62 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:slim 2 | 3 | WORKDIR /app 4 | 5 | ADD . . 6 | 7 | RUN npm install 8 | RUN npm run build 9 | 10 | CMD ["npm", "start"] 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2022 Eyevinn Technology 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HLS Monitor 📺🔍📊 2 | 3 | Service to monitor one or more HLS streams for manifest errors and inconsistencies. 4 | These are: 5 | 6 | - Media sequence counter issues. 7 | - Discontinuity sequence counter issues. 8 | - Detect stale manifests. The default is at least 6000ms but can be configured via the env `HLS_MONITOR_INTERVAL` or set when creating a new HLSMonitor. 9 | The playlist is updating correctly. 10 | 11 |
12 |
13 | 14 | [![Badge OSC](https://img.shields.io/badge/Evaluate-24243B?style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGNpcmNsZSBjeD0iMTIiIGN5PSIxMiIgcj0iMTIiIGZpbGw9InVybCgjcGFpbnQwX2xpbmVhcl8yODIxXzMxNjcyKSIvPgo8Y2lyY2xlIGN4PSIxMiIgY3k9IjEyIiByPSI3IiBzdHJva2U9ImJsYWNrIiBzdHJva2Utd2lkdGg9IjIiLz4KPGRlZnM%2BCjxsaW5lYXJHcmFkaWVudCBpZD0icGFpbnQwX2xpbmVhcl8yODIxXzMxNjcyIiB4MT0iMTIiIHkxPSIwIiB4Mj0iMTIiIHkyPSIyNCIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPgo8c3RvcCBzdG9wLWNvbG9yPSIjQzE4M0ZGIi8%2BCjxzdG9wIG9mZnNldD0iMSIgc3RvcC1jb2xvcj0iIzREQzlGRiIvPgo8L2xpbmVhckdyYWRpZW50Pgo8L2RlZnM%2BCjwvc3ZnPgo%3D)](https://app.osaas.io/browse/eyevinn-hls-monitor) 15 | 16 |
17 | 18 | ## Command Line Executable 19 | 20 | To run HLS monitor first install the executable: 21 | 22 | ``` 23 | npm install -g @eyevinn/hls-monitor 24 | ``` 25 | 26 | Then run: 27 | 28 | ``` 29 | hls-monitor URL-TO-MONITOR 30 | ``` 31 | 32 | ## Setup 33 | 34 | To initialize a new `HLSMonitorService` do the following: 35 | 36 | ```typescript 37 | import { HLSMonitorService } from "@eyevinn/hls-monitor"; 38 | 39 | // initialize a new instance of HLSMonitorService 40 | const hlsMonitorService = new HLSMonitorService(); 41 | // register the routes 42 | hlsMonitorService.listen(3000); 43 | ``` 44 | 45 | The monitor service is now up and running and available on port `3000`. 46 | A basic Swagger doc can be accessed via `hls-monitor-endpoint/docs` 47 | 48 | Start monitoring a new stream by doing a `POST` to `hls-monitor-endpoint/monitor` with the following payload: 49 | 50 | ```json 51 | { 52 | "streams": ["stream-to-monitor/manifest.m3u8"] 53 | } 54 | ``` 55 | 56 | It's also possible to set the interval (in milliseconds) for when a manifest should be considered as stale, this is done via: 57 | 58 | ```json 59 | { 60 | "streams": ["stream-to-monitor/manifest.m3u8"], 61 | "stale_limit": 6000 62 | } 63 | ``` 64 | 65 | To get the latest error for a specific monitor do a `GET` to `hls-monitor-endpoint/monitor/:monitorId/status`. 66 | 67 | To remove a specific stream from a monitor do a `DELETE` to 68 | `hls-monitor-endpoint/monitor/:monitorId` with the following payload: 69 | 70 | ```json 71 | { 72 | "streams": ["streams-to-delete/manifest.m3u8"] 73 | } 74 | ``` 75 | 76 | Available endpoints are: 77 | 78 | | Endpoint | Method | Description | 79 | | -------------------------------- | -------- | ----------------------------------------------------------- | 80 | | `/` | `GET` | Heartbeat endpoint of service | 81 | | `/monitor` | `POST` | Start monitoring a new stream | 82 | | `/monitor` | `GET` | List all monitors | 83 | | `/monitor` | `DELETE` | Delete all monitored streams | 84 | | `/monitor/:monitorId` | `DELETE` | Delete a specific monitor and its streams | 85 | | `/monitor/:monitorId/start` | `POST` | Start a specific monitor | 86 | | `/monitor/:monitorId/stop` | `POST` | Stop a specific monitor | 87 | | `/monitor/:monitorId/status` | `GET` | Get the current status of a stream | 88 | | `/monitor/:monitorId/status` | `DELETE` | Delete the cached status of a stream | 89 | | `/monitor/:monitorId/streams` | `GET` | Returns a list of all streams that are currently monitored | 90 | | `/monitor/:monitorId/streams` | `PUT` | Add a stream to the list of streams that will be monitored | 91 | | `/monitor/:monitorId/streams` | `DELETE` | Remove streams from the monitor | 92 | | `/metrics` | `GET` | Get OpenMetrics/Prometheus compatible metrics | 93 | | `/docs` | `GET` | Swagger documentation UI | 94 | A few environment variables can be set to configure the service: 95 | 96 | ```text 97 | HLS_MONITOR_INTERVAL=6000 # Interval in milliseconds for when a manifest should be considered stale 98 | ERROR_LIMIT=10 # number of errors to be saved in memory 99 | ``` 100 | 101 | The `HLSMonitorService` can also be controlled through code by using the core `HLSMonitor` directly: 102 | 103 | ```typescript 104 | import { HLSMonitor } from "@eyevinn/hls-monitor"; 105 | 106 | // Define a list of streams 107 | const streams = ["stream-to-monitor-01/manifest.m3u8", "stream-to-monitor-02/manifest.m3u8"]; 108 | 109 | // Define stale limit in milliseconds (Defaults at 6000) 110 | const staleLimit = 10000; 111 | 112 | // initialize a new instance of HLSMonitor 113 | const monitor = new HLSMonitor(streams, staleLimit); 114 | 115 | // Start the HLS-Monitor, it will begin polling and analyzing new manifests. 116 | monitor.start(); 117 | 118 | // ... after some time, check for the latest errors 119 | const errors = await monitor.getErrors(); 120 | console.log(errors); 121 | ``` 122 | 123 | ## Error Structure 124 | 125 | When calling `getErrors()`, the monitor returns an array of error objects in reverse chronological order (newest first). Each error object has the following structure: 126 | 127 | ```typescript 128 | type MonitorError = { 129 | eid: string; // Unique error ID 130 | date: string; // ISO timestamp of when the error occurred 131 | errorType: ErrorType; // Type of error (e.g., "Manifest Retrieval", "Media Sequence", etc.) 132 | mediaType: string; // Type of media ("MASTER", "VIDEO", "AUDIO", etc.) 133 | variant: string; // Variant identifier (bandwidth or group-id) 134 | details: string; // Detailed error message 135 | streamUrl: string; // URL of the stream where the error occurred 136 | streamId: string; // ID of the stream 137 | code?: number; // HTTP status code (for manifest retrieval errors) 138 | } 139 | 140 | enum ErrorType { 141 | MANIFEST_RETRIEVAL = "Manifest Retrieval", 142 | MEDIA_SEQUENCE = "Media Sequence", 143 | PLAYLIST_SIZE = "Playlist Size", 144 | PLAYLIST_CONTENT = "Playlist Content", 145 | SEGMENT_CONTINUITY = "Segment Continuity", 146 | DISCONTINUITY_SEQUENCE = "Discontinuity Sequence", 147 | STALE_MANIFEST = "Stale Manifest" 148 | } 149 | ``` 150 | 151 | Example error object: 152 | ```json 153 | { 154 | "eid": "eid-1234567890", 155 | "date": "2024-01-30T12:34:56.789Z", 156 | "errorType": "Manifest Retrieval", 157 | "mediaType": "VIDEO", 158 | "variant": "1200000", 159 | "details": "Failed to fetch variant manifest (404)", 160 | "streamUrl": "https://example.com/stream.m3u8", 161 | "streamId": "stream_1", 162 | "code": 404 163 | } 164 | ``` 165 | 166 | ## Metrics 167 | 168 | The service exposes a `/metrics` endpoint that provides OpenMetrics/Prometheus-compatible metrics. These metrics can be used to monitor the health and status of your HLS streams in real-time. 169 | 170 | ### Available Metrics 171 | 172 | ```text 173 | # HELP hls_monitor_info Information about the HLS monitor 174 | # TYPE hls_monitor_info gauge 175 | hls_monitor_info{monitor_id="...", state="active"} 1 176 | 177 | # HELP hls_monitor_manifest_fetch_errors Current manifest fetch errors with details 178 | # TYPE hls_monitor_manifest_fetch_errors gauge 179 | hls_monitor_manifest_fetch_errors{monitor_id="...",url="...",status_code="404",media_type="VIDEO",variant="1200000",stream_id="..."} 1 180 | 181 | # HELP hls_monitor_stream_total_errors Total number of errors detected per stream since monitor creation 182 | # TYPE hls_monitor_stream_total_errors counter 183 | hls_monitor_stream_total_errors{monitor_id="...",stream_id="..."} 42 184 | 185 | # HELP hls_monitor_stream_time_since_last_error_seconds Time since the last error was detected for each stream 186 | # TYPE hls_monitor_stream_time_since_last_error_seconds gauge 187 | hls_monitor_stream_time_since_last_error_seconds{monitor_id="...",stream_id="..."} 1234.56 188 | 189 | # HELP hls_monitor_new_errors_total Count of new errors detected since last check 190 | # TYPE hls_monitor_new_errors_total counter 191 | hls_monitor_new_errors_total{monitor_id="...",error_type="Manifest Retrieval",media_type="VIDEO",stream_id="..."} 1 192 | ``` 193 | 194 | ### Using with Prometheus and Grafana 195 | 196 | These metrics can be scraped by Prometheus and visualized in Grafana. We provide example configurations and a demo dashboard in the `examples/local-grafana-dashboards` directory. 197 | 198 | To get started with monitoring: 199 | 200 | 1. Configure Prometheus to scrape the `/metrics` endpoint 201 | 2. Import our demo Grafana dashboard 202 | 3. Start monitoring your streams with real-time visualizations 203 | 204 | See our [local Grafana setup guide](examples/local-grafana-dashboards/how-to-setup-local-grafana.md) for detailed instructions. 205 | 206 | ## [Contributing](CONTRIBUTING.md) 207 | 208 | In addition to contributing code, you can help to triage issues. This can include reproducing bug reports or asking for vital information such as version numbers or reproduction instructions. 209 | 210 | ## License (MIT) 211 | 212 | Copyright 2024 Eyevinn Technology 213 | 214 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 215 | 216 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 217 | 218 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 219 | 220 | ## Support 221 | 222 | Join our [community on Slack](http://slack.streamingtech.se) where you can post any questions regarding any of our open-source projects. Eyevinn's consulting business can also offer you: 223 | 224 | - Further development of this component 225 | - Customization and integration of this component into your platform 226 | - Support and maintenance agreement 227 | 228 | Contact [sales@eyevinn.se](mailto:sales@eyevinn.se) if you are interested. 229 | 230 | ## About Eyevinn Technology 231 | 232 | Eyevinn Technology is an independent consultant firm specializing in video and streaming. Independent in a way that we are not commercially tied to any platform or technology vendor. 233 | 234 | At Eyevinn, every software developer consultant has a dedicated budget reserved for open source development and contribution to the open source community. This gives us room for innovation, team building, and personal competence development. And also gives us as a company a way to contribute back to the open source community. 235 | 236 | Want to know more about Eyevinn and how it is to work here. Contact us at work@eyevinn.se! 237 | -------------------------------------------------------------------------------- /cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const { HLSMonitor } = require("./dist/index.js"); 4 | 5 | const opt = require("node-getopt").create([ 6 | ["h", "help", "display this help"] 7 | ]) 8 | .setHelp( 9 | "Usage: node cli.js [OPTIONS] URL\n\n" + 10 | "Monitor live HLS stream for inconsistencies:\n" + 11 | "\n" + 12 | " URL to HLS to monitor\n" + 13 | "[[OPTIONS]]\n" 14 | ) 15 | .bindHelp() 16 | .parseSystem(); 17 | 18 | if (opt.argv.length < 1) { 19 | opt.showHelp(); 20 | process.exit(1); 21 | } 22 | console.log("Monitoring " + opt.argv[0]); 23 | 24 | const url = new URL(opt.argv[0]); 25 | const monitor = new HLSMonitor([ url.toString() ], 8000, true); 26 | monitor.start(); 27 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | networks: 3 | hls-monitor-internal: 4 | driver: bridge 5 | 6 | services: 7 | hls-monitor: 8 | build: 9 | context: . 10 | image: hls-monitor 11 | container_name: hls-monitor 12 | environment: 13 | - ERROR_LIMIT=${ERROR_LIMIT} 14 | - HLS_MONITOR_INTERVAL=${HLS_MONITOR_INTERVAL} 15 | ports: 16 | - 3000:3000 17 | expose: 18 | - 3000 19 | networks: 20 | - hls-monitor-internal 21 | -------------------------------------------------------------------------------- /example.js: -------------------------------------------------------------------------------- 1 | const { HLSMonitorService } = require("./dist/index.js"); 2 | 3 | // initialize a new instance of HLSMonitorService 4 | const hlsMonitorService = new HLSMonitorService(); 5 | // register the routes 6 | hlsMonitorService.listen(process.env.PORT || 3000, '0.0.0.0'); 7 | -------------------------------------------------------------------------------- /examples/local-grafana-dashboards/demo-grafana-dashboard.json: -------------------------------------------------------------------------------- 1 | { 2 | "annotations": { 3 | "list": [ 4 | { 5 | "builtIn": 1, 6 | "datasource": { 7 | "type": "grafana", 8 | "uid": "-- Grafana --" 9 | }, 10 | "enable": true, 11 | "hide": true, 12 | "iconColor": "rgba(0, 211, 255, 1)", 13 | "name": "Annotations & Alerts", 14 | "type": "dashboard" 15 | } 16 | ] 17 | }, 18 | "editable": true, 19 | "fiscalYearStartMonth": 0, 20 | "graphTooltip": 0, 21 | "id": 1, 22 | "links": [], 23 | "panels": [ 24 | { 25 | "datasource": { 26 | "type": "prometheus", 27 | "uid": "prometheus" 28 | }, 29 | "fieldConfig": { 30 | "defaults": { 31 | "color": { 32 | "mode": "thresholds" 33 | }, 34 | "mappings": [ 35 | { 36 | "options": { 37 | "0": { 38 | "color": "red", 39 | "text": "INACTIVE" 40 | }, 41 | "1": { 42 | "color": "green", 43 | "text": "ACTIVE" 44 | } 45 | }, 46 | "type": "value" 47 | } 48 | ], 49 | "thresholds": { 50 | "mode": "absolute", 51 | "steps": [ 52 | { 53 | "color": "red", 54 | "value": null 55 | }, 56 | { 57 | "color": "green", 58 | "value": 1 59 | } 60 | ] 61 | } 62 | }, 63 | "overrides": [] 64 | }, 65 | "gridPos": { 66 | "h": 6, 67 | "w": 6, 68 | "x": 0, 69 | "y": 0 70 | }, 71 | "id": 12, 72 | "options": { 73 | "colorMode": "background", 74 | "graphMode": "none", 75 | "justifyMode": "auto", 76 | "orientation": "horizontal", 77 | "percentChangeColorMode": "standard", 78 | "reduceOptions": { 79 | "calcs": [ 80 | "lastNotNull" 81 | ], 82 | "fields": "", 83 | "values": false 84 | }, 85 | "showPercentChange": false, 86 | "textMode": "auto", 87 | "wideLayout": true 88 | }, 89 | "pluginVersion": "11.5.1", 90 | "targets": [ 91 | { 92 | "editorMode": "code", 93 | "expr": "hls_monitor_state{monitor_id=~\"$monitor\", state=\"active\"}", 94 | "instant": true, 95 | "legendFormat": "{{monitor_id}}", 96 | "refId": "A" 97 | } 98 | ], 99 | "title": "Monitor Active/Inactive Status", 100 | "type": "stat" 101 | }, 102 | { 103 | "datasource": { 104 | "type": "prometheus", 105 | "uid": "prometheus" 106 | }, 107 | "fieldConfig": { 108 | "defaults": { 109 | "mappings": [], 110 | "min": 0, 111 | "thresholds": { 112 | "mode": "absolute", 113 | "steps": [ 114 | { 115 | "color": "red", 116 | "value": null 117 | }, 118 | { 119 | "color": "yellow", 120 | "value": 1 121 | }, 122 | { 123 | "color": "green", 124 | "value": 24 125 | } 126 | ] 127 | }, 128 | "unit": "m" 129 | }, 130 | "overrides": [] 131 | }, 132 | "gridPos": { 133 | "h": 6, 134 | "w": 6, 135 | "x": 6, 136 | "y": 0 137 | }, 138 | "id": 2, 139 | "options": { 140 | "minVizHeight": 75, 141 | "minVizWidth": 75, 142 | "orientation": "auto", 143 | "reduceOptions": { 144 | "calcs": [ 145 | "lastNotNull" 146 | ], 147 | "fields": "", 148 | "values": false 149 | }, 150 | "showThresholdLabels": false, 151 | "showThresholdMarkers": true, 152 | "sizing": "auto" 153 | }, 154 | "pluginVersion": "11.5.1", 155 | "targets": [ 156 | { 157 | "editorMode": "code", 158 | "expr": "hls_monitor_uptime_seconds{monitor_id=~\"$monitor\"} / 60", 159 | "legendFormat": "{{monitor_id}}", 160 | "range": true, 161 | "refId": "A" 162 | } 163 | ], 164 | "title": "Monitor Uptime (Minutes)", 165 | "type": "gauge" 166 | }, 167 | { 168 | "datasource": { 169 | "type": "prometheus", 170 | "uid": "prometheus" 171 | }, 172 | "fieldConfig": { 173 | "defaults": { 174 | "color": { 175 | "mode": "palette-classic" 176 | }, 177 | "custom": { 178 | "axisBorderShow": false, 179 | "axisCenteredZero": false, 180 | "axisColorMode": "text", 181 | "axisLabel": "", 182 | "axisPlacement": "auto", 183 | "barAlignment": 0, 184 | "barWidthFactor": 0.6, 185 | "drawStyle": "line", 186 | "fillOpacity": 0, 187 | "gradientMode": "none", 188 | "hideFrom": { 189 | "legend": false, 190 | "tooltip": false, 191 | "viz": false 192 | }, 193 | "insertNulls": false, 194 | "lineInterpolation": "linear", 195 | "lineWidth": 1, 196 | "pointSize": 5, 197 | "scaleDistribution": { 198 | "type": "linear" 199 | }, 200 | "showPoints": "auto", 201 | "spanNulls": false, 202 | "stacking": { 203 | "group": "A", 204 | "mode": "none" 205 | }, 206 | "thresholdsStyle": { 207 | "mode": "off" 208 | } 209 | }, 210 | "mappings": [], 211 | "thresholds": { 212 | "mode": "absolute", 213 | "steps": [ 214 | { 215 | "color": "green", 216 | "value": null 217 | }, 218 | { 219 | "color": "red", 220 | "value": 80 221 | } 222 | ] 223 | } 224 | }, 225 | "overrides": [] 226 | }, 227 | "gridPos": { 228 | "h": 8, 229 | "w": 12, 230 | "x": 12, 231 | "y": 0 232 | }, 233 | "id": 6, 234 | "options": { 235 | "legend": { 236 | "calcs": [], 237 | "displayMode": "list", 238 | "placement": "bottom", 239 | "showLegend": true 240 | }, 241 | "tooltip": { 242 | "hideZeros": false, 243 | "mode": "single", 244 | "sort": "none" 245 | } 246 | }, 247 | "pluginVersion": "11.5.1", 248 | "targets": [ 249 | { 250 | "editorMode": "code", 251 | "expr": "hls_monitor_stream_total_errors{monitor_id=~\"$monitor\"}", 252 | "legendFormat": "{{monitor_id}} - {{stream_id}}", 253 | "range": true, 254 | "refId": "A" 255 | } 256 | ], 257 | "title": "Cumulative Error Count by Stream", 258 | "type": "timeseries" 259 | }, 260 | { 261 | "datasource": { 262 | "type": "prometheus", 263 | "uid": "prometheus" 264 | }, 265 | "fieldConfig": { 266 | "defaults": { 267 | "color": { 268 | "mode": "thresholds" 269 | }, 270 | "mappings": [], 271 | "thresholds": { 272 | "mode": "absolute", 273 | "steps": [ 274 | { 275 | "color": "green", 276 | "value": null 277 | }, 278 | { 279 | "color": "red", 280 | "value": 80 281 | } 282 | ] 283 | } 284 | }, 285 | "overrides": [] 286 | }, 287 | "gridPos": { 288 | "h": 8, 289 | "w": 12, 290 | "x": 0, 291 | "y": 6 292 | }, 293 | "id": 3, 294 | "options": { 295 | "colorMode": "value", 296 | "graphMode": "area", 297 | "justifyMode": "auto", 298 | "orientation": "auto", 299 | "percentChangeColorMode": "standard", 300 | "reduceOptions": { 301 | "calcs": [ 302 | "lastNotNull" 303 | ], 304 | "fields": "", 305 | "values": false 306 | }, 307 | "showPercentChange": false, 308 | "textMode": "auto", 309 | "wideLayout": true 310 | }, 311 | "pluginVersion": "11.5.1", 312 | "targets": [ 313 | { 314 | "expr": "hls_monitor_streams{monitor_id=~\"$monitor\"}", 315 | "legendFormat": "{{monitor_id}}", 316 | "refId": "A" 317 | } 318 | ], 319 | "title": "Streams per Monitor", 320 | "type": "stat" 321 | }, 322 | { 323 | "datasource": { 324 | "type": "prometheus", 325 | "uid": "prometheus" 326 | }, 327 | "fieldConfig": { 328 | "defaults": { 329 | "custom": { 330 | "align": "auto", 331 | "cellOptions": { 332 | "type": "auto" 333 | }, 334 | "inspect": false 335 | }, 336 | "mappings": [], 337 | "thresholds": { 338 | "mode": "absolute", 339 | "steps": [ 340 | { 341 | "color": "green", 342 | "value": null 343 | }, 344 | { 345 | "color": "red", 346 | "value": 80 347 | } 348 | ] 349 | } 350 | }, 351 | "overrides": [ 352 | { 353 | "matcher": { 354 | "id": "byName", 355 | "options": "status_code" 356 | }, 357 | "properties": [ 358 | { 359 | "id": "custom.cellOptions", 360 | "value": { 361 | "mode": "thresholds", 362 | "type": "color-text" 363 | } 364 | }, 365 | { 366 | "id": "thresholds", 367 | "value": { 368 | "mode": "absolute", 369 | "steps": [ 370 | { 371 | "color": "green", 372 | "value": null 373 | }, 374 | { 375 | "color": "yellow", 376 | "value": 400 377 | }, 378 | { 379 | "color": "red", 380 | "value": 500 381 | } 382 | ] 383 | } 384 | } 385 | ] 386 | }, 387 | { 388 | "matcher": { 389 | "id": "byName", 390 | "options": "monitor_id" 391 | }, 392 | "properties": [ 393 | { 394 | "id": "custom.width", 395 | "value": 312 396 | } 397 | ] 398 | } 399 | ] 400 | }, 401 | "gridPos": { 402 | "h": 8, 403 | "w": 12, 404 | "x": 12, 405 | "y": 8 406 | }, 407 | "id": 8, 408 | "options": { 409 | "cellHeight": "sm", 410 | "footer": { 411 | "countRows": false, 412 | "fields": "", 413 | "reducer": [ 414 | "sum" 415 | ], 416 | "show": false 417 | }, 418 | "showHeader": true, 419 | "sortBy": [] 420 | }, 421 | "pluginVersion": "11.5.1", 422 | "targets": [ 423 | { 424 | "editorMode": "code", 425 | "expr": "hls_monitor_manifest_fetch_errors{monitor_id=~\"$monitor\"}", 426 | "format": "table", 427 | "instant": true, 428 | "refId": "A" 429 | } 430 | ], 431 | "title": "Manifest Fetch Errors Verbose", 432 | "transformations": [ 433 | { 434 | "id": "organize", 435 | "options": { 436 | "excludeByName": { 437 | "Time": true, 438 | "Value": true, 439 | "__name__": true, 440 | "instance": true, 441 | "job": true 442 | }, 443 | "indexByName": { 444 | "media_type": 3, 445 | "monitor_id": 0, 446 | "status_code": 2, 447 | "url": 1, 448 | "variant": 4 449 | } 450 | } 451 | }, 452 | { 453 | "id": "sortBy", 454 | "options": { 455 | "fields": {}, 456 | "sort": [ 457 | { 458 | "desc": false, 459 | "field": "monitor_id" 460 | } 461 | ] 462 | } 463 | } 464 | ], 465 | "type": "table" 466 | }, 467 | { 468 | "datasource": { 469 | "type": "prometheus", 470 | "uid": "prometheus" 471 | }, 472 | "fieldConfig": { 473 | "defaults": { 474 | "color": { 475 | "mode": "palette-classic" 476 | }, 477 | "custom": { 478 | "hideFrom": { 479 | "legend": false, 480 | "tooltip": false, 481 | "viz": false 482 | } 483 | }, 484 | "mappings": [] 485 | }, 486 | "overrides": [ 487 | { 488 | "__systemRef": "hideSeriesFrom", 489 | "matcher": { 490 | "id": "byNames", 491 | "options": { 492 | "mode": "exclude", 493 | "names": [ 494 | "Discontinuity Sequence" 495 | ], 496 | "prefix": "All except:", 497 | "readOnly": true 498 | } 499 | }, 500 | "properties": [] 501 | } 502 | ] 503 | }, 504 | "gridPos": { 505 | "h": 10, 506 | "w": 12, 507 | "x": 0, 508 | "y": 14 509 | }, 510 | "id": 5, 511 | "options": { 512 | "legend": { 513 | "displayMode": "list", 514 | "placement": "bottom", 515 | "showLegend": true 516 | }, 517 | "pieType": "pie", 518 | "reduceOptions": { 519 | "calcs": [ 520 | "lastNotNull" 521 | ], 522 | "fields": "", 523 | "values": false 524 | }, 525 | "tooltip": { 526 | "hideZeros": false, 527 | "mode": "multi", 528 | "sort": "none" 529 | } 530 | }, 531 | "pluginVersion": "11.5.1", 532 | "targets": [ 533 | { 534 | "editorMode": "code", 535 | "expr": "sum(hls_monitor_current_errors{monitor_id=~\"$monitor\"}) by (error_type, stream_url)", 536 | "legendFormat": "{{error_type}}", 537 | "range": true, 538 | "refId": "A" 539 | } 540 | ], 541 | "title": "Current Error Distribution by Type", 542 | "type": "piechart" 543 | }, 544 | { 545 | "datasource": { 546 | "type": "prometheus", 547 | "uid": "prometheus" 548 | }, 549 | "fieldConfig": { 550 | "defaults": { 551 | "color": { 552 | "mode": "palette-classic" 553 | }, 554 | "custom": { 555 | "axisBorderShow": false, 556 | "axisCenteredZero": false, 557 | "axisColorMode": "text", 558 | "axisLabel": "", 559 | "axisPlacement": "auto", 560 | "barAlignment": 0, 561 | "barWidthFactor": 0.6, 562 | "drawStyle": "bars", 563 | "fillOpacity": 70, 564 | "gradientMode": "none", 565 | "hideFrom": { 566 | "legend": false, 567 | "tooltip": false, 568 | "viz": false 569 | }, 570 | "insertNulls": false, 571 | "lineInterpolation": "linear", 572 | "lineWidth": 1, 573 | "pointSize": 5, 574 | "scaleDistribution": { 575 | "type": "linear" 576 | }, 577 | "showPoints": "auto", 578 | "spanNulls": false, 579 | "stacking": { 580 | "group": "A", 581 | "mode": "normal" 582 | }, 583 | "thresholdsStyle": { 584 | "mode": "off" 585 | } 586 | }, 587 | "mappings": [], 588 | "thresholds": { 589 | "mode": "absolute", 590 | "steps": [ 591 | { 592 | "color": "yellow", 593 | "value": null 594 | }, 595 | { 596 | "color": "orange", 597 | "value": 2 598 | }, 599 | { 600 | "color": "red", 601 | "value": 5 602 | } 603 | ] 604 | }, 605 | "unit": "short" 606 | }, 607 | "overrides": [] 608 | }, 609 | "gridPos": { 610 | "h": 8, 611 | "w": 12, 612 | "x": 12, 613 | "y": 16 614 | }, 615 | "id": 15, 616 | "options": { 617 | "legend": { 618 | "calcs": [], 619 | "displayMode": "list", 620 | "placement": "bottom", 621 | "showLegend": true 622 | }, 623 | "tooltip": { 624 | "hideZeros": false, 625 | "mode": "single", 626 | "sort": "none" 627 | } 628 | }, 629 | "pluginVersion": "11.5.1", 630 | "targets": [ 631 | { 632 | "editorMode": "code", 633 | "expr": "sum by(monitor_id, stream_id, status_code, media_type, variant) (hls_monitor_manifest_fetch_errors{status_code!=\"200\", monitor_id=~\"$monitor\"})", 634 | "interval": "30s", 635 | "legendFormat": "{{monitor_id}} - {{stream_id}} ({{media_type}}/{{variant}}) - {{status_code}}", 636 | "range": true, 637 | "refId": "A" 638 | } 639 | ], 640 | "title": "Current Manifest Errors by Stream (Status Codes)", 641 | "type": "timeseries" 642 | }, 643 | { 644 | "datasource": { 645 | "type": "prometheus", 646 | "uid": "prometheus" 647 | }, 648 | "fieldConfig": { 649 | "defaults": { 650 | "custom": { 651 | "align": "auto", 652 | "cellOptions": { 653 | "type": "auto" 654 | }, 655 | "inspect": false 656 | }, 657 | "mappings": [], 658 | "thresholds": { 659 | "mode": "absolute", 660 | "steps": [ 661 | { 662 | "color": "green", 663 | "value": null 664 | }, 665 | { 666 | "color": "red", 667 | "value": 80 668 | } 669 | ] 670 | }, 671 | "unit": "s" 672 | }, 673 | "overrides": [ 674 | { 675 | "matcher": { 676 | "id": "byName", 677 | "options": "Value" 678 | }, 679 | "properties": [ 680 | { 681 | "id": "custom.cellOptions", 682 | "value": { 683 | "mode": "thresholds", 684 | "type": "color-background" 685 | } 686 | }, 687 | { 688 | "id": "thresholds", 689 | "value": { 690 | "mode": "absolute", 691 | "steps": [ 692 | { 693 | "color": "red", 694 | "value": null 695 | }, 696 | { 697 | "color": "yellow", 698 | "value": 300 699 | }, 700 | { 701 | "color": "green", 702 | "value": 3600 703 | } 704 | ] 705 | } 706 | } 707 | ] 708 | }, 709 | { 710 | "matcher": { 711 | "id": "byName", 712 | "options": "__name__" 713 | }, 714 | "properties": [ 715 | { 716 | "id": "custom.width", 717 | "value": 138 718 | } 719 | ] 720 | } 721 | ] 722 | }, 723 | "gridPos": { 724 | "h": 8, 725 | "w": 12, 726 | "x": 0, 727 | "y": 24 728 | }, 729 | "id": 7, 730 | "options": { 731 | "cellHeight": "sm", 732 | "footer": { 733 | "countRows": false, 734 | "fields": "", 735 | "reducer": [ 736 | "sum" 737 | ], 738 | "show": false 739 | }, 740 | "showHeader": true, 741 | "sortBy": [] 742 | }, 743 | "pluginVersion": "11.5.1", 744 | "targets": [ 745 | { 746 | "editorMode": "code", 747 | "expr": "hls_monitor_stream_time_since_last_error_seconds{monitor_id=~\"$monitor\"}", 748 | "format": "table", 749 | "instant": true, 750 | "refId": "A" 751 | } 752 | ], 753 | "title": "Time Since Last Error", 754 | "transformations": [ 755 | { 756 | "id": "organize", 757 | "options": { 758 | "excludeByName": { 759 | "Time": true, 760 | "__name__": true, 761 | "instance": true, 762 | "job": true 763 | }, 764 | "indexByName": { 765 | "Value": 3, 766 | "last_error_time": 2, 767 | "monitor_id": 0, 768 | "stream_id": 1 769 | } 770 | } 771 | }, 772 | { 773 | "id": "sortBy", 774 | "options": { 775 | "fields": {}, 776 | "sort": [ 777 | { 778 | "desc": false, 779 | "field": "monitor_id" 780 | } 781 | ] 782 | } 783 | } 784 | ], 785 | "type": "table" 786 | }, 787 | { 788 | "datasource": { 789 | "type": "prometheus", 790 | "uid": "prometheus" 791 | }, 792 | "fieldConfig": { 793 | "defaults": { 794 | "color": { 795 | "mode": "palette-classic" 796 | }, 797 | "custom": { 798 | "axisBorderShow": false, 799 | "axisCenteredZero": false, 800 | "axisColorMode": "text", 801 | "axisLabel": "", 802 | "axisPlacement": "auto", 803 | "barAlignment": 0, 804 | "barWidthFactor": 0.6, 805 | "drawStyle": "line", 806 | "fillOpacity": 20, 807 | "gradientMode": "none", 808 | "hideFrom": { 809 | "legend": false, 810 | "tooltip": false, 811 | "viz": false 812 | }, 813 | "insertNulls": false, 814 | "lineInterpolation": "linear", 815 | "lineWidth": 2, 816 | "pointSize": 5, 817 | "scaleDistribution": { 818 | "type": "linear" 819 | }, 820 | "showPoints": "auto", 821 | "spanNulls": false, 822 | "stacking": { 823 | "group": "A", 824 | "mode": "none" 825 | }, 826 | "thresholdsStyle": { 827 | "mode": "off" 828 | } 829 | }, 830 | "mappings": [], 831 | "thresholds": { 832 | "mode": "absolute", 833 | "steps": [ 834 | { 835 | "color": "green", 836 | "value": null 837 | }, 838 | { 839 | "color": "red", 840 | "value": 80 841 | } 842 | ] 843 | } 844 | }, 845 | "overrides": [] 846 | }, 847 | "gridPos": { 848 | "h": 8, 849 | "w": 12, 850 | "x": 12, 851 | "y": 24 852 | }, 853 | "id": 14, 854 | "options": { 855 | "legend": { 856 | "calcs": [], 857 | "displayMode": "list", 858 | "placement": "bottom", 859 | "showLegend": true 860 | }, 861 | "tooltip": { 862 | "hideZeros": false, 863 | "mode": "single", 864 | "sort": "none" 865 | } 866 | }, 867 | "pluginVersion": "11.5.1", 868 | "targets": [ 869 | { 870 | "editorMode": "code", 871 | "expr": "hls_monitor_manifest_errors{monitor_id=~\"$monitor\"}", 872 | "legendFormat": "{{monitor_id}}", 873 | "range": true, 874 | "refId": "A" 875 | } 876 | ], 877 | "title": "Cumulative Manifest Errors by Monitor", 878 | "type": "timeseries" 879 | }, 880 | { 881 | "fieldConfig": { 882 | "defaults": { 883 | "color": { 884 | "mode": "palette-classic" 885 | }, 886 | "custom": { 887 | "axisBorderShow": false, 888 | "axisCenteredZero": false, 889 | "axisColorMode": "text", 890 | "axisLabel": "", 891 | "axisPlacement": "auto", 892 | "barAlignment": 0, 893 | "barWidthFactor": 0.6, 894 | "drawStyle": "bars", 895 | "fillOpacity": 50, 896 | "gradientMode": "none", 897 | "hideFrom": { 898 | "legend": false, 899 | "tooltip": false, 900 | "viz": false 901 | }, 902 | "insertNulls": false, 903 | "lineInterpolation": "linear", 904 | "lineWidth": 1, 905 | "pointSize": 5, 906 | "scaleDistribution": { 907 | "type": "linear" 908 | }, 909 | "showPoints": "auto", 910 | "spanNulls": false, 911 | "stacking": { 912 | "group": "A", 913 | "mode": "normal" 914 | }, 915 | "thresholdsStyle": { 916 | "mode": "off" 917 | } 918 | }, 919 | "mappings": [], 920 | "thresholds": { 921 | "mode": "absolute", 922 | "steps": [ 923 | { 924 | "color": "green", 925 | "value": null 926 | }, 927 | { 928 | "color": "red", 929 | "value": 80 930 | } 931 | ] 932 | } 933 | }, 934 | "overrides": [] 935 | }, 936 | "gridPos": { 937 | "h": 8, 938 | "w": 12, 939 | "x": 0, 940 | "y": 32 941 | }, 942 | "id": 10, 943 | "options": { 944 | "legend": { 945 | "calcs": [], 946 | "displayMode": "list", 947 | "placement": "bottom", 948 | "showLegend": true 949 | }, 950 | "tooltip": { 951 | "hideZeros": false, 952 | "mode": "multi", 953 | "sort": "desc" 954 | } 955 | }, 956 | "pluginVersion": "11.5.1", 957 | "targets": [ 958 | { 959 | "expr": "sum(hls_monitor_current_errors{monitor_id=~\"$monitor\"}) by (monitor_id, media_type)", 960 | "legendFormat": "{{monitor_id}} - {{media_type}}", 961 | "refId": "A" 962 | } 963 | ], 964 | "title": "Error Distribution by Media Type (VIDEO/AUDIO/SUBTITLE)", 965 | "type": "timeseries" 966 | }, 967 | { 968 | "datasource": { 969 | "type": "prometheus", 970 | "uid": "prometheus" 971 | }, 972 | "fieldConfig": { 973 | "defaults": { 974 | "color": { 975 | "mode": "palette-classic" 976 | }, 977 | "custom": { 978 | "axisBorderShow": false, 979 | "axisCenteredZero": false, 980 | "axisColorMode": "text", 981 | "axisLabel": "", 982 | "axisPlacement": "auto", 983 | "barAlignment": 0, 984 | "barWidthFactor": 0.6, 985 | "drawStyle": "bars", 986 | "fillOpacity": 70, 987 | "gradientMode": "none", 988 | "hideFrom": { 989 | "legend": false, 990 | "tooltip": false, 991 | "viz": false 992 | }, 993 | "insertNulls": false, 994 | "lineInterpolation": "linear", 995 | "lineWidth": 1, 996 | "pointSize": 5, 997 | "scaleDistribution": { 998 | "type": "linear" 999 | }, 1000 | "showPoints": "auto", 1001 | "spanNulls": false, 1002 | "stacking": { 1003 | "group": "A", 1004 | "mode": "normal" 1005 | }, 1006 | "thresholdsStyle": { 1007 | "mode": "off" 1008 | } 1009 | }, 1010 | "mappings": [], 1011 | "thresholds": { 1012 | "mode": "absolute", 1013 | "steps": [ 1014 | { 1015 | "color": "green", 1016 | "value": null 1017 | }, 1018 | { 1019 | "color": "red", 1020 | "value": 80 1021 | } 1022 | ] 1023 | } 1024 | }, 1025 | "overrides": [] 1026 | }, 1027 | "gridPos": { 1028 | "h": 8, 1029 | "w": 12, 1030 | "x": 12, 1031 | "y": 32 1032 | }, 1033 | "id": 16, 1034 | "options": { 1035 | "legend": { 1036 | "calcs": [], 1037 | "displayMode": "list", 1038 | "placement": "bottom", 1039 | "showLegend": true 1040 | }, 1041 | "tooltip": { 1042 | "hideZeros": false, 1043 | "mode": "multi", 1044 | "sort": "desc" 1045 | } 1046 | }, 1047 | "pluginVersion": "11.5.1", 1048 | "targets": [ 1049 | { 1050 | "editorMode": "code", 1051 | "expr": "sum by(monitor_id, stream_url, error_type) (hls_monitor_new_errors_total{monitor_id=~\"$monitor\"})", 1052 | "interval": "30s", 1053 | "legendFormat": "{{monitor_id}} - {{stream_url}} ({{error_type}})", 1054 | "range": true, 1055 | "refId": "A" 1056 | } 1057 | ], 1058 | "title": "Recent Errors by Stream and Type (30s Window)", 1059 | "type": "timeseries" 1060 | } 1061 | ], 1062 | "preload": false, 1063 | "refresh": "10s", 1064 | "schemaVersion": 40, 1065 | "tags": [ 1066 | "hls", 1067 | "monitoring" 1068 | ], 1069 | "templating": { 1070 | "list": [ 1071 | { 1072 | "allValue": ".*", 1073 | "current": { 1074 | "text": "All", 1075 | "value": "$__all" 1076 | }, 1077 | "datasource": { 1078 | "type": "prometheus", 1079 | "uid": "prometheus" 1080 | }, 1081 | "definition": "label_values(hls_monitor_info, monitor_id)", 1082 | "includeAll": true, 1083 | "multi": true, 1084 | "name": "monitor", 1085 | "options": [], 1086 | "query": "label_values(hls_monitor_info, monitor_id)", 1087 | "refresh": 2, 1088 | "regex": "", 1089 | "sort": 1, 1090 | "type": "query" 1091 | } 1092 | ] 1093 | }, 1094 | "time": { 1095 | "from": "now-1h", 1096 | "to": "now" 1097 | }, 1098 | "timepicker": {}, 1099 | "timezone": "", 1100 | "title": "HLS Monitor Dashboard", 1101 | "uid": "hls-monitor-main", 1102 | "version": 2, 1103 | "weekStart": "" 1104 | } -------------------------------------------------------------------------------- /examples/local-grafana-dashboards/grafana-docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | prometheus: 4 | image: prom/prometheus 5 | ports: 6 | - "9090:9090" 7 | volumes: 8 | - ./prometheus.yml:/etc/prometheus/prometheus.yml 9 | extra_hosts: 10 | - "host.docker.internal:host-gateway" 11 | 12 | grafana: 13 | image: grafana/grafana 14 | ports: 15 | - "3001:3000" 16 | environment: 17 | - GF_AUTH_ANONYMOUS_ENABLED=true 18 | - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin 19 | - GF_INSTALL_PLUGINS=grafana-piechart-panel 20 | volumes: 21 | - ./grafana/provisioning:/etc/grafana/provisioning 22 | depends_on: 23 | - prometheus 24 | -------------------------------------------------------------------------------- /examples/local-grafana-dashboards/how-to-setup-local-grafana.md: -------------------------------------------------------------------------------- 1 | # Setting Up Local Grafana for HLS Monitor 2 | 3 | This guide will help you set up a local Grafana instance with Prometheus for monitoring your HLS streams. The setup includes pre-configured dashboards and data sources. 4 | 5 | ## Prerequisites 6 | 7 | - Docker and Docker Compose installed on your system 8 | - The following files from this repository: 9 | - `grafana-docker-compose.yaml` 10 | - `prometheus.yml` 11 | - `demo-grafana-dashboard.json` 12 | 13 | ## Step-by-Step Setup 14 | 15 | ### 1. Start the Containers 16 | 17 | First, stop any existing containers and start fresh: 18 | 19 | ```bash 20 | docker-compose -f grafana-docker-compose.yaml down 21 | docker-compose -f grafana-docker-compose.yaml up -d 22 | ``` 23 | 24 | This command will: 25 | - Clear your terminal 26 | - Stop and remove any existing containers 27 | - Build and start new containers for Prometheus and Grafana 28 | 29 | ### 2. Verify Prometheus Setup 30 | 31 | 1. Open Prometheus in your browser: http://localhost:9090 32 | 2. Navigate to Status -> Targets 33 | 3. Verify that the `hls-monitor` target is showing as "UP" 34 | - If it's not UP, check that your HLS monitor application is running on port 3000 35 | - Verify the `prometheus.yml` configuration matches your setup 36 | 37 | ### 3. Configure Grafana 38 | 39 | 1. Open Grafana in your browser: http://localhost:3001 40 | - Default login is not required (anonymous access is enabled) 41 | 42 | 2. Add Prometheus Data Source: 43 | - Go to Configuration (⚙️) -> Data Sources 44 | - Click "Add data source" 45 | - Select "Prometheus" 46 | - Set URL to: `http://prometheus:9090` 47 | - Click "Save & Test" 48 | - You should see "Data source is working" 49 | 50 | ### 4. Import the Dashboard 51 | 52 | 1. In Grafana, go to Dashboards (four squares icon) 53 | 2. Click "Import" 54 | 3. Either: 55 | - Copy the contents of `demo-grafana-dashboard.json` and paste into the "Import via panel json" field 56 | - Or click "Upload JSON file" and select the `demo-grafana-dashboard.json` file 57 | 4. Click "Load" 58 | 5. Select your Prometheus data source in the dropdown 59 | 6. Click "Import" 60 | 61 | ### 5. Verify the Dashboard 62 | 63 | Your dashboard should now be loaded and showing metrics from your HLS monitor. You should see: 64 | - Monitor status indicators 65 | - Error counts and distributions 66 | - Stream health metrics 67 | - Various other monitoring panels 68 | 69 | ![alt text](image.png) 70 | 71 | If you don't see data: 72 | - Verify your HLS monitor is running and generating metrics 73 | - Check Prometheus targets are healthy 74 | - Ensure the Prometheus data source is correctly configured in Grafana 75 | 76 | ## Troubleshooting 77 | 78 | - If Prometheus can't reach your application, check that `host.docker.internal` is resolving correctly 79 | - For Windows/macOS, the Docker compose file includes the necessary `extra_hosts` configuration 80 | - Verify your HLS monitor application is exposing metrics on port 3000 81 | - Check Docker logs for any error messages: 82 | ```bash 83 | docker-compose -f grafana-docker-compose.yaml logs 84 | ``` 85 | 86 | ## Customizing the Dashboard 87 | 88 | Feel free to modify the dashboard to suit your needs: 89 | - Click the gear icon on any panel to edit it 90 | - Add new panels using the "Add panel" button 91 | - Save your changes using the save icon in the top bar 92 | 93 | Remember that direct changes to the dashboard will be lost when containers are removed. To persist changes, export your modified dashboard JSON and save it to your project. -------------------------------------------------------------------------------- /examples/local-grafana-dashboards/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Eyevinn/hls-monitor/39ac0113d2fbebe10f2ef275e81782ea7fd516c6/examples/local-grafana-dashboards/image.png -------------------------------------------------------------------------------- /examples/local-grafana-dashboards/prometheus.yml: -------------------------------------------------------------------------------- 1 | global: 2 | scrape_interval: 15s 3 | 4 | scrape_configs: 5 | - job_name: 'hls-monitor' 6 | static_configs: 7 | - targets: ['host.docker.internal:3000'] # Adjust port to match your app 8 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | export { HLSMonitorService } from "./src/HLSMonitorService"; 2 | export { HLSMonitor } from "./src/HLSMonitor"; 3 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@eyevinn/hls-monitor", 3 | "version": "1.0.0", 4 | "description": "Library to monitor a hls stream(s)", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "build": "tsc --project ./", 8 | "start": "node example.js", 9 | "postversion": "git push && git push --tags", 10 | "test": "ts-node node_modules/jasmine/bin/jasmine" 11 | }, 12 | "bin": { 13 | "hls-monitor": "cli.js" 14 | }, 15 | "author": "Eyevinn Technology AB ", 16 | "contributors": [ 17 | "Oscar Nord (Eyevinn Technology AB)", 18 | "Nicholas Frederiksen (Eyevinn Technology AB)", 19 | "Johan Lautakoski (Eyevinn Technology AB)", 20 | "Jonas Birmé (Eyevinn Technology AB)" 21 | ], 22 | "repository": { 23 | "type": "git", 24 | "url": "git+https://github.com/Eyevinn/hls-monitor.git" 25 | }, 26 | "license": "MIT", 27 | "devDependencies": { 28 | "@types/jasmine": "^4.0.0", 29 | "@types/node": "^17.0.5", 30 | "jasmine": "^4.0.2", 31 | "jasmine-ts": "^0.4.0", 32 | "nock": "^13.2.4", 33 | "typescript": "^4.5.5" 34 | }, 35 | "dependencies": { 36 | "@eyevinn/m3u8": "^0.5.6", 37 | "async-mutex": "^0.3.2", 38 | "fastify": "^3.27.0", 39 | "fastify-swagger": "^4.13.1", 40 | "node-fetch": "^2.6.5", 41 | "node-getopt": "^0.3.2", 42 | "short-uuid": "^4.2.0", 43 | "ts-node": "^10.7.0", 44 | "uuid": "^8.3.2" 45 | }, 46 | "keywords": [ 47 | "hls", 48 | "monitor", 49 | "stream", 50 | "tools" 51 | ] 52 | } 53 | -------------------------------------------------------------------------------- /spec/src/HLSMonitor.spec.ts: -------------------------------------------------------------------------------- 1 | const nock = require("nock"); 2 | import { HLSMonitor } from "../../src/HLSMonitor"; 3 | import { mockHLSMultivariantM3u8, mockHLSMediaM3u8Sequences, TMockSequence } from "../util/testvectors"; 4 | 5 | const mockBaseUri = "https://mock.mock.com/"; 6 | const mockLiveUri = "https://mock.mock.com/channels/1xx/master.m3u8"; 7 | 8 | let mockHLSMediaM3u8Sequence: TMockSequence | undefined; 9 | let mockMseq = 0; 10 | 11 | describe("HLSMonitor,", () => { 12 | describe("parseManifests()", () => { 13 | beforeEach(() => { 14 | nock(mockBaseUri) 15 | .persist() 16 | .get("/channels/1xx/master.m3u8") 17 | .reply(200, () => { 18 | return mockHLSMultivariantM3u8; 19 | }) 20 | .get("/channels/1xx/level_0.m3u8") 21 | .reply(200, () => { 22 | const level_0_ = mockHLSMediaM3u8Sequence?.[0]; 23 | const m3u8 = level_0_?.[mockMseq]; 24 | return m3u8; 25 | }) 26 | .get("/channels/1xx/level_1.m3u8") 27 | .reply(200, () => { 28 | const level_1_ = mockHLSMediaM3u8Sequence?.[1]; 29 | const m3u8 = level_1_?.[mockMseq]; 30 | return m3u8; 31 | }); 32 | }); 33 | afterEach(() => { 34 | nock.cleanAll(); 35 | mockMseq = 0; 36 | }); 37 | 38 | it("should have an unique id and properly set stale limit and update interval", async () => { 39 | const STALE_LIMIT = { staleLimit: 8000, monitorInterval: 4000 }; 40 | const STREAMS = [mockLiveUri]; 41 | const hls_monitor_1 = new HLSMonitor(STREAMS, STALE_LIMIT); 42 | const hls_monitor_2 = new HLSMonitor(STREAMS); 43 | const id1 = hls_monitor_1.monitorId; 44 | const id2 = hls_monitor_2.monitorId; 45 | // The update interval will be set to 1/2 of the stale limit 46 | const monitor_1_stale_limit = hls_monitor_1.getUpdateInterval() * 2; 47 | const monitor_2_stale_limit = hls_monitor_2.getUpdateInterval() * 2; 48 | 49 | expect(id1).toBeDefined(); 50 | expect(id1).not.toBeNull(); 51 | expect(id1).not.toBe(""); 52 | 53 | expect(id2).toBeDefined(); 54 | expect(id2).not.toBeNull(); 55 | expect(id2).not.toBe(""); 56 | 57 | expect(id1).not.toEqual(id2); 58 | 59 | expect(monitor_1_stale_limit).toEqual(STALE_LIMIT.staleLimit); 60 | expect(monitor_2_stale_limit).toEqual(6000); // default 61 | }); 62 | 63 | it("should return error if: next mseq starts on wrong segment", async () => { 64 | // Arrange 65 | mockHLSMediaM3u8Sequence = mockHLSMediaM3u8Sequences[0]; 66 | const STALE_LIMIT = { staleLimit: 8000, monitorInterval: 4000 }; 67 | const STREAMS = [mockLiveUri]; 68 | const hls_monitor = new HLSMonitor(STREAMS, STALE_LIMIT); 69 | // Act 70 | await hls_monitor.incrementMonitor(STREAMS); 71 | mockMseq++; 72 | await hls_monitor.incrementMonitor(STREAMS); 73 | mockMseq++; 74 | await hls_monitor.incrementMonitor(STREAMS); 75 | mockMseq++; 76 | await hls_monitor.stop(); 77 | const MonitoredErrors = await hls_monitor.getErrors(); 78 | // Assert 79 | const expectedError = "Faulty Segment Continuity! Expected first item-uri in mseq(2) to be: 'index_0_2.ts'. Got: 'index_0_1.ts'"; 80 | expect(MonitoredErrors[MonitoredErrors.length - 1].details).toContain(expectedError); 81 | }); 82 | 83 | it("should return error if: next mseq is the same and contains any wrong segment", async () => { 84 | // Arrange 85 | mockHLSMediaM3u8Sequence = mockHLSMediaM3u8Sequences[1]; 86 | const STALE_LIMIT = { staleLimit: 8000, monitorInterval: 4000 }; 87 | const STREAMS = [mockLiveUri]; 88 | const hls_monitor = new HLSMonitor(STREAMS, STALE_LIMIT); 89 | // Act 90 | await hls_monitor.incrementMonitor(STREAMS); 91 | mockMseq++; 92 | await hls_monitor.incrementMonitor(STREAMS); 93 | mockMseq++; 94 | await hls_monitor.incrementMonitor(STREAMS); 95 | mockMseq++; 96 | await hls_monitor.incrementMonitor(STREAMS); 97 | mockMseq++; 98 | await hls_monitor.stop(); 99 | const MonitoredErrors = await hls_monitor.getErrors(); 100 | // Assert 101 | const expectedError = "Expected playlist item-uri in mseq(2) at index(0) to be: 'index_0_2.ts'. Got: 'index_0_3.ts'"; 102 | expect(MonitoredErrors[MonitoredErrors.length - 1].details).toContain(expectedError); 103 | }); 104 | 105 | it("should return error if: next mseq is the same and playlist size do not match", async () => { 106 | // Arrange 107 | mockHLSMediaM3u8Sequence = mockHLSMediaM3u8Sequences[2]; 108 | const STALE_LIMIT = { staleLimit: 8000, monitorInterval: 4000 }; 109 | const STREAMS = [mockLiveUri]; 110 | const hls_monitor = new HLSMonitor(STREAMS, STALE_LIMIT); 111 | // Act 112 | await hls_monitor.incrementMonitor(STREAMS); 113 | mockMseq++; 114 | await hls_monitor.incrementMonitor(STREAMS); 115 | mockMseq++; 116 | await hls_monitor.incrementMonitor(STREAMS); 117 | mockMseq++; 118 | await hls_monitor.incrementMonitor(STREAMS); 119 | mockMseq++; 120 | await hls_monitor.incrementMonitor(STREAMS); 121 | mockMseq++; 122 | await hls_monitor.incrementMonitor(STREAMS); 123 | mockMseq++; 124 | await hls_monitor.stop(); 125 | const MonitoredErrors = await hls_monitor.getErrors(); 126 | // Assert 127 | const expectedError = "Expected playlist size in mseq(13) to be: 5. Got: 4"; 128 | expect(MonitoredErrors[MonitoredErrors.length - 1].details).toContain(expectedError); 129 | }); 130 | 131 | it("should return error if: prev mseq is greater than next mseq", async () => { 132 | // Arrange 133 | mockHLSMediaM3u8Sequence = mockHLSMediaM3u8Sequences[3]; 134 | const STALE_LIMIT = { staleLimit: 8000, monitorInterval: 4000 }; 135 | const STREAMS = [mockLiveUri]; 136 | const hls_monitor = new HLSMonitor(STREAMS, STALE_LIMIT); 137 | // Act 138 | await hls_monitor.incrementMonitor(STREAMS); 139 | mockMseq++; 140 | await hls_monitor.incrementMonitor(STREAMS); 141 | mockMseq++; 142 | await hls_monitor.incrementMonitor(STREAMS); 143 | mockMseq++; 144 | await hls_monitor.incrementMonitor(STREAMS); 145 | mockMseq++; 146 | await hls_monitor.stop(); 147 | const MonitoredErrors = await hls_monitor.getErrors(); 148 | // Assert 149 | const expectedError = "Expected mediaSequence >= 3. Got: 2"; 150 | expect(MonitoredErrors[MonitoredErrors.length - 1].details).toContain(expectedError); 151 | }); 152 | 153 | it("should return error if: next mseq does not increment discontinuity-sequence correctly, too big increment", async () => { 154 | // Arrange 155 | mockHLSMediaM3u8Sequence = mockHLSMediaM3u8Sequences[4]; 156 | const STALE_LIMIT = { staleLimit: 8000, monitorInterval: 4000 }; 157 | const STREAMS = [mockLiveUri]; 158 | const hls_monitor = new HLSMonitor(STREAMS, STALE_LIMIT); 159 | // Act 160 | await hls_monitor.incrementMonitor(STREAMS); 161 | mockMseq++; 162 | await hls_monitor.incrementMonitor(STREAMS); 163 | mockMseq++; 164 | await hls_monitor.incrementMonitor(STREAMS); 165 | mockMseq++; 166 | await hls_monitor.incrementMonitor(STREAMS); 167 | mockMseq++; 168 | await hls_monitor.stop(); 169 | const MonitoredErrors = await hls_monitor.getErrors(); 170 | // Assert 171 | const expectedError = "Wrong count increment in mseq(3) - Expected: 11. Got: 12"; 172 | expect(MonitoredErrors[MonitoredErrors.length - 1].details).toContain(expectedError); 173 | }); 174 | 175 | it("should return error if: next mseq does not increment discontinuity-sequence correctly, no increment", async () => { 176 | // Arrange 177 | mockHLSMediaM3u8Sequence = mockHLSMediaM3u8Sequences[5]; 178 | const STALE_LIMIT = { staleLimit: 8000, monitorInterval: 4000 }; 179 | const STREAMS = [mockLiveUri]; 180 | const hls_monitor = new HLSMonitor(STREAMS, STALE_LIMIT); 181 | // Act 182 | await hls_monitor.incrementMonitor(STREAMS); 183 | mockMseq++; 184 | await hls_monitor.incrementMonitor(STREAMS); 185 | mockMseq++; 186 | await hls_monitor.incrementMonitor(STREAMS); 187 | mockMseq++; 188 | await hls_monitor.incrementMonitor(STREAMS); 189 | mockMseq++; 190 | await hls_monitor.stop(); 191 | const MonitoredErrors = await hls_monitor.getErrors(); 192 | // Assert 193 | const expectedError = "Wrong count increment in mseq(3) - Expected: 11. Got: 10"; 194 | expect(MonitoredErrors[MonitoredErrors.length - 1].details).toContain(expectedError); 195 | }); 196 | 197 | it("should return error if: next mseq does not increment discontinuity-sequence correctly, early increment (tag at top)", async () => { 198 | // Arrange 199 | mockHLSMediaM3u8Sequence = mockHLSMediaM3u8Sequences[6]; 200 | const STALE_LIMIT = { staleLimit: 8000, monitorInterval: 4000 }; 201 | const STREAMS = [mockLiveUri]; 202 | const hls_monitor = new HLSMonitor(STREAMS, STALE_LIMIT); 203 | // Act 204 | await hls_monitor.incrementMonitor(STREAMS); 205 | mockMseq++; 206 | await hls_monitor.incrementMonitor(STREAMS); 207 | mockMseq++; 208 | await hls_monitor.incrementMonitor(STREAMS); 209 | mockMseq++; 210 | await hls_monitor.incrementMonitor(STREAMS); 211 | mockMseq++; 212 | await hls_monitor.stop(); 213 | const MonitoredErrors = await hls_monitor.getErrors(); 214 | // Assert 215 | const expectedError = "Early count increment in mseq(22) - Expected: 10. Got: 11"; 216 | expect(MonitoredErrors[MonitoredErrors.length - 1].details).toContain(expectedError); 217 | }); 218 | 219 | it("should return error if: next mseq does not increment discontinuity-sequence correctly, early increment (tag under top)", async () => { 220 | // Arrange 221 | mockHLSMediaM3u8Sequence = mockHLSMediaM3u8Sequences[7]; 222 | const STALE_LIMIT = { staleLimit: 8000, monitorInterval: 4000 }; 223 | const STREAMS = [mockLiveUri]; 224 | const hls_monitor = new HLSMonitor(STREAMS, STALE_LIMIT); 225 | // Act 226 | await hls_monitor.incrementMonitor(STREAMS); 227 | mockMseq++; 228 | await hls_monitor.incrementMonitor(STREAMS); 229 | mockMseq++; 230 | await hls_monitor.incrementMonitor(STREAMS); 231 | mockMseq++; 232 | await hls_monitor.incrementMonitor(STREAMS); 233 | mockMseq++; 234 | await hls_monitor.stop(); 235 | const MonitoredErrors = await hls_monitor.getErrors(); 236 | // Assert 237 | const expectedError = "Early count increment in mseq(21) - Expected: 10. Got: 11"; 238 | expect(MonitoredErrors[0].details).toContain(expectedError); 239 | }); 240 | 241 | it("should return error if: next mseq does not increment discontinuity-sequence correctly, early increment (tag under top) 2nd case", async () => { 242 | // Arrange 243 | mockHLSMediaM3u8Sequence = mockHLSMediaM3u8Sequences[8]; 244 | const STALE_LIMIT = { staleLimit: 8000, monitorInterval: 4000 }; 245 | const STREAMS = [mockLiveUri]; 246 | const hls_monitor = new HLSMonitor(STREAMS, STALE_LIMIT); 247 | // Act 248 | await hls_monitor.incrementMonitor(STREAMS); 249 | mockMseq++; 250 | await hls_monitor.incrementMonitor(STREAMS); 251 | mockMseq++; 252 | await hls_monitor.incrementMonitor(STREAMS); 253 | mockMseq++; 254 | await hls_monitor.stop(); 255 | const MonitoredErrors = await hls_monitor.getErrors(); 256 | // Assert 257 | expect(MonitoredErrors).toEqual([]); 258 | }); 259 | 260 | it("should not return error if: next mseq does not increment discontinuity-sequence correctly, early increment (tag under top) 3rd case", async () => { 261 | // Arrange 262 | mockHLSMediaM3u8Sequence = mockHLSMediaM3u8Sequences[9]; 263 | const STALE_LIMIT = { staleLimit: 8000, monitorInterval: 4000 }; 264 | const STREAMS = [mockLiveUri]; 265 | const hls_monitor = new HLSMonitor(STREAMS, STALE_LIMIT); 266 | // Act 267 | await hls_monitor.incrementMonitor(STREAMS); 268 | mockMseq++; 269 | await hls_monitor.incrementMonitor(STREAMS); 270 | mockMseq++; 271 | await hls_monitor.incrementMonitor(STREAMS); 272 | mockMseq++; 273 | await hls_monitor.stop(); 274 | const MonitoredErrors = await hls_monitor.getErrors(); 275 | // Assert 276 | expect(MonitoredErrors).toEqual([]); 277 | }); 278 | 279 | it("should not return error if: discontinuity-sequence has increased but the media-sequence difference is larger than the playlist size", async () => { 280 | // Arrange 281 | mockHLSMediaM3u8Sequence = mockHLSMediaM3u8Sequences[10]; 282 | const STALE_LIMIT = { staleLimit: 8000, monitorInterval: 4000 }; 283 | const STREAMS = [mockLiveUri]; 284 | const hls_monitor = new HLSMonitor(STREAMS, STALE_LIMIT); 285 | // Act 286 | await hls_monitor.incrementMonitor(STREAMS); 287 | mockMseq++; 288 | await hls_monitor.incrementMonitor(STREAMS); 289 | mockMseq++; 290 | await hls_monitor.incrementMonitor(STREAMS); 291 | mockMseq++; 292 | await hls_monitor.stop(); 293 | const MonitoredErrors = await hls_monitor.getErrors(); 294 | // Assert 295 | expect(MonitoredErrors).toEqual([]); 296 | }); 297 | }); 298 | }); 299 | -------------------------------------------------------------------------------- /spec/support/jasmine.json: -------------------------------------------------------------------------------- 1 | { 2 | "spec_dir": "spec", 3 | "spec_files": [ 4 | "**/*[sS]pec.ts" 5 | ], 6 | "helpers": [ 7 | "helpers/**/*.ts" 8 | ], 9 | "stopSpecOnExpectationFailure": false, 10 | "random": true 11 | } -------------------------------------------------------------------------------- /spec/util/testvectors.ts: -------------------------------------------------------------------------------- 1 | type TMockVariantSequence = { 2 | [mseq: number]: string; 3 | }; 4 | export type TMockSequence = TMockVariantSequence[]; 5 | 6 | export const mockHLSMultivariantM3u8 = `#EXTM3U 7 | #EXT-X-VERSION:3 8 | #EXT-X-STREAM-INF:BANDWIDTH=1212000,RESOLUTION=1280x720,FRAME-RATE=30.000 9 | level_0.m3u8 10 | #EXT-X-STREAM-INF:BANDWIDTH=2424000,RESOLUTION=1280x720,FRAME-RATE=30.000 11 | level_1.m3u8 12 | `; 13 | 14 | const mockSequence1: TMockSequence = [ 15 | // level 0 16 | { 17 | 0: `#EXTM3U 18 | #EXT-X-VERSION:3 19 | #EXT-X-TARGETDURATION:10 20 | #EXT-X-MEDIA-SEQUENCE:0 21 | #EXTINF:10.000, 22 | index_0_0.ts 23 | #EXTINF:10.000, 24 | index_0_1.ts`, 25 | 1: `#EXTM3U 26 | #EXT-X-VERSION:3 27 | #EXT-X-TARGETDURATION:10 28 | #EXT-X-MEDIA-SEQUENCE:1 29 | #EXTINF:10.000, 30 | index_0_1.ts 31 | #EXTINF:10.000, 32 | index_0_2.ts`, 33 | 2: `#EXTM3U 34 | #EXT-X-VERSION:3 35 | #EXT-X-TARGETDURATION:10 36 | #EXT-X-MEDIA-SEQUENCE:2 37 | #EXTINF:10.000, 38 | index_0_1.ts 39 | #EXTINF:10.000, 40 | index_0_2.ts`, 41 | 3: `#EXTM3U 42 | #EXT-X-VERSION:3 43 | #EXT-X-TARGETDURATION:10 44 | #EXT-X-MEDIA-SEQUENCE:3 45 | #EXTINF:10.000, 46 | index_0_2.ts 47 | #EXTINF:10.000, 48 | index_0_3.ts`, 49 | }, 50 | // level 1 51 | { 52 | 0: `#EXTM3U 53 | #EXT-X-VERSION:3 54 | #EXT-X-TARGETDURATION:10 55 | #EXT-X-MEDIA-SEQUENCE:0 56 | #EXTINF:10.000, 57 | index_1_0.ts 58 | #EXTINF:10.000, 59 | index_1_1.ts`, 60 | 1: `#EXTM3U 61 | #EXT-X-VERSION:3 62 | #EXT-X-TARGETDURATION:10 63 | #EXT-X-MEDIA-SEQUENCE:1 64 | #EXTINF:10.000, 65 | index_1_1.ts 66 | #EXTINF:10.000, 67 | index_1_2.ts`, 68 | 2: `#EXTM3U 69 | #EXT-X-VERSION:3 70 | #EXT-X-TARGETDURATION:10 71 | #EXT-X-MEDIA-SEQUENCE:2 72 | #EXTINF:10.000, 73 | index_1_1.ts 74 | #EXTINF:10.000, 75 | index_1_2.ts`, 76 | 3: `#EXTM3U 77 | #EXT-X-VERSION:3 78 | #EXT-X-TARGETDURATION:10 79 | #EXT-X-MEDIA-SEQUENCE:3 80 | #EXTINF:10.000, 81 | index_1_2.ts 82 | #EXTINF:10.000, 83 | index_1_3.ts`, 84 | }, 85 | ]; 86 | 87 | const mockSequence2: TMockSequence = [ 88 | // level 0 89 | { 90 | 0: `#EXTM3U 91 | #EXT-X-VERSION:3 92 | #EXT-X-TARGETDURATION:10 93 | #EXT-X-MEDIA-SEQUENCE:0 94 | #EXTINF:10.000, 95 | index_0_0.ts 96 | #EXTINF:10.000, 97 | index_0_1.ts`, 98 | 1: `#EXTM3U 99 | #EXT-X-VERSION:3 100 | #EXT-X-TARGETDURATION:10 101 | #EXT-X-MEDIA-SEQUENCE:1 102 | #EXTINF:10.000, 103 | index_0_1.ts 104 | #EXTINF:10.000, 105 | index_0_2.ts`, 106 | 2: `#EXTM3U 107 | #EXT-X-VERSION:3 108 | #EXT-X-TARGETDURATION:10 109 | #EXT-X-MEDIA-SEQUENCE:2 110 | #EXTINF:10.000, 111 | index_0_2.ts 112 | #EXTINF:10.000, 113 | index_0_3.ts`, 114 | 3: `#EXTM3U 115 | #EXT-X-VERSION:3 116 | #EXT-X-TARGETDURATION:10 117 | #EXT-X-MEDIA-SEQUENCE:2 118 | #EXTINF:10.000, 119 | index_0_3.ts 120 | #EXTINF:10.000, 121 | index_0_4.ts`, 122 | }, 123 | // level 1 124 | { 125 | 0: `#EXTM3U 126 | #EXT-X-VERSION:3 127 | #EXT-X-TARGETDURATION:10 128 | #EXT-X-MEDIA-SEQUENCE:0 129 | #EXTINF:10.000, 130 | index_1_0.ts 131 | #EXTINF:10.000, 132 | index_1_1.ts`, 133 | 1: `#EXTM3U 134 | #EXT-X-VERSION:3 135 | #EXT-X-TARGETDURATION:10 136 | #EXT-X-MEDIA-SEQUENCE:1 137 | #EXTINF:10.000, 138 | index_1_1.ts 139 | #EXTINF:10.000, 140 | index_1_2.ts`, 141 | 2: `#EXTM3U 142 | #EXT-X-VERSION:3 143 | #EXT-X-TARGETDURATION:10 144 | #EXT-X-MEDIA-SEQUENCE:2 145 | #EXTINF:10.000, 146 | index_1_2.ts 147 | #EXTINF:10.000, 148 | index_1_3.ts`, 149 | 3: `#EXTM3U 150 | #EXT-X-VERSION:3 151 | #EXT-X-TARGETDURATION:10 152 | #EXT-X-MEDIA-SEQUENCE:2 153 | #EXTINF:10.000, 154 | index_1_3.ts 155 | #EXTINF:10.000, 156 | index_1_4.ts`, 157 | }, 158 | ]; 159 | 160 | const mockSequence3: TMockSequence = [ 161 | // level 0 162 | { 163 | 0: `#EXTM3U 164 | #EXT-X-VERSION:3 165 | #EXT-X-TARGETDURATION:10 166 | #EXT-X-MEDIA-SEQUENCE:10 167 | #EXTINF:10.000, 168 | index_0_0.ts 169 | #EXTINF:10.000, 170 | index_0_1.ts 171 | #EXTINF:10.000, 172 | index_0_2.ts`, 173 | 1: `#EXTM3U 174 | #EXT-X-VERSION:3 175 | #EXT-X-TARGETDURATION:10 176 | #EXT-X-MEDIA-SEQUENCE:11 177 | #EXTINF:10.000, 178 | index_0_1.ts 179 | #EXTINF:10.000, 180 | index_0_2.ts 181 | #EXTINF:10.000, 182 | index_0_3.ts`, 183 | 2: `#EXTM3U 184 | #EXT-X-VERSION:3 185 | #EXT-X-TARGETDURATION:10 186 | #EXT-X-MEDIA-SEQUENCE:12 187 | #EXTINF:10.000, 188 | index_0_2.ts 189 | #EXTINF:10.000, 190 | index_0_3.ts 191 | #EXTINF:10.000, 192 | index_0_4.ts`, 193 | 3: `#EXTM3U 194 | #EXT-X-VERSION:3 195 | #EXT-X-TARGETDURATION:10 196 | #EXT-X-MEDIA-SEQUENCE:12 197 | #EXTINF:10.000, 198 | index_0_2.ts 199 | #EXTINF:10.000, 200 | index_0_3.ts 201 | #EXTINF:10.000, 202 | index_0_4.ts 203 | #EXTINF:10.000, 204 | index_0_5.ts`, 205 | 4: `#EXTM3U 206 | #EXT-X-VERSION:3 207 | #EXT-X-TARGETDURATION:10 208 | #EXT-X-MEDIA-SEQUENCE:13 209 | #EXTINF:10.000, 210 | index_0_3.ts 211 | #EXTINF:10.000, 212 | index_0_4.ts 213 | #EXTINF:10.000, 214 | index_0_5.ts 215 | #EXTINF:10.000, 216 | index_0_6.ts 217 | #EXTINF:10.000, 218 | index_0_7.ts`, 219 | 5: `#EXTM3U 220 | #EXT-X-VERSION:3 221 | #EXT-X-TARGETDURATION:10 222 | #EXT-X-MEDIA-SEQUENCE:13 223 | #EXTINF:10.000, 224 | index_0_3.ts 225 | #EXTINF:10.000, 226 | index_0_4.ts 227 | #EXTINF:10.000, 228 | index_0_5.ts 229 | #EXTINF:10.000, 230 | index_0_6.ts`, 231 | }, 232 | // level 1 233 | { 234 | 0: `#EXTM3U 235 | #EXT-X-VERSION:3 236 | #EXT-X-TARGETDURATION:10 237 | #EXT-X-MEDIA-SEQUENCE:10 238 | #EXTINF:10.000, 239 | index_1_0.ts 240 | #EXTINF:10.000, 241 | index_1_1.ts 242 | #EXTINF:10.000, 243 | index_1_2.ts`, 244 | 1: `#EXTM3U 245 | #EXT-X-VERSION:3 246 | #EXT-X-TARGETDURATION:10 247 | #EXT-X-MEDIA-SEQUENCE:11 248 | #EXTINF:10.000, 249 | index_1_1.ts 250 | #EXTINF:10.000, 251 | index_1_2.ts 252 | #EXTINF:10.000, 253 | index_1_3.ts`, 254 | 2: `#EXTM3U 255 | #EXT-X-VERSION:3 256 | #EXT-X-TARGETDURATION:10 257 | #EXT-X-MEDIA-SEQUENCE:12 258 | #EXTINF:10.000, 259 | index_1_2.ts 260 | #EXTINF:10.000, 261 | index_1_3.ts 262 | #EXTINF:10.000, 263 | index_1_4.ts`, 264 | 3: `#EXTM3U 265 | #EXT-X-VERSION:3 266 | #EXT-X-TARGETDURATION:10 267 | #EXT-X-MEDIA-SEQUENCE:12 268 | #EXTINF:10.000, 269 | index_1_2.ts 270 | #EXTINF:10.000, 271 | index_1_3.ts 272 | #EXTINF:10.000, 273 | index_1_4.ts 274 | #EXTINF:10.000, 275 | index_1_5.ts`, 276 | 4: `#EXTM3U 277 | #EXT-X-VERSION:3 278 | #EXT-X-TARGETDURATION:10 279 | #EXT-X-MEDIA-SEQUENCE:13 280 | #EXTINF:10.000, 281 | index_1_3.ts 282 | #EXTINF:10.000, 283 | index_1_4.ts 284 | #EXTINF:10.000, 285 | index_1_5.ts 286 | #EXTINF:10.000, 287 | index_1_6.ts 288 | #EXTINF:10.000, 289 | index_1_7.ts`, 290 | 5: `#EXTM3U 291 | #EXT-X-VERSION:3 292 | #EXT-X-TARGETDURATION:10 293 | #EXT-X-MEDIA-SEQUENCE:13 294 | #EXTINF:10.000, 295 | index_1_3.ts 296 | #EXTINF:10.000, 297 | index_1_4.ts 298 | #EXTINF:10.000, 299 | index_1_5.ts 300 | #EXTINF:10.000, 301 | index_1_6.ts`, 302 | }, 303 | ]; 304 | 305 | const mockSequence4: TMockSequence = [ 306 | // level 0 307 | { 308 | 0: `#EXTM3U 309 | #EXT-X-VERSION:3 310 | #EXT-X-TARGETDURATION:10 311 | #EXT-X-MEDIA-SEQUENCE:0 312 | #EXTINF:10.000, 313 | index_0_0.ts 314 | #EXTINF:10.000, 315 | index_0_1.ts`, 316 | 1: `#EXTM3U 317 | #EXT-X-VERSION:3 318 | #EXT-X-TARGETDURATION:10 319 | #EXT-X-MEDIA-SEQUENCE:1 320 | #EXTINF:10.000, 321 | index_0_1.ts 322 | #EXTINF:10.000, 323 | index_0_2.ts`, 324 | 2: `#EXTM3U 325 | #EXT-X-VERSION:3 326 | #EXT-X-TARGETDURATION:10 327 | #EXT-X-MEDIA-SEQUENCE:3 328 | #EXTINF:10.000, 329 | index_0_3.ts 330 | #EXTINF:10.000, 331 | index_0_4.ts`, 332 | 3: `#EXTM3U 333 | #EXT-X-VERSION:3 334 | #EXT-X-TARGETDURATION:10 335 | #EXT-X-MEDIA-SEQUENCE:2 336 | #EXTINF:10.000, 337 | index_0_2.ts 338 | #EXTINF:10.000, 339 | index_0_3.ts`, 340 | }, 341 | // level 1 342 | { 343 | 0: `#EXTM3U 344 | #EXT-X-VERSION:3 345 | #EXT-X-TARGETDURATION:10 346 | #EXT-X-MEDIA-SEQUENCE:0 347 | #EXTINF:10.000, 348 | index_1_0.ts 349 | #EXTINF:10.000, 350 | index_1_1.ts`, 351 | 1: `#EXTM3U 352 | #EXT-X-VERSION:3 353 | #EXT-X-TARGETDURATION:10 354 | #EXT-X-MEDIA-SEQUENCE:1 355 | #EXTINF:10.000, 356 | index_1_1.ts 357 | #EXTINF:10.000, 358 | index_1_2.ts`, 359 | 2: `#EXTM3U 360 | #EXT-X-VERSION:3 361 | #EXT-X-TARGETDURATION:10 362 | #EXT-X-MEDIA-SEQUENCE:3 363 | #EXTINF:10.000, 364 | index_1_3.ts 365 | #EXTINF:10.000, 366 | index_1_4.ts`, 367 | 3: `#EXTM3U 368 | #EXT-X-VERSION:3 369 | #EXT-X-TARGETDURATION:10 370 | #EXT-X-MEDIA-SEQUENCE:2 371 | #EXTINF:10.000, 372 | index_1_2.ts 373 | #EXTINF:10.000, 374 | index_1_3.ts`, 375 | }, 376 | ]; 377 | 378 | const mockSequence5: TMockSequence = [ 379 | // level 0 380 | { 381 | 0: `#EXTM3U 382 | #EXT-X-VERSION:3 383 | #EXT-X-TARGETDURATION:10 384 | #EXT-X-MEDIA-SEQUENCE:0 385 | #EXT-X-DISCONTINUITY-SEQUENCE:10 386 | #EXTINF:10.000, 387 | index_0_0.ts 388 | #EXTINF:10.000, 389 | index_0_1.ts`, 390 | 1: `#EXTM3U 391 | #EXT-X-VERSION:3 392 | #EXT-X-TARGETDURATION:10 393 | #EXT-X-MEDIA-SEQUENCE:1 394 | #EXT-X-DISCONTINUITY-SEQUENCE:10 395 | #EXTINF:10.000, 396 | index_0_1.ts 397 | #EXT-X-DISCONTINUITY 398 | #EXTINF:10.000, 399 | other_0_1.ts`, 400 | 2: `#EXTM3U 401 | #EXT-X-VERSION:3 402 | #EXT-X-TARGETDURATION:10 403 | #EXT-X-MEDIA-SEQUENCE:2 404 | #EXT-X-DISCONTINUITY-SEQUENCE:10 405 | #EXT-X-DISCONTINUITY 406 | #EXTINF:10.000, 407 | other_0_1.ts 408 | #EXTINF:10.000, 409 | other_0_2.ts`, 410 | 3: `#EXTM3U 411 | #EXT-X-VERSION:3 412 | #EXT-X-TARGETDURATION:10 413 | #EXT-X-MEDIA-SEQUENCE:3 414 | #EXT-X-DISCONTINUITY-SEQUENCE:12 415 | #EXTINF:10.000, 416 | other_0_2.ts 417 | #EXTINF:10.000, 418 | other_0_3.ts`, 419 | }, 420 | // level 1 421 | { 422 | 0: `#EXTM3U 423 | #EXT-X-VERSION:3 424 | #EXT-X-TARGETDURATION:10 425 | #EXT-X-MEDIA-SEQUENCE:0 426 | #EXT-X-DISCONTINUITY-SEQUENCE:10 427 | #EXTINF:10.000, 428 | index_1_0.ts 429 | #EXTINF:10.000, 430 | index_1_1.ts`, 431 | 1: `#EXTM3U 432 | #EXT-X-VERSION:3 433 | #EXT-X-TARGETDURATION:10 434 | #EXT-X-MEDIA-SEQUENCE:1 435 | #EXT-X-DISCONTINUITY-SEQUENCE:10 436 | #EXTINF:10.000, 437 | index_1_1.ts 438 | #EXT-X-DISCONTINUITY 439 | #EXTINF:10.000, 440 | other_1_1.ts`, 441 | 2: `#EXTM3U 442 | #EXT-X-VERSION:3 443 | #EXT-X-TARGETDURATION:10 444 | #EXT-X-MEDIA-SEQUENCE:2 445 | #EXT-X-DISCONTINUITY-SEQUENCE:10 446 | #EXT-X-DISCONTINUITY 447 | #EXTINF:10.000, 448 | other_1_1.ts 449 | #EXTINF:10.000, 450 | other_1_2.ts`, 451 | 3: `#EXTM3U 452 | #EXT-X-VERSION:3 453 | #EXT-X-TARGETDURATION:10 454 | #EXT-X-MEDIA-SEQUENCE:3 455 | #EXT-X-DISCONTINUITY-SEQUENCE:12 456 | #EXTINF:10.000, 457 | other_1_2.ts 458 | #EXTINF:10.000, 459 | other_1_3.ts`, 460 | }, 461 | ]; 462 | 463 | const mockSequence6: TMockSequence = [ 464 | // level 0 465 | { 466 | 0: `#EXTM3U 467 | #EXT-X-VERSION:3 468 | #EXT-X-TARGETDURATION:10 469 | #EXT-X-MEDIA-SEQUENCE:0 470 | #EXT-X-DISCONTINUITY-SEQUENCE:10 471 | #EXTINF:10.000, 472 | index_0_0.ts 473 | #EXTINF:10.000, 474 | index_0_1.ts`, 475 | 1: `#EXTM3U 476 | #EXT-X-VERSION:3 477 | #EXT-X-TARGETDURATION:10 478 | #EXT-X-MEDIA-SEQUENCE:1 479 | #EXT-X-DISCONTINUITY-SEQUENCE:10 480 | #EXTINF:10.000, 481 | index_0_1.ts 482 | #EXT-X-DISCONTINUITY 483 | #EXTINF:10.000, 484 | other_0_1.ts`, 485 | 2: `#EXTM3U 486 | #EXT-X-VERSION:3 487 | #EXT-X-TARGETDURATION:10 488 | #EXT-X-MEDIA-SEQUENCE:2 489 | #EXT-X-DISCONTINUITY-SEQUENCE:10 490 | #EXT-X-DISCONTINUITY 491 | #EXTINF:10.000, 492 | other_0_1.ts 493 | #EXTINF:10.000, 494 | other_0_2.ts`, 495 | 3: `#EXTM3U 496 | #EXT-X-VERSION:3 497 | #EXT-X-TARGETDURATION:10 498 | #EXT-X-MEDIA-SEQUENCE:3 499 | #EXT-X-DISCONTINUITY-SEQUENCE:10 500 | #EXTINF:10.000, 501 | other_0_2.ts 502 | #EXTINF:10.000, 503 | other_0_3.ts`, 504 | }, 505 | // level 1 506 | { 507 | 0: `#EXTM3U 508 | #EXT-X-VERSION:3 509 | #EXT-X-TARGETDURATION:10 510 | #EXT-X-MEDIA-SEQUENCE:0 511 | #EXT-X-DISCONTINUITY-SEQUENCE:10 512 | #EXTINF:10.000, 513 | index_1_0.ts 514 | #EXTINF:10.000, 515 | index_1_1.ts`, 516 | 1: `#EXTM3U 517 | #EXT-X-VERSION:3 518 | #EXT-X-TARGETDURATION:10 519 | #EXT-X-MEDIA-SEQUENCE:1 520 | #EXT-X-DISCONTINUITY-SEQUENCE:10 521 | #EXTINF:10.000, 522 | index_1_1.ts 523 | #EXT-X-DISCONTINUITY 524 | #EXTINF:10.000, 525 | other_1_1.ts`, 526 | 2: `#EXTM3U 527 | #EXT-X-VERSION:3 528 | #EXT-X-TARGETDURATION:10 529 | #EXT-X-MEDIA-SEQUENCE:2 530 | #EXT-X-DISCONTINUITY-SEQUENCE:10 531 | #EXT-X-DISCONTINUITY 532 | #EXTINF:10.000, 533 | other_1_1.ts 534 | #EXTINF:10.000, 535 | other_1_2.ts`, 536 | 3: `#EXTM3U 537 | #EXT-X-VERSION:3 538 | #EXT-X-TARGETDURATION:10 539 | #EXT-X-MEDIA-SEQUENCE:3 540 | #EXT-X-DISCONTINUITY-SEQUENCE:10 541 | #EXTINF:10.000, 542 | other_1_2.ts 543 | #EXTINF:10.000, 544 | other_1_3.ts`, 545 | }, 546 | ]; 547 | const mockSequence7: TMockSequence = [ 548 | // level 0 549 | { 550 | 0: `#EXTM3U 551 | #EXT-X-VERSION:3 552 | #EXT-X-TARGETDURATION:10 553 | #EXT-X-MEDIA-SEQUENCE:20 554 | #EXT-X-DISCONTINUITY-SEQUENCE:10 555 | #EXTINF:10.000, 556 | index_0_0.ts 557 | #EXTINF:10.000, 558 | index_0_1.ts`, 559 | 1: `#EXTM3U 560 | #EXT-X-VERSION:3 561 | #EXT-X-TARGETDURATION:10 562 | #EXT-X-MEDIA-SEQUENCE:21 563 | #EXT-X-DISCONTINUITY-SEQUENCE:10 564 | #EXTINF:10.000, 565 | index_0_1.ts 566 | #EXT-X-DISCONTINUITY 567 | #EXTINF:10.000, 568 | other_0_1.ts`, 569 | 2: `#EXTM3U 570 | #EXT-X-VERSION:3 571 | #EXT-X-TARGETDURATION:10 572 | #EXT-X-MEDIA-SEQUENCE:22 573 | #EXT-X-DISCONTINUITY-SEQUENCE:11 574 | #EXT-X-DISCONTINUITY 575 | #EXTINF:10.000, 576 | other_0_1.ts 577 | #EXTINF:10.000, 578 | other_0_2.ts`, 579 | 3: `#EXTM3U 580 | #EXT-X-VERSION:3 581 | #EXT-X-TARGETDURATION:10 582 | #EXT-X-MEDIA-SEQUENCE:23 583 | #EXT-X-DISCONTINUITY-SEQUENCE:11 584 | #EXTINF:10.000, 585 | other_0_2.ts 586 | #EXTINF:10.000, 587 | other_0_3.ts`, 588 | }, 589 | // level 1 590 | { 591 | 0: `#EXTM3U 592 | #EXT-X-VERSION:3 593 | #EXT-X-TARGETDURATION:10 594 | #EXT-X-MEDIA-SEQUENCE:20 595 | #EXT-X-DISCONTINUITY-SEQUENCE:10 596 | #EXTINF:10.000, 597 | index_1_0.ts 598 | #EXTINF:10.000, 599 | index_1_1.ts`, 600 | 1: `#EXTM3U 601 | #EXT-X-VERSION:3 602 | #EXT-X-TARGETDURATION:10 603 | #EXT-X-MEDIA-SEQUENCE:21 604 | #EXT-X-DISCONTINUITY-SEQUENCE:10 605 | #EXTINF:10.000, 606 | index_1_1.ts 607 | #EXT-X-DISCONTINUITY 608 | #EXTINF:10.000, 609 | other_1_1.ts`, 610 | 2: `#EXTM3U 611 | #EXT-X-VERSION:3 612 | #EXT-X-TARGETDURATION:10 613 | #EXT-X-MEDIA-SEQUENCE:22 614 | #EXT-X-DISCONTINUITY-SEQUENCE:11 615 | #EXT-X-DISCONTINUITY 616 | #EXTINF:10.000, 617 | other_1_1.ts 618 | #EXTINF:10.000, 619 | other_1_2.ts`, 620 | 3: `#EXTM3U 621 | #EXT-X-VERSION:3 622 | #EXT-X-TARGETDURATION:10 623 | #EXT-X-MEDIA-SEQUENCE:23 624 | #EXT-X-DISCONTINUITY-SEQUENCE:11 625 | #EXTINF:10.000, 626 | other_1_2.ts 627 | #EXTINF:10.000, 628 | other_1_3.ts`, 629 | }, 630 | ]; 631 | 632 | const mockSequence8: TMockSequence = [ 633 | // level 0 634 | { 635 | 0: `#EXTM3U 636 | #EXT-X-VERSION:3 637 | #EXT-X-TARGETDURATION:10 638 | #EXT-X-MEDIA-SEQUENCE:20 639 | #EXT-X-DISCONTINUITY-SEQUENCE:10 640 | #EXTINF:10.000, 641 | index_0_0.ts 642 | #EXTINF:10.000, 643 | index_0_1.ts 644 | #EXTINF:10.000, 645 | index_0_2.ts`, 646 | 1: `#EXTM3U 647 | #EXT-X-VERSION:3 648 | #EXT-X-TARGETDURATION:10 649 | #EXT-X-MEDIA-SEQUENCE:21 650 | #EXT-X-DISCONTINUITY-SEQUENCE:11 651 | #EXTINF:10.000, 652 | index_0_1.ts 653 | #EXTINF:10.000, 654 | index_0_2.ts 655 | #EXT-X-DISCONTINUITY 656 | #EXT-X-CUE-IN 657 | #EXTINF:10.000, 658 | other_0_1.ts`, 659 | 2: `#EXTM3U 660 | #EXT-X-VERSION:3 661 | #EXT-X-TARGETDURATION:10 662 | #EXT-X-MEDIA-SEQUENCE:24 663 | #EXT-X-DISCONTINUITY-SEQUENCE:11 664 | #EXT-X-CUE-IN 665 | #EXTINF:10.000, 666 | other_0_1.ts 667 | #EXTINF:10.000, 668 | other_0_2.ts`, 669 | 3: `#EXTM3U 670 | #EXT-X-VERSION:3 671 | #EXT-X-TARGETDURATION:10 672 | #EXT-X-MEDIA-SEQUENCE:25 673 | #EXT-X-DISCONTINUITY-SEQUENCE:11 674 | #EXTINF:10.000, 675 | other_0_2.ts 676 | #EXTINF:10.000, 677 | other_0_3.ts`, 678 | }, 679 | // level 1 680 | { 681 | 0: `#EXTM3U 682 | #EXT-X-VERSION:3 683 | #EXT-X-TARGETDURATION:10 684 | #EXT-X-MEDIA-SEQUENCE:20 685 | #EXT-X-DISCONTINUITY-SEQUENCE:10 686 | #EXTINF:10.000, 687 | index_1_0.ts 688 | #EXTINF:10.000, 689 | index_1_1.ts 690 | #EXTINF:10.000, 691 | index_1_2.ts`, 692 | 1: `#EXTM3U 693 | #EXT-X-VERSION:3 694 | #EXT-X-TARGETDURATION:10 695 | #EXT-X-MEDIA-SEQUENCE:21 696 | #EXT-X-DISCONTINUITY-SEQUENCE:11 697 | #EXTINF:10.000, 698 | index_1_1.ts 699 | #EXTINF:10.000, 700 | index_1_2.ts 701 | #EXT-X-DISCONTINUITY 702 | #EXT-X-CUE-IN 703 | #EXTINF:10.000, 704 | other_1_1.ts`, 705 | 2: `#EXTM3U 706 | #EXT-X-VERSION:3 707 | #EXT-X-TARGETDURATION:10 708 | #EXT-X-MEDIA-SEQUENCE:24 709 | #EXT-X-DISCONTINUITY-SEQUENCE:11 710 | #EXT-X-CUE-IN 711 | #EXTINF:10.000, 712 | other_1_1.ts 713 | #EXTINF:10.000, 714 | other_1_2.ts`, 715 | 3: `#EXTM3U 716 | #EXT-X-VERSION:3 717 | #EXT-X-TARGETDURATION:10 718 | #EXT-X-MEDIA-SEQUENCE:25 719 | #EXT-X-DISCONTINUITY-SEQUENCE:11 720 | #EXTINF:10.000, 721 | other_1_2.ts 722 | #EXTINF:10.000, 723 | other_1_3.ts`, 724 | }, 725 | ]; 726 | 727 | const mockSequence9: TMockSequence = [ 728 | // level 0 729 | { 730 | 0: `#EXTM3U 731 | #EXT-X-VERSION:3 732 | #EXT-X-TARGETDURATION:10 733 | #EXT-X-MEDIA-SEQUENCE:19 734 | #EXT-X-DISCONTINUITY-SEQUENCE:10 735 | #EXTINF:10.000, 736 | index_0_00.ts 737 | #EXTINF:10.000, 738 | index_0_0.ts 739 | #EXT-X-DISCONTINUITY 740 | #EXTINF:10.000, 741 | next_0_0.ts 742 | #EXTINF:10.000, 743 | next_0_1.ts 744 | #EXT-X-DISCONTINUITY 745 | #EXTINF:10.000, 746 | other_0_0.ts`, 747 | 1: `#EXTM3U 748 | #EXT-X-VERSION:3 749 | #EXT-X-TARGETDURATION:10 750 | #EXT-X-MEDIA-SEQUENCE:20 751 | #EXT-X-DISCONTINUITY-SEQUENCE:10 752 | #EXTINF:10.000, 753 | index_0_0.ts 754 | #EXT-X-DISCONTINUITY 755 | #EXTINF:10.000, 756 | next_0_0.ts 757 | #EXTINF:10.000, 758 | next_0_1.ts 759 | #EXT-X-DISCONTINUITY 760 | #EXTINF:10.000, 761 | other_0_0.ts 762 | #EXTINF:10.000, 763 | other_0_1.ts`, 764 | 2: `#EXTM3U 765 | #EXT-X-VERSION:3 766 | #EXT-X-TARGETDURATION:10 767 | #EXT-X-MEDIA-SEQUENCE:23 768 | #EXT-X-DISCONTINUITY-SEQUENCE:11 769 | #EXT-X-DISCONTINUITY 770 | #EXTINF:10.000, 771 | other_0_0.ts 772 | #EXTINF:10.000, 773 | other_0_1.ts 774 | #EXTINF:10.000, 775 | other_0_2.ts 776 | #EXTINF:10.000, 777 | other_0_3.ts 778 | #EXTINF:10.000, 779 | other_0_4.ts`, 780 | }, 781 | // level 1 782 | { 783 | 0: `#EXTM3U 784 | #EXT-X-VERSION:3 785 | #EXT-X-TARGETDURATION:10 786 | #EXT-X-MEDIA-SEQUENCE:19 787 | #EXT-X-DISCONTINUITY-SEQUENCE:10 788 | #EXTINF:10.000, 789 | index_1_00.ts 790 | #EXTINF:10.000, 791 | index_1_0.ts 792 | #EXT-X-DISCONTINUITY 793 | #EXTINF:10.000, 794 | next_1_0.ts 795 | #EXTINF:10.000, 796 | next_1_1.ts 797 | #EXT-X-DISCONTINUITY 798 | #EXTINF:10.000, 799 | other_1_0.ts`, 800 | 1: `#EXTM3U 801 | #EXT-X-VERSION:3 802 | #EXT-X-TARGETDURATION:10 803 | #EXT-X-MEDIA-SEQUENCE:20 804 | #EXT-X-DISCONTINUITY-SEQUENCE:10 805 | #EXTINF:10.000, 806 | index_1_0.ts 807 | #EXT-X-DISCONTINUITY 808 | #EXTINF:10.000, 809 | next_1_0.ts 810 | #EXTINF:10.000, 811 | next_1_1.ts 812 | #EXT-X-DISCONTINUITY 813 | #EXTINF:10.000, 814 | other_1_0.ts 815 | #EXTINF:10.000, 816 | other_1_1.ts`, 817 | 2: `#EXTM3U 818 | #EXT-X-VERSION:3 819 | #EXT-X-TARGETDURATION:10 820 | #EXT-X-MEDIA-SEQUENCE:23 821 | #EXT-X-DISCONTINUITY-SEQUENCE:11 822 | #EXT-X-DISCONTINUITY 823 | #EXTINF:10.000, 824 | other_1_0.ts 825 | #EXTINF:10.000, 826 | other_1_1.ts 827 | #EXTINF:10.000, 828 | other_1_2.ts 829 | #EXTINF:10.000, 830 | other_1_3.ts 831 | #EXTINF:10.000, 832 | other_1_4.ts`, 833 | }, 834 | ]; 835 | 836 | // A Passable Case 837 | const mockSequence10: TMockSequence = [ 838 | // level 0 839 | { 840 | 0: `#EXTM3U 841 | #EXT-X-VERSION:3 842 | #EXT-X-TARGETDURATION:10 843 | #EXT-X-MEDIA-SEQUENCE:19 844 | #EXT-X-DISCONTINUITY-SEQUENCE:10 845 | #EXTINF:10.000, 846 | vod_0_0.ts 847 | #EXTINF:10.000, 848 | vod_0_1.ts 849 | #EXT-X-DISCONTINUITY 850 | #EXTINF:10.000, 851 | slate_0_0.ts 852 | #EXT-X-DISCONTINUITY 853 | #EXTINF:10.000, 854 | nextvod_0_0.ts 855 | #EXT-X-DISCONTINUITY 856 | #EXT-X-CUE-OUT:DURATION=136 857 | #EXTINF:10.000, 858 | live_0_0.ts`, 859 | 1: `#EXTM3U 860 | #EXT-X-VERSION:3 861 | #EXT-X-TARGETDURATION:10 862 | #EXT-X-MEDIA-SEQUENCE:20 863 | #EXT-X-DISCONTINUITY-SEQUENCE:10 864 | #EXTINF:10.000, 865 | vod_0_1.ts 866 | #EXT-X-DISCONTINUITY 867 | #EXTINF:10.000, 868 | slate_0_0.ts 869 | #EXT-X-DISCONTINUITY 870 | #EXTINF:10.000, 871 | nextvod_0_0.ts 872 | #EXT-X-DISCONTINUITY 873 | #EXT-X-CUE-OUT:DURATION=136 874 | #EXTINF:10.000, 875 | live_0_0.ts 876 | #EXTINF:10.000, 877 | live_0_1.ts`, 878 | 2: `#EXTM3U 879 | #EXT-X-VERSION:3 880 | #EXT-X-TARGETDURATION:10 881 | #EXT-X-MEDIA-SEQUENCE:23 882 | #EXT-X-DISCONTINUITY-SEQUENCE:12 883 | #EXT-X-DISCONTINUITY 884 | #EXT-X-CUE-OUT:DURATION=136 885 | #EXTINF:10.000, 886 | live_0_0.ts 887 | #EXTINF:10.000, 888 | live_0_1.ts 889 | #EXTINF:10.000, 890 | live_0_2.ts 891 | #EXTINF:10.000, 892 | live_0_3.ts`, 893 | 3: `#EXTM3U 894 | #EXT-X-VERSION:3 895 | #EXT-X-TARGETDURATION:10 896 | #EXT-X-MEDIA-SEQUENCE:24 897 | #EXT-X-DISCONTINUITY-SEQUENCE:13 898 | #EXTINF:10.000, 899 | live_0_1.ts 900 | #EXTINF:10.000, 901 | live_0_2.ts 902 | #EXTINF:10.000, 903 | live_0_3.ts 904 | #EXTINF:10.000, 905 | live_0_4.ts`, 906 | }, 907 | // level 1 908 | { 909 | 0: `#EXTM3U 910 | #EXT-X-VERSION:3 911 | #EXT-X-TARGETDURATION:10 912 | #EXT-X-MEDIA-SEQUENCE:19 913 | #EXT-X-DISCONTINUITY-SEQUENCE:10 914 | #EXTINF:10.000, 915 | vod_1_0.ts 916 | #EXTINF:10.000, 917 | vod_1_1.ts 918 | #EXT-X-DISCONTINUITY 919 | #EXTINF:10.000, 920 | slate_1_0.ts 921 | #EXT-X-DISCONTINUITY 922 | #EXTINF:10.000, 923 | nextvod_1_0.ts 924 | #EXT-X-DISCONTINUITY 925 | #EXT-X-CUE-OUT:DURATION=136 926 | #EXTINF:10.000, 927 | live_1_0.ts`, 928 | 1: `#EXTM3U 929 | #EXT-X-VERSION:3 930 | #EXT-X-TARGETDURATION:10 931 | #EXT-X-MEDIA-SEQUENCE:20 932 | #EXT-X-DISCONTINUITY-SEQUENCE:10 933 | #EXTINF:10.000, 934 | vod_1_1.ts 935 | #EXT-X-DISCONTINUITY 936 | #EXTINF:10.000, 937 | slate_1_0.ts 938 | #EXT-X-DISCONTINUITY 939 | #EXTINF:10.000, 940 | nextvod_1_0.ts 941 | #EXT-X-DISCONTINUITY 942 | #EXT-X-CUE-OUT:DURATION=136 943 | #EXTINF:10.000, 944 | live_1_0.ts 945 | #EXTINF:10.000, 946 | live_1_1.ts`, 947 | 2: `#EXTM3U 948 | #EXT-X-VERSION:3 949 | #EXT-X-TARGETDURATION:10 950 | #EXT-X-MEDIA-SEQUENCE:23 951 | #EXT-X-DISCONTINUITY-SEQUENCE:12 952 | #EXT-X-DISCONTINUITY 953 | #EXT-X-CUE-OUT:DURATION=136 954 | #EXTINF:10.000, 955 | live_1_0.ts 956 | #EXTINF:10.000, 957 | live_1_1.ts 958 | #EXTINF:10.000, 959 | live_1_2.ts 960 | #EXTINF:10.000, 961 | live_1_3.ts`, 962 | 3: `#EXTM3U 963 | #EXT-X-VERSION:3 964 | #EXT-X-TARGETDURATION:10 965 | #EXT-X-MEDIA-SEQUENCE:24 966 | #EXT-X-DISCONTINUITY-SEQUENCE:13 967 | #EXTINF:10.000, 968 | live_1_1.ts 969 | #EXTINF:10.000, 970 | live_1_2.ts 971 | #EXTINF:10.000, 972 | live_1_3.ts 973 | #EXTINF:10.000, 974 | live_1_4.ts`, 975 | }, 976 | ]; 977 | // A Passable Case 978 | const mockSequence11: TMockSequence = [ 979 | // level 0 980 | { 981 | 0: `#EXTM3U 982 | #EXT-X-VERSION:3 983 | #EXT-X-TARGETDURATION:10 984 | #EXT-X-MEDIA-SEQUENCE:19 985 | #EXT-X-DISCONTINUITY-SEQUENCE:10 986 | #EXTINF:10.000, 987 | vod_0_0.ts 988 | #EXTINF:10.000, 989 | vod_0_1.ts 990 | #EXT-X-DISCONTINUITY 991 | #EXTINF:10.000, 992 | slate_0_0.ts 993 | #EXT-X-DISCONTINUITY 994 | #EXTINF:10.000, 995 | nextvod_0_0.ts 996 | #EXT-X-DISCONTINUITY 997 | #EXT-X-CUE-OUT:DURATION=136 998 | #EXTINF:10.000, 999 | live_0_0.ts`, 1000 | 1: `#EXTM3U 1001 | #EXT-X-VERSION:3 1002 | #EXT-X-TARGETDURATION:10 1003 | #EXT-X-MEDIA-SEQUENCE:20 1004 | #EXT-X-DISCONTINUITY-SEQUENCE:10 1005 | #EXTINF:10.000, 1006 | vod_0_1.ts 1007 | #EXT-X-DISCONTINUITY 1008 | #EXTINF:10.000, 1009 | slate_0_0.ts 1010 | #EXT-X-DISCONTINUITY 1011 | #EXTINF:10.000, 1012 | nextvod_0_0.ts 1013 | #EXT-X-DISCONTINUITY 1014 | #EXT-X-CUE-OUT:DURATION=136 1015 | #EXTINF:10.000, 1016 | live_0_0.ts 1017 | #EXTINF:10.000, 1018 | live_0_1.ts`, 1019 | 2: `#EXTM3U 1020 | #EXT-X-VERSION:3 1021 | #EXT-X-TARGETDURATION:10 1022 | #EXT-X-MEDIA-SEQUENCE:123 1023 | #EXT-X-DISCONTINUITY-SEQUENCE:12 1024 | #EXT-X-DISCONTINUITY 1025 | #EXT-X-CUE-OUT:DURATION=136 1026 | #EXTINF:10.000, 1027 | live_0_0.ts 1028 | #EXTINF:10.000, 1029 | live_0_1.ts 1030 | #EXTINF:10.000, 1031 | live_0_2.ts 1032 | #EXTINF:10.000, 1033 | live_0_3.ts`, 1034 | 3: `#EXTM3U 1035 | #EXT-X-VERSION:3 1036 | #EXT-X-TARGETDURATION:10 1037 | #EXT-X-MEDIA-SEQUENCE:124 1038 | #EXT-X-DISCONTINUITY-SEQUENCE:13 1039 | #EXTINF:10.000, 1040 | live_0_1.ts 1041 | #EXTINF:10.000, 1042 | live_0_2.ts 1043 | #EXTINF:10.000, 1044 | live_0_3.ts 1045 | #EXTINF:10.000, 1046 | live_0_4.ts`, 1047 | }, 1048 | // level 1 1049 | { 1050 | 0: `#EXTM3U 1051 | #EXT-X-VERSION:3 1052 | #EXT-X-TARGETDURATION:10 1053 | #EXT-X-MEDIA-SEQUENCE:19 1054 | #EXT-X-DISCONTINUITY-SEQUENCE:10 1055 | #EXTINF:10.000, 1056 | vod_1_0.ts 1057 | #EXTINF:10.000, 1058 | vod_1_1.ts 1059 | #EXT-X-DISCONTINUITY 1060 | #EXTINF:10.000, 1061 | slate_1_0.ts 1062 | #EXT-X-DISCONTINUITY 1063 | #EXTINF:10.000, 1064 | nextvod_1_0.ts 1065 | #EXT-X-DISCONTINUITY 1066 | #EXT-X-CUE-OUT:DURATION=136 1067 | #EXTINF:10.000, 1068 | live_1_0.ts`, 1069 | 1: `#EXTM3U 1070 | #EXT-X-VERSION:3 1071 | #EXT-X-TARGETDURATION:10 1072 | #EXT-X-MEDIA-SEQUENCE:20 1073 | #EXT-X-DISCONTINUITY-SEQUENCE:10 1074 | #EXTINF:10.000, 1075 | vod_1_1.ts 1076 | #EXT-X-DISCONTINUITY 1077 | #EXTINF:10.000, 1078 | slate_1_0.ts 1079 | #EXT-X-DISCONTINUITY 1080 | #EXTINF:10.000, 1081 | nextvod_1_0.ts 1082 | #EXT-X-DISCONTINUITY 1083 | #EXT-X-CUE-OUT:DURATION=136 1084 | #EXTINF:10.000, 1085 | live_1_0.ts 1086 | #EXTINF:10.000, 1087 | live_1_1.ts`, 1088 | 2: `#EXTM3U 1089 | #EXT-X-VERSION:3 1090 | #EXT-X-TARGETDURATION:10 1091 | #EXT-X-MEDIA-SEQUENCE:123 1092 | #EXT-X-DISCONTINUITY-SEQUENCE:12 1093 | #EXT-X-DISCONTINUITY 1094 | #EXT-X-CUE-OUT:DURATION=136 1095 | #EXTINF:10.000, 1096 | live_1_0.ts 1097 | #EXTINF:10.000, 1098 | live_1_1.ts 1099 | #EXTINF:10.000, 1100 | live_1_2.ts 1101 | #EXTINF:10.000, 1102 | live_1_3.ts`, 1103 | 3: `#EXTM3U 1104 | #EXT-X-VERSION:3 1105 | #EXT-X-TARGETDURATION:10 1106 | #EXT-X-MEDIA-SEQUENCE:124 1107 | #EXT-X-DISCONTINUITY-SEQUENCE:13 1108 | #EXTINF:10.000, 1109 | live_1_1.ts 1110 | #EXTINF:10.000, 1111 | live_1_2.ts 1112 | #EXTINF:10.000, 1113 | live_1_3.ts 1114 | #EXTINF:10.000, 1115 | live_1_4.ts`, 1116 | }, 1117 | ]; 1118 | 1119 | export const mockHLSMediaM3u8Sequences: TMockSequence[] = [ 1120 | mockSequence1, 1121 | mockSequence2, 1122 | mockSequence3, 1123 | mockSequence4, 1124 | mockSequence5, 1125 | mockSequence6, 1126 | mockSequence7, 1127 | mockSequence8, 1128 | mockSequence9, 1129 | mockSequence10, 1130 | mockSequence11 1131 | ]; 1132 | -------------------------------------------------------------------------------- /src/HLSMonitor.ts: -------------------------------------------------------------------------------- 1 | import { HTTPManifestLoader } from "./ManifestLoader"; 2 | import { Mutex } from "async-mutex"; 3 | const { v4: uuidv4 } = require("uuid"); 4 | 5 | const timer = (ms) => new Promise((res) => setTimeout(res, ms)); 6 | export enum State { 7 | IDLE = "idle", 8 | ACTIVE = "active", 9 | INACTIVE = "inactive", 10 | } 11 | 12 | const ERROR_LIMIT = parseInt(process.env.ERROR_LIMIT) || 10; 13 | 14 | type SegmentURI = string; 15 | 16 | type M3UItem = { 17 | get: (key: string) => string | any; 18 | set: (key: string, value: string) => void; 19 | }; 20 | 21 | type M3U = { 22 | items: { 23 | PlaylistItem: M3UItem[]; 24 | StreamItem: M3UItem[]; 25 | IframeStreamItem: M3UItem[]; 26 | MediaItem: M3UItem[]; 27 | }; 28 | properties: {}; 29 | toString(): string; 30 | get(key: any): any; 31 | set(key: any, value: any): void; 32 | serialize(): any; 33 | unserialize(): any; 34 | }; 35 | 36 | type VariantData = { 37 | mediaType?: string; 38 | mediaSequence?: number; 39 | newMediaSequence?: number; 40 | fileSequences?: SegmentURI[]; 41 | newFileSequences?: SegmentURI[]; 42 | discontinuitySequence?: number; 43 | newDiscontinuitySequence?: number; 44 | nextIsDiscontinuity?: boolean; 45 | prevM3U?: M3U; 46 | duration?: number; 47 | cueOut?: number; 48 | cueIn?: number; 49 | }; 50 | 51 | export enum ErrorType { 52 | MANIFEST_RETRIEVAL = "Manifest Retrieval", 53 | MEDIA_SEQUENCE = "Media Sequence", 54 | PLAYLIST_SIZE = "Playlist Size", 55 | PLAYLIST_CONTENT = "Playlist Content", 56 | SEGMENT_CONTINUITY = "Segment Continuity", 57 | DISCONTINUITY_SEQUENCE = "Discontinuity Sequence", 58 | STALE_MANIFEST = "Stale Manifest" 59 | } 60 | 61 | type MonitorError = { 62 | eid: string; 63 | date: string; 64 | errorType: ErrorType; 65 | mediaType: string; 66 | variant: string | number; 67 | details: string; 68 | streamUrl: string; 69 | streamId: string; 70 | code?: number; 71 | } 72 | 73 | type StreamData = { 74 | variants?: { [bandwidth: number]: VariantData }; 75 | lastFetch?: number; 76 | newTime?: number; 77 | errors: ErrorsList; 78 | }; 79 | 80 | type MonitorOptions = { 81 | staleLimit?: number; 82 | monitorInterval?: number; 83 | logConsole?: boolean; 84 | } 85 | 86 | type StreamInput = { 87 | id?: string; 88 | url: string; 89 | } 90 | 91 | type StreamItem = { 92 | id: string; 93 | url: string; 94 | } 95 | 96 | export class ErrorsList { 97 | private errors: MonitorError[] = []; 98 | private LIST_LIMIT: number = parseInt(process.env.ERROR_LIMIT) || 10; 99 | 100 | constructor() {} 101 | 102 | add(error: MonitorError): void { 103 | if (!error.eid) { 104 | error.eid = `eid-${Date.now()}`; 105 | } 106 | 107 | if (this.errors.length >= this.LIST_LIMIT) { 108 | this.remove(); 109 | } 110 | this.errors.push(error); 111 | } 112 | 113 | remove(): void { 114 | this.errors.shift(); 115 | } 116 | 117 | clear(): void { 118 | this.errors = []; 119 | } 120 | 121 | size(): number { 122 | return this.errors.length; 123 | } 124 | 125 | listErrors(): MonitorError[] { 126 | return this.errors; 127 | } 128 | } 129 | 130 | export class HLSMonitor { 131 | private streams: StreamItem[] = []; 132 | private nextStreamId: number = 1; 133 | private state: State; 134 | private streamData = new Map(); 135 | private staleLimit: number; 136 | private logConsole: boolean; 137 | private updateInterval: number; 138 | private lock = new Mutex(); 139 | private id: string; 140 | private lastChecked: number; 141 | private createdAt: string; 142 | private manifestFetchErrors: Map = new Map(); 143 | private manifestErrorCount: number = 0; // Track total manifest errors 144 | private totalErrorsPerStream: Map = new Map(); // Track total errors per stream 145 | private lastErrorTimePerStream: Map = new Map(); // Track last error time per stream 146 | private usedStreamIds: Set = new Set(); // Track used IDs 147 | 148 | private normalizeStreamId(customId: string): string { 149 | // Convert to lowercase and replace whitespace with underscore 150 | let normalizedId = customId.toLowerCase().replace(/\s+/g, '_'); 151 | // Cap at 50 characters 152 | normalizedId = normalizedId.slice(0, 50); 153 | 154 | // If ID already exists, add numeric suffix 155 | let finalId = normalizedId; 156 | let counter = 1; 157 | while (this.usedStreamIds.has(finalId)) { 158 | finalId = `${normalizedId}_${counter}`; 159 | counter++; 160 | } 161 | 162 | return finalId; 163 | } 164 | 165 | private generateStreamId(input: string | StreamInput): string { 166 | if (typeof input === 'string') { 167 | const autoId = `stream_${this.nextStreamId++}`; 168 | this.usedStreamIds.add(autoId); 169 | return autoId; 170 | } 171 | 172 | if (input.id) { 173 | const normalizedId = this.normalizeStreamId(input.id); 174 | this.usedStreamIds.add(normalizedId); 175 | return normalizedId; 176 | } 177 | 178 | const autoId = `stream_${this.nextStreamId++}`; 179 | this.usedStreamIds.add(autoId); 180 | return autoId; 181 | } 182 | 183 | /** 184 | * @param hlsStreams The streams to monitor. 185 | * @param [staleLimit] The monitor interval for streams overrides the default (6000ms) monitor interval and the HLS_MONITOR_INTERVAL environment variable. 186 | */ 187 | constructor(streamInputs: (string | StreamInput)[], options: MonitorOptions = { staleLimit: 6000, monitorInterval: 3000, logConsole: true }) { 188 | this.id = uuidv4(); 189 | this.streams = streamInputs.map(input => { 190 | const url = typeof input === 'string' ? input : input.url; 191 | return { 192 | id: this.generateStreamId(input), 193 | url 194 | }; 195 | }); 196 | this.state = State.IDLE; 197 | this.staleLimit = parseInt(process.env.HLS_MONITOR_INTERVAL) || options.staleLimit; 198 | this.updateInterval = options.monitorInterval || this.staleLimit / 2; 199 | this.logConsole = options.logConsole; 200 | this.createdAt = new Date().toISOString(); 201 | } 202 | 203 | /** 204 | * Function used for unit testing purposes 205 | */ 206 | async incrementMonitor(streams?: string[]) { 207 | if (streams) { 208 | await this.update(streams); 209 | } 210 | this.state = State.ACTIVE; 211 | if (this.state === State.ACTIVE) { 212 | try { 213 | await this.parseManifests(this.streams); 214 | } catch (error) { 215 | console.error(error); 216 | this.state = State.INACTIVE; 217 | } 218 | } 219 | } 220 | 221 | async create(streams?: string[]): Promise { 222 | if (streams) { 223 | await this.update(streams); 224 | } 225 | this.state = State.ACTIVE; 226 | while (this.state === State.ACTIVE) { 227 | try { 228 | this.lastChecked = Date.now(); 229 | await this.parseManifests(this.streams); 230 | await timer(this.updateInterval); 231 | } catch (error) { 232 | console.error(error); 233 | this.state = State.INACTIVE; 234 | } 235 | } 236 | } 237 | 238 | get monitorId(): string { 239 | return this.id; 240 | } 241 | 242 | getUpdateInterval(): number { 243 | return this.updateInterval; 244 | } 245 | 246 | getLastChecked(): number { 247 | return this.lastChecked; 248 | } 249 | 250 | getState(): State { 251 | return this.state; 252 | } 253 | 254 | setState(state: String): void { 255 | this.state = state as State; 256 | } 257 | 258 | async getErrors(): Promise { 259 | let errors: MonitorError[] = []; 260 | let release = await this.lock.acquire(); 261 | for (const [_, data] of this.streamData.entries()) { 262 | if (data.errors.size() > 0) { 263 | errors = errors.concat(data.errors.listErrors()); 264 | } 265 | } 266 | release(); 267 | return errors.reverse(); 268 | } 269 | 270 | async clearErrors() { 271 | let release = await this.lock.acquire(); 272 | for (const [key, data] of this.streamData.entries()) { 273 | if (data.errors.size() > 0) { 274 | data.errors.clear(); 275 | this.streamData.set(key, data); 276 | } 277 | } 278 | release(); 279 | } 280 | 281 | private async reset() { 282 | let release = await this.lock.acquire(); 283 | this.state = State.IDLE; 284 | this.streamData = new Map(); 285 | release(); 286 | } 287 | 288 | async start() { 289 | if (this.state === State.ACTIVE) return; 290 | 291 | // Only reset if we don't have any existing streamData 292 | if (this.streamData.size === 0) { 293 | await this.reset(); 294 | } 295 | 296 | console.log(`Starting HLSMonitor: ${this.id}`); 297 | this.create(); 298 | } 299 | 300 | async stop() { 301 | if (this.state === State.INACTIVE) return; 302 | this.state = State.INACTIVE; 303 | console.log(`HLSMonitor stopped: ${this.id}`); 304 | } 305 | 306 | private log(str: string) { 307 | if (this.logConsole) { 308 | console.log(str); 309 | } 310 | } 311 | 312 | private printSummary(data) { 313 | if (this.logConsole) { 314 | //console.log(data); 315 | const d = new Date(data.lastFetch); 316 | const timeUpdate = d.toLocaleTimeString(); 317 | const variantList = Object.keys(data.variants); 318 | console.log(`${timeUpdate}\t` + variantList.join('\t')); 319 | console.log('--------------------------------------------------------------------------'); 320 | const duration = 'DUR(s):\t\t' + variantList.map((v) => data.variants[v].duration.toFixed(2)).join('\t'); 321 | console.log(duration); 322 | const mediaSeq = 'MSEQ:\t\t' + variantList.map((v) => data.variants[v].mediaSequence).join('\t'); 323 | console.log(mediaSeq); 324 | const discSeq = 'DSEQ:\t\t' + variantList.map((v) => data.variants[v].discontinuitySequence).join('\t'); 325 | console.log(discSeq); 326 | const cueOut = 'CUEOUT:\t\t' + variantList.map((v) => data.variants[v].cueOut).join('\t'); 327 | console.log(cueOut); 328 | 329 | 330 | console.log('=========================================================================='); 331 | } 332 | } 333 | 334 | /** 335 | * Update the list of streams to monitor 336 | * @param streams The list of streams that should be added 337 | * to the list of streams to monitor 338 | * @returns The current list of streams to monitor 339 | */ 340 | async update(newStreamInputs: (string | StreamInput)[]): Promise { 341 | let release = await this.lock.acquire(); 342 | try { 343 | const newStreams = newStreamInputs.map(input => { 344 | const url = typeof input === 'string' ? input : input.url; 345 | return { 346 | id: this.generateStreamId(input), 347 | url 348 | }; 349 | }); 350 | this.streams = [...this.streams, ...newStreams]; 351 | return this.streams; 352 | } finally { 353 | release(); 354 | } 355 | } 356 | 357 | /** 358 | * Removes a stream from the list of streams to monitor 359 | * @param streams The streams to remove 360 | * @returns The current list of streams to monitor 361 | */ 362 | async removeStream(streamId: string): Promise { 363 | let release = await this.lock.acquire(); 364 | try { 365 | const streamToRemove = this.streams.find(s => s.id === streamId); 366 | if (!streamToRemove) { 367 | throw new Error(`Stream with ID ${streamId} not found`); 368 | } 369 | 370 | // Remove from streams array 371 | this.streams = this.streams.filter(s => s.id !== streamId); 372 | 373 | // Clean up associated data 374 | const baseUrl = this.getBaseUrl(streamToRemove.url); 375 | this.streamData.delete(baseUrl); 376 | this.totalErrorsPerStream.delete(streamId); 377 | this.lastErrorTimePerStream.delete(streamId); 378 | this.manifestFetchErrors.delete(streamToRemove.url); 379 | 380 | this.usedStreamIds.delete(streamId); 381 | 382 | return this.streams; 383 | } finally { 384 | release(); 385 | } 386 | } 387 | 388 | getStreams(): StreamItem[] { 389 | return this.streams; 390 | } 391 | 392 | private getBaseUrl(url: string): string { 393 | let baseUrl: string; 394 | const m = url.match(/^(.*)\/.*?$/); 395 | if (m) { 396 | baseUrl = m[1] + "/"; 397 | } 398 | return baseUrl; 399 | } 400 | 401 | // Add this new helper method 402 | private buildPlaylistUrl(baseUrl: string, playlistPath: string): string { 403 | try { 404 | // Check if the playlist path is already an absolute URL 405 | new URL(playlistPath); 406 | return playlistPath; // If no error, it's absolute, use as-is 407 | } catch { 408 | // If error, it's relative, prepend base URL 409 | return `${baseUrl}${playlistPath}`; 410 | } 411 | } 412 | 413 | // Helper function to determine variant identifier 414 | private getVariantIdentifier(mediaM3U8: M3UItem, mediaType: string): string { 415 | if (mediaType === 'VIDEO') { 416 | return mediaM3U8.get("bandwidth") || "unknown"; 417 | } 418 | 419 | // For AUDIO and SUBTITLE, combine groupId with language or name 420 | const groupId = mediaM3U8.get("group-id"); 421 | if (!groupId) { 422 | console.log(Object.keys(mediaM3U8)); 423 | return "unknown"; 424 | } 425 | const language = mediaM3U8.get("language"); 426 | const name = mediaM3U8.get("name"); 427 | const identifier = language || name; 428 | 429 | return identifier ? `${groupId}__${identifier}` : groupId; 430 | } 431 | 432 | private async parseManifests(streamUrls: StreamItem[]): Promise { 433 | const manifestLoader = new HTTPManifestLoader(); 434 | for (const stream of streamUrls) { 435 | let masterM3U8: M3U; 436 | let baseUrl = this.getBaseUrl(stream.url); 437 | let release = await this.lock.acquire(); 438 | let data: StreamData = this.streamData.get(baseUrl) || { 439 | variants: {}, 440 | errors: new ErrorsList() 441 | }; 442 | 443 | try { 444 | masterM3U8 = await manifestLoader.load(stream.url); 445 | this.manifestFetchErrors.delete(stream.url); 446 | } catch (error) { 447 | console.error(error); 448 | console.log("Failed to fetch master manifest:", stream.url); 449 | 450 | if (error.isLastRetry) { 451 | this.manifestFetchErrors.set(stream.url, { 452 | code: error.statusCode || 0, 453 | time: Date.now() 454 | }); 455 | 456 | const manifestError: MonitorError = { 457 | eid: `eid-${Date.now()}`, 458 | date: new Date().toISOString(), 459 | errorType: ErrorType.MANIFEST_RETRIEVAL, 460 | mediaType: "MASTER", 461 | variant: "master", 462 | details: `Failed to fetch master manifest (${error.statusCode}): ${stream.url}`, 463 | streamUrl: stream.url, 464 | streamId: stream.id, 465 | code: error.statusCode || 0 466 | }; 467 | 468 | data.errors.add(manifestError); 469 | this.manifestErrorCount++; 470 | this.totalErrorsPerStream.set(stream.id, (this.totalErrorsPerStream.get(stream.id) || 0) + 1); 471 | this.lastErrorTimePerStream.set(stream.id, Date.now()); 472 | release(); 473 | continue; 474 | } 475 | release(); 476 | continue; 477 | } 478 | 479 | // Process variants 480 | for (const mediaM3U8 of masterM3U8.items.StreamItem.concat(masterM3U8.items.MediaItem)) { 481 | const variantUrl = this.buildPlaylistUrl(baseUrl, mediaM3U8.get("uri")); 482 | let variant: M3U; 483 | try { 484 | variant = await manifestLoader.load(variantUrl); 485 | this.manifestFetchErrors.delete(variantUrl); 486 | } catch (error) { 487 | console.log("Failed to fetch variant manifest:", variantUrl); 488 | 489 | this.manifestFetchErrors.set(variantUrl, { 490 | code: error.statusCode || 0, 491 | time: Date.now() 492 | }); 493 | 494 | const manifestError: MonitorError = { 495 | eid: `eid-${Date.now()}`, 496 | date: new Date().toISOString(), 497 | errorType: ErrorType.MANIFEST_RETRIEVAL, 498 | mediaType: mediaM3U8.get("type") || "VIDEO", 499 | variant: this.getVariantIdentifier(mediaM3U8, mediaM3U8.get("type") || "VIDEO"), 500 | details: `Failed to fetch variant manifest (${error.statusCode}): ${variantUrl}`, 501 | streamUrl: baseUrl, 502 | streamId: stream.id, 503 | code: error.statusCode || 0 504 | }; 505 | data.errors.add(manifestError); 506 | this.manifestErrorCount++; 507 | this.totalErrorsPerStream.set(stream.id, (this.totalErrorsPerStream.get(stream.id) || 0) + 1); 508 | this.lastErrorTimePerStream.set(stream.id, Date.now()); 509 | continue; 510 | } 511 | 512 | let equalMseq = false; 513 | let bw = mediaM3U8.get("bandwidth"); 514 | if (["AUDIO", "SUBTITLES"].includes(mediaM3U8.get("type"))) { 515 | bw = `${mediaM3U8.get("group-id")};${mediaM3U8.get("language") || mediaM3U8.get("name") || ""}`; 516 | } 517 | 518 | const currTime = new Date().toISOString(); 519 | if (!data.variants[bw]) { 520 | data.variants[bw] = { 521 | mediaType: mediaM3U8.get("type") || "VIDEO", 522 | mediaSequence: null, 523 | newMediaSequence: null, 524 | fileSequences: null, 525 | newFileSequences: null, 526 | discontinuitySequence: null, 527 | newDiscontinuitySequence: null, 528 | nextIsDiscontinuity: null, 529 | prevM3U: null, 530 | duration: null, 531 | cueOut: null, 532 | cueIn: null 533 | }; 534 | //console.log(variant.items.PlaylistItem); 535 | data.variants[bw].mediaSequence = variant.get("mediaSequence"); 536 | data.variants[bw].newMediaSequence = 0; 537 | data.variants[bw].fileSequences = variant.items.PlaylistItem.map((segItem) => segItem.get("uri")); 538 | data.variants[bw].newFileSequences = variant.items.PlaylistItem.map((segItem) => segItem.get("uri")); 539 | data.variants[bw].discontinuitySequence = variant.get("discontinuitySequence"); 540 | data.variants[bw].newDiscontinuitySequence = variant.get("discontinuitySequence"); 541 | data.variants[bw].nextIsDiscontinuity = false; 542 | data.variants[bw].prevM3U = variant; 543 | data.variants[bw].duration = 544 | variant.items.PlaylistItem 545 | .map((segItem) => segItem.get("duration")).reduce((acc, cur) => acc + cur); 546 | data.variants[bw].cueOut = 547 | variant.items.PlaylistItem 548 | .map((segItem) => segItem.get("cueout") !== undefined) 549 | .filter(Boolean).length; 550 | data.variants[bw].cueIn = 551 | variant.items.PlaylistItem 552 | .map((segItem) => segItem.get("cuein") !== undefined) 553 | .filter(Boolean).length; 554 | 555 | data.lastFetch = Date.now(); 556 | data.newTime = Date.now(); 557 | data.errors.clear(); 558 | 559 | this.streamData.set(baseUrl, data); 560 | continue; 561 | } 562 | // Update sequence duration 563 | data.variants[bw].duration = 564 | variant.items.PlaylistItem 565 | .map((segItem) => segItem.get("duration")).reduce((acc, cur) => acc + cur); 566 | 567 | // Update cueout count 568 | data.variants[bw].cueOut = 569 | variant.items.PlaylistItem 570 | .map((segItem) => segItem.get("cueout") !== undefined) 571 | .filter(Boolean).length; 572 | 573 | // Update cuein count 574 | data.variants[bw].cueIn = 575 | variant.items.PlaylistItem 576 | .map((segItem) => segItem.get("cuein") !== undefined) 577 | .filter(Boolean).length; 578 | 579 | // Validate mediaSequence 580 | if (data.variants[bw].mediaSequence > variant.get("mediaSequence")) { 581 | const mediaType = data.variants[bw].mediaType; 582 | const mediaSequence = data.variants[bw].mediaSequence; 583 | const latestMediaSequence = variant.get("mediaSequence"); 584 | const error: MonitorError = { 585 | eid: `eid-${Date.now()}`, 586 | date: currTime, 587 | errorType: ErrorType.MEDIA_SEQUENCE, 588 | mediaType: mediaType, 589 | variant: bw, 590 | details: `Expected mediaSequence >= ${mediaSequence}. Got: ${latestMediaSequence}`, 591 | streamUrl: baseUrl, 592 | streamId: stream.id 593 | }; 594 | console.error(`[${baseUrl}]`, error); 595 | data.errors.add(error); 596 | this.totalErrorsPerStream.set(stream.id, (this.totalErrorsPerStream.get(stream.id) || 0) + 1); 597 | this.lastErrorTimePerStream.set(stream.id, Date.now()); 598 | continue; 599 | } else if (data.variants[bw].mediaSequence === variant.get("mediaSequence")) { 600 | data.variants[bw].newMediaSequence = data.variants[bw].mediaSequence; 601 | equalMseq = true; 602 | } else { 603 | data.variants[bw].newMediaSequence = variant.get("mediaSequence"); 604 | data.newTime = Date.now(); 605 | } 606 | // Validate playlist 607 | const currentSegUriList: SegmentURI[] = variant.items.PlaylistItem.map((segItem) => segItem.get("uri")); 608 | if (equalMseq && data.variants[bw].fileSequences.length > 0) { 609 | // Validate playlist Size 610 | if (data.variants[bw].fileSequences.length > currentSegUriList.length) { 611 | const error: MonitorError = { 612 | eid: `eid-${Date.now()}`, 613 | date: currTime, 614 | errorType: ErrorType.PLAYLIST_SIZE, 615 | mediaType: data.variants[bw].mediaType, 616 | variant: bw, 617 | details: `Expected playlist size in mseq(${variant.get("mediaSequence")}) to be: ${ 618 | data.variants[bw].fileSequences.length 619 | }. Got: ${currentSegUriList.length}`, 620 | streamUrl: baseUrl, 621 | streamId: stream.id 622 | }; 623 | console.error(`[${baseUrl}]`, error); 624 | data.errors.add(error); 625 | this.totalErrorsPerStream.set(stream.id, (this.totalErrorsPerStream.get(stream.id) || 0) + 1); 626 | this.lastErrorTimePerStream.set(stream.id, Date.now()); 627 | } else if (data.variants[bw].fileSequences.length === currentSegUriList.length) { 628 | // Validate playlist contents 629 | for (let i = 0; i < currentSegUriList.length; i++) { 630 | if (data.variants[bw].fileSequences[i] !== currentSegUriList[i]) { 631 | // [!] Compare the end of filename instead... 632 | let shouldBeSegURI = new URL("http://.mock.com/" + data.variants[bw].fileSequences[i]).pathname.slice(-5); 633 | let newSegURI = new URL("http://.mock.com/" + currentSegUriList[i]).pathname.slice(-5); 634 | if (newSegURI !== shouldBeSegURI) { 635 | const error: MonitorError = { 636 | eid: `eid-${Date.now()}`, 637 | date: currTime, 638 | errorType: ErrorType.PLAYLIST_CONTENT, 639 | mediaType: data.variants[bw].mediaType, 640 | variant: bw, 641 | details: `Expected playlist item-uri in mseq(${variant.get("mediaSequence")}) at index(${i}) to be: '${ 642 | data.variants[bw].fileSequences[i] 643 | }'. Got: '${currentSegUriList[i]}'`, 644 | streamUrl: baseUrl, 645 | streamId: stream.id 646 | }; 647 | console.error(`[${baseUrl}]`, error); 648 | data.errors.add(error); 649 | this.totalErrorsPerStream.set(stream.id, (this.totalErrorsPerStream.get(stream.id) || 0) + 1); 650 | this.lastErrorTimePerStream.set(stream.id, Date.now()); 651 | } 652 | break; 653 | } 654 | } 655 | } 656 | } else { 657 | // Validate media sequence and file sequence 658 | const mseqDiff = variant.get("mediaSequence") - data.variants[bw].mediaSequence; 659 | if (mseqDiff < data.variants[bw].fileSequences.length) { 660 | const expectedfileSequence = data.variants[bw].fileSequences[mseqDiff]; 661 | 662 | // [!] Compare the end of filename instead... 663 | let shouldBeSegURI = new URL("http://.mock.com/" + expectedfileSequence).pathname.slice(-5); 664 | let newSegURI = new URL("http://.mock.com/" + currentSegUriList[0]).pathname.slice(-5); 665 | if (newSegURI !== shouldBeSegURI) { 666 | const error: MonitorError = { 667 | eid: `eid-${Date.now()}`, 668 | date: currTime, 669 | errorType: ErrorType.SEGMENT_CONTINUITY, 670 | mediaType: data.variants[bw].mediaType, 671 | variant: bw, 672 | details: `Faulty Segment Continuity! Expected first item-uri in mseq(${variant.get( 673 | "mediaSequence" 674 | )}) to be: '${expectedfileSequence}'. Got: '${currentSegUriList[0]}'`, 675 | streamUrl: baseUrl, 676 | streamId: stream.id 677 | }; 678 | console.error(`[${baseUrl}]`, error); 679 | data.errors.add(error); 680 | this.totalErrorsPerStream.set(stream.id, (this.totalErrorsPerStream.get(stream.id) || 0) + 1); 681 | this.lastErrorTimePerStream.set(stream.id, Date.now()); 682 | } 683 | } 684 | } 685 | 686 | // Update newFileSequence... 687 | data.variants[bw].newFileSequences = currentSegUriList; 688 | 689 | // Validate discontinuitySequence 690 | const discontinuityOnTopItem = variant.items.PlaylistItem[0].get("discontinuity"); 691 | const mseqDiff = variant.get("mediaSequence") - data.variants[bw].mediaSequence; 692 | 693 | if (!discontinuityOnTopItem && data.variants[bw].nextIsDiscontinuity) { 694 | // Tag could have been removed, see if count is correct... 695 | const expectedDseq = data.variants[bw].discontinuitySequence + 1; 696 | // Warn: Assuming that only ONE disc-tag has been removed between media-sequences 697 | if (mseqDiff === 1 && expectedDseq !== variant.get("discontinuitySequence")) { 698 | const error: MonitorError = { 699 | eid: `eid-${Date.now()}`, 700 | date: currTime, 701 | errorType: ErrorType.DISCONTINUITY_SEQUENCE, 702 | mediaType: data.variants[bw].mediaType, 703 | variant: bw, 704 | details: `Wrong count increment in mseq(${variant.get( 705 | "mediaSequence" 706 | )}) - Expected: ${expectedDseq}. Got: ${variant.get("discontinuitySequence")}`, 707 | streamUrl: baseUrl, 708 | streamId: stream.id 709 | }; 710 | console.error(`[${baseUrl}]`, error); 711 | data.errors.add(error); 712 | this.totalErrorsPerStream.set(stream.id, (this.totalErrorsPerStream.get(stream.id) || 0) + 1); 713 | this.lastErrorTimePerStream.set(stream.id, Date.now()); 714 | } 715 | } else { 716 | // Case where mseq stepped larger than 1. Check if dseq incremented properly 717 | if (data.variants[bw].discontinuitySequence !== variant.get("discontinuitySequence")) { 718 | const dseqDiff = variant.get("discontinuitySequence") - data.variants[bw].discontinuitySequence; 719 | let foundDiscCount: number = discontinuityOnTopItem ? -1 : 0; 720 | // dseq step should match amount of disc-tags found in prev mseq playlist 721 | const playlistSize = data.variants[bw].prevM3U.items.PlaylistItem.length; 722 | // Ignore dseq count diff when mseq diff is too large to be able to verify 723 | if (mseqDiff < playlistSize) { 724 | const end = mseqDiff + 1 <= playlistSize ? mseqDiff + 1 : playlistSize; 725 | for (let i = 0; i < end; i++) { 726 | let segHasDisc = data.variants[bw].prevM3U.items.PlaylistItem[i].get("discontinuity"); 727 | if (segHasDisc) { 728 | foundDiscCount++; 729 | } 730 | } 731 | if (dseqDiff !== foundDiscCount) { 732 | const error: MonitorError = { 733 | eid: `eid-${Date.now()}`, 734 | date: currTime, 735 | errorType: ErrorType.DISCONTINUITY_SEQUENCE, 736 | mediaType: data.variants[bw].mediaType, 737 | variant: bw, 738 | details: `Early count increment in mseq(${variant.get("mediaSequence")}) - Expected: ${ 739 | data.variants[bw].discontinuitySequence 740 | }. Got: ${variant.get("discontinuitySequence")}`, 741 | streamUrl: baseUrl, 742 | streamId: stream.id 743 | }; 744 | console.error(`[${baseUrl}]`, error); 745 | data.errors.add(error); 746 | this.totalErrorsPerStream.set(stream.id, (this.totalErrorsPerStream.get(stream.id) || 0) + 1); 747 | this.lastErrorTimePerStream.set(stream.id, Date.now()); 748 | } 749 | } 750 | } 751 | } 752 | // Determine if discontinuity tag could be removed next increment. 753 | data.variants[bw].newDiscontinuitySequence = variant.get("discontinuitySequence"); 754 | if (variant.items.PlaylistItem[0].get("discontinuity")) { 755 | data.variants[bw].nextIsDiscontinuity = true; 756 | } else { 757 | if (variant.items.PlaylistItem[1].get("discontinuity")) { 758 | data.variants[bw].nextIsDiscontinuity = true; 759 | } 760 | data.variants[bw].nextIsDiscontinuity = false; 761 | } 762 | // Update Sequence counts... 763 | data.variants[bw].mediaSequence = data.variants[bw].newMediaSequence; 764 | data.variants[bw].fileSequences = data.variants[bw].newFileSequences; 765 | data.variants[bw].nextIsDiscontinuity = data.variants[bw].nextIsDiscontinuity ? data.variants[bw].nextIsDiscontinuity : false; 766 | data.variants[bw].discontinuitySequence = data.variants[bw].newDiscontinuitySequence; 767 | data.variants[bw].prevM3U = variant; 768 | } 769 | // validate update interval (Stale manifest) 770 | const lastFetch = data.newTime ? data.newTime : data.lastFetch; 771 | const interval = Date.now() - lastFetch; 772 | if (interval > this.staleLimit) { 773 | const error: MonitorError = { 774 | eid: `eid-${Date.now()}`, 775 | date: new Date().toISOString(), 776 | errorType: ErrorType.STALE_MANIFEST, 777 | mediaType: "ALL", 778 | variant: "ALL", 779 | details: `Expected: ${this.staleLimit}ms. Got: ${interval}ms`, 780 | streamUrl: baseUrl, 781 | streamId: stream.id 782 | }; 783 | console.error(`[${baseUrl}]`, error); 784 | data.errors.add(error); 785 | this.totalErrorsPerStream.set(stream.id, (this.totalErrorsPerStream.get(stream.id) || 0) + 1); 786 | this.lastErrorTimePerStream.set(stream.id, Date.now()); 787 | } 788 | 789 | this.streamData.set(baseUrl, { 790 | variants: data.variants, 791 | lastFetch: data.newTime ? data.newTime : data.lastFetch, 792 | errors: data.errors, 793 | }); 794 | 795 | this.printSummary(data); 796 | 797 | release(); 798 | } 799 | } 800 | 801 | getManifestFetchErrors(): Map { 802 | return this.manifestFetchErrors; 803 | } 804 | 805 | getCreatedAt(): string { 806 | return this.createdAt; 807 | } 808 | 809 | getTotalErrorsPerStream(): Map { 810 | return this.totalErrorsPerStream; 811 | } 812 | 813 | getLastErrorTimePerStream(): Map { 814 | return this.lastErrorTimePerStream; 815 | } 816 | 817 | getManifestErrorCount(): number { 818 | return this.manifestErrorCount; 819 | } 820 | } 821 | -------------------------------------------------------------------------------- /src/HLSMonitorService.ts: -------------------------------------------------------------------------------- 1 | import Fastify from "fastify"; 2 | import { HLSMonitor } from "./HLSMonitor"; 3 | import { ErrorType } from "./HLSMonitor"; 4 | import { State } from "./HLSMonitor"; 5 | 6 | export class HLSMonitorService { 7 | private fastify: any; 8 | private hlsMonitors = new Map(); 9 | 10 | constructor() { 11 | this.fastify = Fastify({ 12 | logger: true, 13 | ignoreTrailingSlash: true, 14 | }); 15 | } 16 | 17 | get monitors() { 18 | return this.hlsMonitors; 19 | } 20 | 21 | private isValidUrl(urlString: string): boolean { 22 | try { 23 | const url = new URL(urlString); 24 | return url.protocol === 'http:' || url.protocol === 'https:'; 25 | } catch (err) { 26 | return false; 27 | } 28 | } 29 | 30 | private async routes() { 31 | this.fastify.register(require("fastify-swagger"), { 32 | routePrefix: "/docs", 33 | swagger: { 34 | info: { 35 | title: "HLS Monitor", 36 | description: "HLSMonitor API", 37 | version: "0.0.1", 38 | }, 39 | consumes: ["application/json"], 40 | produces: ["application/json"], 41 | }, 42 | uiConfig: { 43 | docExpansion: "full", 44 | deepLinking: false, 45 | }, 46 | uiHooks: { 47 | onRequest: function (request, reply, next) { 48 | next(); 49 | }, 50 | preHandler: function (request, reply, next) { 51 | next(); 52 | }, 53 | }, 54 | staticCSP: true, 55 | transformStaticCSP: (header) => header, 56 | exposeRoute: true, 57 | }); 58 | 59 | const StreamInput = { 60 | type: 'object', 61 | properties: { 62 | id: { type: 'string' }, 63 | url: { type: 'string' } 64 | }, 65 | required: ['url'] 66 | }; 67 | 68 | const MonitorBody = { 69 | type: 'object', 70 | required: ['streams'], 71 | properties: { 72 | streams: { 73 | type: 'array', 74 | items: { 75 | oneOf: [ 76 | { type: 'string' }, 77 | StreamInput 78 | ] 79 | } 80 | }, 81 | stale_limit: { 82 | type: 'number', 83 | nullable: true, 84 | default: 6000 85 | }, 86 | monitor_interval: { 87 | type: 'number', 88 | nullable: true 89 | } 90 | }, 91 | example: { 92 | streams: [ 93 | "http://example.com/master.m3u8", 94 | { 95 | id: "custom_stream_1", 96 | url: "http://example.com/master2.m3u8" 97 | } 98 | ], 99 | stale_limit: 6000, 100 | monitor_interval: 3000 101 | } 102 | }; 103 | 104 | const MonitorResponse = { 105 | type: 'object', 106 | properties: { 107 | status: { type: 'string' }, 108 | streams: { 109 | type: 'array', 110 | items: { 111 | type: 'object', 112 | properties: { 113 | id: { type: 'string' }, 114 | url: { type: 'string' } 115 | } 116 | } 117 | }, 118 | monitorId: { type: 'string' }, 119 | stale_limit: { type: 'number' }, 120 | monitor_interval: { type: 'number' } 121 | }, 122 | example: { 123 | status: "Created a new hls-monitor", 124 | streams: [ 125 | { 126 | id: "stream_1", 127 | url: "http://example.com/master.m3u8" 128 | }, 129 | { 130 | id: "custom_stream", 131 | url: "http://example.com/master2.m3u8" 132 | } 133 | ], 134 | monitorId: "550e8400-e29b-41d4-a716-446655440000", 135 | stale_limit: 6000, 136 | monitor_interval: 3000 137 | } 138 | }; 139 | 140 | const StreamsResponse = { 141 | type: 'object', 142 | properties: { 143 | streams: { 144 | type: 'array', 145 | items: { 146 | type: 'object', 147 | properties: { 148 | id: { type: 'string' }, 149 | url: { type: 'string' } 150 | } 151 | } 152 | } 153 | }, 154 | example: { 155 | streams: [ 156 | { 157 | id: "stream_1", 158 | url: "http://example.com/master.m3u8" 159 | }, 160 | { 161 | id: "custom_stream", 162 | url: "http://example.com/master2.m3u8" 163 | } 164 | ] 165 | } 166 | }; 167 | 168 | const UpdateStreamsResponse = { 169 | type: 'object', 170 | properties: { 171 | message: { type: 'string' }, 172 | streams: { 173 | type: 'array', 174 | items: { 175 | type: 'object', 176 | properties: { 177 | id: { type: 'string' }, 178 | url: { type: 'string' } 179 | } 180 | } 181 | } 182 | }, 183 | example: { 184 | message: "Added streams to monitor", 185 | streams: [ 186 | { 187 | id: "stream_1", 188 | url: "http://example.com/master.m3u8" 189 | }, 190 | { 191 | id: "custom_stream", 192 | url: "http://example.com/master2.m3u8" 193 | } 194 | ] 195 | } 196 | }; 197 | 198 | this.fastify.post("/monitor", 199 | { 200 | schema: { 201 | description: "Start monitoring new streams. Supports both simple URL strings and objects with custom IDs", 202 | body: MonitorBody, 203 | response: { 204 | '200': MonitorResponse, 205 | '400': { 206 | type: 'object', 207 | properties: { 208 | status: { type: 'string' }, 209 | message: { type: 'string' } 210 | } 211 | } 212 | } 213 | } 214 | }, 215 | async (request, reply) => { 216 | const body = request.body; 217 | 218 | // Validate URLs 219 | const invalidUrls = body.streams 220 | .map(s => typeof s === 'string' ? s : s.url) 221 | .filter(url => !this.isValidUrl(url)); 222 | 223 | if (invalidUrls.length > 0) { 224 | reply.code(400).send({ 225 | status: "error", 226 | message: `Invalid URLs detected: ${invalidUrls.join(', ')}` 227 | }); 228 | return; 229 | } 230 | 231 | // Check for duplicate URLs 232 | const urls = body.streams.map(s => typeof s === 'string' ? s : s.url); 233 | const uniqueUrls = [...new Set(urls)]; 234 | if (uniqueUrls.length !== urls.length) { 235 | reply.code(400).send({ 236 | status: "error", 237 | message: "Duplicate stream URLs are not allowed within the same monitor" 238 | }); 239 | return; 240 | } 241 | 242 | let monitor; 243 | let monitorOptions = { staleLimit: 6000, monitorInterval: 3000, logConsole: false }; 244 | if (body["stale_limit"]) { 245 | monitorOptions.staleLimit = body["stale_limit"]; 246 | } 247 | if (body["monitor_interval"]) { 248 | monitorOptions.monitorInterval = body["monitor_interval"]; 249 | } 250 | monitor = new HLSMonitor(body["streams"], monitorOptions); 251 | monitor.create(); 252 | this.hlsMonitors.set(monitor.monitorId, monitor); 253 | const rep = { 254 | status: "Created a new hls-monitor", 255 | streams: body["streams"], 256 | }; 257 | if (body["stale_limit"]) { 258 | rep["stale_limit"] = body["stale_limit"]; 259 | } 260 | if (body["monitor_interval"]) { 261 | rep["monitor_interval"] = body["monitor_interval"]; 262 | } 263 | rep["monitorId"] = monitor.monitorId; 264 | reply.code(201).header("Content-Type", "application/json; charset=utf-8").send(rep); 265 | }); 266 | 267 | this.fastify.get("/monitor", async (request, reply) => { 268 | if (!this.hlsMonitors) { 269 | reply.code(500).send({ 270 | status: "error", 271 | message: "monitor not initialized", 272 | }); 273 | } 274 | let monitorInfo: any = {} 275 | 276 | for (const [monitorId, monitor] of this.hlsMonitors.entries()) { 277 | monitorInfo[monitorId] = { 278 | createdAt: monitor.getCreatedAt(), 279 | streams: monitor.getStreams(), 280 | state: monitor.getState(), 281 | errorCount: (await monitor.getErrors()).length, 282 | statusEndpoint: `/monitor/${monitorId}/status`, 283 | } 284 | } 285 | reply 286 | .code(200) 287 | .header("Content-Type", "application/json; charset=utf-8") 288 | .send(JSON.stringify(monitorInfo)); 289 | }); 290 | 291 | this.fastify.get("/monitor/:monitorId/streams", 292 | { 293 | schema: { 294 | description: "Returns a list of all streams that are currently monitored", 295 | params: { 296 | monitorId: { type: 'string' } 297 | }, 298 | response: { 299 | '200': StreamsResponse, 300 | '404': { 301 | type: 'object', 302 | properties: { 303 | status: { type: 'string' }, 304 | message: { type: 'string' } 305 | } 306 | } 307 | } 308 | } 309 | }, 310 | async (request, reply) => { 311 | if (!this.hlsMonitors.has(request.params.monitorId)) { 312 | reply.code(500).send({ 313 | status: "error", 314 | message: "monitor not initialized", 315 | }); 316 | return; 317 | } 318 | reply.send({ streams: this.hlsMonitors.get(request.params.monitorId).getStreams() }); 319 | }); 320 | 321 | this.fastify.put("/monitor/:monitorId/streams", 322 | { 323 | schema: { 324 | description: "Add streams to the list of streams that will be monitored. Supports both URL strings and objects with custom IDs", 325 | params: { 326 | monitorId: { type: 'string' } 327 | }, 328 | body: { 329 | type: 'object', 330 | required: ['streams'], 331 | properties: { 332 | streams: { 333 | type: 'array', 334 | items: { 335 | oneOf: [ 336 | { type: 'string' }, 337 | StreamInput 338 | ] 339 | } 340 | }, 341 | }, 342 | example: { 343 | streams: [ 344 | "http://example.com/master.m3u8", 345 | { 346 | id: "custom_stream_1", 347 | url: "http://example.com/master2.m3u8" 348 | } 349 | ], 350 | } 351 | }, 352 | response: { 353 | '201': UpdateStreamsResponse, 354 | '400': { 355 | type: 'object', 356 | properties: { 357 | status: { type: 'string' }, 358 | message: { type: 'string' } 359 | } 360 | }, 361 | '500': { 362 | type: 'object', 363 | properties: { 364 | status: { type: 'string' }, 365 | message: { type: 'string' } 366 | } 367 | } 368 | } 369 | } 370 | }, 371 | async (request, reply) => { 372 | if (!this.hlsMonitors.has(request.params.monitorId)) { 373 | reply.code(500).send({ 374 | status: "error", 375 | message: "monitor not initialized", 376 | }); 377 | return; 378 | } 379 | 380 | // Validate URLs 381 | const invalidUrls = request.body.streams 382 | .map(s => typeof s === 'string' ? s : s.url) 383 | .filter(url => !this.isValidUrl(url)); 384 | 385 | if (invalidUrls.length > 0) { 386 | reply.code(400).send({ 387 | status: "error", 388 | message: `Invalid URLs detected: ${invalidUrls.join(', ')}` 389 | }); 390 | return; 391 | } 392 | 393 | const monitor = this.hlsMonitors.get(request.params.monitorId); 394 | 395 | // Extract URLs from both string and object formats 396 | const newUrls = request.body.streams.map(s => typeof s === 'string' ? s : s.url); 397 | 398 | // Check for duplicates within the new streams 399 | const uniqueNewUrls = [...new Set(newUrls)]; 400 | if (uniqueNewUrls.length !== newUrls.length) { 401 | reply.code(400).send({ 402 | status: "error", 403 | message: "Duplicate stream URLs are not allowed within the same monitor" 404 | }); 405 | return; 406 | } 407 | 408 | // Check against existing streams 409 | const currentUrls = monitor.getStreams().map(s => s.url); 410 | const alreadyMonitored = newUrls.filter(url => currentUrls.includes(url)); 411 | 412 | if (alreadyMonitored.length > 0) { 413 | reply.code(400).send({ 414 | status: "error", 415 | message: `${alreadyMonitored.length} stream(s) are already being monitored` 416 | }); 417 | return; 418 | } 419 | 420 | const streams = await monitor.update(request.body.streams); 421 | 422 | reply.code(201).send(JSON.stringify({ 423 | message: "Added streams to monitor", 424 | streams: streams 425 | })); 426 | }); 427 | 428 | this.fastify.delete("/monitor/:monitorId/stream", 429 | { 430 | schema: { 431 | description: "Remove a stream from the monitor", 432 | params: { 433 | monitorId: { type: 'string' } 434 | }, 435 | querystring: { 436 | type: 'object', 437 | required: ['streamId'], 438 | properties: { 439 | streamId: { type: 'string' } 440 | } 441 | }, 442 | response: { 443 | '200': { 444 | type: 'object', 445 | properties: { 446 | message: { type: 'string' }, 447 | streams: { 448 | type: 'array', 449 | items: { 450 | type: 'object', 451 | properties: { 452 | id: { type: 'number' }, 453 | url: { type: 'string' } 454 | } 455 | } 456 | } 457 | } 458 | } 459 | } 460 | } 461 | }, 462 | async (request, reply) => { 463 | if (!this.hlsMonitors.has(request.params.monitorId)) { 464 | reply.code(500).send({ 465 | status: "error", 466 | message: "monitor not initialized" 467 | }); 468 | return; 469 | } 470 | 471 | try { 472 | const remainingStreams = await this.hlsMonitors 473 | .get(request.params.monitorId) 474 | .removeStream(request.query.streamId); 475 | 476 | reply.code(200).send({ 477 | message: "Stream removed successfully", 478 | streams: remainingStreams 479 | }); 480 | } catch (error) { 481 | reply.code(404).send({ 482 | status: "error", 483 | message: error.message 484 | }); 485 | } 486 | }); 487 | 488 | this.fastify.get("/monitor/:monitorId/status", 489 | { 490 | schema: { 491 | description: "Get the current status of a stream", 492 | params: { 493 | monitorId: { type: 'string' } 494 | } 495 | } 496 | }, 497 | async (request, reply) => { 498 | if (!this.hlsMonitors.has(request.params.monitorId)) { 499 | reply.code(500).send({ 500 | status: "error", 501 | message: "monitor not initialized", 502 | }); 503 | return; 504 | } 505 | 506 | const logs = await this.hlsMonitors.get(request.params.monitorId).getErrors(); 507 | const lastCheckedMS = this.hlsMonitors.get(request.params.monitorId).getLastChecked(); 508 | const monitorState = this.hlsMonitors.get(request.params.monitorId).getState(); 509 | const lastChecked = new Date(lastCheckedMS).toLocaleString(); 510 | reply.code(200).header("Content-Type", "application/json; charset=utf-8").send({ lastChecked: lastChecked, state: monitorState, logs: logs }); 511 | }); 512 | 513 | this.fastify.delete("/monitor/:monitorId/status", 514 | { 515 | schema: { 516 | description: "Delete the cached status of a stream", 517 | params: { 518 | monitorId: { type: 'string' } 519 | } 520 | } 521 | }, 522 | async (request, reply) => { 523 | if (!this.hlsMonitors.has(request.params.monitorId)) { 524 | reply.code(500).send({ 525 | status: "error", 526 | message: "monitor not initialized", 527 | }); 528 | } 529 | await this.hlsMonitors.get(request.params.monitorId).clearErrors(); 530 | reply.code(200).header("Content-Type", "application/json; charset=utf-8").send({ message: "Cleared errors" }); 531 | }); 532 | 533 | this.fastify.post("/monitor/:monitorId/stop", 534 | { 535 | schema: { 536 | description: "Stop a specific monitor", 537 | params: { 538 | monitorId: { type: 'string' } 539 | } 540 | } 541 | }, 542 | async (request, reply) => { 543 | if (!this.hlsMonitors.has(request.params.monitorId)) { 544 | reply.code(500).send({ 545 | status: "error", 546 | message: "monitor not initialized", 547 | }); 548 | } 549 | await this.hlsMonitors.get(request.params.monitorId).stop(); 550 | reply.code(200).header("Content-Type", "application/json; charset=utf-8").send({ status: "Stopped monitoring" }); 551 | }); 552 | 553 | this.fastify.post("/monitor/:monitorId/start", 554 | { 555 | schema: { 556 | description: "Start a specific monitor", 557 | params: { 558 | monitorId: { type: 'string' } 559 | } 560 | } 561 | }, 562 | async (request, reply) => { 563 | if (!this.hlsMonitors.has(request.params.monitorId)) { 564 | reply.code(500).send({ 565 | status: "error", 566 | message: "monitor not initialized", 567 | }); 568 | } 569 | this.hlsMonitors.get(request.params.monitorId).start(); 570 | reply.code(200).header("Content-Type", "application/json; charset=utf-8").send({ status: "Started monitoring" }); 571 | }); 572 | 573 | this.fastify.delete("/monitor", 574 | { 575 | schema: { 576 | description: "Stop and delete all monitors", 577 | response: { 578 | '200': { 579 | type: 'object', 580 | properties: { 581 | message: { type: 'string' }, 582 | deletedCount: { type: 'number' }, 583 | deletedMonitors: { 584 | type: 'array', 585 | items: { type: 'string' } 586 | } 587 | } 588 | } 589 | } 590 | } 591 | }, 592 | async (request, reply) => { 593 | const monitorIds = Array.from(this.hlsMonitors.keys()); 594 | 595 | // Stop and delete all monitors 596 | for (const monitorId of monitorIds) { 597 | const monitor = this.hlsMonitors.get(monitorId); 598 | monitor.setState(State.INACTIVE); 599 | this.hlsMonitors.delete(monitorId); 600 | } 601 | 602 | reply.code(200).send({ 603 | message: "All monitors stopped and deleted successfully", 604 | deletedCount: monitorIds.length, 605 | deletedMonitors: monitorIds 606 | }); 607 | }); 608 | 609 | this.fastify.delete("/monitor/:monitorId", 610 | { 611 | schema: { 612 | description: "Stop and delete a specific monitor", 613 | params: { 614 | monitorId: { type: 'string' } 615 | }, 616 | response: { 617 | '200': { 618 | type: 'object', 619 | properties: { 620 | message: { type: 'string' }, 621 | monitorId: { type: 'string' } 622 | } 623 | }, 624 | '404': { 625 | type: 'object', 626 | properties: { 627 | status: { type: 'string' }, 628 | message: { type: 'string' } 629 | } 630 | } 631 | } 632 | } 633 | }, 634 | async (request, reply) => { 635 | if (!this.hlsMonitors.has(request.params.monitorId)) { 636 | reply.code(404).send({ 637 | status: "error", 638 | message: "Monitor not found" 639 | }); 640 | return; 641 | } 642 | 643 | const monitor = this.hlsMonitors.get(request.params.monitorId); 644 | monitor.setState(State.INACTIVE); // Stop the monitor 645 | this.hlsMonitors.delete(request.params.monitorId); // Remove from map 646 | 647 | reply.code(200).send({ 648 | message: "Monitor stopped and deleted successfully", 649 | monitorId: request.params.monitorId 650 | }); 651 | }); 652 | 653 | this.fastify.get("/metrics", async (request, reply) => { 654 | let output = []; 655 | 656 | // Monitor info metric 657 | output.push('# TYPE hls_monitor_info info'); 658 | output.push('# HELP hls_monitor_info Information about the HLS monitor'); 659 | 660 | for (const [monitorId, monitor] of this.hlsMonitors.entries()) { 661 | output.push(`hls_monitor_info{monitor_id="${monitorId}",created="${monitor.getCreatedAt()}"} 1`); 662 | } 663 | 664 | // Monitor state metric (as a stateset) 665 | output.push('# TYPE hls_monitor_state stateset'); 666 | output.push('# HELP hls_monitor_state Current state of the HLS monitor'); 667 | 668 | for (const [monitorId, monitor] of this.hlsMonitors.entries()) { 669 | const state = monitor.getState(); 670 | output.push(`hls_monitor_state{monitor_id="${monitorId}",state="active"} ${state === 'active' ? 1 : 0}`); 671 | output.push(`hls_monitor_state{monitor_id="${monitorId}",state="idle"} ${state === 'idle' ? 1 : 0}`); 672 | output.push(`hls_monitor_state{monitor_id="${monitorId}",state="inactive"} ${state === 'inactive' ? 1 : 0}`); 673 | } 674 | 675 | // Current error counts by type and media type 676 | output.push('# TYPE hls_monitor_current_errors gauge'); 677 | output.push('# HELP hls_monitor_current_errors Current number of active errors broken down by type and media type'); 678 | 679 | for (const [monitorId, monitor] of this.hlsMonitors.entries()) { 680 | const errors = await monitor.getErrors(); 681 | // Count errors by type and media type 682 | const errorCounts = errors.reduce((acc, error) => { 683 | const key = `${error.errorType}__${error.mediaType}__${error.streamId}`; 684 | acc[key] = (acc[key] || 0) + 1; 685 | return acc; 686 | }, {}); 687 | 688 | for (const [key, count] of Object.entries(errorCounts)) { 689 | const [errorType, mediaType, streamId] = key.split('__'); 690 | output.push( 691 | `hls_monitor_current_errors{monitor_id="${monitorId}",error_type="${errorType}",media_type="${mediaType}",stream_id="${streamId}"} ${count}` 692 | ); 693 | } 694 | } 695 | 696 | // Total error count (keep this as well for quick overview) 697 | output.push('# TYPE hls_monitor_total_errors gauge'); 698 | output.push('# HELP hls_monitor_total_errors Total number of errors detected by the monitor'); 699 | 700 | for (const [monitorId, monitor] of this.hlsMonitors.entries()) { 701 | const errorCount = (await monitor.getErrors()).length; 702 | output.push(`hls_monitor_total_errors{monitor_id="${monitorId}"} ${errorCount}`); 703 | } 704 | 705 | // Last check timestamp (as a gauge) 706 | output.push('# TYPE hls_monitor_last_check_timestamp_seconds gauge'); 707 | output.push('# UNIT hls_monitor_last_check_timestamp_seconds seconds'); 708 | output.push('# HELP hls_monitor_last_check_timestamp_seconds Unix timestamp of the last check'); 709 | 710 | for (const [monitorId, monitor] of this.hlsMonitors.entries()) { 711 | const lastCheck = monitor.getLastChecked() / 1000; // Convert to seconds 712 | output.push(`hls_monitor_last_check_timestamp_seconds{monitor_id="${monitorId}"} ${lastCheck}`); 713 | } 714 | 715 | // Stream count (as a gauge) 716 | output.push('# TYPE hls_monitor_streams gauge'); 717 | output.push('# HELP hls_monitor_streams Number of streams being monitored'); 718 | 719 | for (const [monitorId, monitor] of this.hlsMonitors.entries()) { 720 | const streamCount = monitor.getStreams().length; 721 | output.push(`hls_monitor_streams{monitor_id="${monitorId}"} ${streamCount}`); 722 | } 723 | 724 | // Monitor uptime (as a gauge in seconds) 725 | output.push('# TYPE hls_monitor_uptime_seconds gauge'); 726 | output.push('# HELP hls_monitor_uptime_seconds Time since monitor was created'); 727 | 728 | for (const [monitorId, monitor] of this.hlsMonitors.entries()) { 729 | const uptime = (Date.now() - new Date(monitor.getCreatedAt()).getTime()) / 1000; 730 | output.push(`hls_monitor_uptime_seconds{monitor_id="${monitorId}"} ${uptime}`); 731 | } 732 | 733 | // Manifest fetch errors (as a gauge) - Combined version 734 | output.push('# TYPE hls_monitor_manifest_fetch_errors gauge'); 735 | output.push('# HELP hls_monitor_manifest_fetch_errors Current manifest fetch errors with details'); 736 | 737 | for (const [monitorId, monitor] of this.hlsMonitors.entries()) { 738 | const errors = await monitor.getErrors(); 739 | const manifestErrors = errors.filter(e => e.errorType === ErrorType.MANIFEST_RETRIEVAL); 740 | 741 | // Get all streams for this monitor 742 | const streams = monitor.getStreams(); 743 | 744 | // Create a map to track which streams we've reported errors for 745 | const reportedStreams = new Set(); 746 | 747 | // First report any actual errors 748 | for (const error of manifestErrors) { 749 | const key = `${error.streamUrl}__${error.code || 0}__${error.mediaType}__${error.variant}__${error.streamId}`; 750 | reportedStreams.add(error.streamId); 751 | output.push( 752 | `hls_monitor_manifest_fetch_errors{monitor_id="${monitorId}",url="${error.streamUrl}",status_code="${error.code || 0}",media_type="${error.mediaType}",variant="${error.variant}",stream_id="${error.streamId}"} 1` 753 | ); 754 | } 755 | 756 | // Then report 0 for all streams that don't have errors 757 | for (const stream of streams) { 758 | if (!reportedStreams.has(stream.id)) { 759 | output.push( 760 | `hls_monitor_manifest_fetch_errors{monitor_id="${monitorId}",url="${stream.url}",status_code="200",media_type="MASTER",variant="master",stream_id="${stream.id}"} 0` 761 | ); 762 | } 763 | } 764 | } 765 | 766 | // Total errors per stream (as a counter) 767 | output.push('# TYPE hls_monitor_stream_total_errors counter'); 768 | output.push('# HELP hls_monitor_stream_total_errors Total number of errors detected per stream since monitor creation'); 769 | 770 | for (const [monitorId, monitor] of this.hlsMonitors.entries()) { 771 | const errorsPerStream = monitor.getTotalErrorsPerStream(); 772 | for (const [streamId, count] of errorsPerStream.entries()) { 773 | output.push(`hls_monitor_stream_total_errors{monitor_id="${monitorId}",stream_id="${streamId}"} ${count}`); 774 | } 775 | } 776 | 777 | // Time since last error per stream (as a gauge in seconds) 778 | output.push('# TYPE hls_monitor_stream_time_since_last_error_seconds gauge'); 779 | output.push('# HELP hls_monitor_stream_time_since_last_error_seconds Time since the last error was detected for each stream'); 780 | 781 | for (const [monitorId, monitor] of this.hlsMonitors.entries()) { 782 | const lastErrorTimes = monitor.getLastErrorTimePerStream(); 783 | for (const [streamId, lastErrorTime] of lastErrorTimes.entries()) { 784 | const timeSinceError = (Date.now() - lastErrorTime) / 1000; 785 | const lastErrorDate = new Date(lastErrorTime).toISOString(); 786 | output.push( 787 | `hls_monitor_stream_time_since_last_error_seconds{monitor_id="${monitorId}",stream_id="${streamId}",last_error_time="${lastErrorDate}"} ${timeSinceError}` 788 | ); 789 | } 790 | } 791 | 792 | // Total manifest errors (as a counter) 793 | output.push('# TYPE hls_monitor_manifest_errors counter'); 794 | output.push('# HELP hls_monitor_manifest_errors Total number of manifest fetch errors (non-200 status codes)'); 795 | 796 | for (const [monitorId, monitor] of this.hlsMonitors.entries()) { 797 | output.push( 798 | `hls_monitor_manifest_errors{monitor_id="${monitorId}"} ${monitor.getManifestErrorCount()}` 799 | ); 800 | } 801 | 802 | // Manifest errors by type (master vs variant) 803 | output.push('# TYPE hls_monitor_manifest_error_types gauge'); 804 | output.push('# HELP hls_monitor_manifest_error_types Current manifest errors broken down by type'); 805 | 806 | for (const [monitorId, monitor] of this.hlsMonitors.entries()) { 807 | const errors = await monitor.getErrors(); 808 | if (errors.length > 100) { 809 | process.exit(1); 810 | } 811 | const manifestErrors = errors.filter(e => e.errorType === ErrorType.MANIFEST_RETRIEVAL); 812 | 813 | // Count by media type (MASTER vs VIDEO/AUDIO) 814 | const typeCount = manifestErrors.reduce((acc, err) => { 815 | acc[err.mediaType] = (acc[err.mediaType] || 0) + 1; 816 | return acc; 817 | }, {}); 818 | 819 | for (const [mediaType, count] of Object.entries(typeCount)) { 820 | output.push( 821 | `hls_monitor_manifest_error_types{monitor_id="${monitorId}",media_type="${mediaType}"} ${count}` 822 | ); 823 | } 824 | } 825 | 826 | // New errors counter (as a counter) 827 | output.push('# TYPE hls_monitor_new_errors_total counter'); 828 | output.push('# HELP hls_monitor_new_errors_total Count of new errors detected since last check'); 829 | 830 | for (const [monitorId, monitor] of this.hlsMonitors.entries()) { 831 | const errors = await monitor.getErrors(); 832 | // Only count errors that occurred in the last check interval 833 | const newErrors = errors.filter(error => { 834 | const errorTime = new Date(error.date).getTime(); 835 | return (Date.now() - errorTime) <= monitor.getUpdateInterval() + 500; 836 | }); 837 | // Group by error type and media type 838 | const errorCounts = newErrors.reduce((acc, error) => { 839 | const key = `${error.errorType}__${error.mediaType}__${error.streamId}`; 840 | acc[key] = (acc[key] || 0) + 1; 841 | return acc; 842 | }, {}); 843 | for (const [key, count] of Object.entries(errorCounts)) { 844 | const [errorType, mediaType, streamId] = key.split('__'); 845 | output.push( 846 | `hls_monitor_new_errors_total{monitor_id="${monitorId}",error_type="${errorType}",media_type="${mediaType}",stream_id="${streamId}"} ${count}` 847 | ); 848 | } 849 | } 850 | 851 | output.push('# EOF'); 852 | 853 | reply 854 | .code(200) 855 | .header('Content-Type', 'application/openmetrics-text; version=1.0.0; charset=utf-8') 856 | .send(output.join('\n')); 857 | }); 858 | } 859 | 860 | /** 861 | * Start the server 862 | * @param {number} port - The port 863 | * @param {string} host - The host (ip) address (Optional) 864 | */ 865 | async listen(port: number, host?: string) { 866 | await this.routes(); 867 | this.fastify.listen(port, host, (err, address) => { 868 | if (err) { 869 | console.error(err); 870 | throw err; 871 | } 872 | console.log(`Server is now listening on ${address}`); 873 | }); 874 | } 875 | } 876 | -------------------------------------------------------------------------------- /src/ManifestLoader.ts: -------------------------------------------------------------------------------- 1 | import fetch from "node-fetch"; 2 | import { Readable } from "stream"; 3 | import m3u8 from "@eyevinn/m3u8"; 4 | 5 | export interface IManifestLoader { 6 | load: (uri: string) => Promise; 7 | } 8 | 9 | class AbstractManifestParser { 10 | parse(stream: Readable): Promise { 11 | return new Promise((resolve, reject) => { 12 | const parser = m3u8.createStream(); 13 | parser.on("m3u", m3u => { 14 | resolve(m3u); 15 | }); 16 | parser.on("error", err => { 17 | reject("Failed to parse manifest: " + err); 18 | }); 19 | try { 20 | stream.pipe(parser); 21 | } catch (err) { 22 | reject("Failed to fetch manifest: " + err); 23 | } 24 | }); 25 | } 26 | } 27 | 28 | export class HTTPManifestLoader extends AbstractManifestParser implements IManifestLoader { 29 | load(uri: string): Promise { 30 | return new Promise((resolve, reject) => { 31 | const url = new URL(uri); 32 | fetch(url.href) 33 | .then(response => { 34 | if (response.ok) { 35 | this.parse(response.body as Readable).then(resolve); 36 | } else { 37 | console.log(`Failed to fetch manifest (${response.status}): ` + response.statusText); 38 | reject({statusCode: response.status, statusText: response.statusText}); 39 | } 40 | }) 41 | .catch(reject); 42 | }); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 4 | "module": "commonjs" /* Specify what module code is generated. */, 5 | "moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */, 6 | "resolveJsonModule": true /* Enable importing .json files */, 7 | "sourceMap": true /* Create source map files for emitted JavaScript files. */, 8 | "outDir": "./dist" /* Specify an output folder for all emitted files. */, 9 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */ 10 | }, 11 | "exclude": ["node_modules", "spec", "example.js"] 12 | } 13 | --------------------------------------------------------------------------------