From 625acd7e11950edff24e2fb882db6e6e601907b2 Mon Sep 17 00:00:00 2001 From: Grigory Gorshkov Date: Thu, 18 Feb 2021 21:19:04 +0300 Subject: [PATCH] Major improvements (#210) Major release --- .gitignore | 4 +- README.md | 58 ++-- examples/example.html | 4 +- examples/server.js | 41 +-- jest.config.json | 5 +- package.json | 15 +- src/__tests__/fail-path/common.fp.test.ts | 14 + src/__tests__/happy-path/event.hp.test.ts | 47 ++++ src/__tests__/happy-path/station.hp.test.ts | 82 ++++++ src/__tests__/test-utils.mock.ts | 41 +++ src/base/Playlist/Playlist.ts | 104 +++++++ src/base/Playlist/Playlist.types.ts | 22 ++ src/base/Playlist/__tests__/Playlist.test.ts | 3 + src/base/Playlist/__tests__/methods.test.ts | 16 ++ src/base/Playlist/methods.ts | 30 ++ src/base/Queuestream.ts | 69 +++++ src/base/Station.ts | 75 +++++ src/base/Track/Track.ts | 16 ++ .../Track.h.ts => base/Track/Track.types.ts} | 7 +- .../Track/__tests__/methods.test.ts} | 58 ++-- .../sound.ts => base/Track/methods.ts} | 21 +- src/base/__tests__/Queuestream.test.ts | 3 + src/config/index.ts | 19 ++ src/config/responseHeaders.ts | 13 + src/features/EventBus/EventBus.ts | 43 +++ src/features/EventBus/events.ts | 32 +++ src/features/Prebuffer.ts | 33 +++ src/features/__tests__/EventBus.test.ts | 36 +++ src/features/__tests__/Prebuffer.test.ts | 38 +++ src/index.ts | 6 +- src/sound/Playlist.ts | 48 ---- src/sound/Queuestream.ts | 89 ------ src/sound/Station.ts | 72 ----- src/sound/Track.ts | 22 -- src/sound/__tests__/Station.test.ts | 194 ------------- src/sound/defaults/responseHeaders.ts | 13 - src/sound/features/Prebuffer.ts | 31 --- src/sound/methods/playlist.ts | 24 -- src/types/Playlist.h.ts | 13 - src/types/public.h.ts | 26 +- src/types/untyped-modules.d.ts | 2 - src/utils/__tests__/deprecate.test.ts | 9 - src/utils/__tests__/funcs.test.ts | 8 +- src/utils/deprecate.ts | 5 - src/utils/fs.ts | 24 ++ src/utils/funcs.ts | 18 -- src/utils/logger.ts | 50 ++-- src/utils/time.ts | 6 + tsconfig.json | 5 +- yarn.lock | 261 +++++++++++++++++- 50 files changed, 1174 insertions(+), 701 deletions(-) create mode 100644 src/__tests__/fail-path/common.fp.test.ts create mode 100644 src/__tests__/happy-path/event.hp.test.ts create mode 100644 src/__tests__/happy-path/station.hp.test.ts create mode 100644 src/__tests__/test-utils.mock.ts create mode 100644 src/base/Playlist/Playlist.ts create mode 100644 src/base/Playlist/Playlist.types.ts create mode 100644 src/base/Playlist/__tests__/Playlist.test.ts create mode 100644 src/base/Playlist/__tests__/methods.test.ts create mode 100644 src/base/Playlist/methods.ts create mode 100644 src/base/Queuestream.ts create mode 100644 src/base/Station.ts create mode 100644 src/base/Track/Track.ts rename src/{types/Track.h.ts => base/Track/Track.types.ts} (83%) rename src/{sound/methods/__tests__/sound.test.ts => base/Track/__tests__/methods.test.ts} (66%) rename src/{sound/methods/sound.ts => base/Track/methods.ts} (79%) create mode 100644 src/base/__tests__/Queuestream.test.ts create mode 100644 src/config/index.ts create mode 100644 src/config/responseHeaders.ts create mode 100644 src/features/EventBus/EventBus.ts create mode 100644 src/features/EventBus/events.ts create mode 100644 src/features/Prebuffer.ts create mode 100644 src/features/__tests__/EventBus.test.ts create mode 100644 src/features/__tests__/Prebuffer.test.ts delete mode 100644 src/sound/Playlist.ts delete mode 100644 src/sound/Queuestream.ts delete mode 100644 src/sound/Station.ts delete mode 100644 src/sound/Track.ts delete mode 100644 src/sound/__tests__/Station.test.ts delete mode 100644 src/sound/defaults/responseHeaders.ts delete mode 100644 src/sound/features/Prebuffer.ts delete mode 100644 src/sound/methods/playlist.ts delete mode 100644 src/types/Playlist.h.ts delete mode 100644 src/utils/__tests__/deprecate.test.ts delete mode 100644 src/utils/deprecate.ts create mode 100644 src/utils/fs.ts diff --git a/.gitignore b/.gitignore index 758e7e4..9d17f5e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,6 @@ node_modules .DS_Store lib coverage -yarn-error.log \ No newline at end of file +yarn-error.log +.clinic +examples/music/.test \ No newline at end of file diff --git a/README.md b/README.md index b0e92a5..3cd4757 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Radio engine for NodeJS -[![build](https://img.shields.io/circleci/build/github/Kefir100/fridgefm-radio-core.svg)](https://circleci.com/gh/Kefir100/fridgefm-radio-core) -[![coverage](https://img.shields.io/codecov/c/gh/Kefir100/fridgefm-radio-core.svg)](https://codecov.io/gh/Kefir100/fridgefm-radio-core) +[![build](https://img.shields.io/circleci/build/github/ch1ller0/fridgefm-radio-core.svg)](https://circleci.com/gh/ch1ller0/fridgefm-radio-core) +[![coverage](https://img.shields.io/codecov/c/gh/ch1ller0/fridgefm-radio-core.svg)](https://codecov.io/gh/ch1ller0/fridgefm-radio-core) [![npm](https://img.shields.io/npm/dw/@fridgefm/radio-core.svg)](https://www.npmjs.com/package/@fridgefm/radio-core) -![GitHub](https://img.shields.io/github/license/kefir100/fridgefm-radio-core.svg) +![GitHub](https://img.shields.io/github/license/ch1ller0/fridgefm-radio-core.svg) ![node](https://img.shields.io/node/v/@fridgefm/radio-core.svg) ## Usage @@ -37,6 +37,17 @@ station.start(); /> ``` +## Station constructor +Creating a station is as simple as +```javascript +const myAwesomeStation = new Station({ + verbose: false, // if true - enables verbose logging (great for debugging), + responseHeaders: { // in case you want custom response headers for your endpoint + 'icy-genre': 'jazz' + } +}) +``` + ## Station methods ### Public methods that should be exposed to users `connectListener` connects real users to your station @@ -61,42 +72,39 @@ station.next(); ```javascript station.getPlaylist(); ``` -`shufflePlaylist` shuffles playlist once -You may want to pass your own sorting function, defaults to random shuffle -```javascript -station.shufflePlaylist(sortingFunction); -``` -`rearrangePlaylist` just returns you the entire playlist -```javascript -// the example moves the first track to the 5th position in playlist -station.rearrangePlaylist(0, 4); -``` ## Station events -#### `nextTrack` +Station emits several events - they are available via +```javascript +const { PUBLIC_EVENTS } = require('@fridgefm/radio-core') +``` +#### `NEXT_TRACK` event fires when track changes useful for getting to know when exactly the track changed and what track that is ```javascript -station.on('nextTrack', (track) => { console.log(track) }); +station.on(PUBLIC_EVENTS.NEXT_TRACK, (track) => { + const result = await track.getMetaAsync(); + console.log(result) +}) ``` -#### `start` -event fires on station start +#### `START` +Event fires on station start ```javascript -station.on('start', () => { console.log('Station started') }); +station.on(PUBLIC_EVENTS.START, () => { console.log('Station started') }); ``` -#### `restart` -event fires on station restart (when playlist is drained and new one is created) +#### `RESTART` +Event fires on station restart (when playlist is drained and new one is created) it might be a nice time to shuffle your playlist for example ```javascript -station.on('restart', () => { station.shufflePlaylist() }); +station.on(PUBLIC_EVENTS.RESTART, () => { /* do something*/ }); ``` -#### `error` -event fires when there is some error +#### `ERROR` +Event fires when there is some error. You `should` add this handler - otherwise any error will be treated as unhandled (causing `process.exit` depending on Node version) ```javascript -station.on('error', (e) => { handleError(e) }); +station.on(PUBLIC_EVENTS.ERROR, (e) => { handleError(e) }); ``` ## or just go to [examples](./examples/server.js) @@ -112,4 +120,4 @@ node examples/server.js [path/to/your_mp3tracks] ``` ## Demo -Fully working demo is available on http://fridgefm.com +Fully working demo is available on https://fridgefm.com diff --git a/examples/example.html b/examples/example.html index cd7c001..4a027a7 100644 --- a/examples/example.html +++ b/examples/example.html @@ -68,7 +68,7 @@

Station controls should be private

} const getCoverUrl = (image = {}) => { const { imageBuffer } = image; - if (!imageBuffer) return; + if (!imageBuffer) return 'https://t3.ftcdn.net/jpg/01/09/40/34/240_F_109403479_3BJH2QY7zrMV5OUGPePPmxPYZf0zY4lR.jpg'; const { imageBuffer: { data: uintarr } = {} } = image; const blob = new Blob([new Uint8Array(uintarr)]); const urlCreator = window.URL || window.webkitURL; @@ -104,7 +104,7 @@

Station controls should be private

request('/controls/shufflePlaylist').then(controlsGetPlaylist) } function controlsNext() { - request('/controls/next').then(controlsGetPlaylist) + request('/controls/next').then(controlsGetPlaylist).then(info) } function controlsRearrange({ oldIndex, newIndex }) { request(`/controls/rearrangePlaylist?oldIndex=${oldIndex}&newIndex=${newIndex}`).then(controlsGetPlaylist) diff --git a/examples/server.js b/examples/server.js index 61cdf1b..0b30eb6 100644 --- a/examples/server.js +++ b/examples/server.js @@ -1,31 +1,38 @@ const express = require('express'); const path = require('path'); -const { Station } = require('../lib/index'); +const { Station, SHUFFLE_METHODS, PUBLIC_EVENTS } = require('../lib/index'); const port = 3001; const server = express(); const musicPath = path.resolve(__dirname, process.argv[2] || './music'); -const station = new Station(); +const station = new Station({ + verbose: true, // for verbose logging to console + responseHeaders: { + 'icy-genre': 'jazz' + }, +}); // add folder to station station.addFolder(musicPath); // update currently playing track info let currentTrack; -station.on('nextTrack', async (track, stats) => { - console.log(`nextTrack handler took ${stats.time}ms`) +station.on(PUBLIC_EVENTS.NEXT_TRACK, async (track) => { const result = await track.getMetaAsync(); currentTrack = result; }); -station.on('start', () => { - station.shufflePlaylist(); +station.on(PUBLIC_EVENTS.START, () => { + // double the playlist on start + station.reorderPlaylist(a => a.concat(a)) }); -station.on('restart', (_, stats) => { - console.log(`restart handler took ${stats.time}ms`) - station.shufflePlaylist(); +station.on(PUBLIC_EVENTS.RESTART, () => { + station.reorderPlaylist(a => a.concat(a)) }); +// add this handler - otherwise any error will exit the process as unhandled +station.on(PUBLIC_EVENTS.ERROR, console.error) + // main stream route server.get('/stream', (req, res) => { station.connectListener(req, res); @@ -44,23 +51,23 @@ server.get('/controls/next', (req, res) => { // shuffle playlist server.get('/controls/shufflePlaylist', (req, res) => { - station.shufflePlaylist(); + station.reorderPlaylist(SHUFFLE_METHODS.randomShuffle()); res.json('Playlist shuffled'); }); +// rearrange tracks in a playlist +server.get('/controls/rearrangePlaylist', (req, res) => { + const { newIndex, oldIndex } = req.query; + station.reorderPlaylist(SHUFFLE_METHODS.rearrange({ from: oldIndex, to: newIndex })) + res.json(`Succesfully moved element from "${oldIndex}" to "${newIndex}"`); +}); + // just get the entire playlist server.get('/controls/getPlaylist', (req, res) => { const plist = station.getPlaylist(); res.json(plist); }); -// rearrange tracks in a playlist -server.get('/controls/rearrangePlaylist', (req, res) => { - const { newIndex, oldIndex } = req.query; - const { from, to } = station.rearrangePlaylist(oldIndex, newIndex); - res.json(`Succesfully moved element from "${from}" to "${to}"`); -}); - // route for serving static server.get('*', (req, res) => { res.sendFile(path.resolve(__dirname, './example.html')); diff --git a/jest.config.json b/jest.config.json index 0f3e71a..723461b 100644 --- a/jest.config.json +++ b/jest.config.json @@ -5,7 +5,7 @@ "transform": { "^.+\\.ts$": "ts-jest" }, - "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$", + "testRegex": "(test|spec)\\.[jt]sx?$", "collectCoverageFrom": [ "src/**/*.ts" ], @@ -14,5 +14,6 @@ "tsConfig": false, "diagnostics": false } - } + }, + "verbose": true } diff --git a/package.json b/package.json index 47ca337..cac6561 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@fridgefm/radio-core", "author": "Grigory Gorshkov", - "version": "2.3.2", + "version": "3.0.0", "description": "internet radio engine made on NodeJS platform", "license": "MIT", "main": "./lib/index.js", @@ -25,10 +25,11 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/Kefir100/fridgefm-radio-core.git" + "url": "git+https://github.com/ch1ller0/fridgefm-radio-core.git" }, "scripts": { "build": "tsc", + "watch": "tsc --watch", "lint": "eslint --fix ./src/**/*.*", "lint:ci": "eslint ./src/**/*.*", "test": "jest --config jest.config.json", @@ -38,15 +39,20 @@ "chalk": "^4.1.0", "dev-null": "^0.1.1", "express": "^4.17.1", + "fs-extra": "^9.1.0", "get-mp3-duration": "^1.0.0", "highland": "^2.13.5", "klaw-sync": "^6.0.0", "lodash": "^4.17.20", - "node-id3": "^0.2.2" + "node-id3": "^0.2.2", + "winston": "^3.3.3" }, "devDependencies": { "@types/express": "^4.17.11", + "@types/fs-extra": "^9.0.7", + "@types/highland": "^2.12.11", "@types/jest": "^26.0.20", + "@types/klaw-sync": "^6.0.0", "@types/lodash": "^4.14.168", "@types/node": "^14.14.25", "@typescript-eslint/eslint-plugin": "^4.14.2", @@ -58,10 +64,11 @@ "jest": "^26.6.3", "lint-staged": ">=10.5.4", "ts-jest": "^26.5.0", + "typed-emitter": "^1.3.1", "typescript": "4.1.3" }, "bugs": { - "url": "https://github.com/Kefir100/fridgefm-radio-core/issues" + "url": "https://github.com/ch1ller0/fridgefm-radio-core/issues" }, "homepage": "http://fridgefm.com", "directories": { diff --git a/src/__tests__/fail-path/common.fp.test.ts b/src/__tests__/fail-path/common.fp.test.ts new file mode 100644 index 0000000..b68f728 --- /dev/null +++ b/src/__tests__/fail-path/common.fp.test.ts @@ -0,0 +1,14 @@ +import { Station } from '../../index'; + +describe('public/FailPaths/common', () => { + it('non-existing folder - throws error', () => { + const station = new Station(); + expect(() => { + station.addFolder('biba'); // non-existing + }).toThrow(); + }); + + it.todo('track was deleted while playback - revalidates folders'); + it.todo('track has some problems while reading - skip it'); + it.todo('track has some problems on stream - skip it'); +}); diff --git a/src/__tests__/happy-path/event.hp.test.ts b/src/__tests__/happy-path/event.hp.test.ts new file mode 100644 index 0000000..92f9a2a --- /dev/null +++ b/src/__tests__/happy-path/event.hp.test.ts @@ -0,0 +1,47 @@ +import { Station, PUBLIC_EVENTS } from '../../index'; +import { pathToMusic } from '../test-utils.mock'; + +const createChecker = () => ({ + start: jest.fn(), + nextTrack: jest.fn(), + restart: jest.fn(), + error: jest.fn(), +}); + +describe('public/HappyPath/events', () => { + it('station events - fires', () => { + const checker = createChecker(); + const station = new Station(); + + station.on(PUBLIC_EVENTS.START, (...args) => checker.start(...args)); + station.on(PUBLIC_EVENTS.NEXT_TRACK, (...args) => checker.nextTrack(...args)); + station.on(PUBLIC_EVENTS.RESTART, (...args) => checker.restart(...args)); + station.on(PUBLIC_EVENTS.ERROR, (...args) => checker.error(...args)); + + station.addFolder(pathToMusic); + + // event "start" + station.start(); + + expect(checker.start).toHaveBeenCalledTimes(1); + // start event fired + expect(checker.start.mock.calls[0][0]).toEqual(station.getPlaylist()); + // expect(checker.start).toHaveBeenCalledWith(station.getPlaylist()); + // nextTrack event fired + expect(checker.nextTrack.mock.calls[0][0]).toEqual(station.getPlaylist()[0]); + + // event "nextTrack" + station.next(); + // nextTrack returns a track + expect(checker.nextTrack.mock.calls[1][0]).toEqual(station.getPlaylist()[1]); + + // event "restart" + station.next(); + + // returns playlist + expect(checker.restart.mock.calls[0][0].map((v) => v.fsStats)) + .toEqual(station.getPlaylist().map((v) => v.fsStats)); + + // @TODO find some way to test error event + }); +}); diff --git a/src/__tests__/happy-path/station.hp.test.ts b/src/__tests__/happy-path/station.hp.test.ts new file mode 100644 index 0000000..7ea8c91 --- /dev/null +++ b/src/__tests__/happy-path/station.hp.test.ts @@ -0,0 +1,82 @@ +import { Station } from '../../index'; +import { pathToMusic } from '../test-utils.mock'; + +describe('public/HappyPath/Station', () => { + describe('playlist methods', () => { + it('adding folders - deduplicates same tracks', () => { + const station = new Station(); + + expect(station.getPlaylist().length).toEqual(0); + station.addFolder(pathToMusic); + + expect(station.getPlaylist().length).toEqual(2); + station.addFolder(pathToMusic); + expect(station.getPlaylist().length).toEqual(2); + }); + + it.todo('reorder folders - customly changes tracks order'); + it.todo('getting playlist - returns the playlist'); + }); + + describe('control methods', () => { + const station = new Station(); + const getPlaying = (pl) => pl.filter((track) => track.isPlaying); + + describe('start method', () => { + it('playlist empty - start method wont execute', () => { + station.start(); + expect(getPlaying(station.getPlaylist())).toEqual([]); + + station.addFolder(pathToMusic); + // still empty because station has not started + expect(getPlaying(station.getPlaylist())).toEqual([]); + }); + + it('playlist not empty - start method executes', () => { + station.start(); + expect(getPlaying(station.getPlaylist()).length).toEqual(1); + }); + }); + + it('next method - switches to next track', () => { + const playing1 = getPlaying(station.getPlaylist()); + + station.next(); + + const playing2 = getPlaying(station.getPlaylist()); + + expect(playing1).not.toEqual(playing2); + }); + }); + + it('listner connects - response methods are called', () => { + const resMock = { + writeHead: jest.fn(), + write: jest.fn(), + emit: jest.fn(), + on: jest.fn(), + once: jest.fn(), + }; + + const cbMock = jest.fn(); + + const station = new Station(); + + station.addFolder(pathToMusic); + // @ts-ignore + station.connectListener(null, resMock, cbMock); + expect(resMock.writeHead.mock.calls[0][0]).toEqual(200); // success code + expect(resMock.writeHead.mock.calls[0][1]).toBeInstanceOf(Object); // response headers + expect(resMock.write.mock.calls[0][0]).toBeInstanceOf(Buffer); // prebuffer attached + expect(resMock.emit.mock.calls[0][0]).toEqual('pipe'); // pipes stream + expect(cbMock).toBeCalledTimes(1); // callback executed + + // @ts-ignore + station.connectListener(null, resMock); + expect(resMock.writeHead.mock.calls[1][0]).toEqual(200); // success code + expect(resMock.writeHead.mock.calls[1][1]).toBeInstanceOf(Object); // response headers + expect(resMock.write.mock.calls[1][0]).toBeInstanceOf(Buffer); // prebuffer attached + expect(resMock.emit.mock.calls[1][0]).toEqual('pipe'); // pipes stream + expect(cbMock).toBeCalledTimes(1); // callback executed + }); +}); diff --git a/src/__tests__/test-utils.mock.ts b/src/__tests__/test-utils.mock.ts new file mode 100644 index 0000000..0e1af19 --- /dev/null +++ b/src/__tests__/test-utils.mock.ts @@ -0,0 +1,41 @@ +/* eslint-disable max-classes-per-file */ +/* eslint-disable jest/no-export */ +import * as fs from 'fs-extra'; +import * as id3 from 'node-id3'; + +export const pathToMusic = `${process.cwd()}/examples/music`; + +export const tracks = [ + { fullPath: `${pathToMusic}/Artist1 - Track1.mp3` }, + { fullPath: `${pathToMusic}/Artist1 - Track2.mp3` }, +]; + +export class TestFile { + public fullPath: string; + + static TestPath = `${pathToMusic}/.test/`; + + constructor(name?: string) { + const created = Date.now().toString().slice(-8); + fs.ensureDirSync(TestFile.TestPath); + this.fullPath = `${TestFile.TestPath}${name || created}.mp3`; + fs.copyFileSync(tracks[0].fullPath, this.fullPath); + } + + addMeta(meta = {}) { + id3.write(meta, this.fullPath); + } + + remove() { + fs.removeSync(this.fullPath); + } +} + +// describe('test-utils', () => { +// it('new TestFile', () => { +// const t1 = new TestFile(); +// expect(fs.statSync(t1.fullPath).isFile()).toEqual(true); +// t1.remove(); +// expect(() => fs.statSync(t1.fullPath).isFile()).toThrow(); +// }); +// }); diff --git a/src/base/Playlist/Playlist.ts b/src/base/Playlist/Playlist.ts new file mode 100644 index 0000000..5e9c0c6 --- /dev/null +++ b/src/base/Playlist/Playlist.ts @@ -0,0 +1,104 @@ +import { createList } from '../../utils/fs'; +import { createTrackMap } from './methods'; +import { captureTime } from '../../utils/time'; +import { PUBLIC_EVENTS } from '../../features/EventBus/events'; + +import type { + TPlaylist, + TrackMap, + TrackList, + ReorderCb, + PathList, + PlaylistElement, +} from './Playlist.types'; +import type { EventBus } from '../../features/EventBus/EventBus'; +import type { InfoEvent } from '../../features/EventBus/events'; +import type { TTrack } from '../Track/Track.types'; + +type Deps = { eventBus: EventBus }; + +export class Playlist implements TPlaylist { + private _currentIndex = -1; + + private _list: PathList = []; + + private _tracksMap: TrackMap = new Map(); + + private _folders: Set = new Set(); + + private _deps: Deps; + + private revalidate() { + const ct = captureTime(); + this._list = createList(Array.from(this._folders)); + this._tracksMap = createTrackMap(this._list); + + const result = this.getList(); + this._emitInfo({ event: 'revalidate', message: 'Playlist revalidated', timings: ct() }); + return result; + } + + private _emitInfo(a: InfoEvent) { + this._deps.eventBus.emit(PUBLIC_EVENTS.INFO, { name: 'playlist', ...a }); + } + + constructor(deps: Deps) { + this._deps = deps; + } + + public addFolder(folder: string) { + this._folders.add(folder); + return this.revalidate(); + } + + public getNext(): PlaylistElement { + if (this._list.length - 1 === this._currentIndex) { // the playlist drained + const ct = captureTime(); + this.revalidate(); + this._currentIndex = 0; + this._deps.eventBus.emit(PUBLIC_EVENTS.RESTART, this.getList(), ct()); + } else { + this._currentIndex += 1; + } + const nextPath = this._list[this._currentIndex]; + const nextTrack = this._tracksMap.get(nextPath); + + if (!nextTrack) { + this._emitInfo({ level: 'warn', event: 'no-next-track', message: `No next track found for ${nextPath}` }); + // try next tracks + return this.getNext(); + } + nextTrack.playCount += 1; + + return { ...nextTrack, isPlaying: true }; + } + + public reorder(cb: ReorderCb) { + const ct = captureTime(); + const prevList = this.getList(); + const currentlyPlaying = prevList.find((v) => !!v.isPlaying); + + this._list = cb(prevList).map((b) => b.fsStats.fullPath); + this._currentIndex = this._list.findIndex((v) => v === currentlyPlaying?.fsStats.fullPath); + + this._emitInfo({ + level: 'info', + event: 'reorder', + message: 'Playlist reordered', + timings: ct(), + }); + + return this.getList(); + } + + public getList(): TrackList { + return this._list.map((v, i) => { + const tra = this._tracksMap.get(v) as TTrack; + + return { + ...tra, + isPlaying: this._currentIndex === i, + }; + }); + } +} diff --git a/src/base/Playlist/Playlist.types.ts b/src/base/Playlist/Playlist.types.ts new file mode 100644 index 0000000..8a2a08d --- /dev/null +++ b/src/base/Playlist/Playlist.types.ts @@ -0,0 +1,22 @@ +import type { TTrack, TrackPath } from '../Track/Track.types'; + +export type PlaylistElement = { + isPlaying: boolean +} & TTrack; + +export type SortAlg = (a: TTrack, b: TTrack) => number; + +export type ReorderCb = (current: PlaylistElement[]) => PlaylistElement[]; + +export type TrackList = PlaylistElement[]; + +export type PathList = readonly string[]; + +export type TrackMap = Map; + +export interface TPlaylist { + addFolder(folder: string): TrackList + reorder(cb: ReorderCb): TrackList + getList(): TrackList; + getNext(): TTrack; +} diff --git a/src/base/Playlist/__tests__/Playlist.test.ts b/src/base/Playlist/__tests__/Playlist.test.ts new file mode 100644 index 0000000..d862539 --- /dev/null +++ b/src/base/Playlist/__tests__/Playlist.test.ts @@ -0,0 +1,3 @@ +describe('base/Playlist', () => { + it.todo('test playlist interface (really big)'); +}); diff --git a/src/base/Playlist/__tests__/methods.test.ts b/src/base/Playlist/__tests__/methods.test.ts new file mode 100644 index 0000000..420d14b --- /dev/null +++ b/src/base/Playlist/__tests__/methods.test.ts @@ -0,0 +1,16 @@ +import { createTrackMap } from '../methods'; +import { tracks } from '../../../__tests__/test-utils.mock'; + +const tracksArr = tracks.map((v) => v.fullPath); + +describe('base/Playlist/createTrackMap', () => { + it('dedupes by path', () => { + const map = createTrackMap([...tracksArr, ...tracksArr]); + expect(Array.from(map)).toHaveLength(2); + }); + + it('inits with zero values', () => { + const map = createTrackMap(tracksArr); + expect(Array.from(map).every(([, tr]) => tr.playCount === 0)).toEqual(true); + }); +}); diff --git a/src/base/Playlist/methods.ts b/src/base/Playlist/methods.ts new file mode 100644 index 0000000..32a9069 --- /dev/null +++ b/src/base/Playlist/methods.ts @@ -0,0 +1,30 @@ +import * as Mp3 from '../../utils/mp3'; +import { extractLast, shuffleArray } from '../../utils/funcs'; +import { Track } from '../Track/Track'; + +import type { TrackList, TrackMap } from './Playlist.types'; + +export const createTrackMap = (paths: readonly string[]): TrackMap => paths + .filter((path) => { + const f = extractLast(path, '/'); + + return Mp3.isSupported(f[1]); + }) + .reduce((acc, path) => { + // deduplicate if already in map + if (acc.has(path)) { + return acc; + } + + return acc.set(path, new Track(path)); + }, new Map() as TrackMap); + +export const SHUFFLE_METHODS = { + rearrange: ({ to, from }: { to: number, from: number }) => (arr: TrackList) => { + const movedElement = arr.splice(from, 1)[0]; + arr.splice(to, 0, movedElement); + + return arr; + }, + randomShuffle: () => shuffleArray, +}; diff --git a/src/base/Queuestream.ts b/src/base/Queuestream.ts new file mode 100644 index 0000000..b4843fb --- /dev/null +++ b/src/base/Queuestream.ts @@ -0,0 +1,69 @@ +import * as devnull from 'dev-null'; +import { Readable, Transform, Writable } from 'stream'; +import { captureTime } from '../utils/time'; +import { Prebuffer } from '../features/Prebuffer'; +import { EventBus } from '../features/EventBus/EventBus'; +import { PUBLIC_EVENTS } from '../features/EventBus/events'; +import type { TPlaylist } from './Playlist/Playlist.types'; + +type Deps = { + playlist: TPlaylist, + eventBus: EventBus +}; + +export class QueueStream { + private _deps: Deps; + + // this stream is always live + private _current = new Transform({ + transform: (chunk, encoding, callback) => { + this._prebuffer.modify([chunk]); + callback(undefined, chunk); + }, + }); + + // this stream switches on each track + private _trackStream: Readable; + + // prebuffering for faster client response (side-effect) + private _prebuffer = new Prebuffer(); + + constructor(deps: Deps) { + this._deps = deps; + this.currentPipe(devnull(), { end: false }); + this._trackStream = new Readable(); + } + + private _handleError(error: Error, event: string) { + this._deps.eventBus.emit(PUBLIC_EVENTS.ERROR, { name: 'queuestream', error, event }); + this.next(); + } + + public getPrebuffer = () => this._prebuffer.getStorage(); + + public currentPipe = (wrstr: Writable, opts = {}) => this._current.pipe(wrstr, opts); + + public next = () => { + const ct = captureTime(); + const { playlist, eventBus } = this._deps; + const nextTrack = playlist.getNext(); + + // destroy previous track stream if there was one + this._trackStream?.destroy(); + + // populate newly created stream with some handlers + const [error, newStream] = nextTrack.getSound(); + if (error) { + this._handleError(error, 'get-sound-error'); + return; + } + + newStream.once('error', (e) => this._handleError(e, 'stream-error')); + newStream.once('end', this.next); + newStream.pipe(this._current, { end: false }); + + this._trackStream = newStream; + + eventBus.emit(PUBLIC_EVENTS.NEXT_TRACK, nextTrack, ct()); + }; +} diff --git a/src/base/Station.ts b/src/base/Station.ts new file mode 100644 index 0000000..f0f1308 --- /dev/null +++ b/src/base/Station.ts @@ -0,0 +1,75 @@ +import { noop } from 'lodash'; +import type { Response, Request } from 'express'; +import { QueueStream } from './Queuestream'; +import { Playlist } from './Playlist/Playlist'; +import { EventBus } from '../features/EventBus/EventBus'; +import { PUBLIC_EVENTS } from '../features/EventBus/events'; +import { captureTime } from '../utils/time'; +import { mergeConfig, Config } from '../config/index'; + +import type { TStation } from '../types/public.h'; +import type { TEmitter } from '../features/EventBus/events'; +import type { TPlaylist, ReorderCb } from './Playlist/Playlist.types'; + +interface StationDeps { + queuestream: QueueStream, + eventBus: EventBus, + playlist: TPlaylist, + config: Config +} + +export class Station implements TStation { + private _deps: StationDeps; + + constructor(extConfig?: Partial) { + const config = mergeConfig(extConfig || {}); + const eventBus = new EventBus({ config }); + const playlist = new Playlist({ eventBus }); + const queuestream = new QueueStream({ playlist, eventBus }); + + this._deps = { + playlist, + eventBus, + queuestream, + config, + }; + } + + public start() { + const ct = captureTime(); + const { eventBus, queuestream } = this._deps; + + if (this.getPlaylist().length) { + queuestream.next(); + } + + eventBus.emit(PUBLIC_EVENTS.START, this.getPlaylist(), ct()); + } + + public addFolder(folder: string) { + return this._deps.playlist.addFolder(folder); + } + + public next() { + return this._deps.queuestream.next(); + } + + public getPlaylist() { + return this._deps.playlist.getList(); + } + + public connectListener(_: Request, res: Response, cb = noop) { + const { currentPipe, getPrebuffer } = this._deps.queuestream; + + res.writeHead(200, this._deps.config.responseHeaders); + res.write(getPrebuffer()); + currentPipe(res); + cb(); + } + + public reorderPlaylist(cb: ReorderCb) { + return this._deps.playlist.reorder(cb); + } + + public on: TEmitter['on'] = (...args) => this._deps.eventBus.on(...args); +} diff --git a/src/base/Track/Track.ts b/src/base/Track/Track.ts new file mode 100644 index 0000000..e137743 --- /dev/null +++ b/src/base/Track/Track.ts @@ -0,0 +1,16 @@ +import { createSoundStream, getMetaAsync, getStats } from './methods'; +import type { TTrack, TrackStats } from './Track.types'; + +export class Track implements TTrack { + public playCount = 0; + + public readonly fsStats: TrackStats; + + constructor(fullPath: string) { + this.fsStats = getStats(fullPath); + } + + public getMetaAsync = () => getMetaAsync(this.fsStats); + + public getSound = () => createSoundStream(this.fsStats); +} diff --git a/src/types/Track.h.ts b/src/base/Track/Track.types.ts similarity index 83% rename from src/types/Track.h.ts rename to src/base/Track/Track.types.ts index fca8aec..95471c0 100644 --- a/src/types/Track.h.ts +++ b/src/base/Track/Track.types.ts @@ -19,10 +19,9 @@ export interface ShallowTrackMeta extends Tags { origin: 'id3' | 'fs', } -export interface TrackI { - isPlaying: boolean; - playCount: number; +export interface TTrack { + playCount: number fsStats: TrackStats; - getSound: () => Readable; + getSound: () => [Error | null, Readable]; getMetaAsync: () => Promise; } diff --git a/src/sound/methods/__tests__/sound.test.ts b/src/base/Track/__tests__/methods.test.ts similarity index 66% rename from src/sound/methods/__tests__/sound.test.ts rename to src/base/Track/__tests__/methods.test.ts index 0157fc5..43cdd90 100644 --- a/src/sound/methods/__tests__/sound.test.ts +++ b/src/base/Track/__tests__/methods.test.ts @@ -1,32 +1,8 @@ import * as devnull from 'dev-null'; -import * as id3 from 'node-id3'; -import * as fs from 'fs'; -import { getStats, getMetaAsync, createSoundStream } from '../sound'; - -const pathToMusic = `${process.cwd()}/examples/music`; - -const tracks = [ - { - fullPath: `${pathToMusic}/Artist1 - Track1.mp3`, - }, - { - fullPath: `${pathToMusic}/Artist1 - Track2.mp3`, - }, -]; - -// helper for creating mp3 files with custom meta -const TestFile = { - path: `${pathToMusic}/test - test.mp3`, - create(meta = {}) { - fs.copyFileSync(tracks[0].fullPath, this.path); - id3.write(meta, this.path); - }, - clear() { - fs.unlinkSync(this.path); - }, -}; - -describe('methods/sound', () => { +import { getStats, getMetaAsync, createSoundStream } from '../methods'; +import { pathToMusic, tracks, TestFile } from '../../../__tests__/test-utils.mock'; + +describe('base/Track/methods', () => { it('getStats', () => { const common = { bitrate: 16018, @@ -77,13 +53,13 @@ describe('methods/sound', () => { }); it('returns meta based on filename if id3 meta is not enough', async () => { - TestFile.create(); - - const res = await getMetaAsync(getStats(TestFile.path)); + const t1 = new TestFile('test - test'); + t1.addMeta({}); + const res = await getMetaAsync(getStats(t1.fullPath)); expect(res).toEqual({ artist: 'test', title: 'test', origin: 'fs' }); - TestFile.clear(); + t1.remove(); }); }); @@ -91,7 +67,7 @@ describe('methods/sound', () => { it('throws error on buggy stream', async () => { jest.useFakeTimers(); - const stream = createSoundStream({ fullPath: tracks[0].fullPath, bitrate: 16036 }); + const [, stream] = createSoundStream({ fullPath: tracks[0].fullPath, bitrate: 16036 }); const prom = new Promise((res, rej) => stream.on('error', (e) => rej(e))); const err = new Error('test_error'); stream.pipe(devnull()); @@ -107,12 +83,20 @@ describe('methods/sound', () => { await expect(prom).rejects.toEqual(err); }); - it('throws error on non-existent file', async () => { + it('returns empty stream on non-existent file', async () => { const fullPath = `${process.cwd()}/non-existent.mp3`; - const stream = createSoundStream({ fullPath, bitrate: 16036 }); + const [err, stream] = createSoundStream({ fullPath, bitrate: 16036 }); + stream.pipe(devnull()); + // const prom = new Promise((res, rej) => stream.on('error', (e: Error) => rej(e))); + await expect(err.message).toEqual(`ENOENT: no such file or directory, stat '${fullPath}'`); + }); + + it('returns empty stream on not-a-file path', async () => { + const fullPath = `${process.cwd()}/`; + const [err, stream] = createSoundStream({ fullPath, bitrate: 16036 }); stream.pipe(devnull()); - const prom = new Promise((res, rej) => stream.on('error', (e: Error) => rej(e))); - await expect(prom).rejects.toThrow(`ENOENT: no such file or directory, open '${fullPath}'`); + // const prom = new Promise((res, rej) => stream.on('error', (e: Error) => rej(e))); + await expect(err.message).toEqual(`Not a file: '${fullPath}'`); }); }); }); diff --git a/src/sound/methods/sound.ts b/src/base/Track/methods.ts similarity index 79% rename from src/sound/methods/sound.ts rename to src/base/Track/methods.ts index ce03150..7e1e969 100644 --- a/src/sound/methods/sound.ts +++ b/src/base/Track/methods.ts @@ -1,4 +1,4 @@ -import * as fs from 'fs'; +import * as fs from 'fs-extra'; import * as _ from 'highland'; import * as id3 from 'node-id3'; import type { Tags } from 'node-id3'; @@ -6,7 +6,7 @@ import { Readable } from 'stream'; import { extractLast } from '../../utils/funcs'; import { getDateFromMsecs } from '../../utils/time'; import * as Mp3 from '../../utils/mp3'; -import type { ShallowTrackMeta, TrackPath, TrackStats } from '../../types/Track.h'; +import type { ShallowTrackMeta, TrackPath, TrackStats } from './Track.types'; const getMetaAsync = async (stats: TrackStats): Promise => { const { fullPath, name } = stats; @@ -47,18 +47,27 @@ const getStats = (fullPath: TrackPath) => { }; }; -const createSoundStream = ({ fullPath, bitrate, tagsSize }: TrackStats): Readable => { +const createSoundStream = ({ fullPath, bitrate, tagsSize }: TrackStats): +[Error | null, Readable] => { try { - const rs = _(fs.createReadStream(fullPath, { highWaterMark: bitrate })); + if (!fs.statSync(fullPath).isFile()) { + throw new Error(`Not a file: '${fullPath}'`); + } + + const stream = _(fs.createReadStream(fullPath, { highWaterMark: bitrate })); + const comp = _.seq( + // @ts-ignore _.drop(Math.floor(tagsSize / bitrate)), // remove id3tags from stream + // @ts-ignore _.ratelimit(1, 1000), ); - return comp(rs); + return [null, comp(stream)]; } catch (e) { // skip track if it is not accessible - return _(new Array(0)); + // @ts-ignore + return [e, _(new Array(0))]; } }; diff --git a/src/base/__tests__/Queuestream.test.ts b/src/base/__tests__/Queuestream.test.ts new file mode 100644 index 0000000..e7d07ce --- /dev/null +++ b/src/base/__tests__/Queuestream.test.ts @@ -0,0 +1,3 @@ +describe('base/Queuestream', () => { + it.todo('test all methods'); +}); diff --git a/src/config/index.ts b/src/config/index.ts new file mode 100644 index 0000000..fffb092 --- /dev/null +++ b/src/config/index.ts @@ -0,0 +1,19 @@ +import { responseHeaders } from './responseHeaders'; + +export const defaultConfig = { + /** + * Pass your custom response headers from the station endpoint + */ + responseHeaders: responseHeaders(), + /** + * If set to true enables verbose info logging + */ + verbose: false, +}; + +export const mergeConfig = (cfg: Partial) => ({ + ...cfg, + responseHeaders: responseHeaders(cfg.responseHeaders), +}) as Config; + +export type Config = typeof defaultConfig; diff --git a/src/config/responseHeaders.ts b/src/config/responseHeaders.ts new file mode 100644 index 0000000..1a44125 --- /dev/null +++ b/src/config/responseHeaders.ts @@ -0,0 +1,13 @@ +export const responseHeaders = (cfg?: Record) => ({ + ...(cfg || { + 'icy-br': '56', + 'icy-genre': 'house', + 'icy-metaint': '0', + 'icy-pub': '0', + 'icy-url': 'https://', + }), + 'icy-name': '@fridgefm/radio-engine', + 'icy-notice1': 'Live radio powered by https://www.npmjs.com/package/@fridgefm/radio-engine', + 'Cache-Control': 'no-cache,no-store,must-revalidate,max-age=0', + 'Content-Type': 'audio/mpeg', +}) as Record; diff --git a/src/features/EventBus/EventBus.ts b/src/features/EventBus/EventBus.ts new file mode 100644 index 0000000..4540e2e --- /dev/null +++ b/src/features/EventBus/EventBus.ts @@ -0,0 +1,43 @@ +import { EventEmitter } from 'events'; +import { logger } from '../../utils/logger'; +import { PUBLIC_EVENTS } from './events'; +import { defaultConfig } from '../../config/index'; + +import type { TEmitter, InfoEvent, ErrorEvent } from './events'; + +export class EventBus { + private _emitter = new EventEmitter() as TEmitter; + + constructor({ config } = { config: defaultConfig }) { + this._infoEmitOn(); + if (config.verbose) this._cliLoggerOn(); + } + + private _cliLoggerOn() { + this.on(PUBLIC_EVENTS.INFO, ({ level, ...rest }: InfoEvent) => logger + .log(level || 'info', { ...rest })); + + this.on(PUBLIC_EVENTS.ERROR, (event: ErrorEvent) => logger + .log('error', { ...event, message: event.error.message })); + } + + private _infoEmitOn() { + this.on(PUBLIC_EVENTS.NEXT_TRACK, (tr, timings) => this.emit(PUBLIC_EVENTS.INFO, { + event: PUBLIC_EVENTS.NEXT_TRACK, + message: tr.fsStats.stringified, + timings, + })); + + this.on(PUBLIC_EVENTS.START, (_, timings) => this.emit(PUBLIC_EVENTS.INFO, { + event: PUBLIC_EVENTS.START, message: 'Station started', timings, + })); + + this.on(PUBLIC_EVENTS.RESTART, (_, timings) => this.emit(PUBLIC_EVENTS.INFO, { + event: PUBLIC_EVENTS.RESTART, message: 'Station restarted', timings, + })); + } + + on: TEmitter['on'] = (eventName, handler) => this._emitter.on(eventName, handler); + + emit: TEmitter['emit'] = (eventName, ...args) => this._emitter.emit(eventName, ...args); +} diff --git a/src/features/EventBus/events.ts b/src/features/EventBus/events.ts new file mode 100644 index 0000000..c01bd20 --- /dev/null +++ b/src/features/EventBus/events.ts @@ -0,0 +1,32 @@ +import type TypedEmitter from 'typed-emitter'; +import type { TrackList } from '../../base/Playlist/Playlist.types'; +import type { TTrack } from '../../base/Track/Track.types'; + +export const PUBLIC_EVENTS = { + ERROR: 'error', + INFO: 'einfo', + START: 'estart', + RESTART: 'erestart', + NEXT_TRACK: 'enexttrack', +} as const; + +type BaseEvent = { + event: string + name?: string + message?: string + timings?: number +}; + +export type InfoEvent = { level?: 'debug' | 'info' | 'warn' } & BaseEvent; + +export type ErrorEvent = { error: Error } & BaseEvent; + +export interface PublicEvents { + [PUBLIC_EVENTS.ERROR]: (i: ErrorEvent) => void, + [PUBLIC_EVENTS.INFO]: (i: InfoEvent) => void, + [PUBLIC_EVENTS.START]: (list: TrackList, timings: number) => void, + [PUBLIC_EVENTS.RESTART]: (list: TrackList, timings: number) => void, + [PUBLIC_EVENTS.NEXT_TRACK]: (tr: TTrack, timings: number) => void, +} + +export type TEmitter = TypedEmitter; diff --git a/src/features/Prebuffer.ts b/src/features/Prebuffer.ts new file mode 100644 index 0000000..c3030a7 --- /dev/null +++ b/src/features/Prebuffer.ts @@ -0,0 +1,33 @@ +import { Buffer } from 'buffer'; + +const PREBUFFER_LENGTH_DEFAULT = 12; + +type PrebufferArgs = { prebufferLength? : number }; + +export class Prebuffer { + private _storage: Buffer[]; + + private readonly _prebufferLength: number; + + constructor(args: PrebufferArgs = {}) { + const { prebufferLength } = args; + this._storage = []; + this._prebufferLength = prebufferLength || PREBUFFER_LENGTH_DEFAULT; + } + + public modify(chunks: Buffer[]) { + chunks.forEach((ch) => { + if (this._storage.length > this._prebufferLength) { + this._storage.shift(); + } + + this._storage.push(ch); + }); + } + + public getStorage() { + const totalPrebufferLength = (this._storage[0] || []).length * this._prebufferLength; + + return Buffer.concat(this._storage, totalPrebufferLength); + } +} diff --git a/src/features/__tests__/EventBus.test.ts b/src/features/__tests__/EventBus.test.ts new file mode 100644 index 0000000..e87360d --- /dev/null +++ b/src/features/__tests__/EventBus.test.ts @@ -0,0 +1,36 @@ +import { PUBLIC_EVENTS } from '../EventBus/events'; +import { EventBus } from '../EventBus/EventBus'; + +const createMocks = () => ({ + start: jest.fn(), + restart: jest.fn(), + next_track: jest.fn(), + info: jest.fn(), +}); + +describe('features/EventBus', () => { + it.each([ + ['START', { message: 'Station started', payload: [] }], + ['RESTART', { message: 'Station restarted', payload: [] }], + ['NEXT_TRACK', { message: 'stringified-mock', payload: { fsStats: { stringified: 'stringified-mock' } } }], + ])('"%s" infos public event', (eventName, { message, payload }) => { + const mocks = createMocks(); + const instance = new EventBus(); + const lowerCased = eventName.toLowerCase(); + + instance.on(PUBLIC_EVENTS.INFO, mocks.info); + instance.on(PUBLIC_EVENTS[eventName], mocks[lowerCased]); + expect(mocks[lowerCased]).toHaveBeenCalledTimes(0); + expect(mocks.info).toHaveBeenCalledTimes(0); + + instance.emit(PUBLIC_EVENTS[eventName], payload, 10); + + expect(mocks[lowerCased]).toHaveBeenCalledTimes(1); + expect(mocks[lowerCased]).toHaveBeenCalledWith(payload, 10); + + expect(mocks.info).toHaveBeenCalledTimes(1); + expect(mocks.info).toHaveBeenCalledWith({ + event: PUBLIC_EVENTS[eventName], message, timings: 10, + }); + }); +}); diff --git a/src/features/__tests__/Prebuffer.test.ts b/src/features/__tests__/Prebuffer.test.ts new file mode 100644 index 0000000..81b8378 --- /dev/null +++ b/src/features/__tests__/Prebuffer.test.ts @@ -0,0 +1,38 @@ +import { Buffer } from 'buffer'; +import { Prebuffer } from '../Prebuffer'; + +const createChunks = (from: string) => from.split('').map((v) => Buffer.from([Number(v)])); + +describe('features/Prebuffer', () => { + it('adds chunks up to max', () => { + const instance = new Prebuffer({ prebufferLength: 10 }); + expect(instance.getStorage()).toHaveLength(0); + + instance.modify(createChunks('1')); + + expect(instance.getStorage()).toHaveLength(10); + expect(instance.getStorage().join('')).toEqual('1000000000'); + }); + + it('does not overflow max values', () => { + const instance = new Prebuffer({ prebufferLength: 10 }); + + instance.modify(createChunks('1234567890')); + expect(instance.getStorage()).toHaveLength(10); + expect(instance.getStorage().join('')).toEqual('1234567890'); + + // overflow - the length stays the same + instance.modify(createChunks('1234')); + expect(instance.getStorage().join('')).toEqual('4567890123'); + + instance.modify(createChunks('1234')); + expect(instance.getStorage().join('')).toEqual('8901234123'); + }); + + it('uses default if length not set', () => { + const instance = new Prebuffer(); + instance.modify(createChunks('1')); + + expect(instance.getStorage()).toHaveLength(12); + }); +}); diff --git a/src/index.ts b/src/index.ts index 2fcbff8..e614b44 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,9 @@ -import { Station } from './sound/Station'; +import { Station } from './base/Station'; +import { PUBLIC_EVENTS } from './features/EventBus/events'; +import { SHUFFLE_METHODS } from './base/Playlist/methods'; export { Station, + PUBLIC_EVENTS, + SHUFFLE_METHODS, }; diff --git a/src/sound/Playlist.ts b/src/sound/Playlist.ts deleted file mode 100644 index 8cc9d48..0000000 --- a/src/sound/Playlist.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { findWithIndex, shuffleArray } from '../utils/funcs'; -import { logger } from '../utils/logger'; -import { createPlaylist, rearrangePlaylist } from './methods/playlist'; -import type { PlaylistI, SortAlg } from '../types/Playlist.h'; -import type { TrackI } from '../types/Track.h'; - -export class Playlist implements PlaylistI { - private tracks: TrackI[] = []; - - public createPlaylist(folder: string) { - this.tracks = [...this.tracks, ...createPlaylist(folder)]; - - return this.tracks; - } - - public getNext() { - const [currentTrack, currentIndex] = findWithIndex(this.tracks, (t) => t.isPlaying); - - if (currentTrack) { - currentTrack.isPlaying = false; - } - - const next = this.tracks[currentIndex + 1]; - if (next) { - next.isPlaying = true; - next.playCount += 1; - } - - return next; - } - - public shuffle(algorithm?: SortAlg) { - logger('Playlist:shuffle', 'bb'); - this.tracks = algorithm ? this.tracks.sort(algorithm) : shuffleArray(this.tracks); - return this.tracks; - } - - public rearrange(from: number, to: number) { - logger('Playlist:rearrange', 'bb'); - this.tracks = rearrangePlaylist(this.tracks, { from, to }); - - return this.tracks; - } - - public getList() { - return this.tracks; - } -} diff --git a/src/sound/Queuestream.ts b/src/sound/Queuestream.ts deleted file mode 100644 index b0c413c..0000000 --- a/src/sound/Queuestream.ts +++ /dev/null @@ -1,89 +0,0 @@ -import * as devnull from 'dev-null'; -import { EventEmitter } from 'events'; -import { Readable, Transform, Writable } from 'stream'; -import { logger } from '../utils/logger'; -import { Playlist } from './Playlist'; -import { Prebuffer } from './features/Prebuffer'; - -export class QueueStream { - public playlist = new Playlist(); - - // this stream is always live - private current = new Transform({ - transform: (chunk, encoding, callback) => { - this.prebuffer.modify(chunk); - callback(undefined, chunk); - }, - }); - - private eventBus = new EventEmitter(); - - // this stream switches on a each track - private trackStream: Readable; - - // prebuffering for faster client response (side-effect) - private prebuffer = new Prebuffer(); - - private folders: string[] = []; - - constructor() { - this.currentPipe(devnull(), { end: false }); - this.trackStream = new Readable(); - } - - public getPrebuffer = () => this.prebuffer.getStorage(); - - public currentPipe = (wrstr: Writable, opts = {}) => this.current.pipe(wrstr, opts); - - public next = () => { - const startTime = Date.now(); - const nextTrack = this.playlist.getNext(); - - if (nextTrack) { - // destroy previous track stream - if (this.trackStream) { - this.trackStream.destroy(); - } - - const newStream = nextTrack.getSound(); - newStream.once('error', (e: Error) => { - logger('Queuestream:error', 'r'); - logger(e, 'r', false); - this.eventBus.emit('error', e); - }); - newStream.once('end', this.next); - newStream.pipe(this.current, { end: false }); - this.trackStream = newStream; - logger(`Queuestream:nextTrack "${nextTrack.fsStats.stringified}"`, 'g'); - this.eventBus.emit('nextTrack', nextTrack, { time: Date.now() - startTime }); - } else { - this.restart(); - } - }; - - public addFolder(folder: string) { - this.folders = [...this.folders || [], folder]; - this.playlist.createPlaylist(folder); - } - - public start() { - logger('Queuestream:start', 'bb'); - this.eventBus.emit('start', this.playlist.getList()); - this.next(); - } - - public on(event: string, listener: (...args: any[]) => void) { - return this.eventBus.on(event, listener); - } - - private restart = () => { - const startTime = Date.now(); - logger('Queuestream:restart', 'bb'); - this.playlist = new Playlist(); - this.folders.forEach((folder) => { - this.playlist.createPlaylist(folder); - }); - this.eventBus.emit('restart', this.playlist.getList(), { time: Date.now() - startTime }); - this.next(); - }; -} diff --git a/src/sound/Station.ts b/src/sound/Station.ts deleted file mode 100644 index f386acc..0000000 --- a/src/sound/Station.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { EventEmitter } from 'events'; -import type { Response, Request } from 'express'; -import { noop } from 'lodash'; -import { QueueStream } from './Queuestream'; -import { responseHeaders } from './defaults/responseHeaders'; -import type { StationI, HandlerStats } from '../types/public.h'; -import type { SortAlg, TrackList } from '../types/Playlist.h'; -import type { TrackI } from '../types/Track.h'; - -// events that should be hoisted up to the station from queuestream -const EXPOSED_EVENTS = ['start', 'restart', 'error', 'nextTrack']; - -export class Station implements StationI { - private _queuestream: QueueStream; - - private _eventBus: EventEmitter; - - constructor() { - this._eventBus = new EventEmitter(); - this._queuestream = new QueueStream(); - EXPOSED_EVENTS.forEach((event) => { - this._queuestream.on(event, (...args) => this._eventBus.emit(event, ...args)); - }); - } - - public start() { - if (this.getPlaylist().length) { - this._queuestream.start(); - } - } - - public addFolder(folder: string) { - return this._queuestream.addFolder(folder); - } - - public next() { - return this._queuestream.next(); - } - - public getPlaylist() { - return this._queuestream.playlist.getList(); - } - - public shufflePlaylist(arg?: SortAlg) { - return this._queuestream.playlist.shuffle(arg); - } - - public rearrangePlaylist(from: number, to: number) { - return this._queuestream.playlist.rearrange(from, to); - } - - public connectListener(req: Request, res: Response, cb = noop) { - const { currentPipe, getPrebuffer } = this._queuestream; - - res.writeHead(200, responseHeaders); - res.write(getPrebuffer()); - currentPipe(res); - cb(); - } - - public on(event: 'start', listener: (pl: TrackList) => void): void - - public on(event: 'restart', listener: (pl: TrackList, stats: HandlerStats) => void): void - - public on(event: 'nextTrack', listener: (nextTrack: TrackI, stats: HandlerStats) => void): void - - public on(event: 'error', listener: (err: Error) => void): void - - public on(event: string, listener: (...args: any[]) => void) { - this._eventBus.on(event, listener); - } -} diff --git a/src/sound/Track.ts b/src/sound/Track.ts deleted file mode 100644 index 61033c9..0000000 --- a/src/sound/Track.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { createSoundStream, getMetaAsync, getStats } from './methods/sound'; -import type { TrackI, TrackStats } from '../types/Track.h'; - -export class Track implements TrackI { - public isPlaying = false; - - public playCount = 0; - - public readonly fsStats: TrackStats; - - constructor(fullPath: string) { - this.fsStats = getStats(fullPath); - } - - public getMetaAsync() { - return getMetaAsync(this.fsStats); - } - - public getSound() { - return createSoundStream(this.fsStats); - } -} diff --git a/src/sound/__tests__/Station.test.ts b/src/sound/__tests__/Station.test.ts deleted file mode 100644 index eaaf436..0000000 --- a/src/sound/__tests__/Station.test.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { times, range } from 'lodash'; -import { Station } from '../../index'; - -const pathToMusic = `${process.cwd()}/examples/music`; - -describe('public/Station', () => { - describe('playlist methods', () => { - it('addFolder works as expected', () => { - const station = new Station(); - - expect(station.getPlaylist().length).toEqual(0); - station.addFolder(pathToMusic); - - expect(station.getPlaylist().length).toEqual(2); - station.addFolder(pathToMusic); - expect(station.getPlaylist().length).toEqual(4); - }); - - it('shufflePlaylist with built-in random algorythm', () => { - const station = new Station(); - - // if random sort equals previous result, test will fail - // minimize random factor by scheduling more tracks - times(100, () => { // results in 200 tracks in playlist - station.addFolder(pathToMusic); - }); - - const firstPlaylist = station.getPlaylist(); - station.shufflePlaylist(); - const secondPlaylist = station.getPlaylist(); - - expect(firstPlaylist).not.toEqual(secondPlaylist); - expect(firstPlaylist.length).toEqual(secondPlaylist.length); - }); - - const getTracksSeq = (pl) => pl.map((t) => t.fsStats.name); - - it('shufflePlaylist with custom algorythm', () => { - const station = new Station(); - - times(5, () => { - station.addFolder(pathToMusic); - }); - - // custom sorting algorithm - station.shufflePlaylist((a, b) => { - const getLastChar = (t) => t[t.length - 1]; - - return getLastChar(a.fsStats.name) - getLastChar(b.fsStats.name); - }); - - const result = getTracksSeq(station.getPlaylist()); - - expect(range(0, 5).map(() => 'Artist1 - Track1')).toEqual(result.slice(0, 5)); - expect(range(5, 10).map(() => 'Artist1 - Track2')).toEqual(result.slice(5, 10)); - }); - - it('rearrangePlaylist works right', () => { - const station = new Station(); - - times(2, () => { - station.addFolder(pathToMusic); - }); - - expect(getTracksSeq(station.getPlaylist())).toEqual([ - 'Artist1 - Track1', - 'Artist1 - Track2', - 'Artist1 - Track1', - 'Artist1 - Track2', - ]); - - // swap first and second track position - station.rearrangePlaylist(0, 1); - - expect(getTracksSeq(station.getPlaylist())).toEqual([ - 'Artist1 - Track2', - 'Artist1 - Track1', - 'Artist1 - Track1', - 'Artist1 - Track2', - ]); - }); - }); - - describe('control methods', () => { - const station = new Station(); - const getPlaying = (pl) => pl.filter((track) => track.isPlaying); - - it('start method wont start if playlist empty', () => { - station.start(); - expect(getPlaying(station.getPlaylist())).toEqual([]); - - station.addFolder(pathToMusic); - // still empty because station has not started - expect(getPlaying(station.getPlaylist())).toEqual([]); - }); - - it('start if playlist not empty', () => { - station.start(); - expect(getPlaying(station.getPlaylist()).length).toEqual(1); - }); - - it('next method switches to next track', () => { - const playing1 = getPlaying(station.getPlaylist()); - - station.next(); - - const playing2 = getPlaying(station.getPlaylist()); - - expect(playing1).not.toEqual(playing2); - }); - }); - - it('connectListener method', () => { - const resMock = { - writeHead: jest.fn(), - write: jest.fn(), - emit: jest.fn(), - on: jest.fn(), - once: jest.fn(), - }; - - const cbMock = jest.fn(); - - const station = new Station(); - - station.addFolder(pathToMusic); - // @ts-ignore - station.connectListener(null, resMock, cbMock); - expect(resMock.writeHead.mock.calls[0][0]).toEqual(200); // success code - expect(resMock.writeHead.mock.calls[0][1]).toBeInstanceOf(Object); // response headers - expect(resMock.write.mock.calls[0][0]).toBeInstanceOf(Buffer); // prebuffer attached - expect(resMock.emit.mock.calls[0][0]).toEqual('pipe'); // pipes stream - expect(cbMock).toBeCalledTimes(1); // callback executed - - // @ts-ignore - station.connectListener(null, resMock); - expect(resMock.writeHead.mock.calls[1][0]).toEqual(200); // success code - expect(resMock.writeHead.mock.calls[1][1]).toBeInstanceOf(Object); // response headers - expect(resMock.write.mock.calls[1][0]).toBeInstanceOf(Buffer); // prebuffer attached - expect(resMock.emit.mock.calls[1][0]).toEqual('pipe'); // pipes stream - expect(cbMock).toBeCalledTimes(1); // callback executed - }); - - it('events firing', () => { - const station = new Station(); - const checker = { - start: jest.fn(), - nextTrack: jest.fn(), - restart: jest.fn(), - error: jest.fn(), - }; - - station.on('start', (...args) => checker.start(...args)); - station.on('nextTrack', (...args) => checker.nextTrack(...args)); - station.on('restart', (...args) => checker.restart(...args)); - station.on('error', (...args) => checker.error(...args)); - - station.addFolder(pathToMusic); - - // event "start" - station.start(); - // start event fired - expect(checker.start.mock.calls[0][0]).toEqual(station.getPlaylist()); - // nextTrack event fired - expect(checker.nextTrack.mock.calls[0][0]).toEqual(station.getPlaylist()[0]); - - // event "nextTrack" - station.next(); - // nextTrack returns a track - expect(checker.nextTrack.mock.calls[1][0]).toEqual(station.getPlaylist()[1]); - // also sends time which the handler took - const nextTrStats = checker.nextTrack.mock.calls[1][1]; - expect(typeof nextTrStats.time).toEqual('number'); - - // event "restart" - station.next(); - // returns playlist - expect(checker.restart.mock.calls[0][0]).toEqual(station.getPlaylist()); - // also sends time which the handler took - const nextStats = checker.nextTrack.mock.calls[1][1]; - expect(typeof nextStats.time).toEqual('number'); - - // @TODO find some way to test error event - }); - - describe('unhappy paths', () => { - it('wrong folder', () => { - const station = new Station(); - expect(() => { - station.addFolder('biba'); // non-existing - }).toThrow(); - }); - }); -}); diff --git a/src/sound/defaults/responseHeaders.ts b/src/sound/defaults/responseHeaders.ts deleted file mode 100644 index 8c1e9c2..0000000 --- a/src/sound/defaults/responseHeaders.ts +++ /dev/null @@ -1,13 +0,0 @@ - -// TODO add icy metaint https://github.com/Kefir100/fridgefm-radio-core/issues/16 -export const responseHeaders = { - 'Cache-Control': 'no-cache,no-store,must-revalidate,max-age=0', - 'Content-Type': 'audio/mpeg', - 'icy-br': '56', - 'icy-genre': 'house', - 'icy-metaint': '0', - 'icy-name': '@fridgefm/radio-engine', - 'icy-notice1': 'Live radio powered by https://www.npmjs.com/package/@fridgefm/radio-engine', - 'icy-pub': '0', - 'icy-url': 'https://', -}; diff --git a/src/sound/features/Prebuffer.ts b/src/sound/features/Prebuffer.ts deleted file mode 100644 index 6a7df46..0000000 --- a/src/sound/features/Prebuffer.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Buffer } from 'buffer'; - -const PREBUFFER_LENGTH_DEFAULT = 12; - -type PrebufferArgs = { prebufferLength? : number }; - -export class Prebuffer { - private storage: Buffer[]; - - private readonly prebufferLength: number; - - constructor(args: PrebufferArgs = {}) { - const { prebufferLength } = args; - this.storage = []; - this.prebufferLength = prebufferLength || PREBUFFER_LENGTH_DEFAULT; - } - - public modify(chunk: Buffer) { - if (this.storage.length > this.prebufferLength) { - this.storage.shift(); - } - - this.storage.push(chunk); - } - - public getStorage() { - const totalPrebufferLength = (this.storage[0] || []).length * this.prebufferLength; - - return Buffer.concat(this.storage, totalPrebufferLength); - } -} diff --git a/src/sound/methods/playlist.ts b/src/sound/methods/playlist.ts deleted file mode 100644 index cd69a71..0000000 --- a/src/sound/methods/playlist.ts +++ /dev/null @@ -1,24 +0,0 @@ -import * as klaw from 'klaw-sync'; -import { extractLast } from '../../utils/funcs'; -import * as Mp3 from '../../utils/mp3'; -import { Track } from '../Track'; -import type { TrackList } from '../../types/Playlist.h'; - -type KlawObj = { path: string }; - -export const createPlaylist = (folder: string): TrackList => klaw(folder, { nodir: true }) - .filter(({ path }: KlawObj) => { - const f = extractLast(path, '/'); - - return Mp3.isSupported(f[1]); - }) - .map(({ path }: KlawObj) => new Track(path)); - -type RearrangeOpts = { to: number, from: number }; - -export const rearrangePlaylist = (arr: TrackList, { to, from }: RearrangeOpts): TrackList => { - const movedElement = arr.splice(from, 1)[0]; - arr.splice(to, 0, movedElement); - - return arr; -}; diff --git a/src/types/Playlist.h.ts b/src/types/Playlist.h.ts deleted file mode 100644 index 0bb7253..0000000 --- a/src/types/Playlist.h.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { TrackI } from './Track.h'; - -export type SortAlg = (a: TrackI, b: TrackI) => number; - -export type TrackList = TrackI[]; - -export interface PlaylistI { - createPlaylist(folder: string): TrackList; - getNext(): TrackI; - shuffle(alg?: SortAlg): TrackList; - rearrange(from: number, to: number): TrackList; - getList(): TrackList; -} diff --git a/src/types/public.h.ts b/src/types/public.h.ts index e97ecd7..a2cbd9c 100644 --- a/src/types/public.h.ts +++ b/src/types/public.h.ts @@ -1,25 +1,13 @@ import type { Request, Response } from 'express'; -import type { TrackI } from './Track.h'; -import type { TrackList, SortAlg } from './Playlist.h'; +import type { TrackList, ReorderCb } from '../base/Playlist/Playlist.types'; +import type { TEmitter } from '../features/EventBus/events'; -export type HandlerStats = { - time: number // how much time the handler took -}; - -export type EmitterOverload = { - on(event: 'restart', listener: (pl: TrackList, stats: HandlerStats) => void): void; - on(event: 'nextTrack', listener: (nextTrack: TrackI, stats: HandlerStats) => void): void; - on(event: 'start', listener: (pl: TrackList) => void): void; - on(event: 'error', listener: (err: Error) => void): void; -}; - -export interface StationI extends EmitterOverload { - // new @TODO di container +export interface TStation { start(): void; addFolder(folder: string): void; next(): void; - getPlaylist(): TrackI[]; - shufflePlaylist(alg: SortAlg): void; - rearrangePlaylist(from: number, to: number): void; - connectListener(req: Request, res: Response, cb: () => void): void; + reorderPlaylist(cb: ReorderCb): TrackList + getPlaylist(): TrackList; + connectListener(req: Request | undefined, res: Response, cb: () => void): void; + on: TEmitter['on'] } diff --git a/src/types/untyped-modules.d.ts b/src/types/untyped-modules.d.ts index e62f2a3..74be1a5 100644 --- a/src/types/untyped-modules.d.ts +++ b/src/types/untyped-modules.d.ts @@ -1,4 +1,2 @@ declare module 'get-mp3-duration'; -declare module 'highland'; declare module 'dev-null'; -declare module 'klaw-sync'; diff --git a/src/utils/__tests__/deprecate.test.ts b/src/utils/__tests__/deprecate.test.ts deleted file mode 100644 index ab8f237..0000000 --- a/src/utils/__tests__/deprecate.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -const { deprecateError } = require('../deprecate'); - -describe('utils/deprecate', () => { - it('deprecateError', () => { - expect(() => deprecateError('deprecatedMethodName', 'alternativeMethod', 'referencedIssue')) - .toThrow(`"deprecatedMethodName" method is no longer supported, use "alternativeMethod" instead. -It is referenced in issue: referencedIssue`); - }); -}); diff --git a/src/utils/__tests__/funcs.test.ts b/src/utils/__tests__/funcs.test.ts index 7fcdb30..360df2a 100644 --- a/src/utils/__tests__/funcs.test.ts +++ b/src/utils/__tests__/funcs.test.ts @@ -1,4 +1,4 @@ -import { extractLast, shuffleArray, findWithIndex } from '../funcs'; +import { extractLast, shuffleArray } from '../funcs'; const arr = [1, 2, 3, 4]; @@ -15,10 +15,4 @@ describe('utils/funcs', () => { expect(shuffleArray(arr)).toHaveLength(arr.length); expect(shuffleArray(arr).sort((a, b) => a - b)).toEqual(arr); }); - - it('findWithIndex', () => { - expect(findWithIndex(arr, (v) => v === 1)).toEqual([1, 0]); - expect(findWithIndex(arr, (v) => v > 3)).toEqual([4, 3]); - expect(findWithIndex(arr, (v) => v > 5)).toEqual([undefined, -1]); - }); }); diff --git a/src/utils/deprecate.ts b/src/utils/deprecate.ts deleted file mode 100644 index 082e0b7..0000000 --- a/src/utils/deprecate.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const deprecateError = (oldKey: string, alternativeKey: string, issue: string) => { - const pre = `"${oldKey}" method is no longer supported, use "${alternativeKey}" instead. -${issue && `It is referenced in issue: ${issue}`}`; - throw new Error(pre); -}; diff --git a/src/utils/fs.ts b/src/utils/fs.ts new file mode 100644 index 0000000..1737fed --- /dev/null +++ b/src/utils/fs.ts @@ -0,0 +1,24 @@ +import * as klaw from 'klaw-sync'; +import * as fs from 'fs-extra'; +import * as Mp3 from './mp3'; + +const getPaths = (fullPath: string): readonly string[] => { + const stats = fs.statSync(fullPath); + + switch (true) { + case stats.isDirectory(): { + return klaw(fullPath, { nodir: true }).map(({ path }) => path); + } + case stats.isFile(): { + return [fullPath]; + } + default: return []; + } +}; + +export const createList = (folders: string[]) => folders + .reduce((acc, cur) => [ + ...acc, + ...getPaths(cur) + .filter(Mp3.isSupported), + ], [] as string[]); diff --git a/src/utils/funcs.ts b/src/utils/funcs.ts index 94a085f..50eb367 100644 --- a/src/utils/funcs.ts +++ b/src/utils/funcs.ts @@ -1,6 +1,3 @@ -export const identity = (x: T): T => x; -export const noop = (): void => {}; - export const extractLast = (str: string, symb: string) => { const temp = str.split(symb); const last = temp.pop() || ''; @@ -15,18 +12,3 @@ export const shuffleArray = (arr: T[]): T[] => arr }) .sort((a, b) => a[0] - b[0]) .map((a) => a[1]); - -export const findWithIndex = (arr: T[], cb: (s: T, i: number) => boolean) => { - const index = arr.findIndex(cb); - const element = arr[index]; - - if (!element) { - const notFound: [undefined, number] = [undefined, index]; - - return notFound; - } - - const res: [T, number] = [element, index]; - - return res; -}; diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 819ca3b..1967ebc 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -1,29 +1,35 @@ +/* eslint-disable no-param-reassign */ +import * as winston from 'winston'; import * as chalk from 'chalk'; -import { identity } from 'lodash'; import { getHHMMSS } from './time'; -const { NODE_ENV } = process.env; +const colors = { + error: chalk.redBright, + info: chalk.greenBright, + warn: chalk.yellowBright, + default: chalk.grey, + time: chalk.cyan, +}; -type Color = 'b' | 'bb' | 'bg' | 'br' | 'g' | 'r' | 't'; +const fridgeformat = winston.format((info) => { + // @ts-ignore + const chalked = colors[info.level] || colors.default; -const cols = { - b: chalk.blue, - bb: chalk.black.bgBlue, - bg: chalk.black.bgGreen, - br: chalk.black.bgRed, - g: chalk.green, - r: chalk.red, - t: identity, -}; + const add = { + timings: typeof info.timings !== 'undefined' ? `+${info.timings}ms` : '', + event: `${info.name || ''}:${info.event}::`, + }; -export const logger = (data: any, color: Color = 't', showTime = true, ...args: any): void => { - if (NODE_ENV !== 'development') { - return; - } - const stringData = typeof data === 'string'; - const time = showTime ? `${getHHMMSS(Date.now())} ` : ''; + // @ts-ignore + info.message = `${colors.time(getHHMMSS(Date.now()))} ${chalked(add.event)} ${info.message || ''} ${chalked(add.timings)}`; + return info; +}); - stringData // eslint-disable-line @typescript-eslint/no-unused-expressions - ? console.log(`${cols.b(time)}${cols[color](data)}${args[0] || ''}`) // eslint-disable-line no-console - : console.log(data); // eslint-disable-line no-console -}; +export const logger = winston.createLogger({ + format: fridgeformat(), + transports: [ + new winston.transports.Console({ + format: winston.format.cli(), + }), + ], +}); diff --git a/src/utils/time.ts b/src/utils/time.ts index de83260..67773c2 100644 --- a/src/utils/time.ts +++ b/src/utils/time.ts @@ -15,3 +15,9 @@ export const getHHMMSS = (ms: Msecs) => new Date(ms) .map((t) => parseInt(t, 10)) .map(zeroDangle) .join(':'); + +export const captureTime = () => { + const startTime = Date.now(); + + return () => Date.now() - startTime; +}; diff --git a/tsconfig.json b/tsconfig.json index d1a7b45..5821cd6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,10 +8,11 @@ "sourceMap": false, "strictNullChecks": true, "declaration": true, - "listEmittedFiles": true + "listEmittedFiles": true, + "experimentalDecorators": true }, "include": [ "./src/**/*" ], - "exclude": ["node_modules", "**/__tests__/"] + "exclude": ["node_modules", "**/__tests__/**/*.test.ts"] } \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index ed5662e..1347706 100644 --- a/yarn.lock +++ b/yarn.lock @@ -278,6 +278,15 @@ exec-sh "^0.3.2" minimist "^1.2.0" +"@dabh/diagnostics@^2.0.2": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@dabh/diagnostics/-/diagnostics-2.0.2.tgz#290d08f7b381b8f94607dc8f471a12c675f9db31" + integrity sha512-+A1YivoVDNNVCdfozHSR8v/jyuuLTMXwjWuxPFlFlUapXoGc+Gj9mDlTDDfrwl7rXCl2tNZ0kE8sIBO6YOn96Q== + dependencies: + colorspace "1.1.x" + enabled "2.0.x" + kuler "^2.0.0" + "@eslint/eslintrc@^0.3.0": version "0.3.0" resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-0.3.0.tgz#d736d6963d7003b6514e6324bec9c602ac340318" @@ -583,6 +592,13 @@ "@types/qs" "*" "@types/serve-static" "*" +"@types/fs-extra@^9.0.7": + version "9.0.7" + resolved "https://registry.yarnpkg.com/@types/fs-extra/-/fs-extra-9.0.7.tgz#a9ef2ffdab043def080c5bec94c03402f793577f" + integrity sha512-YGq2A6Yc3bldrLUlm17VNWOnUbnEzJ9CMgOeLFtQF3HOCN5lQBO8VyjG00a5acA5NNSM30kHVGp1trZgnVgi1Q== + dependencies: + "@types/node" "*" + "@types/graceful-fs@^4.1.2": version "4.1.4" resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.4.tgz#4ff9f641a7c6d1a3508ff88bc3141b152772e753" @@ -590,6 +606,13 @@ dependencies: "@types/node" "*" +"@types/highland@^2.12.11": + version "2.12.11" + resolved "https://registry.yarnpkg.com/@types/highland/-/highland-2.12.11.tgz#7d9f8c75698d1325b381ca6dc9e03b6ee66b69a1" + integrity sha512-h9BuAes/8T+DMSN+DPg6f8/bsAveX2H/Fu/r8HeYzmrqXIkBvm75b/UBlvbjUcrWKKTcatJOnxgl5Rr7IZeE9g== + dependencies: + "@types/node" "*" + "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": version "2.0.3" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz#4ba8ddb720221f432e443bd5f9117fd22cfd4762" @@ -627,6 +650,13 @@ resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4= +"@types/klaw-sync@^6.0.0": + version "6.0.0" + resolved "https://registry.yarnpkg.com/@types/klaw-sync/-/klaw-sync-6.0.0.tgz#ff0b36601efaaa109d513c4ced109311fd06ba36" + integrity sha512-Ibfb2jgpjYUxnl7RSVvUzOrv/vhkTVKEfPwQf9ZlDDsSyWVDp/2JtTBxO4eRrKBYtxc3cZQabdR38i8R0o1uww== + dependencies: + "@types/node" "*" + "@types/lodash@^4.14.168": version "4.14.168" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.168.tgz#fe24632e79b7ade3f132891afff86caa5e5ce008" @@ -953,11 +983,21 @@ astral-regex@^2.0.0: resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31" integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ== +async@^3.1.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/async/-/async-3.2.0.tgz#b3a2685c5ebb641d3de02d161002c60fc9f85720" + integrity sha512-TR2mEZFVOj2pLStYxLht7TyfuRzaydfpxr3k9RpHIzMgw7A64dzsdqCxH1WJyQdoe8T10nDXd9wnEigmiuHIZw== + asynckit@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" integrity sha1-x57Zf380y48robyXkLzDZkdLS3k= +at-least-node@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2" + integrity sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg== + atob@^2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9" @@ -1274,7 +1314,7 @@ collection-visit@^1.0.0: map-visit "^1.0.0" object-visit "^1.0.0" -color-convert@^1.9.0: +color-convert@^1.9.0, color-convert@^1.9.1: version "1.9.3" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== @@ -1293,11 +1333,40 @@ color-name@1.1.3: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= -color-name@~1.1.4: +color-name@^1.0.0, color-name@~1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== +color-string@^1.5.2: + version "1.5.4" + resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.5.4.tgz#dd51cd25cfee953d138fe4002372cc3d0e504cb6" + integrity sha512-57yF5yt8Xa3czSEW1jfQDE79Idk0+AkN/4KWad6tbdxUmAs3MvjxlWSWD4deYytcRfoZ9nhKyFl1kj5tBvidbw== + dependencies: + color-name "^1.0.0" + simple-swizzle "^0.2.2" + +color@3.0.x: + version "3.0.0" + resolved "https://registry.yarnpkg.com/color/-/color-3.0.0.tgz#d920b4328d534a3ac8295d68f7bd4ba6c427be9a" + integrity sha512-jCpd5+s0s0t7p3pHQKpnJ0TpQKKdleP71LWcA0aqiljpiuAkOSUFN/dyH8ZwF0hRmFlrIuRhufds1QyEP9EB+w== + dependencies: + color-convert "^1.9.1" + color-string "^1.5.2" + +colors@^1.2.1: + version "1.4.0" + resolved "https://registry.yarnpkg.com/colors/-/colors-1.4.0.tgz#c50491479d4c1bdaed2c9ced32cf7c7dc2360f78" + integrity sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA== + +colorspace@1.1.x: + version "1.1.2" + resolved "https://registry.yarnpkg.com/colorspace/-/colorspace-1.1.2.tgz#e0128950d082b86a2168580796a0aa5d6c68d8c5" + integrity sha512-vt+OoIP2d76xLhjwbBaucYlNSpPsrJWPlBTtwCpQKIu6/CSMutyzX93O/Do0qzpH3YoHEes8YEFXyZ797rEhzQ== + dependencies: + color "3.0.x" + text-hex "1.0.x" + combined-stream@^1.0.6, combined-stream@~1.0.6: version "1.0.8" resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" @@ -1369,7 +1438,7 @@ copy-descriptor@^0.1.0: resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d" integrity sha1-Z29us8OZl8LuGsOpJP1hJHSPV40= -core-util-is@1.0.2: +core-util-is@1.0.2, core-util-is@~1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= @@ -1593,6 +1662,11 @@ emoji-regex@^8.0.0: resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== +enabled@2.0.x: + version "2.0.0" + resolved "https://registry.yarnpkg.com/enabled/-/enabled-2.0.0.tgz#f9dd92ec2d6f4bbc0d5d1e64e21d61cd4665e7c2" + integrity sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ== + encodeurl@~1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" @@ -2030,6 +2104,11 @@ fast-levenshtein@^2.0.6, fast-levenshtein@~2.0.6: resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc= +fast-safe-stringify@^2.0.4: + version "2.0.7" + resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.0.7.tgz#124aa885899261f68aedb42a7c080de9da608743" + integrity sha512-Utm6CdzT+6xsDk2m8S6uL8VHxNwI6Jub+e9NYTcAms28T84pTa25GJQV9j0CY0N1rM8hK4x6grpF2BQf+2qwVA== + fastq@^1.6.0: version "1.10.1" resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.10.1.tgz#8b8f2ac8bf3632d67afcd65dac248d5fdc45385e" @@ -2044,6 +2123,11 @@ fb-watchman@^2.0.0: dependencies: bser "2.1.1" +fecha@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/fecha/-/fecha-4.2.0.tgz#3ffb6395453e3f3efff850404f0a59b6747f5f41" + integrity sha512-aN3pcx/DSmtyoovUudctc8+6Hl4T+hI9GBBHLjA76jdZl7+b1sgh5g4k+u/GL3dTy1/pnYzKp69FpJ0OicE3Wg== + figures@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/figures/-/figures-3.2.0.tgz#625c18bd293c604dc4a8ddb2febf0c88341746af" @@ -2131,6 +2215,11 @@ flatted@^3.1.0: resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.1.1.tgz#c4b489e80096d9df1dfc97c79871aea7c617c469" integrity sha512-zAoAQiudy+r5SvnSw3KJy5os/oRJYHzrzja/tBDqrZtNhUw8bt6y8OBzMWcjWr+8liV8Eb6yOhw8WZ7VFZ5ZzA== +fn.name@1.x.x: + version "1.1.0" + resolved "https://registry.yarnpkg.com/fn.name/-/fn.name-1.1.0.tgz#26cad8017967aea8731bc42961d04a3d5988accc" + integrity sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw== + for-in@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" @@ -2167,6 +2256,16 @@ fresh@0.5.2: resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" integrity sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac= +fs-extra@^9.1.0: + version "9.1.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.1.0.tgz#5954460c764a8da2094ba3554bf839e6b9a7c86d" + integrity sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ== + dependencies: + at-least-node "^1.0.0" + graceful-fs "^4.2.0" + jsonfile "^6.0.1" + universalify "^2.0.0" + fs.realpath@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" @@ -2290,10 +2389,10 @@ globby@^11.0.1: merge2 "^1.3.0" slash "^3.0.0" -graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.2.4: - version "4.2.5" - resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.5.tgz#bc18864a6c9fc7b303f2e2abdb9155ad178fbe29" - integrity sha512-kBBSQbz2K0Nyn+31j/w36fUfxkBW9/gfwRWdUY1ULReH3iokVJgddZAFcD1D0xlgTmFxJCbUkUclAlc6/IDJkw== +graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.4: + version "4.2.6" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.6.tgz#ff040b2b0853b23c3d31027523706f1885d76bee" + integrity sha512-nTnJ528pbqxYanhpDYsi4Rd8MAeaBA67+RZ10CM1m3bTAVFEDcd5AuA4a6W5YkGZ1iNXHzZz8T6TBKLeBuNriQ== growly@^1.3.0: version "1.3.0" @@ -2500,7 +2599,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@2.0.4: +inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@~2.0.3: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -2539,6 +2638,11 @@ is-arrayish@^0.2.1: resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" integrity sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0= +is-arrayish@^0.3.1: + version "0.3.2" + resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.2.tgz#4574a2ae56f7ab206896fb431eaeed066fdf8f03" + integrity sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ== + is-buffer@^1.1.5: version "1.1.6" resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" @@ -2725,7 +2829,7 @@ is-wsl@^2.2.0: dependencies: is-docker "^2.0.0" -isarray@1.0.0, isarray@^1.0.0: +isarray@1.0.0, isarray@^1.0.0, isarray@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE= @@ -3265,6 +3369,15 @@ json5@^1.0.1: dependencies: minimist "^1.2.0" +jsonfile@^6.0.1: + version "6.1.0" + resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" + integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ== + dependencies: + universalify "^2.0.0" + optionalDependencies: + graceful-fs "^4.1.6" + jsprim@^1.2.2: version "1.4.1" resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2" @@ -3311,6 +3424,11 @@ kleur@^3.0.3: resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w== +kuler@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/kuler/-/kuler-2.0.0.tgz#e2c570a3800388fb44407e851531c1d670b061b3" + integrity sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A== + leven@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2" @@ -3432,6 +3550,17 @@ log-update@^4.0.0: slice-ansi "^4.0.0" wrap-ansi "^6.2.0" +logform@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/logform/-/logform-2.2.0.tgz#40f036d19161fc76b68ab50fdc7fe495544492f2" + integrity sha512-N0qPlqfypFx7UHNn4B3lzS/b0uLqt2hmuoa+PpuXNYgozdJYAyauF5Ky0BWVjrxDlMWiT3qN4zPq3vVAfZy7Yg== + dependencies: + colors "^1.2.1" + fast-safe-stringify "^2.0.4" + fecha "^4.2.0" + ms "^2.1.1" + triple-beam "^1.3.0" + lru-cache@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" @@ -3584,6 +3713,11 @@ ms@2.1.2: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== +ms@^2.1.1: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + nanomatch@^1.2.9: version "1.2.13" resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119" @@ -3768,6 +3902,13 @@ once@^1.3.0, once@^1.3.1, once@^1.4.0: dependencies: wrappy "1" +one-time@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/one-time/-/one-time-1.0.0.tgz#e06bc174aed214ed58edede573b433bbf827cb45" + integrity sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g== + dependencies: + fn.name "1.x.x" + onetime@^5.1.0: version "5.1.2" resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e" @@ -4034,6 +4175,11 @@ pretty-format@^26.0.0, pretty-format@^26.6.2: ansi-styles "^4.0.0" react-is "^17.0.1" +process-nextick-args@~2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" + integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== + progress@^2.0.0: version "2.0.3" resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" @@ -4139,6 +4285,28 @@ read-pkg@^5.2.0: parse-json "^5.0.0" type-fest "^0.6.0" +readable-stream@^2.3.7: + version "2.3.7" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57" + integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw== + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.3" + isarray "~1.0.0" + process-nextick-args "~2.0.0" + safe-buffer "~5.1.1" + string_decoder "~1.1.1" + util-deprecate "~1.0.1" + +readable-stream@^3.4.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198" + integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA== + dependencies: + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + regex-not@^1.0.0, regex-not@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/regex-not/-/regex-not-1.0.2.tgz#1f4ece27e00b0b65e0247a6810e6a85d83a5752c" @@ -4296,12 +4464,12 @@ rxjs@^6.6.3: dependencies: tslib "^1.9.0" -safe-buffer@5.1.2, safe-buffer@~5.1.1: +safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: version "5.1.2" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== -safe-buffer@^5.0.1, safe-buffer@^5.1.2: +safe-buffer@^5.0.1, safe-buffer@^5.1.2, safe-buffer@~5.2.0: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== @@ -4450,6 +4618,13 @@ signal-exit@^3.0.0, signal-exit@^3.0.2: resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.3.tgz#a1410c2edd8f077b08b4e253c8eacfcaf057461c" integrity sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA== +simple-swizzle@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a" + integrity sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo= + dependencies: + is-arrayish "^0.3.1" + sisteransi@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" @@ -4600,6 +4775,11 @@ sshpk@^1.7.0: safer-buffer "^2.0.2" tweetnacl "~0.14.0" +stack-trace@0.0.x: + version "0.0.10" + resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.10.tgz#547c70b347e8d32b4e108ea1a2a159e5fdde19c0" + integrity sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA= + stack-utils@^2.0.2: version "2.0.3" resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-2.0.3.tgz#cd5f030126ff116b78ccb3c027fe302713b61277" @@ -4663,6 +4843,20 @@ string.prototype.trimstart@^1.0.3: call-bind "^1.0.0" define-properties "^1.1.3" +string_decoder@^1.1.1: + version "1.3.0" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" + integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== + dependencies: + safe-buffer "~5.2.0" + +string_decoder@~1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" + integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== + dependencies: + safe-buffer "~5.1.0" + stringify-object@^3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/stringify-object/-/stringify-object-3.3.0.tgz#703065aefca19300d3ce88af4f5b3956d7556629" @@ -4758,6 +4952,11 @@ test-exclude@^6.0.0: glob "^7.1.4" minimatch "^3.0.4" +text-hex@1.0.x: + version "1.0.0" + resolved "https://registry.yarnpkg.com/text-hex/-/text-hex-1.0.0.tgz#69dc9c1b17446ee79a92bf5b884bb4b9127506f5" + integrity sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg== + text-table@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" @@ -4844,6 +5043,11 @@ tr46@^2.0.2: dependencies: punycode "^2.1.1" +triple-beam@^1.2.0, triple-beam@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/triple-beam/-/triple-beam-1.3.0.tgz#a595214c7298db8339eeeee083e4d10bd8cb8dd9" + integrity sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw== + ts-jest@^26.5.0: version "26.5.0" resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-26.5.0.tgz#3e3417d91bc40178a6716d7dacc5b0505835aa21" @@ -4937,6 +5141,11 @@ type-is@~1.6.17, type-is@~1.6.18: media-typer "0.3.0" mime-types "~2.1.24" +typed-emitter@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/typed-emitter/-/typed-emitter-1.3.1.tgz#c98d71551a99d5f08ba9085ee44b8fc9b2357502" + integrity sha512-2h7utWyXgd2R2u2IuL8B4yu1gqMxbgUj2VS/MGVbFhEVQNJKXoQQoS5CBMh+eW31zFeSmDfEQ3qQf4xy5SlPVQ== + typedarray-to-buffer@^3.1.5: version "3.1.5" resolved "https://registry.yarnpkg.com/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz#a97ee7a9ff42691b9f783ff1bc5112fe3fca9080" @@ -4959,6 +5168,11 @@ union-value@^1.0.0: is-extendable "^0.1.1" set-value "^2.0.1" +universalify@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717" + integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ== + unpipe@1.0.0, unpipe@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" @@ -4989,7 +5203,7 @@ use@^3.1.0: resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" integrity sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ== -util-deprecate@^1.0.2: +util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= @@ -5121,6 +5335,29 @@ which@^2.0.1, which@^2.0.2: dependencies: isexe "^2.0.0" +winston-transport@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/winston-transport/-/winston-transport-4.4.0.tgz#17af518daa690d5b2ecccaa7acf7b20ca7925e59" + integrity sha512-Lc7/p3GtqtqPBYYtS6KCN3c77/2QCev51DvcJKbkFPQNoj1sinkGwLGFDxkXY9J6p9+EPnYs+D90uwbnaiURTw== + dependencies: + readable-stream "^2.3.7" + triple-beam "^1.2.0" + +winston@^3.3.3: + version "3.3.3" + resolved "https://registry.yarnpkg.com/winston/-/winston-3.3.3.tgz#ae6172042cafb29786afa3d09c8ff833ab7c9170" + integrity sha512-oEXTISQnC8VlSAKf1KYSSd7J6IWuRPQqDdo8eoRNaYKLvwSb5+79Z3Yi1lrl6KDpU6/VWaxpakDAtb1oQ4n9aw== + dependencies: + "@dabh/diagnostics" "^2.0.2" + async "^3.1.0" + is-stream "^2.0.0" + logform "^2.2.0" + one-time "^1.0.0" + readable-stream "^3.4.0" + stack-trace "0.0.x" + triple-beam "^1.3.0" + winston-transport "^4.4.0" + word-wrap@^1.2.3, word-wrap@~1.2.3: version "1.2.3" resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c"