├── .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 | [](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 | 
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 |
--------------------------------------------------------------------------------