├── .auto-changelog
├── .circleci
└── config.yml
├── .czrc
├── .gitignore
├── .husky
└── pre-commit
├── .nvmrc
├── .release-it.json
├── CHANGELOG.md
├── LICENSE.md
├── README.md
├── eslint.config.mjs
├── package-lock.json
├── package.json
├── src
├── index.ts
├── m3u-generator.ts
├── m3u-parser.ts
└── m3u-playlist.ts
├── test
├── main.spec.ts
└── test-m3u.ts
├── tsconfig.json
├── typedoc.js
└── vite.config.ts
/.auto-changelog:
--------------------------------------------------------------------------------
1 | {
2 | "ignoreCommitPattern": "(test\\()|(release\\()|(chore\\()",
3 | "commitLimit": false,
4 | "hideCredit": true,
5 | "startingVersion": "3.0.0"
6 | }
--------------------------------------------------------------------------------
/.circleci/config.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | jobs:
3 | build:
4 | working_directory: ~/my-project
5 | docker:
6 | - image: cimg/node:22.9.0-browsers
7 | steps:
8 | - checkout
9 | - restore_cache:
10 | key: AngularCircleCI-{{ .Branch }}-{{ checksum "package.json" }}
11 | - run: npm install
12 | - save_cache:
13 | key: AngularCircleCI-{{ .Branch }}-{{ checksum "package.json" }}
14 | paths:
15 | - "node_modules"
16 | - run: xvfb-run -a npm run lint
17 | - run: xvfb-run -a npm run test:ci
18 | - run: xvfb-run -a npm run build
--------------------------------------------------------------------------------
/.czrc:
--------------------------------------------------------------------------------
1 | {
2 | "path": "./node_modules/cz-conventional-changelog"
3 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea
2 | node_modules
3 | dist
4 | coverage
5 | .nyc_output
6 | docs
7 |
8 | # Local Netlify folder
9 | .netlify
10 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | npm run test
2 | npm run lint
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | v22.9.0
--------------------------------------------------------------------------------
/.release-it.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://unpkg.com/release-it@17/schema/release-it.json",
3 | "git": {
4 | "commitMessage": "chore(release): v${version}",
5 | "changelog": "npm run changelog -- --stdout --commit-limit false -u --template https://raw.githubusercontent.com/release-it/release-it/main/templates/changelog-compact.hbs"
6 | },
7 | "github": {
8 | "release": true
9 | },
10 | "npm": {
11 | "publish": true
12 | },
13 | "hooks": {
14 | "before:init": ["npm run lint", "npm run test"],
15 | "after:bump": ["npm run changelog", "npm run build"],
16 | "after:release": ["npm run deploy"]
17 | },
18 | "plugins": {
19 | "@release-it/bumper": {
20 | "out": "README.md"
21 | }
22 | }
23 | }
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | ### Changelog
2 |
3 | All notable changes to this project will be documented in this file. Dates are displayed in UTC.
4 |
5 | #### [5.0.1](https://github.com/Raiper34/m3u-parser-generator/compare/5.0.0...5.0.1)
6 |
7 | - docs(website): modernize website api documentation [`38094f2`](https://github.com/Raiper34/m3u-parser-generator/commit/38094f2db99731598d2859036e2b15e6548ef803)
8 | - fix(parser): Change EXTINF parsing to split on first, not last, comma [`c8e4791`](https://github.com/Raiper34/m3u-parser-generator/commit/c8e479161dcc4ec3d5490631fa42a1647741481d)
9 | - docs(readme): fix browser jsdelivr link in readme [`fc105ad`](https://github.com/Raiper34/m3u-parser-generator/commit/fc105ad8fcb3f4d5438f6a629e6b1eac0b92f113)
10 |
11 | ### [5.0.0](https://github.com/Raiper34/m3u-parser-generator/compare/4.0.0...5.0.0)
12 |
13 | > 5 February 2025
14 |
15 | - build(vite): add vite to build everrything with one tool and remove browserify and uglify [`da17e56`](https://github.com/Raiper34/m3u-parser-generator/commit/da17e5619c7940782359f35d0871433198e8f47e)
16 | - docs(readme): automatic version dump [`3398d61`](https://github.com/Raiper34/m3u-parser-generator/commit/3398d61fca8125f2384753f9926a4b64ae659a7a)
17 | - docs(readme): improve style of readme [`d972b7b`](https://github.com/Raiper34/m3u-parser-generator/commit/d972b7b688727cd116a2e06ebe8700e1c9817dd7)
18 | - docs(readme): change style of instruction code of instalation chapter [`789131a`](https://github.com/Raiper34/m3u-parser-generator/commit/789131a47597d95201e598f1507a9d2b99367919)
19 |
20 | ### [4.0.0](https://github.com/Raiper34/m3u-parser-generator/compare/3.0.0...4.0.0)
21 |
22 | > 1 February 2025
23 |
24 | - feat(parser): change parser api [`8fac5f1`](https://github.com/Raiper34/m3u-parser-generator/commit/8fac5f16dc64fabe838a4c4b733b05720b922d91)
25 | - fix(kodiprops): remove default kodi props map, should be undefined by default [`b98bba5`](https://github.com/Raiper34/m3u-parser-generator/commit/b98bba5b81c56f7c84010888f373561770ff71a5)
26 |
27 | ### [3.0.0](https://github.com/Raiper34/m3u-parser-generator/compare/2.0.0...3.0.0)
28 |
29 | > 30 December 2024
30 |
31 | - build(node): update node to v22, typescript to v5 and all possible dependencies [`b60d4fa`](https://github.com/Raiper34/m3u-parser-generator/commit/b60d4fa4096ceb80d8dd0fb2ea4ebd10aece0859)
32 | - docs(readme): improve readme.md [`8c05961`](https://github.com/Raiper34/m3u-parser-generator/commit/8c05961e5e74648d1fd088bd1910841a22dc0324)
33 | - docs(readme): unify badges [`191ae57`](https://github.com/Raiper34/m3u-parser-generator/commit/191ae571061359ebdb4163f2f365d1877208fd48)
34 |
35 |
36 |
37 | #### 2.0.0 (2024-07-31)
38 | * added ability to configure to parse custom or unknown directives
39 |
40 | #### 1.7.2 (2024-05-08)
41 | * fix `KODIPROP` with multiple `=` characters
42 |
43 | #### ~~1.7.1 (2024-05-07)~~
44 | * ~~fix `KODIPROP` with multiple `=` characters~~
45 |
46 | #### 1.7.0 (2024-02-23)
47 | * added support for `#EXTBYT`/`EXTIMG`/`EXTALB`/`EXTART`/`EXTGENRE` directives and `#EXTM3U` attributes
48 |
49 | #### 1.6.0 (2023-12-20)
50 | * change attributes parser to be able to handle strings starting with space (attribute=" value"), also skip invalid attributes by default
51 |
52 | #### 1.5.0 (2023-11-27)
53 | * enhance M3U Playlist with extra attributes (url-tvg attribute, extra attributes from URL, extra HTTP headers, Kodi properties)
54 |
55 | #### 1.4.1 (2023-10-21)
56 | * fix ignoreErrors to be able also parse playlists with errors
57 |
58 | #### 1.4.0 (2023-07-17)
59 | * ability to ignore file errors during parsing (`ignoreErrors` argument)
60 |
61 | #### 1.3.0 (2023-06-18)
62 | * ignore blank lines
63 | * fix empty attributes
64 |
65 | #### 1.2.0 (2022-10-01)
66 |
67 | * add API documentation
68 | * add delivrJs badge
69 | * build project on node 14
70 |
71 | #### 1.1.1 (2022-09-08)
72 |
73 | * \#EXTM3U tag do not need to be placed on own line
74 |
75 | #### 1.1.0 (2022-09-03)
76 |
77 | * browser bundle
78 | * coveralls readme badge
79 |
80 | #### 1.0.4 (2022-03-09)
81 |
82 | * remove unit tests from npm dist package
83 |
84 | #### 1.0.3 (2022-01-24)
85 |
86 | * remove unit tests from npm dist package
87 |
88 | #### 1.0.2 (2022-01-24)
89 |
90 | * add README badges
91 | * improve documentation
92 |
93 | #### 1.0.1 (2022-01-24)
94 |
95 | * change exports/imports path
96 | * add circle ci to project
97 | * add circle ci badge and others
98 |
99 |
100 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Filip Gulan
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://badge.fury.io/js/m3u-parser-generator)
2 | [](https://m3u-parser-generator.netlify.app)
3 | 
4 | 
5 | [](https://circleci.com/gh/Raiper34/m3u-parser-generator)
6 | [](https://coveralls.io/github/Raiper34/m3u-parser-generator?branch=main)
7 | [](https://badge.fury.io/js/m3u-parser-generator)
8 | [](https://badge.fury.io/js/m3u-parser-generator)
9 | [](https://badge.fury.io/js/m3u-parser-generator)
10 | [](https://www.jsdelivr.com/package/npm/m3u-parser-generator)
11 | [](https://github.com/Raiper34/m3u-parser-generator)
12 |
13 | # M3U Parser Generator
14 | Library to parse and generate [m3u or m3u8 IPTV playlist files](https://en.wikipedia.org/wiki/M3U).
15 |
16 | ### Content
17 | - [🚀 Installation](#-installation)
18 | - [📚 Documentation](#-documentation)
19 | - [💻 Usage](#-usage)
20 | - [🌐 Browser](#-browser)
21 | - [⚖️ License](#-license)
22 |
23 | # 🚀 Installation
24 | Install **M3U Parser Generator** with npm
25 | ```sh
26 | npm install m3u-parser-generator --save
27 | ```
28 | or with jsdelivr
29 | ```html
30 |
31 | ```
32 |
33 | # 📚 Documentation
34 | [Documentation](https://m3u-parser-generator.netlify.app/)
35 |
36 | # 💻 Usage
37 | You can parse your loaded m3u string:
38 | ```javascript
39 | import {M3uParser} from 'm3u-parser-generator';
40 |
41 | const parser = new M3uParser();
42 | const playlist = parser.parse(m3uString);
43 | playlist.medias.forEach(media => media.location);
44 | ```
45 | and you get object with following structure
46 | ```json
47 | {
48 | "title": "Test TV",
49 | "medias": [
50 | {
51 | "location": "http://iptv.test1.com/playlist.m3u8",
52 | "duration": -1,
53 | "attributes": {
54 | "tvg-id": "Test tv 1",
55 | "tvg-country": "CZ",
56 | "tvg-language": "CS",
57 | "tvg-logo": "logo1.png",
58 | "group-title": "Test1",
59 | "unknown": "0"
60 | },
61 | "name": "Test tv 1 [CZ]",
62 | "group": "Test TV group 1"
63 | },
64 | {
65 | "location": "http://iptv.test2.com/playlist.m3u8",
66 | "duration": 100,
67 | "attributes": {
68 | "tvg-id": "Test tv 2",
69 | "tvg-country": "SK",
70 | "tvg-language": "SK",
71 | "tvg-logo": "logo2.png",
72 | "group-title": "Test2"
73 | },
74 | "name": "Test tv 2 [SK]",
75 | "group": "Test TV group 2"
76 | },
77 | {
78 | "location": "http://iptv.test3.com/playlist.m3u8",
79 | "duration": 120,
80 | "attributes": {
81 | "tvg-id": "Test tv 3",
82 | "tvg-country": "EN",
83 | "tvg-language": "EN",
84 | "tvg-logo": "logo3.png",
85 | "group-title": "Test3"
86 | },
87 | "name": "Test tv 3 [EN]"
88 | },
89 | {
90 | "location": "http://iptv.test4.com/playlist.m3u8",
91 | "duration": -1,
92 | "attributes": {}
93 | }
94 | ]
95 | }
96 | ```
97 |
98 | Or you can generate new playlist by your own:
99 | ```javascript
100 | import {M3uPlaylist, M3uMedia} from 'm3u-parser-generator';
101 |
102 | const playlist = new M3uPlaylist();
103 | playlist.title = 'Test playlist';
104 |
105 | const media1 = new M3uMedia('http://my-stream-ulr.com/playlist.m3u8');
106 | media1.attributes = {'tvg-id': '5', 'tvg-language': 'EN', 'unknown': 'my custom attribute'};
107 | media1.duration = 500;
108 | media1.name = 'Test Channel';
109 | media1.group = 'Test Group';
110 |
111 | playlist.medias.push(media1);
112 | const m3uString = playlist.getM3uString();
113 | ```
114 | you get
115 | ```
116 | #EXTM3U
117 | #PLAYLIST:Test playlist
118 | #EXTINF:500 tvg-id="5" tvg-language="EN" unknown="my custom attribute",Test Channel
119 | #EXTGRP:Test Group
120 | http://my-stream-ulr.com/playlist.m3u8
121 | ```
122 |
123 | # 🌐 Browser
124 | You can also use this library in the browser without compiling using jsDelivr.
125 | Import script into HTML file, and you can access classes through the global `m3uParserGenerator` object.
126 | ```html
127 |
128 |
136 | ```
137 |
138 | # ⚖️ License
139 | [MIT](https://choosealicense.com/licenses/mit/)
140 |
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import eslint from '@eslint/js';
2 | import tseslint from 'typescript-eslint';
3 |
4 | export default tseslint.config(
5 | eslint.configs.recommended,
6 | ...tseslint.configs.recommended,
7 | );
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "m3u-parser-generator",
3 | "version": "5.0.1",
4 | "description": "Library to parse and generate m3u or m3u8 IPTV playlist files",
5 | "keywords": [
6 | "m3u",
7 | "m3u8",
8 | "m3u parser",
9 | "m3u generator",
10 | "iptv"
11 | ],
12 | "repository": "https://github.com/Raiper34/m3u-parser-generator",
13 | "homepage": "https://m3u-parser-generator.netlify.app",
14 | "author": "Raiper34",
15 | "license": "MIT",
16 | "type": "module",
17 | "main": "./dist/m3u-parser-generator.umd.cjs",
18 | "module": "./dist/m3u-parser-generator.js",
19 | "types": "./dist/index.d.ts",
20 | "exports": {
21 | ".": {
22 | "import": "./dist/m3u-parser-generator.js",
23 | "require": "./dist/m3u-parser-generator.umd.cjs"
24 | }
25 | },
26 | "files": [
27 | "src",
28 | "dist",
29 | "test",
30 | "CHANGELOG.md",
31 | ".nvmrc"
32 | ],
33 | "scripts": {
34 | "build": "tsc && vite build",
35 | "start": "npm run build && node dist/main.js",
36 | "test": "vitest run --coverage",
37 | "test:dev": "vitest --ui --coverage",
38 | "test:ci": "npm run test && cat ./coverage/lcov.info | coveralls",
39 | "lint": "eslint ./src",
40 | "lint:fix": "npm run lint -- --fix",
41 | "docs": "typedoc src/index.ts",
42 | "deploy": "npm run docs && netlify deploy --dir=docs --prod",
43 | "release": "release-it",
44 | "changelog": "auto-changelog -p",
45 | "commit": "cz",
46 | "prepare": "husky"
47 | },
48 | "devDependencies": {
49 | "@eslint/js": "^9.13.0",
50 | "@release-it/bumper": "^7.0.1",
51 | "@types/eslint__js": "^8.42.3",
52 | "@vitest/coverage-v8": "^3.0.1",
53 | "@vitest/ui": "^3.0.1",
54 | "auto-changelog": "^2.5.0",
55 | "commitizen": "^4.3.1",
56 | "coveralls": "^3.1.1",
57 | "cz-conventional-changelog": "^3.3.0",
58 | "eslint": "^9.13.0",
59 | "husky": "^9.1.7",
60 | "netlify-cli": "^17.37.1",
61 | "release-it": "^18.1.2",
62 | "typedoc": "^0.27.6",
63 | "typedoc-material-theme": "^1.3.0",
64 | "typescript": "^5.6.0",
65 | "typescript-eslint": "^8.10.0",
66 | "vite": "^6.0.11",
67 | "vite-plugin-dts": "^4.5.0",
68 | "vitest": "^3.0.1"
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export {M3uParser} from './m3u-parser'
2 | export type { M3uCustomDataMapping, M3uParserConfig } from './m3u-parser'
3 | export {M3uGenerator} from './m3u-generator'
4 | export {M3uPlaylist, M3uMedia, M3uAttributes} from './m3u-playlist'
5 | export type { M3uCustomData } from './m3u-playlist'
6 |
--------------------------------------------------------------------------------
/src/m3u-generator.ts:
--------------------------------------------------------------------------------
1 | import {
2 | M3uCustomData,
3 | M3uDirectives,
4 | M3uMedia,
5 | M3uPlaylist,
6 | M3uAttributes,
7 | DEFAULT_MEDIA_DURATION
8 | } from "./m3u-playlist";
9 |
10 | /**
11 | * M3u generator class to generate m3u playlist string from playlist object
12 | */
13 | export class M3uGenerator {
14 |
15 | /**
16 | * Generate is static method to generate m3u playlist string from playlist object
17 | * @param playlist - playlist object to generate m3u playlist string
18 | * @returns final m3u playlist string
19 | * @example
20 | * ```ts
21 | * const playlist = new M3uPlaylist();
22 | * playlist.title = 'Test playlist';
23 | * M3uGenerator.generate(playlist);
24 | * ```
25 | */
26 | static generate(playlist: M3uPlaylist): string {
27 | const pls = playlist.title ? `${M3uDirectives.PLAYLIST}:${playlist.title}` : undefined;
28 | const customData = this.getCustomDataDirective(playlist.customData);
29 | const medias = playlist.medias.map(item => this.getMedia(item)).join('\n');
30 | const attributesString = this.getAttributes(playlist.attributes);
31 | return [M3uDirectives.EXTM3U + attributesString, pls, customData, medias].filter(item => item).join('\n');
32 | }
33 |
34 | /**
35 | * Get generated media part string from m3u playlist media object
36 | * @param media - media object
37 | * @returns media part string with info, group and location each on separated line
38 | * @private
39 | */
40 | private static getMedia(media: M3uMedia): string {
41 | const attributesString = this.getAttributes(media.attributes);
42 | const info = this.shouldAddInfoDirective(media, attributesString) ? `${M3uDirectives.EXTINF}:${media.duration}${attributesString},${media.name}` : null;
43 | const group = media.group ? `${M3uDirectives.EXTGRP}:${media.group}` : null;
44 | const bytes = media.bytes ? `${M3uDirectives.EXTBYT}:${media.bytes}` : null;
45 | const image = media.image ? `${M3uDirectives.EXTIMG}:${media.image}` : null;
46 | const album = media.album ? `${M3uDirectives.EXTALB}:${media.album}` : null;
47 | const artist = media.artist ? `${M3uDirectives.EXTART}:${media.artist}` : null;
48 | const genre = media.genre ? `${M3uDirectives.EXTGENRE}:${media.genre}` : null;
49 | const extraAttributesFromUrl = media.extraAttributesFromUrl ? `${M3uDirectives.EXTATTRFROMURL}:${media.extraAttributesFromUrl}` : null;
50 | const extraHttpHeaders = media.extraHttpHeaders ? `${M3uDirectives.EXTHTTP}:${JSON.stringify(media.extraHttpHeaders)}` : null;
51 | const kodiProps = media.kodiProps ? [...media.kodiProps].map(([key, value]) => `${M3uDirectives.KODIPROP}:${key}=${value}`).join('\n') : null;
52 | const customData = this.getCustomDataDirective(media.customData);
53 |
54 | return [
55 | info,
56 | group,
57 | bytes,
58 | image,
59 | album,
60 | artist,
61 | genre,
62 | extraAttributesFromUrl,
63 | extraHttpHeaders,
64 | kodiProps,
65 | customData,
66 | media.location
67 | ].filter(item => item).join('\n');
68 | }
69 |
70 | /**
71 | * Get generated string of custom directives for both, playlist and media
72 | * @param customData - custom data object, that represents unknown directives
73 | * @private
74 | */
75 | private static getCustomDataDirective(customData: M3uCustomData[]): string {
76 | return customData.map(data => `${data.directive}:${data.value}`).join('\n');
77 | }
78 |
79 | /**
80 | * Get generated attributes media part string from m3u attributes object
81 | * @param attributes - attributes object
82 | * @returns attributes generated string (attributeName="attributeValue" ...)
83 | * @private
84 | */
85 | private static getAttributes(attributes: M3uAttributes): string {
86 | const keys = Object.keys(attributes);
87 | return keys.length ? ' ' + keys.map(key => `${key}="${attributes[key]}"`).join(' ') : '';
88 | }
89 |
90 | /**
91 | * Method to determine if we need to add info directive or not based on media object and attributes string.
92 | * At least media duration, media name or some attributes must be present to return true
93 | * @param media - m3u media object
94 | * @param attributesString - m3u attributes string
95 | * @returns boolean if we should add info directive into final media
96 | * @private
97 | */
98 | private static shouldAddInfoDirective(media: M3uMedia, attributesString: string): boolean {
99 | return media.duration !== DEFAULT_MEDIA_DURATION || attributesString !== '' || media.name !== undefined;
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/src/m3u-parser.ts:
--------------------------------------------------------------------------------
1 | import {
2 | M3uPlaylist,
3 | M3uMedia,
4 | M3uAttributes,
5 | M3uDirectives,
6 | M3U_COMMENT,
7 | } from "./m3u-playlist";
8 |
9 | /**
10 | * Custom data mapping, that defines parsing of unknown directives.
11 | * Directive can belong to whole playlist, or specific media.
12 | */
13 | export interface M3uCustomDataMapping {media?: string[], playlist?: string[]}
14 |
15 |
16 | /**
17 | * Interface to configure m3u parser
18 | */
19 | export interface M3uParserConfig {
20 | /**
21 | * Ignore errors in file and try to parse it with it
22 | */
23 | ignoreErrors?: boolean;
24 | /**
25 | * Custom mapping for unknown directives, that can be partially parsed too and added to parsed object
26 | */
27 | customDataMapping?: M3uCustomDataMapping,
28 | }
29 |
30 | /**
31 | * M3u parser class to parse m3u playlist string to playlist object
32 | */
33 | export class M3uParser {
34 |
35 | /**
36 | * Constructor of class
37 | * @param config - to configure parser behaviour
38 | */
39 | constructor(private readonly config?: M3uParserConfig) { }
40 |
41 | /**
42 | * Get m3u attributes object from attributes string
43 | * @param attributesString e.g. 'tvg-id="" group-title=""'
44 | * @returns attributes object e.g. {"tvg-id": "", "group-title": ""}
45 | * @private
46 | */
47 | private getAttributes(attributesString: string): M3uAttributes {
48 | const attributes: M3uAttributes = new M3uAttributes();
49 | if (!attributesString) {
50 | return attributes;
51 | }
52 | const attributeValuePair = attributesString.match(/[^ ]*?=".*?"/g) ?? []; // regex to find `attribute="value"`
53 | attributeValuePair.forEach((item) => {
54 | const [key, value] = item.split('="');
55 | attributes[key] = value.replace('"', '');
56 | });
57 | return attributes;
58 | }
59 |
60 | /**
61 | * Process media method parse trackInformation and fill media with parsed info
62 | * @param trackInformation - media substring of m3u string line e.g. '-1 tvg-id="" group-title="",Tv Name'
63 | * @param media - actual m3u media object
64 | * @private
65 | */
66 | private processMedia(trackInformation: string, media: M3uMedia): void {
67 | const firstCommaIndex = trackInformation.indexOf(',');
68 | const durationAttributes = trackInformation.substring(0, firstCommaIndex);
69 | media.name = trackInformation.substring(firstCommaIndex + 1);
70 |
71 | const firstSpaceIndex = durationAttributes.indexOf(' ');
72 | const durationEndIndex = firstSpaceIndex > 0 ? firstSpaceIndex : durationAttributes.length;
73 | media.duration = Number(durationAttributes.substring(0, durationEndIndex));
74 | const attributes = durationAttributes.substring(durationEndIndex + 1);
75 |
76 | media.attributes = this.getAttributes(attributes);
77 | }
78 |
79 | /**
80 | * Process directive method detects directive on line and call proper method to another processing
81 | * @param item - actual line of m3u playlist string e.g. '#EXTINF:-1 tvg-id="" group-title="",Tv Name'
82 | * @param playlist - m3u playlist object processed until now
83 | * @param media - actual m3u media object
84 | * @private
85 | */
86 | private processDirective(item: string, playlist: M3uPlaylist, media: M3uMedia): void {
87 | const firstSemicolonIndex = item.indexOf(':');
88 | const directive = item.substring(0, firstSemicolonIndex);
89 | const trackInformation = item.substring(firstSemicolonIndex + 1);
90 | switch(directive) {
91 | case M3uDirectives.EXTINF: {
92 | this.processMedia(trackInformation, media);
93 | break;
94 | }
95 | case M3uDirectives.EXTGRP: {
96 | media.group = trackInformation;
97 | break;
98 | }
99 | case M3uDirectives.EXTBYT: {
100 | media.bytes = Number(trackInformation);
101 | break;
102 | }
103 | case M3uDirectives.EXTIMG: {
104 | media.image = trackInformation;
105 | break;
106 | }
107 | case M3uDirectives.EXTALB: {
108 | media.album = trackInformation;
109 | break;
110 | }
111 | case M3uDirectives.EXTART: {
112 | media.artist = trackInformation;
113 | break;
114 | }
115 | case M3uDirectives.EXTGENRE: {
116 | media.genre = trackInformation;
117 | break;
118 | }
119 | case M3uDirectives.PLAYLIST: {
120 | playlist.title = trackInformation;
121 | break;
122 | }
123 | case M3uDirectives.EXTATTRFROMURL: {
124 | media.extraAttributesFromUrl = trackInformation;
125 | break;
126 | }
127 | case M3uDirectives.EXTHTTP: {
128 | media.extraHttpHeaders = JSON.parse(trackInformation);
129 | break;
130 | }
131 | case M3uDirectives.KODIPROP: {
132 | const [key, ...valueParts] = trackInformation.split('=');
133 | const value = valueParts.join('='); // in case value contains '=', ie. '#KODIPROP:inputstream.adaptive.license_key=https://example.com/license.php?id=example'
134 |
135 | if(!media.kodiProps) {
136 | media.kodiProps = new Map();
137 | }
138 |
139 | media.kodiProps.set(key, value);
140 | break;
141 | }
142 | default: {
143 | this.processCustomData(playlist, media, trackInformation, directive);
144 | }
145 | }
146 | }
147 |
148 | /**
149 | * Process custom unknown directive and add it into playlist or media object, based on mapping configuration
150 | * @param playlist - m3u playlist object processed until now
151 | * @param media - actual m3u media object
152 | * @param trackInformation - track information, whole part of string after directive and semicolon
153 | * @param directive - unknown directive e.g. #EXT-CUSTOM
154 | * @private
155 | */
156 | private processCustomData(
157 | playlist: M3uPlaylist,
158 | media: M3uMedia,
159 | trackInformation: string,
160 | directive: string,
161 | ): void {
162 | if (this.config?.customDataMapping?.media && this.config.customDataMapping.media.includes(directive)) {
163 | media.customData.push({directive, value: trackInformation});
164 | } else if (this.config?.customDataMapping?.playlist && this.config.customDataMapping.playlist.includes(directive)) {
165 | playlist.customData.push({directive, value: trackInformation});
166 | }
167 | }
168 |
169 | /**
170 | * Process attributes in #EXTM3U line
171 | * @param item - first line of m3u playlist string e.g. '#EXTM3U url-tvg="http://example.com/tvg.xml"'
172 | * @param playlist - m3u playlist object processed until now
173 | * @private
174 | */
175 | private processExtM3uAttributes(item: string, playlist: M3uPlaylist): void {
176 | if(item.startsWith(M3uDirectives.EXTM3U)) {
177 | const firstSpaceIndex = item.indexOf(' ');
178 | if(firstSpaceIndex > 0) {
179 | const attributes = item.substring(firstSpaceIndex + 1);
180 | playlist.attributes = this.getAttributes(attributes);
181 | }
182 | }
183 | }
184 |
185 | /**
186 | * Get playlist returns m3u playlist object parsed from m3u string lines
187 | * @param lines - m3u string lines
188 | * @returns parsed m3u playlist object
189 | * @private
190 | */
191 | private getPlaylist(lines: string[]): M3uPlaylist {
192 | const playlist = new M3uPlaylist();
193 | let media = new M3uMedia('');
194 |
195 | this.processExtM3uAttributes(lines[0], playlist);
196 |
197 | lines.forEach(item => {
198 | if (this.isDirective(item)) {
199 | this.processDirective(item, playlist, media);
200 | } else {
201 | media.location = item;
202 | playlist.medias.push(media);
203 | media = new M3uMedia('');
204 | }
205 | });
206 | return playlist;
207 | }
208 |
209 | /**
210 | * Is directive method detect if line contains m3u directive
211 | * @param item - string line of playlist
212 | * @returns true if it is line with directive, otherwise false
213 | * @private
214 | */
215 | private isDirective(item: string): boolean {
216 | return item[0] === M3U_COMMENT;
217 | }
218 |
219 | /**
220 | * Is valid m3u method detect if first line of playlist contains #EXTM3U directive
221 | * @param firstLine - first line of m3u playlist string
222 | * @returns true if line starts with #EXTM3U, false otherwise
223 | * @private
224 | */
225 | private isValidM3u(firstLine: string[]): boolean {
226 | return firstLine[0].startsWith(M3uDirectives.EXTM3U);
227 | }
228 |
229 | /**
230 | * Parse is method to parse m3u playlist string into m3u playlist object.
231 | * Playlist need to contain #EXTM3U directive on first line.
232 | * All lines are trimmed and blank ones are removed.
233 | * @param m3uString - whole m3u playlist string
234 | * @returns parsed m3u playlist object
235 | * @example
236 | * ```ts
237 | * const playlist = M3uParser.parse(m3uString);
238 | * playlist.medias.forEach(media => media.location);
239 | * ```
240 | */
241 | parse(m3uString: string): M3uPlaylist {
242 | if (!this.config?.ignoreErrors && !m3uString) {
243 | throw new Error(`m3uString can't be null!`);
244 | }
245 |
246 | const lines = m3uString.split('\n').map(item => item.trim()).filter(item => item != '');
247 |
248 | if (!this.config?.ignoreErrors && !this.isValidM3u(lines)) {
249 | throw new Error(`Missing ${M3uDirectives.EXTM3U} directive!`);
250 | }
251 | return this.getPlaylist(lines);
252 | }
253 | }
254 |
--------------------------------------------------------------------------------
/src/m3u-playlist.ts:
--------------------------------------------------------------------------------
1 | import {M3uGenerator} from "./m3u-generator";
2 |
3 | export const M3U_COMMENT = '#'
4 | export const DEFAULT_MEDIA_DURATION = -1;
5 | export enum M3uDirectives {
6 | EXTM3U = '#EXTM3U',
7 | EXTINF = '#EXTINF',
8 | PLAYLIST = '#PLAYLIST',
9 | EXTGRP = '#EXTGRP',
10 | EXTBYT = '#EXTBYT',
11 | EXTIMG = '#EXTIMG',
12 | EXTALB = '#EXTALB',
13 | EXTART = '#EXTART',
14 | EXTGENRE = '#EXTGENRE',
15 | EXTATTRFROMURL = '#EXTATTRFROMURL',
16 | EXTHTTP = '#EXTHTTP',
17 | KODIPROP = '#KODIPROP'
18 | }
19 |
20 | /**
21 | * Custom data, that represents unknown directives, that can be parsed also, when mapping is presented
22 | */
23 | export interface M3uCustomData {
24 | /**
25 | * Directive name with '#' symbol at the start
26 | * e.g. #EXT-CUSTOM
27 | */
28 | directive: string;
29 | /**
30 | * Value or parameters of directive
31 | * in case of #EXT-CUSTOM:Something , the 'Something' string is the value
32 | */
33 | value: string;
34 | }
35 |
36 | /**
37 | * M3u playlist object
38 | */
39 | export class M3uPlaylist {
40 | /**
41 | * Title of playlist
42 | * @example code
43 | * ```ts
44 | * const playlist = new M3uPlaylist();
45 | * playlist.title = 'Test playlist';
46 | * ```
47 | * @example example output in final m3u string
48 | * ```
49 | * #PLAYLIST:Test TV
50 | * ```
51 | */
52 | title = '';
53 |
54 |
55 | /**
56 | * Get url-tvg url
57 | * @returns url-tvg url
58 | * @deprecated The method should not be used, use playlist.attributes['url-tvg'] instead
59 | */
60 | get urlTvg(): string | undefined {
61 | return this.attributes['url-tvg'];
62 | }
63 |
64 | /**
65 | * Set url-tvg url
66 | * @param urlTvg - url-tvg url
67 | * @deprecated The method should not be used, use playlist.attributes['url-tvg'] instead
68 | */
69 | set urlTvg(urlTvg: string | undefined) {
70 | this.attributes = { ...this.attributes, 'url-tvg': urlTvg }
71 | }
72 |
73 | /**
74 | * Attributes of of the EXTM3U tag. Default value is empty attributes object.
75 | */
76 | attributes: M3uAttributes = new M3uAttributes();
77 |
78 | /**
79 | * M3u media objects
80 | * @example
81 | * ```ts
82 | * const playlist = new M3uPlaylist();
83 | * const media1 = new M3uMedia('http://my-stream-ulr.com/playlist.m3u8');
84 | * playlist.medias.push(media1);
85 | * ```
86 | */
87 | medias: M3uMedia[] = [];
88 |
89 | /**
90 | * Unknown directives, that belong to the whole playlist
91 | */
92 | customData: M3uCustomData[] = [];
93 |
94 | /**
95 | * Get m3u string method to get m3u playlist string of current playlist object
96 | * @returns m3u playlist string
97 | */
98 | getM3uString(): string {
99 | return M3uGenerator.generate(this);
100 | }
101 | }
102 |
103 | /**
104 | * M3u media object
105 | * @example code example
106 | * ```ts
107 | * const media1 = new M3uMedia('http://my-stream-ulr.com/playlist.m3u8');
108 | * ```
109 | * @example example output in final m3u string
110 | * ```
111 | * #EXTINF:-1 tvg-id="Test tv 1" tvg-country="CZ" tvg-language="CS" tvg-logo="logo1.png" group-title="Test1" unknown="0",Test tv 1 [CZ]
112 | * #EXTGRP:Test TV group 1
113 | * http://iptv.test1.com/playlist.m3u8
114 | * ```
115 | */
116 | export class M3uMedia {
117 |
118 | /**
119 | * Name of media
120 | */
121 | name?: string;
122 | /**
123 | * Group of media
124 | */
125 | group?: string;
126 | /**
127 | * Duration of media. Default value is -1 (infinity).
128 | */
129 | duration: number = DEFAULT_MEDIA_DURATION;
130 | /**
131 | * Attributes of media. Default value is empty attributes object.
132 | */
133 | attributes: M3uAttributes = new M3uAttributes();
134 |
135 | /**
136 | * Extra attributes from url
137 | */
138 | extraAttributesFromUrl?: string = undefined;
139 |
140 | /**
141 | * Extra HTTP headers
142 | */
143 | extraHttpHeaders?: unknown = undefined;
144 |
145 | /**
146 | * Kodi props
147 | */
148 | kodiProps?: Map;
149 |
150 | /**
151 | * Size of media in bytes.
152 | */
153 | bytes?: number = undefined;
154 |
155 | /**
156 | * image (e.g. cover) URL
157 | */
158 | image?: string = undefined;
159 |
160 | /**
161 | * album
162 | */
163 | album?: string = undefined;
164 |
165 | /**
166 | * artist
167 | */
168 | artist?: string = undefined;
169 |
170 | /**
171 | * genre
172 | */
173 | genre?: string = undefined;
174 |
175 | /**
176 | * Unknown directives, that belong to the specific media
177 | */
178 | customData: M3uCustomData[] = [];
179 |
180 | /**
181 | * Constructor
182 | * @param location - location of stream
183 | */
184 | constructor(public location: string) {}
185 | }
186 |
187 | /**
188 | * M3u media attributes. Can contains know attributes, or unknown custom user defined.
189 | * @example
190 | * ```ts
191 | * const media1 = new M3uMedia('http://my-stream-ulr.com/playlist.m3u8');
192 | * media1.attributes = {'tvg-id': '5', 'tvg-language': 'EN', 'unknown': 'my custom attribute'};
193 | * ```
194 | */
195 | export class M3uAttributes {
196 | /**
197 | * url-tvg attribute, widely used for EPG
198 | */
199 | 'url-tvg'?: string;
200 | /**
201 | * tvg-id attribute, widely used
202 | */
203 | 'tvg-id'?: string;
204 | /**
205 | * tvg-language attribute, widely used
206 | */
207 | 'tvg-language'?: string;
208 | /**
209 | * tvg-country attribute, widely used
210 | */
211 | 'tvg-country'?: string;
212 | /**
213 | * tvg-logo attribute, widely used
214 | */
215 | 'tvg-logo'?: string;
216 | /**
217 | * group-title attribute, widely used
218 | */
219 | 'group-title'?: string;
220 |
221 | /**
222 | * unknown user defined attribute
223 | */
224 | [key: string]: string | undefined;
225 | }
226 |
--------------------------------------------------------------------------------
/test/main.spec.ts:
--------------------------------------------------------------------------------
1 | import {beforeEach, describe, expect, it} from 'vitest'
2 | import {M3uAttributes, M3uMedia, M3uParser, M3uPlaylist} from "../src";
3 | import {
4 | complex,
5 | extGroupDirectiveOrder,
6 | emptyAttributes,
7 | invalidPlaylist,
8 | urlTvgTags,
9 | playlistWithExtAttrFromUrl,
10 | playlistWithExtraHTTPHeaders,
11 | playlistWithKodiProps,
12 | playlistWithExtraProps,
13 | invalidExtM3uAttributes,
14 | playlistWithCustomDirectives,
15 | commaNames
16 | } from "./test-m3u";
17 |
18 | describe('Parse and generate test', () => {
19 |
20 | let parser: M3uParser;
21 |
22 | beforeEach(() => {
23 | parser = new M3uParser()
24 | })
25 |
26 | it('should be same as original after parse and generate', () => {
27 | expect(parser.parse(complex).getM3uString()).toEqual(complex);
28 | expect(parser.parse(commaNames).getM3uString()).toEqual(commaNames);
29 | expect(parser.parse(emptyAttributes).getM3uString()).toEqual(emptyAttributes);
30 | });
31 |
32 | it('should be parsed when random order of #EXTGRP directive is present', () => {
33 | const parsed = parser.parse(extGroupDirectiveOrder);
34 | expect(parsed.medias[0].group).toEqual('Test TV group 1');
35 | expect(parsed.medias[1].group).toEqual('Test TV group 2');
36 | });
37 |
38 | it('should raise exception when first line is missing', () => {
39 | const stringWithoutFirstLine = extGroupDirectiveOrder.split('\n').slice(1).join('\n');
40 | expect(() => parser.parse(stringWithoutFirstLine)).toThrow(new Error('Missing #EXTM3U directive!'));
41 | });
42 |
43 | it('should generate without playlist title', () => {
44 | const playlist = new M3uPlaylist();
45 | playlist.medias.push(new M3uMedia('location'));
46 | expect(playlist.getM3uString()).toEqual('#EXTM3U\nlocation');
47 | });
48 |
49 | it('should be parsed when no attributes are present', () => {
50 | const parsed = parser.parse(emptyAttributes);
51 | expect(Object.keys(parsed.medias[0].attributes)).toEqual([]);
52 | expect(Object.keys(parsed.medias[1].attributes)).not.toEqual([]);
53 | });
54 |
55 | it('should raise exception when parsing invalid m3u string', () => {
56 | expect(() => parser.parse('')).toThrow(new Error(`m3uString can't be null!`));
57 | });
58 |
59 | it('should NOT raise exception when parsing invalid m3u string with ignoreErrors argument', () => {
60 | parser = new M3uParser({ignoreErrors: true});
61 | expect(() => parser.parse('')).not.toThrow(new Error(`m3uString can't be null!`));
62 | });
63 |
64 | it('should parse with invalid attributes', () => {
65 | const attr = new M3uAttributes();
66 | attr["tvg-id"] = 'Test tv 1';
67 | attr["tvg-language"] = ' CS';
68 | const media1 = new M3uMedia('playlist.m3u');
69 | media1.name = 'Test tv 1 [CZ]';
70 | media1.group = 'Test TV group 1';
71 | media1.attributes = attr;
72 |
73 | const media2 = new M3uMedia('playlist.m3u');
74 | media2.name = '100 group-title="Test2"Test tv 2 [SK]';
75 | media2.group = '';
76 | media2.duration = 0;
77 |
78 | const expectedPlaylist = new M3uPlaylist();
79 | expectedPlaylist.medias = [media1, media2]
80 |
81 | parser = new M3uParser({ignoreErrors: true});
82 | expect(parser.parse(invalidPlaylist)).toEqual(expectedPlaylist);
83 | });
84 |
85 | it('should parse url-tvg attribute', () => {
86 | const playlist = parser.parse(urlTvgTags);
87 | expect(playlist.urlTvg).toEqual('http://example.com/tvg.xml');
88 | });
89 |
90 | it('should write url-tvg attribute', () => {
91 | const playlist = new M3uPlaylist();
92 | playlist.urlTvg = 'http://example.com/tvg.xml';
93 | expect(playlist.getM3uString()).toEqual(urlTvgTags);
94 | });
95 |
96 | it('should parse extra attributes from url', () => {
97 | const playlist = parser.parse(playlistWithExtAttrFromUrl);
98 | expect(playlist.medias[0].extraAttributesFromUrl).toEqual('https://example.com/attributes.txt');
99 | });
100 |
101 | it('should write extra attributes from url', () => {
102 | const media = new M3uMedia('http://iptv.test1.com/playlist.m3u8');
103 | media.name = 'Test tv 1 [CZ]';
104 | media.group = 'Test TV group 1';
105 | media.attributes["tvg-id"] = 'Test tv 1';
106 | media.attributes["tvg-country"] = 'CZ';
107 | media.attributes["tvg-language"] = 'CS';
108 | media.attributes["tvg-logo"] = 'logo1.png';
109 | media.attributes["group-title"] = 'Test1';
110 | media.attributes["unknown"] = '0';
111 | media.extraAttributesFromUrl = 'https://example.com/attributes.txt';
112 | const playlist = new M3uPlaylist();
113 | playlist.medias.push(media);
114 | expect(playlist.getM3uString()).toEqual(playlistWithExtAttrFromUrl);
115 | });
116 |
117 | it('should parse extra http headers', () => {
118 | const playlist = parser.parse(playlistWithExtraHTTPHeaders);
119 | expect(playlist.medias[0].extraHttpHeaders).toEqual(JSON.parse('{"User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:90.0) Gecko/20100101 Firefox/90.0"}'));
120 | });
121 |
122 | it('should write extra http headers', () => {
123 | const media = new M3uMedia('http://iptv.test1.com/playlist.m3u8');
124 | media.name = 'Test tv 1 [CZ]';
125 | media.name = 'Test tv 1 [CZ]';
126 | media.group = 'Test TV group 1';
127 | media.attributes["tvg-id"] = 'Test tv 1';
128 | media.attributes["tvg-country"] = 'CZ';
129 | media.attributes["tvg-language"] = 'CS';
130 | media.attributes["tvg-logo"] = 'logo1.png';
131 | media.attributes["group-title"] = 'Test1';
132 | media.attributes["unknown"] = '0';
133 | media.extraHttpHeaders = JSON.parse('{"User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:90.0) Gecko/20100101 Firefox/90.0"}');
134 | const playlist = new M3uPlaylist();
135 | playlist.medias.push(media);
136 | expect(playlist.getM3uString()).toEqual(playlistWithExtraHTTPHeaders);
137 | });
138 |
139 | it('should parse kodi props', () => {
140 | const playlist = parser.parse(playlistWithKodiProps);
141 | expect(playlist.medias[0].kodiProps).toEqual(new Map([
142 | [ 'inputstream.adaptive.manifest_type', 'm3u8' ],
143 | [ 'inputstream.adaptive.license_type', 'org.w3.clearkey' ],
144 | [ 'inputstream.adaptive.license_key', 'https://example.com/license.php?id=example' ]
145 | ]));
146 | });
147 |
148 | it('should write kodi props', () => {
149 | const media = new M3uMedia('http://iptv.test1.com/playlist.m3u8');
150 | media.name = 'Test tv 1 [CZ]';
151 | media.name = 'Test tv 1 [CZ]';
152 | media.group = 'Test TV group 1';
153 | media.attributes["tvg-id"] = 'Test tv 1';
154 | media.attributes["tvg-country"] = 'CZ';
155 | media.attributes["tvg-language"] = 'CS';
156 | media.attributes["tvg-logo"] = 'logo1.png';
157 | media.attributes["group-title"] = 'Test1';
158 | media.attributes["unknown"] = '0';
159 | media.kodiProps = new Map([
160 | [ 'inputstream.adaptive.manifest_type', 'm3u8' ],
161 | [ 'inputstream.adaptive.license_type', 'org.w3.clearkey' ],
162 | [ 'inputstream.adaptive.license_key', 'https://example.com/license.php?id=example' ]
163 | ]);
164 | const playlist = new M3uPlaylist();
165 | playlist.medias.push(media);
166 | expect(playlist.getM3uString()).toEqual(playlistWithKodiProps);
167 | });
168 |
169 | it('should parse extra props/attributes', () => {
170 | const playlist = parser.parse(playlistWithExtraProps);
171 | expect(playlist.urlTvg).toEqual('http://example.com/tvg.xml');
172 | expect(playlist.attributes['url-tvg']).toEqual('http://example.com/tvg.xml');
173 | expect(playlist.attributes['url-logo']).toEqual('http://path/to/icons/root/');
174 | expect(playlist.medias[0].group).toEqual("Test TV group 1");
175 | expect(playlist.medias[0].bytes).toEqual(123);
176 | expect(playlist.medias[0].image).toEqual("cover.jpg");
177 | expect(playlist.medias[0].album).toEqual("test album");
178 | expect(playlist.medias[0].artist).toEqual("test artist");
179 | expect(playlist.medias[0].genre).toEqual("test genre");
180 | });
181 |
182 | it('should write extra props/attributes', () => {
183 | const media = new M3uMedia('http://iptv.test1.com/playlist.m3u8');
184 | media.name = 'Test tv 1 [CZ]';
185 | media.group = 'Test TV group 1';
186 | media.bytes = 123;
187 | media.image = "cover.jpg";
188 | media.album = "test album";
189 | media.artist = "test artist";
190 | media.genre = "test genre";
191 | media.attributes["tvg-id"] = 'Test tv 1';
192 | media.attributes["tvg-country"] = 'CZ';
193 | media.attributes["tvg-language"] = 'CS';
194 | media.attributes["tvg-logo"] = 'logo1.png';
195 | media.attributes["group-title"] = 'Test1';
196 | media.attributes["unknown"] = '0';
197 | const playlist = new M3uPlaylist();
198 | playlist.attributes['url-tvg'] = 'http://example.com/tvg.xml'
199 | playlist.attributes['url-logo'] = 'http://path/to/icons/root/'
200 | playlist.medias.push(media);
201 | expect(playlist.getM3uString()).toEqual(playlistWithExtraProps);
202 | });
203 |
204 | it('should parse invalid attributes', () => {
205 | const playlist = parser.parse(invalidExtM3uAttributes);
206 | expect(playlist.attributes).toEqual(new M3uAttributes());
207 | });
208 |
209 | it('should parse and generate with custom directives', () => {
210 | parser = new M3uParser({customDataMapping: {playlist: ['#EXTCUSTOMPLAYLIST'], media: ['#EXTCUSTOMMEDIA']}});
211 | expect(parser.parse(playlistWithCustomDirectives).getM3uString()).toEqual(playlistWithCustomDirectives);
212 | })
213 | });
214 |
--------------------------------------------------------------------------------
/test/test-m3u.ts:
--------------------------------------------------------------------------------
1 | export const complex = `#EXTM3U
2 | #PLAYLIST:Test TV
3 | #EXTINF:-1 tvg-id="Test tv 1" tvg-country="CZ" tvg-language="CS" tvg-logo="logo1.png" group-title="Test1" unknown="0",Test tv 1 [CZ]
4 | #EXTGRP:Test TV group 1
5 | http://iptv.test1.com/playlist.m3u8
6 | #EXTINF:100 tvg-id="Test tv 2" tvg-country="SK" tvg-language="SK" tvg-logo="logo2.png" group-title="Test2",Test tv 2 [SK]
7 | #EXTGRP:Test TV group 2
8 | http://iptv.test2.com/playlist.m3u8
9 | #EXTINF:120 tvg-id="Test tv 3" tvg-country="EN" tvg-language="EN" tvg-logo="logo3.png" group-title="Test3",Test tv 3 [EN]
10 | http://iptv.test3.com/playlist.m3u8
11 | http://iptv.test4.com/playlist.m3u8`;
12 |
13 | export const extGroupDirectiveOrder = `#EXTM3U
14 | #EXTINF:-1 tvg-id="Test tv 1" tvg-country="CZ" tvg-language="CS" tvg-logo="logo1.png" group-title="Test1" unknown="0",Test tv 1 [CZ]
15 | #EXTGRP:Test TV group 1
16 | http://iptv.test1.com/playlist.m3u8
17 | #EXTGRP:Test TV group 2
18 | #EXTINF:100 tvg-id="Test tv 2" tvg-country="SK" tvg-language="SK" tvg-logo="logo2.png" group-title="Test2",Test tv 2 [SK]
19 | http://iptv.test2.com/playlist.m3u8`;
20 |
21 | export const emptyAttributes = `#EXTM3U
22 | #EXTINF:-1,Test tv 1 [CZ]
23 | #EXTGRP:Test TV group 1
24 | http://iptv.test1.com/playlist.m3u8
25 | #EXTINF:100 tvg-id="Test tv 2" tvg-country="SK" tvg-language="SK" tvg-logo="logo2.png" group-title="Test2",Test tv 2 [SK]
26 | #EXTGRP:Test TV group 2
27 | http://iptv.test2.com/playlist.m3u8`;
28 |
29 | export const invalidPlaylist = `
30 | #EXTINF:-1 tvg-id="Test tv 1" unknown= tvg-language=" CS",Test tv 1 [CZ]
31 | #EXTGRP:Test TV group 1
32 | #INVALID:Something
33 | playlist.m3u
34 | #EXTINF:100 group-title="Test2"Test tv 2 [SK]
35 | #EXTGRP:
36 | playlist.m3u`;
37 |
38 | export const urlTvgTags = `#EXTM3U url-tvg="http://example.com/tvg.xml"`;
39 |
40 | export const playlistWithExtAttrFromUrl = `#EXTM3U
41 | #EXTINF:-1 tvg-id="Test tv 1" tvg-country="CZ" tvg-language="CS" tvg-logo="logo1.png" group-title="Test1" unknown="0",Test tv 1 [CZ]
42 | #EXTGRP:Test TV group 1
43 | #EXTATTRFROMURL:https://example.com/attributes.txt
44 | http://iptv.test1.com/playlist.m3u8`
45 |
46 | export const playlistWithExtraHTTPHeaders = `#EXTM3U
47 | #EXTINF:-1 tvg-id="Test tv 1" tvg-country="CZ" tvg-language="CS" tvg-logo="logo1.png" group-title="Test1" unknown="0",Test tv 1 [CZ]
48 | #EXTGRP:Test TV group 1
49 | #EXTHTTP:{"User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:90.0) Gecko/20100101 Firefox/90.0"}
50 | http://iptv.test1.com/playlist.m3u8`
51 |
52 | export const playlistWithKodiProps = `#EXTM3U
53 | #EXTINF:-1 tvg-id="Test tv 1" tvg-country="CZ" tvg-language="CS" tvg-logo="logo1.png" group-title="Test1" unknown="0",Test tv 1 [CZ]
54 | #EXTGRP:Test TV group 1
55 | #KODIPROP:inputstream.adaptive.manifest_type=m3u8
56 | #KODIPROP:inputstream.adaptive.license_type=org.w3.clearkey
57 | #KODIPROP:inputstream.adaptive.license_key=https://example.com/license.php?id=example
58 | http://iptv.test1.com/playlist.m3u8`
59 |
60 | export const playlistWithExtraProps = `#EXTM3U url-tvg="http://example.com/tvg.xml" url-logo="http://path/to/icons/root/"
61 | #EXTINF:-1 tvg-id="Test tv 1" tvg-country="CZ" tvg-language="CS" tvg-logo="logo1.png" group-title="Test1" unknown="0",Test tv 1 [CZ]
62 | #EXTGRP:Test TV group 1
63 | #EXTBYT:123
64 | #EXTIMG:cover.jpg
65 | #EXTALB:test album
66 | #EXTART:test artist
67 | #EXTGENRE:test genre
68 | http://iptv.test1.com/playlist.m3u8`
69 |
70 | export const invalidExtM3uAttributes = `#EXTM3U foo="bar`;
71 |
72 | export const playlistWithCustomDirectives = `#EXTM3U
73 | #EXTCUSTOMPLAYLIST:playlist
74 | #EXTCUSTOMMEDIA:MEDIA1
75 | http://iptv.test1.com/playlist.m3u8
76 | #EXTCUSTOMMEDIA:MEDIA2
77 | http://iptv.test1.com/playlist2.m3u8`
78 |
79 |
80 | export const commaNames = `#EXTM3U
81 | #PLAYLIST:Test Commas
82 | #EXTINF:-1,TiNGL - Pitch, Please
83 | #EXTART:Pitch, Please
84 | /Podcasts/somefile.mp3`
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2016",
4 | //"useDefineForClassFields": true,
5 | "module": "ESNext",
6 | "lib": ["ES2016"],
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "isolatedModules": true,
13 | "moduleDetection": "force",
14 | "noEmit": true,
15 |
16 | /* Linting */
17 | "strict": true,
18 | "noUnusedLocals": true,
19 | "noUnusedParameters": true,
20 | "noFallthroughCasesInSwitch": true,
21 | "noUncheckedSideEffectImports": true
22 | },
23 | "include": ["src"]
24 | }
25 |
--------------------------------------------------------------------------------
/typedoc.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugin: ["typedoc-material-theme"],
3 | themeColor: "#66BB6A"
4 | }
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import {resolve} from 'path';
2 | import {defineConfig} from 'vitest/config';
3 | import dts from 'vite-plugin-dts';
4 |
5 | export default defineConfig({
6 | plugins: [dts()],
7 | build: {
8 | lib: {
9 | entry: resolve(__dirname, 'src/index.ts'),
10 | name: 'm3uParserGenerator',
11 | fileName: 'm3u-parser-generator',
12 | formats: ['es', 'cjs', 'umd', 'iife'],
13 | },
14 | },
15 | test: {
16 | include: ["test/**/*.spec.ts"],
17 | coverage: {
18 | include: ["src/**"],
19 | reporter: ['html', 'lcov']
20 | },
21 | },
22 | });
--------------------------------------------------------------------------------