├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .github └── ISSUE_TEMPLATE │ ├── bug-report.md │ └── feature-request.md ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── examples ├── options.ts └── simple.ts ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── Parser.ts └── StreamReader.ts ├── test ├── Parser.spec.ts └── StreamReader.spec.ts ├── tsconfig.eslint.json └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | coverage 2 | dist 3 | node_modules 4 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | commonjs: true, 4 | es6: true, 5 | jest: true, 6 | node: true, 7 | }, 8 | extends: [ 9 | 'eslint:all', 10 | 'plugin:@typescript-eslint/all', 11 | 'plugin:import/errors', 12 | 'plugin:import/warnings', 13 | 'plugin:import/typescript', 14 | 'plugin:jest/all', 15 | 'plugin:node/recommended', 16 | 'plugin:promise/recommended', 17 | 'standard-with-typescript', 18 | ], 19 | parser: '@typescript-eslint/parser', 20 | parserOptions: { 21 | ecmaVersion: 2020, 22 | project: 'tsconfig.eslint.json', 23 | }, 24 | plugins: [ 25 | '@typescript-eslint', 26 | 'import', 27 | 'jest', 28 | 'node', 29 | 'promise', 30 | 'standard', 31 | ], 32 | root: true, 33 | rules: { 34 | '@typescript-eslint/comma-dangle': ['error', 'always-multiline'], 35 | '@typescript-eslint/indent': ['error', 2], 36 | '@typescript-eslint/no-magic-numbers': ['off'], 37 | '@typescript-eslint/no-shadow': ['off'], 38 | '@typescript-eslint/no-type-alias': ['off'], 39 | '@typescript-eslint/prefer-enum-initializers': ['off'], 40 | '@typescript-eslint/prefer-readonly-parameter-types': ['off'], 41 | '@typescript-eslint/quotes': ['error', 'single'], 42 | '@typescript-eslint/semi': ['error', 'always'], 43 | 'array-bracket-newline': ['error', 'consistent'], 44 | 'array-element-newline': ['error', 'consistent'], 45 | 'comma-dangle': ['error', 'always-multiline'], 46 | 'consistent-return': ['off'], 47 | 'func-style': ['off'], 48 | 'function-call-argument-newline': ['error', 'consistent'], 49 | 'function-paren-newline': ['error', 'consistent'], 50 | 'max-len': ['error', 120], 51 | 'max-lines-per-function': ['off'], 52 | 'max-statements': ['off'], 53 | 'multiline-comment-style': ['error', 'separate-lines'], 54 | 'multiline-ternary': ['error', 'always-multiline'], 55 | 'no-ternary': ['off'], 56 | 'node/no-missing-import': ['error', { tryExtensions: ['.ts'] }], 57 | 'node/no-unsupported-features/es-syntax': ['error', { ignores: ['modules'] }], 58 | semi: ['error', 'always'], 59 | }, 60 | }; 61 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: ghaiklor 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 16 | 1. 17 | 18 | **Expected behavior** 19 | A clear and concise description of what you expected to happen. 20 | 21 | **Additional context** 22 | Add any other context about the problem here. 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: ghaiklor 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # General 2 | .DS_Store 3 | .AppleDouble 4 | .LSOverride 5 | 6 | # Icon must end with two \r 7 | Icon 8 | 9 | # Thumbnails 10 | ._* 11 | 12 | # Files that might appear in the root of a volume 13 | .DocumentRevisions-V100 14 | .fseventsd 15 | .Spotlight-V100 16 | .TemporaryItems 17 | .Trashes 18 | .VolumeIcon.icns 19 | .com.apple.timemachine.donotpresent 20 | 21 | # Directories potentially created on remote AFP share 22 | .AppleDB 23 | .AppleDesktop 24 | Network Trash Folder 25 | Temporary Items 26 | .apdisk 27 | 28 | # Logs 29 | logs 30 | *.log 31 | npm-debug.log* 32 | yarn-debug.log* 33 | yarn-error.log* 34 | lerna-debug.log* 35 | 36 | # Diagnostic reports (https://nodejs.org/api/report.html) 37 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 38 | 39 | # Runtime data 40 | pids 41 | *.pid 42 | *.seed 43 | *.pid.lock 44 | 45 | # Directory for instrumented libs generated by jscoverage/JSCover 46 | lib-cov 47 | 48 | # Coverage directory used by tools like istanbul 49 | coverage 50 | *.lcov 51 | 52 | # nyc test coverage 53 | .nyc_output 54 | 55 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 56 | .grunt 57 | 58 | # Bower dependency directory (https://bower.io/) 59 | bower_components 60 | 61 | # node-waf configuration 62 | .lock-wscript 63 | 64 | # Compiled binary addons (https://nodejs.org/api/addons.html) 65 | build/Release 66 | 67 | # Dependency directories 68 | node_modules/ 69 | jspm_packages/ 70 | 71 | # TypeScript v1 declaration files 72 | typings/ 73 | 74 | # TypeScript cache 75 | *.tsbuildinfo 76 | 77 | # Optional npm cache directory 78 | .npm 79 | 80 | # Optional eslint cache 81 | .eslintcache 82 | 83 | # Microbundle cache 84 | .rpt2_cache/ 85 | .rts2_cache_cjs/ 86 | .rts2_cache_es/ 87 | .rts2_cache_umd/ 88 | 89 | # Optional REPL history 90 | .node_repl_history 91 | 92 | # Output of 'npm pack' 93 | *.tgz 94 | 95 | # Yarn Integrity file 96 | .yarn-integrity 97 | 98 | # dotenv environment variables file 99 | .env 100 | .env.test 101 | 102 | # parcel-bundler cache (https://parceljs.org/) 103 | .cache 104 | 105 | # Next.js build output 106 | .next 107 | 108 | # Nuxt.js build / generate output 109 | .nuxt 110 | dist 111 | 112 | # Gatsby files 113 | .cache/ 114 | 115 | # vuepress build output 116 | .vuepress/dist 117 | 118 | # Serverless directories 119 | .serverless/ 120 | 121 | # FuseBox cache 122 | .fusebox/ 123 | 124 | # DynamoDB Local files 125 | .dynamodb/ 126 | 127 | # TernJS port file 128 | .tern-port 129 | 130 | # Stores VSCode versions used for testing VSCode extensions 131 | .vscode-test 132 | 133 | # VisualStudioCode 134 | .vscode/* 135 | !.vscode/settings.json 136 | !.vscode/tasks.json 137 | !.vscode/launch.json 138 | !.vscode/extensions.json 139 | *.code-workspace 140 | 141 | # Ignore all local history of files 142 | .history 143 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "node" 4 | script: 5 | - npm run all 6 | after_success: 7 | - bash <(curl -s https://codecov.io/bash) 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [4.0.2](https://github.com/ghaiklor/icecast-parser/compare/v4.0.1...v4.0.2) (2020-09-20) 2 | 3 | ### Bug Fixes 4 | 5 | * 🐛 issue when notifyOnChangeOnly stops emitting at all ([e4700ca](https://github.com/ghaiklor/icecast-parser/commit/e4700ca231e9d3f837af21317e82e9f7e945d630)) 6 | 7 | ## [4.0.1](https://github.com/ghaiklor/icecast-parser/compare/v4.0.0...v4.0.1) (2020-09-20) 8 | 9 | ### Features 10 | 11 | * 🎸 typed events in Parser ([14be58a](https://github.com/ghaiklor/icecast-parser/commit/14be58a7335898d2711e4d32ef07c243920da6b0)) 12 | 13 | # [4.0.0](https://github.com/ghaiklor/icecast-parser/compare/v3.2.1...v4.0.0) (2020-08-25) 14 | 15 | ### Bug Fixes 16 | 17 | * 🐛 metadata regex and _transform in stream reader ([ef1c6c7](https://github.com/ghaiklor/icecast-parser/commit/ef1c6c715866b8c94b18ebc18ded63aa6184e582)) 18 | * 🐛 named capture group in metadata regex ([6273940](https://github.com/ghaiklor/icecast-parser/commit/62739404247bd77467fb7f2f8495e4e8849dd41f)) 19 | 20 | ## [3.2.1](https://github.com/ghaiklor/icecast-parser/compare/v3.2.0...v3.2.1) (2019-08-10) 21 | 22 | # [3.2.0](https://github.com/ghaiklor/icecast-parser/compare/v3.1.0...v3.2.0) (2019-08-10) 23 | 24 | ### Features 25 | 26 | * 🎸 improve parsing metadata, when more complex cases arise ([a60b164](https://github.com/ghaiklor/icecast-parser/commit/a60b1645a2114cf7e70197bf39b78800b44b5505)) 27 | 28 | # [3.1.0](https://github.com/ghaiklor/icecast-parser/compare/v3.0.0...v3.1.0) (2019-05-14) 29 | 30 | ### Features 31 | 32 | * https support, custom user-agent, end event); ([#111](https://github.com/ghaiklor/icecast-parser/issues/111)) ([7fb3df8](https://github.com/ghaiklor/icecast-parser/commit/7fb3df83b3ca86e16ef8db02216cf918d9a71165)) 33 | 34 | # [3.0.0](https://github.com/ghaiklor/icecast-parser/compare/v2.0.0...v3.0.0) (2017-03-20) 35 | 36 | ### Code Refactoring 37 | 38 | * **package:** Remove Babel compiler and support for older NodeJS versions ([8fc96ef](https://github.com/ghaiklor/icecast-parser/commit/8fc96eff4f7f42e1f19a0939d4ad6d68daed0658)) 39 | 40 | ### BREAKING CHANGES 41 | 42 | * **package:** Removed support for older NodeJS versions 43 | 44 | # [2.0.0](https://github.com/ghaiklor/icecast-parser/compare/45ded78df94d2347fcd2d76da7c0b729938b4a0c...v2.0.0) (2016-02-01) 45 | 46 | ### Bug Fixes 47 | 48 | * **package:** Fix issue with trampoline function on arrow functions ([55fc2cf](https://github.com/ghaiklor/icecast-parser/commit/55fc2cfb674b001ab42c3cd623c72b5f555a63e9)) 49 | 50 | ### Features 51 | 52 | * **package:** Rewrite sources to ES6 ([45ded78](https://github.com/ghaiklor/icecast-parser/commit/45ded78df94d2347fcd2d76da7c0b729938b4a0c)) 53 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at ghaiklor@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016-2020 Eugene Obrezkov aka ghaiklor 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # icecast-parser 2 | 3 | [![Build Status](https://travis-ci.com/ghaiklor/icecast-parser.svg?branch=master)](https://travis-ci.com/ghaiklor/icecast-parser) 4 | [![Code Coverage](https://codecov.io/gh/ghaiklor/icecast-parser/branch/master/graph/badge.svg)](https://codecov.io/gh/ghaiklor/icecast-parser) 5 | 6 | [![GitHub followers](https://img.shields.io/github/followers/ghaiklor?label=Follow&style=social)](https://github.com/ghaiklor) 7 | [![Twitter Follow](https://img.shields.io/twitter/follow/ghaiklor?label=Follow&style=social)](https://twitter.com/ghaiklor) 8 | 9 | Node.js module for getting and parsing metadata from SHOUTcast/Icecast radio streams. 10 | 11 | *NOTE: the server that serves radio station stream must support `Icy-Metadata` header. If that is not the case, this parser cannot parse the metadata from there.* 12 | 13 | ## Features 14 | 15 | - Opens async connection to URL and gets response with radio stream and metadata. Then pipes the response to `Transform` stream for processing; 16 | - Getting metadata from stream is implemented as `Transform` stream, so you can pipe it to another Writable\Duplex\Transform; 17 | - Once it receives metadata, `metadata` event triggers with metadata object; 18 | - After metadata is received, connection to radio station closes automatically, so you will not spend a lot of traffic; 19 | - But you can set `keepListen` flag in configuration object and continue listening radio station; 20 | - Auto updating metadata from radio station by interval in economical way (connection is opens when time has come); 21 | - Metadata parsed as a `Map` with key-value; 22 | - When you create a new instance, you get `EventEmitter`. So you can subscribe to other events; 23 | - Easy to configure and use; 24 | 25 | ## Getting Started 26 | 27 | You can install icecast-parser from npm. 28 | 29 | ```shell 30 | npm install icecast-parser 31 | ``` 32 | 33 | Get your first metadata from radio station. 34 | 35 | ```typescript 36 | import { Parser } from 'icecast-parser'; 37 | 38 | const radioStation = new Parser({ url: 'https://live.hunter.fm/80s_high' }); 39 | radioStation.on('metadata', (metadata) => process.stdout.write(`${metadata.get('StreamTitle') ?? 'unknown'}\n`)); 40 | ``` 41 | 42 | ## Configuration 43 | 44 | You can provide additional parameters to constructor: 45 | 46 | - `url` - by default empty and **REQUIRED**. Otherwise, you will get an error. 47 | - `userAgent` - by default `icecast-parser`. 48 | - `keepListen` - by default `false`. If you set to `true`, then response from radio station will not be destroyed and you can pipe it to another streams. E.g. piping it to the `speaker` module. 49 | - `autoUpdate` - by default `true`. If you set to `false`, then parser will not be listening for recent updates and immediately close the stream. So that, you will get a metadata only once. 50 | - `notifyOnChangeOnly` - by default `false`. If you set both `autoUpdate` and `notifyOnChangeOnly` to `true`, it will keep listening the stream and notifying you about metadata, but it will not notify if metadata did not change from the previous time. 51 | - `errorInterval` - by default 10 minutes. If an error occurred when requesting, the next try will be executed after this interval. Works only if `autoUpdate` is enabled. 52 | - `emptyInterval` - by default 5 minutes. If the request was fullfiled but the metadata field was empty, the next try will be executed after this interval. Works only if `autoUpdate` is enabled. 53 | - `metadataInterval` - by default 5 seconds. If the request was fullfiled and the metadata was present, the next update will be scheduled after this interval. Works only if `autoUpdate` is enabled. 54 | 55 | ```typescript 56 | import { Parser } from 'icecast-parser'; 57 | 58 | const radioStation = new Parser({ 59 | autoUpdate: true, 60 | emptyInterval: 5 * 60, 61 | errorInterval: 10 * 60, 62 | keepListen: false, 63 | metadataInterval: 5, 64 | notifyOnChangeOnly: false, 65 | url: 'https://live.hunter.fm/80s_high', 66 | userAgent: 'Custom User Agent', 67 | }); 68 | 69 | radioStation.on('metadata', (metadata) => process.stdout.write(`${metadata.get('StreamTitle') ?? 'unknown'}\n`)); 70 | ``` 71 | 72 | ## Events 73 | 74 | You can subscribe to following events: `end`, `error`, `empty`, `metadata`, `stream`. 75 | 76 | - `end` event triggers when connection to radio station was ended; 77 | - `error` event triggers when connection to radio station was refused, rejected or timed out; 78 | - `empty` event triggers when connection was established successfully, but the radio station doesn't have metadata in there; 79 | - `metadata` event triggers when connection was established successfully and metadata is parsed; 80 | - `stream` event triggers when response from radio station returned and successfully piped to `Transform` stream. 81 | 82 | ## License 83 | 84 | [MIT](./LICENSE) 85 | -------------------------------------------------------------------------------- /examples/options.ts: -------------------------------------------------------------------------------- 1 | import { Parser } from '..'; 2 | 3 | const radioStation = new Parser({ 4 | autoUpdate: true, 5 | emptyInterval: 5 * 60, 6 | errorInterval: 10 * 60, 7 | keepListen: false, 8 | metadataInterval: 5, 9 | notifyOnChangeOnly: true, 10 | url: 'https://live.hunter.fm/80s_high', 11 | userAgent: 'Custom User Agent', 12 | }); 13 | 14 | radioStation.on('metadata', (metadata) => process.stdout.write(`${metadata.get('StreamTitle') ?? 'unknown'}\n`)); 15 | -------------------------------------------------------------------------------- /examples/simple.ts: -------------------------------------------------------------------------------- 1 | import { Parser } from '..'; 2 | 3 | const radioStation = new Parser({ url: 'https://live.hunter.fm/80s_high' }); 4 | radioStation.on('metadata', (metadata) => process.stdout.write(`${metadata.get('StreamTitle') ?? 'unknown'}\n`)); 5 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | collectCoverage: true, 3 | coverageDirectory: 'coverage', 4 | coverageProvider: 'v8', 5 | coverageReporters: ['json', 'lcov', 'clover'], 6 | errorOnDeprecated: true, 7 | preset: 'ts-jest', 8 | slowTestThreshold: 10, 9 | testEnvironment: 'node', 10 | testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'], 11 | }; 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "icecast-parser", 3 | "version": "4.0.2", 4 | "description": "Node.js module for getting and parsing metadata from SHOUTcast/Icecast radio streams", 5 | "main": "dist/Parser.js", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/ghaiklor/icecast-parser.git" 10 | }, 11 | "author": { 12 | "name": "Eugene Obrezkov", 13 | "email": "ghaiklor@gmail.com", 14 | "url": "https://ghaiklor.com" 15 | }, 16 | "bugs": { 17 | "url": "https://github.com/ghaiklor/icecast-parser/issues", 18 | "email": "ghaiklor@gmail.com" 19 | }, 20 | "engines": { 21 | "node": ">=14" 22 | }, 23 | "homepage": "https://github.com/ghaiklor/icecast-parser", 24 | "keywords": [ 25 | "shoutcast", 26 | "SHOUTcast", 27 | "icecast", 28 | "metadata", 29 | "parser", 30 | "radio", 31 | "cli", 32 | "internet", 33 | "stream" 34 | ], 35 | "files": [ 36 | "dist" 37 | ], 38 | "scripts": { 39 | "all": "npm run clean && npm run build && npm run test && npm run lint", 40 | "build": "tsc", 41 | "changelog": "standard-changelog -i CHANGELOG.md --same-file", 42 | "clean": "rm -rf coverage dist", 43 | "commit": "git-cz", 44 | "lint": "eslint --fix .", 45 | "prepublishOnly": "npm run all", 46 | "preversion": "npm run all", 47 | "test": "jest" 48 | }, 49 | "devDependencies": { 50 | "@types/jest": "26.0.14", 51 | "@typescript-eslint/eslint-plugin": "4.2.0", 52 | "@typescript-eslint/parser": "4.2.0", 53 | "eslint": "7.10.0", 54 | "eslint-config-standard-with-typescript": "19.0.1", 55 | "eslint-plugin-import": "2.22.0", 56 | "eslint-plugin-jest": "24.0.2", 57 | "eslint-plugin-node": "11.1.0", 58 | "eslint-plugin-promise": "4.2.1", 59 | "eslint-plugin-standard": "4.0.1", 60 | "git-cz": "4.7.1", 61 | "jest": "29.7.0", 62 | "standard-changelog": "2.0.24", 63 | "ts-jest": "26.4.0", 64 | "typescript": "4.0.3" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Parser.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | import { StreamReader } from './StreamReader'; 3 | import http from 'http'; 4 | import https from 'https'; 5 | 6 | export interface ParserOptions { 7 | autoUpdate: boolean 8 | emptyInterval: number 9 | errorInterval: number 10 | keepListen: boolean 11 | metadataInterval: number 12 | notifyOnChangeOnly: boolean 13 | url: string 14 | userAgent: string 15 | } 16 | 17 | export interface ParserEvents { 18 | 'empty': () => void 19 | 'end': () => void 20 | 'error': (error: Error) => void 21 | 'metadata': (metadata: Map) => void 22 | 'stream': (stream: StreamReader) => void 23 | } 24 | 25 | export declare interface Parser { 26 | emit: (event: T, ...args: Parameters) => boolean 27 | on: (event: T, listener: ParserEvents[T]) => this 28 | } 29 | 30 | export class Parser extends EventEmitter { 31 | private previousMetadata: Map = new Map(); 32 | private readonly options: ParserOptions = { 33 | autoUpdate: true, 34 | emptyInterval: 5 * 60, 35 | errorInterval: 10 * 60, 36 | keepListen: false, 37 | metadataInterval: 5, 38 | notifyOnChangeOnly: false, 39 | url: '', 40 | userAgent: 'icecast-parser', 41 | }; 42 | 43 | public constructor (options: Partial) { 44 | super(); 45 | 46 | this.options = { ...this.options, ...options }; 47 | this.queueRequest(); 48 | } 49 | 50 | protected onRequestResponse (response: http.IncomingMessage): void { 51 | const icyMetaInt = response.headers['icy-metaint']; 52 | 53 | if (typeof icyMetaInt === 'undefined') { 54 | this.destroyResponse(response); 55 | this.queueNextRequest(this.options.emptyInterval); 56 | this.emit('empty'); 57 | } else { 58 | const reader = new StreamReader(Array.isArray(icyMetaInt) ? Number(icyMetaInt[0]) : Number(icyMetaInt)); 59 | 60 | reader.on('metadata', (metadata: Map) => { 61 | this.destroyResponse(response); 62 | this.queueNextRequest(this.options.metadataInterval); 63 | 64 | if (this.options.notifyOnChangeOnly && this.isMetadataChanged(metadata)) { 65 | this.previousMetadata = metadata; 66 | this.emit('metadata', metadata); 67 | } else if (!this.options.notifyOnChangeOnly) { 68 | this.emit('metadata', metadata); 69 | } 70 | }); 71 | 72 | response.pipe(reader); 73 | this.emit('stream', reader); 74 | } 75 | } 76 | 77 | protected onRequestError (error: Error): void { 78 | this.queueNextRequest(this.options.errorInterval); 79 | this.emit('error', error); 80 | } 81 | 82 | protected onSocketEnd (): void { 83 | if (this.options.keepListen) { 84 | this.emit('end'); 85 | } 86 | } 87 | 88 | protected makeRequest (): void { 89 | const request = this.options.url.startsWith('https://') 90 | ? https.request(this.options.url) 91 | : http.request(this.options.url); 92 | 93 | request.setHeader('Icy-MetaData', '1'); 94 | request.setHeader('User-Agent', this.options.userAgent); 95 | request.once('socket', (socket) => socket.once('end', this.onSocketEnd.bind(this))); 96 | request.once('response', this.onRequestResponse.bind(this)); 97 | request.once('error', this.onRequestError.bind(this)); 98 | request.end(); 99 | } 100 | 101 | protected destroyResponse (response: http.IncomingMessage): void { 102 | if (!this.options.keepListen) { 103 | response.destroy(); 104 | } 105 | } 106 | 107 | protected queueNextRequest (timeout: number): void { 108 | if (this.options.autoUpdate && !this.options.keepListen) { 109 | this.queueRequest(timeout); 110 | } 111 | } 112 | 113 | protected queueRequest (timeout = 0): void { 114 | setTimeout(this.makeRequest.bind(this), timeout * 1000); 115 | } 116 | 117 | protected isMetadataChanged (metadata: Map): boolean { 118 | for (const [key, value] of metadata.entries()) { 119 | if (this.previousMetadata.get(key) !== value) { 120 | return true; 121 | } 122 | } 123 | 124 | return false; 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/StreamReader.ts: -------------------------------------------------------------------------------- 1 | import { Transform } from 'stream'; 2 | 3 | export type Trampoline = T | ((...args: never[]) => Trampoline); 4 | export type TransformCallback = (error: Error | null | undefined, data?: Buffer) => void; 5 | 6 | const METADATA_BLOCK_SIZE = 16; 7 | const METADATA_REGEX = /(?\w+)=['"](?.+?)['"];/gu; 8 | 9 | const enum STATES { 10 | INIT_STATE, 11 | BUFFERING_STATE, 12 | PASSTHROUGH_STATE, 13 | } 14 | 15 | function parseMetadata (metadata: Buffer): Map { 16 | const map = new Map(); 17 | const data = metadata.toString('utf8'); 18 | const parts = [...data.replace(/\0*$/u, '').matchAll(METADATA_REGEX)]; 19 | 20 | parts.forEach((part) => map.set(part.groups?.key ?? '', part.groups?.value ?? '')); 21 | 22 | return map; 23 | } 24 | 25 | function trampoline < 26 | T, 27 | P extends never[], 28 | R extends Trampoline, 29 | F extends (...args: P) => R, 30 | > (fn: F): (...args: Parameters) => ReturnType { 31 | return function executor (...args: Parameters): ReturnType { 32 | let result = fn(...args); 33 | 34 | while (typeof result === 'function') { 35 | result = result() as ReturnType; 36 | } 37 | 38 | return result as ReturnType; 39 | }; 40 | } 41 | 42 | function processData (stream: StreamReader, chunk: Buffer, done: TransformCallback): TransformCallback { 43 | stream.bytesLeft -= chunk.length; 44 | 45 | if (stream.currentState === STATES.BUFFERING_STATE) { 46 | stream.buffers.push(chunk); 47 | stream.buffersLength += chunk.length; 48 | } else if (stream.currentState === STATES.PASSTHROUGH_STATE) { 49 | stream.push(chunk); 50 | } 51 | 52 | if (stream.bytesLeft === 0) { 53 | const { callback } = stream; 54 | const chunkToPass = stream.currentState === STATES.BUFFERING_STATE && stream.buffers.length > 1 55 | ? Buffer.concat(stream.buffers, stream.buffersLength) 56 | : chunk; 57 | 58 | stream.currentState = STATES.INIT_STATE; 59 | stream.callback = null; 60 | stream.buffers.splice(0); 61 | stream.buffersLength = 0; 62 | 63 | callback?.call(stream, chunkToPass); 64 | } 65 | 66 | return done; 67 | } 68 | 69 | const onData = trampoline((stream: StreamReader, chunk: Buffer, done: TransformCallback): () => TransformCallback => { 70 | if (chunk.length <= stream.bytesLeft) { 71 | return (): TransformCallback => processData(stream, chunk, done); 72 | } 73 | 74 | return (): TransformCallback => { 75 | const buffer = chunk.slice(0, stream.bytesLeft); 76 | 77 | return processData(stream, buffer, (error) => { 78 | if (error !== null && typeof error !== 'undefined') return done(error); 79 | if (chunk.length > buffer.length) { 80 | return (): TransformCallback => onData(stream, chunk.slice(buffer.length), done); 81 | } 82 | }); 83 | }; 84 | }); 85 | 86 | export class StreamReader extends Transform { 87 | public buffers: Buffer[] = []; 88 | public buffersLength = 0; 89 | public bytesLeft = 0; 90 | public callback: ((chunk: Buffer) => void) | null = null; 91 | public currentState = STATES.INIT_STATE; 92 | public readonly icyMetaInt: number = 0; 93 | 94 | public constructor (icyMetaInt: number) { 95 | super(); 96 | 97 | this.icyMetaInt = icyMetaInt; 98 | this.passthrough(this.icyMetaInt, this.onMetaSectionStart.bind(this)); 99 | } 100 | 101 | public _transform (chunk: Buffer, _encoding: string, done: TransformCallback): void { 102 | onData(this, chunk, done); 103 | } 104 | 105 | protected bytes (length: number, cb: (chunk: Buffer) => void): void { 106 | this.bytesLeft = length; 107 | this.currentState = STATES.BUFFERING_STATE; 108 | this.callback = cb; 109 | } 110 | 111 | protected passthrough (length: number, cb: (chunk: Buffer) => void): void { 112 | this.bytesLeft = length; 113 | this.currentState = STATES.PASSTHROUGH_STATE; 114 | this.callback = cb; 115 | } 116 | 117 | protected onMetaSectionStart (): void { 118 | this.bytes(1, this.onMetaSectionLengthByte.bind(this)); 119 | } 120 | 121 | protected onMetaSectionLengthByte (chunk: Buffer): void { 122 | const length = chunk[0] * METADATA_BLOCK_SIZE; 123 | 124 | if (length > 0) { 125 | this.bytes(length, this.onMetaData.bind(this)); 126 | } else { 127 | this.passthrough(this.icyMetaInt, this.onMetaSectionStart.bind(this)); 128 | } 129 | } 130 | 131 | protected onMetaData (chunk: Buffer): void { 132 | this.emit('metadata', parseMetadata(chunk)); 133 | this.passthrough(this.icyMetaInt, this.onMetaSectionStart.bind(this)); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /test/Parser.spec.ts: -------------------------------------------------------------------------------- 1 | import { Parser } from '../src/Parser'; 2 | 3 | describe('parser', () => { 4 | it('should properly emit metadata event from the radio', async () => await new Promise((resolve) => { 5 | expect.hasAssertions(); 6 | 7 | const radio = new Parser({ autoUpdate: false, url: 'https://live.hunter.fm/80s_high' }); 8 | radio.on('metadata', (metadata) => { 9 | expect(metadata.size).toBe(2); 10 | expect(metadata.get('StreamTitle')).toStrictEqual(expect.any(String)); 11 | expect(metadata.get('StreamUrl')).toStrictEqual(expect.any(String)); 12 | resolve(); 13 | }); 14 | })); 15 | 16 | it('should properly emit empty event from the radio when no support', async () => await new Promise((resolve) => { 17 | expect.hasAssertions(); 18 | 19 | let isMetadataFired = false; 20 | 21 | const radio = new Parser({ autoUpdate: false, url: 'http://stream-dc1.radioparadise.com:80/ogg-96m' }); 22 | 23 | radio.on('metadata', (_metadata) => { 24 | isMetadataFired = true; 25 | }); 26 | 27 | radio.on('empty', () => { 28 | expect(isMetadataFired).toBe(false); 29 | resolve(); 30 | }); 31 | })); 32 | 33 | it('should properly emit error event if request has failed', async () => await new Promise((resolve) => { 34 | expect.hasAssertions(); 35 | 36 | let isMetadataFired = false; 37 | let isEmptyFired = false; 38 | 39 | const radio = new Parser({ autoUpdate: false, url: 'http://non-existing-url.com' }); 40 | 41 | radio.on('metadata', (_metadata) => { 42 | isMetadataFired = true; 43 | }); 44 | 45 | radio.on('empty', () => { 46 | isEmptyFired = true; 47 | }); 48 | 49 | radio.on('error', (error) => { 50 | expect(isMetadataFired).toBe(false); 51 | expect(isEmptyFired).toBe(false); 52 | expect(error).toMatchObject({ errno: -3008, hostname: 'non-existing-url.com', syscall: 'getaddrinfo' }); 53 | resolve(); 54 | }); 55 | })); 56 | 57 | it('should properly emit metadata event when metadata has been updated', async () => await new Promise((resolve) => { 58 | expect.hasAssertions(); 59 | 60 | const radio = new Parser({ autoUpdate: false, notifyOnChangeOnly: true, url: 'https://live.hunter.fm/80s_high' }); 61 | radio.on('metadata', (metadata) => { 62 | // @ts-expect-error I want to check that metadata was stored in the private property to later comparsion 63 | expect(radio.previousMetadata).toStrictEqual(metadata); 64 | resolve(); 65 | }); 66 | })); 67 | }); 68 | -------------------------------------------------------------------------------- /test/StreamReader.spec.ts: -------------------------------------------------------------------------------- 1 | import { StreamReader } from '../src/StreamReader'; 2 | 3 | describe('stream reader', () => { 4 | it('should properly parse stream data, when there is no metadata at all', async () => await new Promise((resolve) => { 5 | expect.hasAssertions(); 6 | 7 | const reader = new StreamReader(1); 8 | const data = 'f\0a\0k\0e\x01\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \0d\0a\0t\0a'; 9 | 10 | let output = ''; 11 | let isMetadataFired = false; 12 | 13 | reader.on('metadata', (metadata: Map) => { 14 | expect(metadata.size).toBe(0); 15 | isMetadataFired = true; 16 | }); 17 | 18 | reader.on('data', (data: string) => { 19 | output += data; 20 | }); 21 | 22 | reader.on('end', () => { 23 | expect(isMetadataFired).toBe(true); 24 | expect(output).toBe('fake data'); 25 | resolve(); 26 | }); 27 | 28 | reader.end(data); 29 | })); 30 | 31 | it('should properly parse stream data', async () => await new Promise((resolve) => { 32 | expect.hasAssertions(); 33 | 34 | const reader = new StreamReader(1); 35 | const data = 'f\0a\0k\0e\x02StreamTitle=\'fake metadata\';\0\0\0\0 \0d\0a\0t\0a'; 36 | 37 | let output = ''; 38 | let isMetadataFired = false; 39 | 40 | reader.on('metadata', (metadata: Map) => { 41 | expect(metadata.get('StreamTitle')).toBe('fake metadata'); 42 | isMetadataFired = true; 43 | }); 44 | 45 | reader.on('data', (data: string) => { 46 | output += data; 47 | }); 48 | 49 | reader.on('end', () => { 50 | expect(isMetadataFired).toBe(true); 51 | expect(output).toBe('fake data'); 52 | resolve(); 53 | }); 54 | 55 | reader.end(data); 56 | })); 57 | 58 | it('should properly parse stream data with several semicolons', async () => await new Promise((resolve) => { 59 | expect.hasAssertions(); 60 | 61 | const reader = new StreamReader(1); 62 | const data = 'f\0a\0k\0e\x02StreamTitle=\'fake; meta;data\';\0\0 \0d\0a\0t\0a'; 63 | 64 | let output = ''; 65 | let isMetadataFired = false; 66 | 67 | reader.on('metadata', (metadata: Map) => { 68 | expect(metadata.get('StreamTitle')).toBe('fake; meta;data'); 69 | isMetadataFired = true; 70 | }); 71 | 72 | reader.on('data', (data: string) => { 73 | output += data; 74 | }); 75 | 76 | reader.on('end', () => { 77 | expect(isMetadataFired).toBe(true); 78 | expect(output).toBe('fake data'); 79 | resolve(); 80 | }); 81 | 82 | reader.end(data); 83 | })); 84 | 85 | it('should properly parse stream data with several semicolons and parts', async () => await new Promise((resolve) => { 86 | expect.hasAssertions(); 87 | 88 | // eslint-disable-next-line max-len 89 | const data = 'f\0a\0k\0e\x08StreamTitle=\'Kurtis; Blow; - Basketball; (1984)\';StreamUrl=\'&artist=Kurtis%20Blow&title=Basketball%20(1984)&idthumb=538\';\0\0\0\0\0\0\0 \0d\0a\0t\0a'; 90 | const reader = new StreamReader(1); 91 | 92 | let output = ''; 93 | let isMetadataFired = false; 94 | 95 | reader.on('metadata', (metadata: Map) => { 96 | expect(metadata.get('StreamTitle')).toBe('Kurtis; Blow; - Basketball; (1984)'); 97 | expect(metadata.get('StreamUrl')).toBe('&artist=Kurtis%20Blow&title=Basketball%20(1984)&idthumb=538'); 98 | isMetadataFired = true; 99 | }); 100 | 101 | reader.on('data', (data: string) => { 102 | output += data; 103 | }); 104 | 105 | reader.on('end', () => { 106 | expect(isMetadataFired).toBe(true); 107 | expect(output).toBe('fake data'); 108 | resolve(); 109 | }); 110 | 111 | reader.end(data); 112 | })); 113 | 114 | it('should properly parse stream data with several quotes inside', async () => await new Promise((resolve) => { 115 | expect.hasAssertions(); 116 | 117 | // eslint-disable-next-line max-len 118 | const data = 'f\0a\0k\0e\x08StreamTitle=\'Talk Talk - It\'s My Life (1984)\';StreamUrl=\'&artist=Talk%20Talk&title=It%27s%20My%20Life%20(1984)&idthumb=765\';\0\0\0\0 \0d\0a\0t\0a'; 119 | const reader = new StreamReader(1); 120 | 121 | let output = ''; 122 | let isMetadataFired = false; 123 | 124 | reader.on('metadata', (metadata: Map) => { 125 | expect(metadata.get('StreamTitle')).toBe('Talk Talk - It\'s My Life (1984)'); 126 | expect(metadata.get('StreamUrl')).toBe('&artist=Talk%20Talk&title=It%27s%20My%20Life%20(1984)&idthumb=765'); 127 | isMetadataFired = true; 128 | }); 129 | 130 | reader.on('data', (data: string) => { 131 | output += data; 132 | }); 133 | 134 | reader.on('end', () => { 135 | expect(isMetadataFired).toBe(true); 136 | expect(output).toBe('fake data'); 137 | resolve(); 138 | }); 139 | 140 | reader.end(data); 141 | })); 142 | }); 143 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": [ 4 | "**/*.*" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "alwaysStrict": true, 4 | "declaration": true, 5 | "declarationMap": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "module": "CommonJS", 9 | "moduleResolution": "node", 10 | "noFallthroughCasesInSwitch": true, 11 | "noImplicitAny": true, 12 | "noImplicitReturns": true, 13 | "noImplicitThis": true, 14 | "noUnusedLocals": true, 15 | "noUnusedParameters": true, 16 | "outDir": "dist", 17 | "removeComments": true, 18 | "rootDir": "src", 19 | "sourceMap": true, 20 | "strict": true, 21 | "strictBindCallApply": true, 22 | "strictFunctionTypes": true, 23 | "strictNullChecks": true, 24 | "strictPropertyInitialization": true, 25 | "target": "ES2020" 26 | }, 27 | "include": [ 28 | "src/**/*.*" 29 | ] 30 | } 31 | --------------------------------------------------------------------------------