├── .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 | [![npm version](https://badge.fury.io/js/m3u-parser-generator.svg)](https://badge.fury.io/js/m3u-parser-generator) 2 | [![docs](https://badgen.net/badge/docs/online/orange)](https://m3u-parser-generator.netlify.app) 3 | ![npm bundle size](https://img.shields.io/bundlephobia/min/m3u-parser-generator) 4 | ![NPM](https://img.shields.io/npm/l/m3u-parser-generator) 5 | [![CircleCI](https://circleci.com/gh/Raiper34/m3u-parser-generator.svg?style=shield)](https://circleci.com/gh/Raiper34/m3u-parser-generator) 6 | [![Coverage Status](https://coveralls.io/repos/github/Raiper34/m3u-parser-generator/badge.svg?branch=main)](https://coveralls.io/github/Raiper34/m3u-parser-generator?branch=main) 7 | [![npm](https://img.shields.io/npm/dt/m3u-parser-generator)](https://badge.fury.io/js/m3u-parser-generator) 8 | [![npm](https://img.shields.io/npm/dm/m3u-parser-generator)](https://badge.fury.io/js/m3u-parser-generator) 9 | [![npm](https://img.shields.io/npm/dw/m3u-parser-generator)](https://badge.fury.io/js/m3u-parser-generator) 10 | [![](https://data.jsdelivr.com/v1/package/npm/m3u-parser-generator/badge?style=rounded)](https://www.jsdelivr.com/package/npm/m3u-parser-generator) 11 | [![GitHub Repo stars](https://img.shields.io/github/stars/raiper34/m3u-parser-generator)](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 | }); --------------------------------------------------------------------------------