├── .eslintignore ├── test ├── tsconfig.json ├── __helpers__ │ └── utils.ts ├── sources │ ├── itunes │ │ ├── __snapshots__ │ │ │ └── ITunesSourceProvider.test.ts.snap │ │ └── ITunesSourceProvider.test.ts │ ├── spotify │ │ ├── __snapshots__ │ │ │ └── SpotifySourceProvider.test.ts.snap │ │ └── SpotifySourceProvider.test.ts │ └── lastfm │ │ └── LastFMSourceProvider.test.ts ├── __fixtures__ │ └── lastfm-responses.ts ├── SimpleEventEmitter.test.ts ├── GitHubNowPlaying.test.ts ├── SourceProvider.test.ts └── NowPlayingMonitor.test.ts ├── .gitignore ├── lib ├── index.ts ├── types.ts ├── sources │ ├── index.ts │ ├── itunes │ │ └── ITunesSourceProvider.ts │ ├── spotify │ │ └── SpotifySourceProvider.ts │ └── lastfm │ │ └── LastFMSourceProvider.ts ├── GitHubNowPlaying.ts ├── SimpleEventEmitter.ts ├── SourceProvider.ts └── NowPlayingMonitor.ts ├── .editorconfig ├── .travis.yml ├── jest.config.js ├── tsconfig.json ├── .eslintrc ├── LICENSE ├── package.json └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | dist 4 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": ["."] 4 | } 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | .DS_Store 4 | .npmrc 5 | *.log 6 | .vscode 7 | coverage 8 | package-lock.json 9 | test/tmp 10 | .env 11 | -------------------------------------------------------------------------------- /lib/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | GitHubNowPlaying, 3 | GitHubNowPlayingConstructorOptions, 4 | NowPlayingSources, 5 | } from './GitHubNowPlaying'; 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | -------------------------------------------------------------------------------- /test/__helpers__/utils.ts: -------------------------------------------------------------------------------- 1 | export function runNextUpdateTick() { 2 | return new Promise(resolve => { 3 | process.nextTick(resolve); 4 | jest.runOnlyPendingTimers(); 5 | }); 6 | } 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - node 5 | cache: 6 | yarn: true 7 | directories: 8 | - node_modules 9 | script: 10 | - yarn run test:all 11 | after_success: 12 | - yarn run report-coverage 13 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | collectCoverageFrom: [ 5 | '/lib/**/*.ts', 6 | '!**/node_modules/**', 7 | '!/lib/**/index.ts', 8 | ], 9 | testMatch: ['/test/**/*.test.ts'], 10 | roots: ['/lib/', '/test/'], 11 | moduleNameMapper: { 12 | '^lib/(.*)': '/lib/$1', 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /lib/types.ts: -------------------------------------------------------------------------------- 1 | import { UserStatus } from 'github-profile-status'; 2 | 3 | export interface NowPlayingTrack { 4 | title: string; 5 | artist: string; 6 | } 7 | 8 | export interface NowPlayingStatus { 9 | emoji: UserStatus['emoji']; 10 | message: string | null; 11 | } 12 | 13 | export interface StatusPublisher { 14 | set(status: NowPlayingStatus): Promise; 15 | clear(): Promise; 16 | } 17 | -------------------------------------------------------------------------------- /lib/sources/index.ts: -------------------------------------------------------------------------------- 1 | import { LastFMSourceProvider } from './lastfm/LastFMSourceProvider'; 2 | import { ITunesSourceProvider } from './itunes/ITunesSourceProvider'; 3 | import { SpotifySourceProvider } from './spotify/SpotifySourceProvider'; 4 | 5 | const NowPlayingSources = { 6 | LastFM: LastFMSourceProvider, 7 | ITunes: ITunesSourceProvider, 8 | Spotify: SpotifySourceProvider, 9 | }; 10 | 11 | export { NowPlayingSources }; 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "incremental": true, 4 | "target": "es2017", 5 | "module": "commonjs", 6 | "lib": ["es2016", "dom"], 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "declaration": true, 10 | "outDir": "dist", 11 | "baseUrl": ".", 12 | "keyofStringsOnly": true, 13 | "paths": { 14 | "lib/*": ["lib/*"] 15 | } 16 | }, 17 | "include": ["lib"], 18 | "exclude": ["node_modules", "dist"] 19 | } 20 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@wsmd/eslint-config/base", 4 | "@wsmd/eslint-config/typescript", 5 | "@wsmd/eslint-config/prettier", 6 | "@wsmd/eslint-config/jest" 7 | ], 8 | "overrides": [ 9 | { 10 | "files": ["test/**/*.ts"], 11 | "rules": { 12 | "@typescript-eslint/explicit-function-return-type": "off", 13 | "@typescript-eslint/no-explicit-any": "off", 14 | "max-classes-per-file": "off" 15 | } 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /lib/GitHubNowPlaying.ts: -------------------------------------------------------------------------------- 1 | import { GitHubProfileStatus, UserStatus } from 'github-profile-status'; 2 | import { NowPlayingMonitor } from './NowPlayingMonitor'; 3 | import { NowPlayingSources } from './sources'; 4 | 5 | export interface GitHubNowPlayingConstructorOptions { 6 | token: string; 7 | } 8 | 9 | class GitHubNowPlaying extends NowPlayingMonitor { 10 | public static Sources = NowPlayingSources; 11 | 12 | constructor(options: GitHubNowPlayingConstructorOptions) { 13 | super(new GitHubProfileStatus(options)); 14 | } 15 | } 16 | 17 | export { GitHubNowPlaying, NowPlayingSources }; 18 | -------------------------------------------------------------------------------- /test/sources/itunes/__snapshots__/ITunesSourceProvider.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`ITunesSourceProvider calls osascript.jxa with correct script 1`] = ` 4 | Array [ 5 | Array [ 6 | " 7 | const iTunes = Application('iTunes'); 8 | 9 | if (!iTunes.running()) { 10 | return null; 11 | } 12 | 13 | if (iTunes.playerState() !== 'playing') { 14 | return null; 15 | } 16 | 17 | return { 18 | artist: iTunes.currentTrack.artist(), 19 | title: iTunes.currentTrack.name(), 20 | }; 21 | ", 22 | ], 23 | ] 24 | `; 25 | -------------------------------------------------------------------------------- /test/sources/spotify/__snapshots__/SpotifySourceProvider.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`SpotifySourceProvider calls osascript.jxa with correct script 1`] = ` 4 | Array [ 5 | Array [ 6 | " 7 | const spotify = Application('Spotify'); 8 | 9 | if (!spotify.running()) { 10 | return null; 11 | } 12 | 13 | if (spotify.playerState() !== 'playing') { 14 | return null; 15 | } 16 | 17 | return { 18 | artist: spotify.currentTrack.artist(), 19 | title: spotify.currentTrack.name(), 20 | } 21 | ", 22 | ], 23 | ] 24 | `; 25 | -------------------------------------------------------------------------------- /test/__fixtures__/lastfm-responses.ts: -------------------------------------------------------------------------------- 1 | export const expectedTrack = { 2 | title: 'foo', 3 | artist: 'bar', 4 | }; 5 | 6 | export const trackCurrentlyPlaying = { 7 | recenttracks: { 8 | track: [ 9 | { 10 | '@attr': { 11 | nowplaying: 'true', 12 | }, 13 | name: expectedTrack.title, 14 | artist: { 15 | '#text': expectedTrack.artist, 16 | }, 17 | }, 18 | ], 19 | }, 20 | }; 21 | 22 | export const noTrackCurrentlyPlaying = { 23 | recenttracks: { 24 | track: [ 25 | { 26 | name: expectedTrack.title, 27 | artist: { 28 | '#text': expectedTrack.artist, 29 | }, 30 | }, 31 | ], 32 | }, 33 | }; 34 | -------------------------------------------------------------------------------- /lib/sources/itunes/ITunesSourceProvider.ts: -------------------------------------------------------------------------------- 1 | import osascript from 'osascript-tag'; 2 | import { SourceProvider } from '../../SourceProvider'; 3 | import { NowPlayingTrack } from '../../types'; 4 | 5 | export class ITunesSourceProvider extends SourceProvider { 6 | protected getNowPlaying(): Promise { 7 | return osascript.jxa({ parse: true })` 8 | const iTunes = Application('iTunes'); 9 | 10 | if (!iTunes.running()) { 11 | return null; 12 | } 13 | 14 | if (iTunes.playerState() !== 'playing') { 15 | return null; 16 | } 17 | 18 | return { 19 | artist: iTunes.currentTrack.artist(), 20 | title: iTunes.currentTrack.name(), 21 | }; 22 | `; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /lib/sources/spotify/SpotifySourceProvider.ts: -------------------------------------------------------------------------------- 1 | import osascript from 'osascript-tag'; 2 | import { SourceProvider } from '../../SourceProvider'; 3 | import { NowPlayingTrack } from '../../types'; 4 | 5 | export class SpotifySourceProvider extends SourceProvider { 6 | protected getNowPlaying(): Promise { 7 | return osascript.jxa({ parse: true })` 8 | const spotify = Application('Spotify'); 9 | 10 | if (!spotify.running()) { 11 | return null; 12 | } 13 | 14 | if (spotify.playerState() !== 'playing') { 15 | return null; 16 | } 17 | 18 | return { 19 | artist: spotify.currentTrack.artist(), 20 | title: spotify.currentTrack.name(), 21 | } 22 | `; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /lib/SimpleEventEmitter.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | import { logger } from 'yanl'; 3 | 4 | type ListenersMap = { 5 | [event: string]: (...args: any[]) => void; 6 | }; 7 | 8 | export class SimpleEventEmitter { 9 | private emitter = new EventEmitter(); 10 | 11 | public on(event: E, listener: Listeners[E]): this { 12 | this.emitter.addListener(event, listener); 13 | return this; 14 | } 15 | 16 | public off(event: E, listener: Listeners[E]): this { 17 | logger.debug('removed all listeners', event); 18 | this.emitter.removeListener(event, listener); 19 | return this; 20 | } 21 | 22 | public removeAllListeners(event?: string): this { 23 | this.emitter.removeAllListeners(event); 24 | return this; 25 | } 26 | 27 | protected emit(event: E, ...args: Parameters): boolean { 28 | logger.debug('emitted event', event, ...args); 29 | return this.emitter.emit(event, ...args); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Waseem Dahman 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 | -------------------------------------------------------------------------------- /lib/sources/lastfm/LastFMSourceProvider.ts: -------------------------------------------------------------------------------- 1 | import url from 'url'; 2 | import query from 'querystring'; 3 | import fetch from 'node-fetch'; 4 | import { logger } from 'yanl'; 5 | import { SourceProvider } from '../../SourceProvider'; 6 | import { NowPlayingTrack } from '../../types'; 7 | 8 | export interface LastFMSourceProviderOptions { 9 | apiKey: string; 10 | user: string; 11 | } 12 | 13 | export class LastFMSourceProvider extends SourceProvider { 14 | protected async getNowPlaying(): Promise { 15 | const response = await fetch(this.recentTracksUrl); 16 | const payload = await response.json(); 17 | 18 | logger.debug('api request to last.fm', response.status, response.statusText); 19 | 20 | const track = payload?.recenttracks?.track[0]; 21 | if (track?.['@attr']?.nowplaying === 'true') { 22 | return { 23 | artist: track.artist['#text'], 24 | title: track.name, 25 | }; 26 | } 27 | return null; 28 | } 29 | 30 | private get recentTracksUrl(): string { 31 | return url.format({ 32 | hostname: 'ws.audioscrobbler.com', 33 | pathname: '/2.0', 34 | protocol: 'http', 35 | search: query.stringify({ 36 | api_key: this.options.apiKey, 37 | format: 'json', 38 | method: 'user.getrecenttracks', 39 | user: this.options.user, 40 | }), 41 | }); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /test/SimpleEventEmitter.test.ts: -------------------------------------------------------------------------------- 1 | import { SimpleEventEmitter } from 'lib/SimpleEventEmitter'; 2 | 3 | function createConcreteEventEmitter() { 4 | class ConcreteEventEmitter extends SimpleEventEmitter { 5 | public emitEvent(name: string, ...data: any) { 6 | this.emit(name, ...data); 7 | } 8 | } 9 | return new ConcreteEventEmitter(); 10 | } 11 | 12 | describe('SimpleEventEmitter', () => { 13 | test('calls a listener when event is emitted', () => { 14 | const emitter = createConcreteEventEmitter(); 15 | const listenerA = jest.fn(); 16 | const listenerB = jest.fn(); 17 | emitter.on('a', listenerA); 18 | emitter.on('b', listenerB); 19 | emitter.emitEvent('a', { value: 'a' }); 20 | expect(listenerA).toHaveBeenCalledTimes(1); 21 | expect(listenerA).toHaveBeenCalledWith({ value: 'a' }); 22 | expect(listenerB).not.toHaveBeenCalled(); 23 | }); 24 | 25 | test('does not call a listener if removed', () => { 26 | const emitter = createConcreteEventEmitter(); 27 | const callback = jest.fn(); 28 | emitter.on('a', callback); 29 | emitter.off('a', callback); 30 | emitter.emitEvent('a', { value: 'a' }); 31 | expect(callback).not.toHaveBeenCalled(); 32 | }); 33 | 34 | test('does not call a listener if all listeners are removed', () => { 35 | const emitter = createConcreteEventEmitter(); 36 | const callback = jest.fn(); 37 | emitter.on('a', callback); 38 | emitter.removeAllListeners('a'); 39 | emitter.emitEvent('a', { value: 'a' }); 40 | expect(callback).not.toHaveBeenCalled(); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "github-now-playing", 3 | "description": "Display what you're currently listening to on your Github profile", 4 | "version": "0.1.0", 5 | "main": "dist/index.js", 6 | "repository": "wsmd/github-now-playing", 7 | "author": "Waseem Dahman ", 8 | "license": "MIT", 9 | "scripts": { 10 | "typecheck": "tsc --noEmit", 11 | "build": "tsc", 12 | "prebuild": "rm -rf dist", 13 | "prepack": "yarn build", 14 | "lint": "eslint . --ext .ts", 15 | "test": "jest", 16 | "test:all": "yarn typecheck && yarn lint && yarn test:coverage", 17 | "test:coverage": "jest --coverage --verbose", 18 | "report-coverage": "cat ./coverage/lcov.info | coveralls" 19 | }, 20 | "files": [ 21 | "dist" 22 | ], 23 | "prettier": { 24 | "singleQuote": true, 25 | "trailingComma": "all", 26 | "printWidth": 100 27 | }, 28 | "dependencies": { 29 | "github-profile-status": "^1.0.0", 30 | "node-fetch": "^2.6.0", 31 | "osascript-tag": "^0.1.2", 32 | "yanl": "^0.1.0-beta.0" 33 | }, 34 | "devDependencies": { 35 | "@types/jest": "^24.0.23", 36 | "@types/node-fetch": "^2.5.3", 37 | "@wsmd/eslint-config": "1.1.0", 38 | "coveralls": "^3.0.9", 39 | "eslint": "^6.7.1", 40 | "jest": "^24.9.0", 41 | "prettier": "^1.19.1", 42 | "ts-jest": "^24.2.0", 43 | "tslint": "^6.0.0-beta1", 44 | "typescript": "^3.7.2" 45 | }, 46 | "keywords": [ 47 | "music", 48 | "nowplaying", 49 | "now-playing", 50 | "status", 51 | "profile", 52 | "github", 53 | "spotify", 54 | "itunes", 55 | "last.fm" 56 | ] 57 | } 58 | -------------------------------------------------------------------------------- /test/sources/itunes/ITunesSourceProvider.test.ts: -------------------------------------------------------------------------------- 1 | import osascript from 'osascript-tag'; 2 | import { ITunesSourceProvider } from 'lib/sources/itunes/ITunesSourceProvider'; 3 | 4 | beforeEach(jest.useFakeTimers); 5 | afterEach(jest.clearAllTimers); 6 | 7 | describe('ITunesSourceProvider', () => { 8 | it('calls osascript.jxa with correct script', () => { 9 | const runScriptFn = jest.fn(); 10 | jest.spyOn(osascript, 'jxa').mockImplementationOnce(() => runScriptFn); 11 | const source = new ITunesSourceProvider({ updateFrequency: 10000 }); 12 | source.listen(); 13 | expect(runScriptFn.mock.calls[0]).toMatchSnapshot(); 14 | }); 15 | 16 | it('reports the track via osascript', async () => { 17 | const expectedTrack = { artist: 'UFO', title: 'Space Child' }; 18 | 19 | // osascript.jxa does returns a track 20 | jest.spyOn(osascript, 'jxa').mockImplementation(() => async () => expectedTrack); 21 | 22 | const onChangeFn = jest.fn(); 23 | 24 | const source = new ITunesSourceProvider({ updateFrequency: 1 }); 25 | source.on(ITunesSourceProvider.Events.TrackChanged, onChangeFn); 26 | source.listen(); 27 | 28 | await new Promise(process.nextTick); 29 | expect(onChangeFn).toHaveBeenCalledWith(expectedTrack); 30 | }); 31 | 32 | it('does not report a track if nothing is currently playing', async () => { 33 | // osascript.jxa does not return a track 34 | jest.spyOn(osascript, 'jxa').mockImplementation(() => async () => null); 35 | 36 | const onChangeFn = jest.fn(); 37 | const source = new ITunesSourceProvider({ updateFrequency: 1 }); 38 | source.on(ITunesSourceProvider.Events.TrackChanged, onChangeFn); 39 | source.listen(); 40 | 41 | await new Promise(process.nextTick); 42 | expect(onChangeFn).not.toHaveBeenCalled(); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /test/sources/spotify/SpotifySourceProvider.test.ts: -------------------------------------------------------------------------------- 1 | import osascript from 'osascript-tag'; 2 | import { SpotifySourceProvider } from 'lib/sources/spotify/SpotifySourceProvider'; 3 | 4 | beforeEach(jest.useFakeTimers); 5 | afterEach(jest.clearAllTimers); 6 | 7 | describe('SpotifySourceProvider', () => { 8 | it('calls osascript.jxa with correct script', () => { 9 | const runScriptFn = jest.fn(); 10 | jest.spyOn(osascript, 'jxa').mockImplementationOnce(() => runScriptFn); 11 | const source = new SpotifySourceProvider({ updateFrequency: 10000 }); 12 | source.listen(); 13 | expect(runScriptFn.mock.calls[0]).toMatchSnapshot(); 14 | }); 15 | 16 | it('reports the track via osascript', async () => { 17 | const expectedTrack = { artist: 'UFO', title: 'Space Child' }; 18 | 19 | // osascript.jxa does returns a track 20 | jest.spyOn(osascript, 'jxa').mockImplementation(() => async () => expectedTrack); 21 | 22 | const onChangeFn = jest.fn(); 23 | 24 | const source = new SpotifySourceProvider({ updateFrequency: 1 }); 25 | source.on(SpotifySourceProvider.Events.TrackChanged, onChangeFn); 26 | source.listen(); 27 | 28 | await new Promise(process.nextTick); 29 | expect(onChangeFn).toHaveBeenCalledWith(expectedTrack); 30 | }); 31 | 32 | it('does not report a track if nothing is currently playing', async () => { 33 | // osascript.jxa does not return a track 34 | jest.spyOn(osascript, 'jxa').mockImplementation(() => async () => null); 35 | 36 | const onChangeFn = jest.fn(); 37 | const source = new SpotifySourceProvider({ updateFrequency: 1 }); 38 | source.on(SpotifySourceProvider.Events.TrackChanged, onChangeFn); 39 | source.listen(); 40 | 41 | await new Promise(process.nextTick); 42 | expect(onChangeFn).not.toHaveBeenCalled(); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /test/sources/lastfm/LastFMSourceProvider.test.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | import { LastFMSourceProvider } from 'lib/sources/lastfm/LastFMSourceProvider'; 3 | import { runNextUpdateTick } from '../../__helpers__/utils'; 4 | import * as LastFMResponseFixtures from '../../__fixtures__/lastfm-responses'; 5 | 6 | jest.mock('node-fetch'); 7 | 8 | function mockNextJSONResponse(value: any) { 9 | const fetchMock = (fetch as unknown) as jest.Mock; 10 | fetchMock.mockReturnValue({ 11 | json: jest.fn().mockResolvedValueOnce(value), 12 | }); 13 | } 14 | 15 | function createSourceProviderInstance() { 16 | return new LastFMSourceProvider({ updateFrequency: 1, apiKey: '123', user: 'batman' }); 17 | } 18 | 19 | beforeEach(jest.useFakeTimers); 20 | 21 | afterEach(() => { 22 | jest.clearAllMocks(); 23 | jest.clearAllTimers(); 24 | }); 25 | 26 | describe('LastFMSourceProvider', () => { 27 | it('makes a request to the last.fm API', async () => { 28 | const source = createSourceProviderInstance(); 29 | 30 | mockNextJSONResponse(LastFMResponseFixtures.trackCurrentlyPlaying); 31 | 32 | expect(fetch).not.toHaveBeenCalled(); 33 | source.listen(); 34 | await runNextUpdateTick(); 35 | 36 | expect(fetch).toHaveBeenCalledWith( 37 | 'http://ws.audioscrobbler.com/2.0' + 38 | '?api_key=123' + 39 | '&format=json' + 40 | '&method=user.getrecenttracks' + 41 | '&user=batman', 42 | ); 43 | }); 44 | 45 | it('reports the now playing track', async () => { 46 | const onTrackChange = jest.fn(); 47 | const source = createSourceProviderInstance(); 48 | 49 | mockNextJSONResponse(LastFMResponseFixtures.trackCurrentlyPlaying); 50 | 51 | source.on(LastFMSourceProvider.Events.TrackChanged, onTrackChange); 52 | 53 | source.listen(); 54 | await runNextUpdateTick(); 55 | 56 | expect(onTrackChange).toHaveBeenCalledWith(LastFMResponseFixtures.expectedTrack); 57 | }); 58 | 59 | it('does not report a track if nothing is currently playing', async () => { 60 | const onTrackStopped = jest.fn(); 61 | const source = createSourceProviderInstance(); 62 | 63 | mockNextJSONResponse(LastFMResponseFixtures.trackCurrentlyPlaying); 64 | 65 | source.on(LastFMSourceProvider.Events.TrackStopped, onTrackStopped); 66 | 67 | source.listen(); 68 | await runNextUpdateTick(); 69 | 70 | mockNextJSONResponse(LastFMResponseFixtures.noTrackCurrentlyPlaying); 71 | await runNextUpdateTick(); 72 | 73 | expect(onTrackStopped).toHaveBeenCalled(); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /test/GitHubNowPlaying.test.ts: -------------------------------------------------------------------------------- 1 | import { GitHubProfileStatus } from 'github-profile-status'; 2 | import { GitHubNowPlaying } from 'lib/GitHubNowPlaying'; 3 | import { NowPlayingMonitor } from 'lib/NowPlayingMonitor'; 4 | import { SourceProvider } from 'lib/SourceProvider'; 5 | import { NowPlayingSources } from 'lib/sources'; 6 | import { runNextUpdateTick } from './__helpers__/utils'; 7 | 8 | function createConcreteSourceProvider(expectedTrack: { current: any }) { 9 | class ConcreteSourceProvider extends SourceProvider { 10 | protected getNowPlaying = async () => expectedTrack.current; 11 | } 12 | return new ConcreteSourceProvider({ updateFrequency: 1 }); 13 | } 14 | 15 | jest.mock('github-profile-status'); 16 | 17 | beforeEach(() => { 18 | jest.useFakeTimers(); 19 | }); 20 | 21 | afterEach(() => { 22 | jest.clearAllMocks(); 23 | jest.clearAllTimers(); 24 | }); 25 | 26 | describe('GitHubNowPlaying', () => { 27 | it('creates an instance of NowPlayingMonitor', () => { 28 | const nowPlayingInstance = new GitHubNowPlaying({ token: '12345' }); 29 | expect(nowPlayingInstance).toBeInstanceOf(NowPlayingMonitor); 30 | }); 31 | 32 | it('has Events statics', () => { 33 | expect(GitHubNowPlaying.Events).toMatchInlineSnapshot(` 34 | Object { 35 | "Error": "error", 36 | "ListenStart": "listen-start", 37 | "ListenStop": "listen-stop", 38 | "StatusCleared": "status-cleared", 39 | "StatusUpdated": "status-updated", 40 | } 41 | `); 42 | }); 43 | 44 | it('has Sources statics', () => { 45 | expect(GitHubNowPlaying.Sources).toEqual({ 46 | Spotify: NowPlayingSources.Spotify, 47 | LastFM: NowPlayingSources.LastFM, 48 | ITunes: NowPlayingSources.ITunes, 49 | }); 50 | }); 51 | 52 | it('creates an instance of GitHubProfileStatus as the status publisher', async () => { 53 | const currentTrack: { current: any } = { current: { title: 'A', artist: 'B' } }; 54 | const nowPlaying = new GitHubNowPlaying({ token: 'foobar' }); 55 | const source = createConcreteSourceProvider(currentTrack); 56 | nowPlaying.setSource(source); 57 | 58 | const profileStatusInstance = ((GitHubProfileStatus as any) as jest.Mock).mock.instances[0]; 59 | 60 | // an instance of GitHubProfileStatus is created with the provided token 61 | expect(GitHubProfileStatus).toHaveBeenCalledWith({ token: 'foobar' }); 62 | 63 | nowPlaying.listen(); 64 | await runNextUpdateTick(); 65 | 66 | expect(profileStatusInstance.set).toHaveBeenCalledWith({ 67 | emoji: ':musical_note:', 68 | message: 'is listening to "A" by B', 69 | }); 70 | 71 | currentTrack.current = null; 72 | await runNextUpdateTick(); 73 | 74 | expect(profileStatusInstance.clear).toHaveBeenCalled(); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /lib/SourceProvider.ts: -------------------------------------------------------------------------------- 1 | import { logger } from 'yanl'; 2 | import { NowPlayingTrack } from './types'; 3 | import { SimpleEventEmitter } from './SimpleEventEmitter'; 4 | 5 | enum State { 6 | Listening, 7 | Stopped, 8 | } 9 | 10 | enum Events { 11 | Error = 'error', 12 | TrackChanged = 'track-changed', 13 | TrackStopped = 'track-stopped', 14 | } 15 | 16 | type EventListeners = { 17 | [Events.Error](error: unknown): void; 18 | [Events.TrackChanged](track: NowPlayingTrack): void; 19 | [Events.TrackStopped](): void; 20 | }; 21 | 22 | export abstract class SourceProvider extends SimpleEventEmitter { 23 | public static Events = Events; 24 | 25 | private state: State = State.Stopped; 26 | 27 | private nextCheckTimeout!: NodeJS.Timeout; 28 | 29 | private lastTrack: NowPlayingTrack | null = null; 30 | 31 | constructor(protected options: T & { updateFrequency: number }) { 32 | super(); 33 | } 34 | 35 | public isListening(): boolean { 36 | return this.state === State.Listening; 37 | } 38 | 39 | public listen(): void { 40 | // istanbul ignore if 41 | if (this.isListening()) { 42 | return; 43 | } 44 | logger.debug('started listening from source'); 45 | this.state = State.Listening; 46 | this.checkNowPlaying(); 47 | } 48 | 49 | public stop(): void { 50 | logger.debug('stopped listening from source'); 51 | this.state = State.Stopped; 52 | clearTimeout(this.nextCheckTimeout); 53 | } 54 | 55 | protected getNowPlaying(): Promise { 56 | throw new Error(`Method \`getNowPlaying\` is not implemented in ${this.constructor.name}`); 57 | } 58 | 59 | private async checkNowPlaying(): Promise { 60 | logger.debug('checking source for now playing track'); 61 | 62 | let track = null; 63 | try { 64 | track = await this.getNowPlaying(); 65 | } catch (error) { 66 | this.emit(Events.Error, error); 67 | return; 68 | } 69 | 70 | // bail out in case the source provider has been stopped while the check was pending 71 | if (this.state === State.Stopped) { 72 | return; 73 | } 74 | 75 | if (this.hasTrackChanged(track)) { 76 | if (track) { 77 | this.emit(Events.TrackChanged, track); 78 | } else { 79 | this.emit(Events.TrackStopped); 80 | } 81 | this.lastTrack = track; 82 | } 83 | 84 | this.nextCheckTimeout = setTimeout(() => this.checkNowPlaying(), this.options.updateFrequency); 85 | logger.debug('scheduled next source check'); 86 | } 87 | 88 | private hasTrackChanged(track: NowPlayingTrack | null): boolean { 89 | const { lastTrack } = this; 90 | if (lastTrack == null || track == null) { 91 | return lastTrack !== track; 92 | } 93 | const keys = Object.keys(track) as Array; 94 | return keys.some(key => track[key] !== lastTrack[key]); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /lib/NowPlayingMonitor.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import { logger } from 'yanl'; 3 | import { SourceProvider } from './SourceProvider'; 4 | import { SimpleEventEmitter } from './SimpleEventEmitter'; 5 | import { NowPlayingTrack, NowPlayingStatus, StatusPublisher } from './types'; 6 | 7 | enum State { 8 | Listening, 9 | Stopping, 10 | Stopped, 11 | } 12 | 13 | enum Events { 14 | Error = 'error', 15 | ListenStart = 'listen-start', 16 | ListenStop = 'listen-stop', 17 | StatusCleared = 'status-cleared', 18 | StatusUpdated = 'status-updated', 19 | } 20 | 21 | type EventListeners = { 22 | [Events.Error](error: unknown): void; 23 | [Events.ListenStart](): void; 24 | [Events.ListenStop](): void; 25 | [Events.StatusCleared](): void; 26 | [Events.StatusUpdated](status: Status | null): void; 27 | }; 28 | 29 | function assertIsDefined(value: T, message: string): asserts value is NonNullable { 30 | assert(value != null, message); 31 | } 32 | 33 | export class NowPlayingMonitor extends SimpleEventEmitter< 34 | EventListeners 35 | > { 36 | /** 37 | * A dictionary containing event names for a GitHubNowPlaying instance 38 | */ 39 | public static Events = Events; 40 | 41 | private isStatusDirty: boolean = false; 42 | 43 | private sourceProvider: SourceProvider | null = null; 44 | 45 | private state: State = State.Stopped; 46 | 47 | constructor(private statusPublisher: StatusPublisher) { 48 | super(); 49 | } 50 | 51 | /** 52 | * Listens to track changes using the provided source. 53 | */ 54 | public listen(): void { 55 | assertIsDefined(this.sourceProvider, 'Expected source to be specified'); 56 | 57 | // istanbul ignore if 58 | if (this.state === State.Listening) { 59 | return; 60 | } 61 | 62 | this.state = State.Listening; 63 | this.sourceProvider.listen(); 64 | this.emit(Events.ListenStart); 65 | } 66 | 67 | /** 68 | * Stop listening to track changes using the provided source. Calling this 69 | * will clear the profile status if it has been already updated. 70 | */ 71 | public async stop(): Promise { 72 | assertIsDefined(this.sourceProvider, 'Expected source to be specified'); 73 | 74 | // istanbul ignore if 75 | if (this.state === State.Stopped || this.state === State.Stopping) { 76 | return; 77 | } 78 | 79 | this.state = State.Stopping; 80 | this.sourceProvider.stop(); 81 | await this.cleanUp(); 82 | this.state = State.Stopped; 83 | this.emit(Events.ListenStop); 84 | } 85 | 86 | /** 87 | * Sets a source from which the currently playing track will be retrieved. 88 | */ 89 | public setSource(source: SourceProvider): void { 90 | let autoStartNextSource = false; 91 | if (this.sourceProvider) { 92 | autoStartNextSource = this.sourceProvider.isListening(); 93 | this.unsetSourceProvider(); 94 | } 95 | this.setSourceProvider(source, autoStartNextSource); 96 | } 97 | 98 | private setSourceProvider(source: SourceProvider, autoStart: boolean): void { 99 | this.sourceProvider = source; 100 | this.sourceProvider 101 | .on(SourceProvider.Events.TrackChanged, this.updateStatus.bind(this)) 102 | .on(SourceProvider.Events.TrackStopped, this.clearStatus.bind(this)) 103 | .on(SourceProvider.Events.Error, this.emitError.bind(this)); 104 | if (autoStart) { 105 | this.sourceProvider.listen(); 106 | } 107 | } 108 | 109 | private unsetSourceProvider(): void { 110 | assertIsDefined(this.sourceProvider, 'Expected source to be specified'); 111 | this.sourceProvider.stop(); 112 | this.sourceProvider.removeAllListeners(); 113 | } 114 | 115 | private async updateStatus(track: NowPlayingTrack): Promise { 116 | try { 117 | const status = await this.statusPublisher.set({ 118 | emoji: ':musical_note:', 119 | message: `is listening to "${track.title}" by ${track.artist}`, 120 | }); 121 | this.isStatusDirty = true; 122 | this.emit(Events.StatusUpdated, status as Status); 123 | } catch (error) { 124 | this.emitError(error); 125 | } 126 | } 127 | 128 | private async clearStatus(): Promise { 129 | try { 130 | logger.debug('clearing the profile status'); 131 | await this.statusPublisher.clear(); 132 | this.isStatusDirty = false; 133 | this.emit(Events.StatusCleared); 134 | } catch (error) { 135 | this.emitError(error); 136 | } 137 | } 138 | 139 | private emitError(error: {}): void { 140 | this.emit(Events.Error, error); 141 | } 142 | 143 | private async cleanUp(): Promise { 144 | if (this.isStatusDirty) { 145 | await this.clearStatus(); 146 | } 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /test/SourceProvider.test.ts: -------------------------------------------------------------------------------- 1 | import { SourceProvider } from 'lib/SourceProvider'; 2 | import { runNextUpdateTick } from './__helpers__/utils'; 3 | 4 | function createConcreteSourceProvider(expectedTrack: { current: any }) { 5 | class ConcreteSourceProvider extends SourceProvider { 6 | protected getNowPlaying = async () => expectedTrack.current; 7 | } 8 | return new ConcreteSourceProvider({ updateFrequency: 1 }); 9 | } 10 | 11 | beforeEach(jest.useFakeTimers); 12 | afterEach(jest.clearAllTimers); 13 | 14 | describe('SourceProvider', () => { 15 | it('emits a track-changed event after a track change', async () => { 16 | const expectedTrack: { current: any } = { current: null }; 17 | const source = createConcreteSourceProvider(expectedTrack); 18 | 19 | const trackChangedFn = jest.fn(); 20 | source.on(SourceProvider.Events.TrackChanged, trackChangedFn); 21 | source.listen(); 22 | 23 | // next check 24 | await runNextUpdateTick(); 25 | expect(trackChangedFn).not.toHaveBeenCalled(); 26 | 27 | expectedTrack.current = { title: 'foo', artist: 'bar' }; 28 | 29 | await runNextUpdateTick(); 30 | expect(trackChangedFn).toHaveBeenCalledWith(expectedTrack.current); 31 | 32 | source.stop(); 33 | }); 34 | 35 | it('emits a track-changed event if a track is already playing', async () => { 36 | const expectedTrack: { current: any } = { current: { title: 'foo', artist: 'bar' } }; 37 | const source = createConcreteSourceProvider(expectedTrack); 38 | 39 | const trackChangedFn = jest.fn(); 40 | source.on(SourceProvider.Events.TrackChanged, trackChangedFn); 41 | source.listen(); 42 | 43 | // next check 44 | await runNextUpdateTick(); 45 | expect(trackChangedFn).toHaveBeenCalledWith(expectedTrack.current); 46 | 47 | source.stop(); 48 | }); 49 | 50 | it('emits a track-changed event once if a track has not changed', async () => { 51 | const expectedTrack: { current: any } = { current: { title: 'a', artist: 'b' } }; 52 | const source = createConcreteSourceProvider(expectedTrack); 53 | 54 | const trackChangedFn = jest.fn(); 55 | source.on(SourceProvider.Events.TrackChanged, trackChangedFn); 56 | source.listen(); 57 | 58 | // first tick 59 | await runNextUpdateTick(); 60 | expect(trackChangedFn).toHaveBeenCalled(); 61 | 62 | // second tick 63 | await runNextUpdateTick(); 64 | expect(trackChangedFn).toHaveBeenCalledTimes(1); 65 | 66 | source.stop(); 67 | }); 68 | 69 | it('emits a track-changed event if a track has changed', async () => { 70 | const expectedTrack: { current: any } = { current: { title: 'a', artist: 'a' } }; 71 | const source = createConcreteSourceProvider(expectedTrack); 72 | 73 | const trackChangedFn = jest.fn(); 74 | source.on(SourceProvider.Events.TrackChanged, trackChangedFn); 75 | source.listen(); 76 | 77 | // first tick 78 | await runNextUpdateTick(); 79 | expect(trackChangedFn).toHaveBeenCalled(); 80 | 81 | expectedTrack.current = { title: 'a', artist: 'b' }; 82 | 83 | // second tick 84 | await runNextUpdateTick(); 85 | expect(trackChangedFn).toHaveBeenCalledTimes(2); 86 | 87 | source.stop(); 88 | }); 89 | 90 | it('emits a track-stopped when a track stops', async () => { 91 | const expectedTrack: { current: any } = { current: null }; 92 | const source = createConcreteSourceProvider(expectedTrack); 93 | 94 | const trackStoppedFn = jest.fn(); 95 | source.on(SourceProvider.Events.TrackStopped, trackStoppedFn); 96 | source.listen(); 97 | 98 | await runNextUpdateTick(); 99 | expect(trackStoppedFn).not.toHaveBeenCalled(); 100 | 101 | // track changed 102 | expectedTrack.current = { title: 'foo', artist: 'bar' }; 103 | 104 | await runNextUpdateTick(); 105 | expect(trackStoppedFn).not.toHaveBeenCalled(); 106 | 107 | // track becomes null 108 | expectedTrack.current = null; 109 | 110 | await runNextUpdateTick(); 111 | expect(trackStoppedFn).toHaveBeenCalled(); 112 | 113 | source.stop(); 114 | }); 115 | 116 | it('emits an error event when something goes wrong', async () => { 117 | const source = createConcreteSourceProvider({ 118 | get current() { 119 | throw new Error(); 120 | }, 121 | }); 122 | 123 | const onErrorFn = jest.fn(); 124 | source.on(SourceProvider.Events.Error, onErrorFn); 125 | source.listen(); 126 | 127 | await runNextUpdateTick(); 128 | 129 | expect(onErrorFn).toHaveBeenCalled(); 130 | }); 131 | 132 | it('does not emit a track change if stopped', async () => { 133 | const expectedTrack: { current: any } = { current: { title: 'foo', artist: 'bar' } }; 134 | const source = createConcreteSourceProvider(expectedTrack); 135 | 136 | const trackChangedFn = jest.fn(); 137 | source.on(SourceProvider.Events.TrackChanged, trackChangedFn); 138 | source.listen(); 139 | source.stop(); 140 | 141 | await runNextUpdateTick(); 142 | expect(trackChangedFn).not.toHaveBeenCalled(); 143 | }); 144 | 145 | it('throws if checkNowPlayingMethod is not implemented', () => { 146 | class ConcreteSourceProvider extends SourceProvider {} 147 | const onErrorFn = jest.fn(); 148 | const source = new ConcreteSourceProvider({ updateFrequency: 1 }); 149 | source.on(SourceProvider.Events.Error, onErrorFn); 150 | source.listen(); 151 | expect(onErrorFn).toHaveBeenCalledWith( 152 | expect.objectContaining({ message: expect.stringMatching(/not implemented/) }), 153 | ); 154 | }); 155 | }); 156 | -------------------------------------------------------------------------------- /test/NowPlayingMonitor.test.ts: -------------------------------------------------------------------------------- 1 | import { SourceProvider } from 'lib/SourceProvider'; 2 | import { NowPlayingMonitor } from 'lib/NowPlayingMonitor'; 3 | import { StatusPublisher } from 'lib/types'; 4 | import { runNextUpdateTick } from './__helpers__/utils'; 5 | 6 | function createStatusPublisher(): StatusPublisher { 7 | return { 8 | clear: jest.fn(), 9 | set: jest.fn().mockImplementation(async value => value), 10 | }; 11 | } 12 | 13 | function createConcreteSourceProvider(expectedTrack: { current: any }) { 14 | class ConcreteSourceProvider extends SourceProvider { 15 | protected getNowPlaying = async () => expectedTrack.current; 16 | } 17 | return new ConcreteSourceProvider({ updateFrequency: 1 }); 18 | } 19 | 20 | beforeEach(jest.useFakeTimers); 21 | afterEach(jest.clearAllTimers); 22 | 23 | describe('NowPlayingMonitor', () => { 24 | it('calls listen-start event on start', () => { 25 | const onStartFn = jest.fn(); 26 | const currentTrack = { current: null }; 27 | const sourceProvider = createConcreteSourceProvider(currentTrack); 28 | const statusPublisher = createStatusPublisher(); 29 | const nowPlayingMonitor = new NowPlayingMonitor(statusPublisher); 30 | 31 | nowPlayingMonitor.setSource(sourceProvider); 32 | nowPlayingMonitor.on(NowPlayingMonitor.Events.ListenStart, onStartFn); 33 | nowPlayingMonitor.listen(); 34 | 35 | expect(onStartFn).toHaveBeenCalled(); 36 | }); 37 | 38 | it('updates the status on track change', async () => { 39 | const onStatusUpdated = jest.fn(); 40 | const currentTrack = { current: { title: 'foo', artist: 'bar' } }; 41 | const sourceProvider = createConcreteSourceProvider(currentTrack); 42 | const statusPublisher = createStatusPublisher(); 43 | const nowPlayingMonitor = new NowPlayingMonitor(statusPublisher); 44 | 45 | nowPlayingMonitor.setSource(sourceProvider); 46 | nowPlayingMonitor.on(NowPlayingMonitor.Events.StatusUpdated, onStatusUpdated); 47 | 48 | nowPlayingMonitor.listen(); 49 | await runNextUpdateTick(); 50 | 51 | expect(statusPublisher.set).toHaveBeenCalledWith({ 52 | emoji: ':musical_note:', 53 | message: 'is listening to "foo" by bar', 54 | }); 55 | 56 | expect(onStatusUpdated).toHaveBeenCalledWith({ 57 | emoji: ':musical_note:', 58 | message: 'is listening to "foo" by bar', 59 | }); 60 | }); 61 | 62 | it('clears the status on track stop', async () => { 63 | const onStatusClear = jest.fn(); 64 | const currentTrack: { current: any } = { current: { title: 'foo', artist: 'bar' } }; 65 | const sourceProvider = createConcreteSourceProvider(currentTrack); 66 | const statusPublisher = createStatusPublisher(); 67 | const nowPlayingMonitor = new NowPlayingMonitor(statusPublisher); 68 | 69 | nowPlayingMonitor.setSource(sourceProvider); 70 | nowPlayingMonitor.on(NowPlayingMonitor.Events.StatusCleared, onStatusClear); 71 | 72 | nowPlayingMonitor.listen(); 73 | await runNextUpdateTick(); 74 | 75 | currentTrack.current = null; 76 | await runNextUpdateTick(); 77 | 78 | expect(statusPublisher.clear).toHaveBeenCalled(); 79 | expect(onStatusClear).toHaveBeenCalled(); 80 | }); 81 | 82 | it('clears the status on monitor stop if status is dirty', async () => { 83 | const onStatusClear = jest.fn(); 84 | const currentTrack: { current: any } = { current: { title: 'foo', artist: 'bar' } }; 85 | const sourceProvider = createConcreteSourceProvider(currentTrack); 86 | const statusPublisher = createStatusPublisher(); 87 | const nowPlayingMonitor = new NowPlayingMonitor(statusPublisher); 88 | 89 | nowPlayingMonitor.setSource(sourceProvider); 90 | nowPlayingMonitor.on(NowPlayingMonitor.Events.StatusCleared, onStatusClear); 91 | 92 | nowPlayingMonitor.listen(); 93 | await runNextUpdateTick(); 94 | 95 | nowPlayingMonitor.stop(); 96 | await runNextUpdateTick(); 97 | 98 | expect(statusPublisher.clear).toHaveBeenCalled(); 99 | expect(onStatusClear).toHaveBeenCalled(); 100 | }); 101 | 102 | it('does not update status when monitor is stopped', async () => { 103 | const onListenStop = jest.fn(); 104 | const currentTrack: { current: any } = { current: { title: 'foo', artist: 'bar' } }; 105 | const sourceProvider = createConcreteSourceProvider(currentTrack); 106 | const statusPublisher = createStatusPublisher(); 107 | const nowPlayingMonitor = new NowPlayingMonitor(statusPublisher); 108 | 109 | nowPlayingMonitor.setSource(sourceProvider); 110 | nowPlayingMonitor.on(NowPlayingMonitor.Events.ListenStop, onListenStop); 111 | nowPlayingMonitor.listen(); 112 | nowPlayingMonitor.stop(); 113 | 114 | await runNextUpdateTick(); 115 | 116 | expect(statusPublisher.set).not.toHaveBeenCalled(); 117 | expect(onListenStop).toHaveBeenCalled(); 118 | }); 119 | 120 | it('stops current source provider when using a new one', async () => { 121 | const onStatusUpdated = jest.fn(); 122 | const currentTrackSourceA: { current: any } = { current: { title: 'A', artist: 'A' } }; 123 | const currentTrackSourceB: { current: any } = { current: { title: 'B', artist: 'B' } }; 124 | const sourceProviderA = createConcreteSourceProvider(currentTrackSourceA); 125 | const sourceProviderB = createConcreteSourceProvider(currentTrackSourceB); 126 | 127 | const statusPublisher = createStatusPublisher(); 128 | const nowPlayingMonitor = new NowPlayingMonitor(statusPublisher); 129 | nowPlayingMonitor.on(NowPlayingMonitor.Events.StatusUpdated, onStatusUpdated); 130 | 131 | nowPlayingMonitor.setSource(sourceProviderA); 132 | 133 | nowPlayingMonitor.listen(); 134 | 135 | nowPlayingMonitor.setSource(sourceProviderB); 136 | 137 | await runNextUpdateTick(); 138 | 139 | expect(onStatusUpdated).toHaveBeenCalledTimes(1); 140 | expect(onStatusUpdated).toHaveBeenCalledWith({ 141 | emoji: ':musical_note:', 142 | message: 'is listening to "B" by B', 143 | }); 144 | }); 145 | 146 | it('emits errors when the status publisher fails to update', async () => { 147 | const onErrorFn = jest.fn(); 148 | const currentTrack: { current: any } = { current: { title: 'A', artist: 'A' } }; 149 | const statusPublisher = createStatusPublisher(); 150 | (statusPublisher.set as jest.Mock).mockRejectedValue(new Error()); 151 | const sourceProvider = createConcreteSourceProvider(currentTrack); 152 | const nowPlayingMonitor = new NowPlayingMonitor(statusPublisher); 153 | nowPlayingMonitor.setSource(sourceProvider); 154 | nowPlayingMonitor.on(NowPlayingMonitor.Events.Error, onErrorFn); 155 | 156 | // update status 157 | nowPlayingMonitor.listen(); 158 | await runNextUpdateTick(); 159 | 160 | expect(onErrorFn).toHaveBeenCalled(); 161 | }); 162 | 163 | it('emits errors when the status publisher fails to clear', async () => { 164 | const onErrorFn = jest.fn(); 165 | const currentTrack: { current: any } = { current: { title: 'A', artist: 'A' } }; 166 | const statusPublisher = createStatusPublisher(); 167 | (statusPublisher.clear as jest.Mock).mockRejectedValue(new Error()); 168 | const sourceProvider = createConcreteSourceProvider(currentTrack); 169 | const nowPlayingMonitor = new NowPlayingMonitor(statusPublisher); 170 | nowPlayingMonitor.setSource(sourceProvider); 171 | nowPlayingMonitor.on(NowPlayingMonitor.Events.Error, onErrorFn); 172 | 173 | // update status 174 | nowPlayingMonitor.listen(); 175 | await runNextUpdateTick(); 176 | 177 | // clear status 178 | nowPlayingMonitor.stop(); 179 | await runNextUpdateTick(); 180 | 181 | expect(onErrorFn).toHaveBeenCalled(); 182 | }); 183 | }); 184 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

github-now-playing

2 | 3 |

4 | 5 | Current Release 6 | 7 | 8 | CI Build 9 | 10 | 11 | Coverage Status 12 | 13 | 14 | Licence 15 | 16 |

17 | 18 |
19 | 📖 Table of Contents 20 |

21 | 22 | - [Motivation](#motivation) 23 | - [Installation](#installation) 24 | - [Example](#example) 25 | - [API](#api) 26 | - [Class: `GitHubNowPlaying`](#class-githubnowplaying) 27 | - [Constructor Options](#constructor-options) 28 | - [Methods](#methods) 29 | - [`nowPlaying.setSource(source)`](#nowplayingsetsourcesource) 30 | - [`nowPlaying.on(event, listener)`](#nowplayingonevent-listener) 31 | - [`nowPlaying.off(event, listener)`](#nowplayingoffevent-listener) 32 | - [`nowPlaying.listen()`](#nowplayinglisten) 33 | - [`nowPlaying.stop()`](#nowplayingstop) 34 | - [Sources Providers: `GitHubNowPlaying.Sources`](#sources-providers-githubnowplayingsources) 35 | - [`LastFM`](#lastfm) 36 | - [`ITunes`](#itunes) 37 | - [`Spotify`](#spotify) 38 | - [Events: `GitHubNowPlaying.Events`](#events-githubnowplayingevents) 39 | - [`Error`](#error) 40 | - [`ListenStart`](#listenstart) 41 | - [`ListenStop`](#listenstop) 42 | - [`StatusUpdated`](#statusupdated) 43 | - [`StatusCleared`](#statuscleared) 44 | 45 |

46 |
47 | 48 | ## Motivation 49 | 50 | GitHub introduced a [new feature](https://github.blog/changelog/2019-01-09-set-your-status/) that allows you to set a status on your profile, so I thought it would be a cool idea if I could share the music I'm listening to — kind of like #NowPlaying — right on my GitHub profile! 51 | 52 |
53 | Screen Shot 2019-04-09 at 7 28 36 PM 54 |
55 | 56 | ## Installation 57 | 58 | This library is available on the [npm](https://www.npmjs.com/package/github-now-playing) registry as a [node](https://nodejs.org/en/) module and can be installed by running: 59 | 60 | ```sh 61 | # via npm 62 | npm install --save github-now-playing 63 | 64 | # via yarn 65 | yarn add github-now-playing 66 | ``` 67 | 68 | You also need to generate a [personal access token](https://help.github.com/en/github/authenticating-to-github/creating-a-personal-access-token-for-the-command-line) with the **user** scope to allow this library to communicate with GitHub. 69 | 70 | ## Example 71 | 72 | ```ts 73 | import { GitHubNowPlaying } from 'github-now-playing'; 74 | 75 | const nowPlaying = new GitHubNowPlaying({ 76 | token: process.env.GITHUB_ACCESS_TOKEN, 77 | }); 78 | 79 | // Create a new source to retrieve the track that is currently playing 80 | const spotifySource = new GitHubNowPlaying.Sources.Spotify({ 81 | // wait time in milliseconds between checks for any track changes 82 | updateFrequency: 1000, 83 | }); 84 | 85 | // Make sure a source is set before calling listen() 86 | nowPlaying.setSource(spotifySource); 87 | 88 | // Don't forget to handle the error event! 89 | nowPlaying.on(GitHubNowPlaying.Events.Error, error => { 90 | console.log('something went wrong'); 91 | }); 92 | 93 | // Listen to any track changes and update the profile status accordingly. 94 | nowPlaying.listen(); 95 | 96 | // Don't forget to stop reporting any track changes when the process exists. 97 | process.on('SIGINT', () => { 98 | // Calling the stop() method will clear the profile status. 99 | nowPlaying.stop() 100 | }); 101 | ``` 102 | 103 | ## API 104 | 105 | `github-now-playing` exposes a named export class `GitHubNowPlaying` that is also a namespace for various [source providers](#sources-providers-githubnowplayingsources) and [event names](#events-githubnowplayingevents). 106 | 107 | ### Class: `GitHubNowPlaying` 108 | 109 | ```ts 110 | new GitHubNowPlaying(options: GitHubNowPlayingConstructorOptions) 111 | ``` 112 | 113 | Creates a new instance of `GitHubNowPlaying`. 114 | 115 | #### Constructor Options 116 | 117 | An object with the following keys: 118 | 119 | - `token: string`: a [personal access token](https://help.github.com/en/github/authenticating-to-github/creating-a-personal-access-token-for-the-command-line) with the **user** scope. 120 | 121 | #### Methods 122 | 123 | ##### `nowPlaying.setSource(source)` 124 | 125 | Assigns a [source provider object](#sources) from which the currently-playing track will be retrieved. 126 | 127 | This method must be called before calling [`listen()`](#listen-void). 128 | 129 | ##### `nowPlaying.on(event, listener)` 130 | 131 | Adds the `listener` function as an event handler for the named `event`. The `event` parameter can be one of the values available under the [`GitHubNowPlaying.Events` namespace](#events). 132 | 133 | Returns a reference to the `GitHubNowPlaying` instance, so that calls can be chained. 134 | 135 | ##### `nowPlaying.off(event, listener)` 136 | 137 | Removes the specified `listener` function for the named `event`. 138 | 139 | The `event` parameter can be one of the values available under the [`GitHubNowPlaying.Events` namespace](#events). 140 | 141 | Returns a reference to the `GitHubNowPlaying` instance, so that calls can be chained. 142 | 143 | ##### `nowPlaying.listen()` 144 | 145 | Starts listening to any track changes coming from the specified source and updates the GitHub profile status accordingly. 146 | 147 | The event `GitHubNowPlaying.Events.ListenStart` is emitted upon calling this method. 148 | 149 | Additionally, every time the profile status is updated, the event `GitHubNowPlaying.Events.StatusUpdated` is emitted with the [profile status](https://github.com/wsmd/github-profile-status#userstatus) object. 150 | 151 | Note that upon calling `listen()`, the profile status will be updated immediately to reflect the currently playing track. 152 | 153 | ##### `nowPlaying.stop()` 154 | 155 | Stops listening to any track changes. 156 | 157 | Calling this method will result in clearing the profile status if it has been already updated with a currently playing track. 158 | 159 | If the status is cleared, the event `GitHubNowPlaying.Events.StatusCleared` is emitted, then followed by the event `GitHubNowPlaying.Events.ListenStop`. 160 | 161 | This method is asynchronous and will resolve after clearing the profile status. 162 | 163 | ---- 164 | 165 | ### Sources Providers: `GitHubNowPlaying.Sources` 166 | 167 | `GitHubNowPlaying` relies on a source provider object that retrieves the currently playing track from a specific source. 168 | 169 | These sources can be either local desktop applications, such [iTunes](https://www.apple.com/itunes/) or [Spotify](https://www.spotify.com/us/), or even via web APIs, such as Last.fm. 170 | 171 | `GitHubNowPlaying` comes with built-in support for all of these sources. Note that support for desktop applications is currently limited to macOS. 172 | 173 | The following sources are available under the namespace `GitHubNowPlaying.Sources`: 174 | 175 | #### `LastFM` 176 | 177 | Fetches information about the currently-playing track via Last.fm. 178 | 179 | ```js 180 | const lastFmSource = new GitHubNowPlaying.Sources.LastFM({ 181 | apiKey: process.env.LAST_FM_API_KEY, // Your Last.fm API key 182 | updateFrequency: 1000, 183 | }); 184 | 185 | nowPlaying.setSource(lastFmSource); 186 | ``` 187 | 188 | #### `ITunes` 189 | 190 | Fetches information about the currently-playing track in [iTunes](https://www.apple.com/itunes/). 191 | 192 | ```js 193 | const iTunesSource = new GitHubNowPlaying.Sources.ITunes({ 194 | updateFrequency: 1000, 195 | }); 196 | 197 | nowPlaying.setSource(iTunesSource); 198 | ``` 199 | 200 | Platforms supported: macOS 201 | 202 | #### `Spotify` 203 | 204 | Fetches information about the currently-playing track in Spotify. 205 | 206 | ```js 207 | const spotifySource = new GitHubNowPlaying.Sources.Spotify({ 208 | updateFrequency: 1000, 209 | }); 210 | 211 | nowPlaying.setSource(spotifySource); 212 | ``` 213 | 214 | Platforms supported: macOS 215 | 216 | --- 217 | 218 | ### Events: `GitHubNowPlaying.Events` 219 | 220 | Instances of `GitHubNowPlaying` emit various events during the program life-cycle. You can add or remove event listeners via [`on()`](#nowplayingonevent-listener) and [`off()`](#nowplayingoffevent-listener) respectively. 221 | 222 | #### `Error` 223 | 224 | Emitted when an error occurs: 225 | 226 | ```ts 227 | nowPlaying.on(GitHubNowPlaying.Events.Error, (error) => { /* ... */ }); 228 | ``` 229 | 230 | #### `ListenStart` 231 | 232 | Emitted when `GitHubNowPlaying` starts listening to track changes via the specified source: 233 | 234 | ```ts 235 | nowPlaying.on(GitHubNowPlaying.Events.ListenStart, () => { /* ... */ }); 236 | ``` 237 | 238 | #### `ListenStop` 239 | 240 | 241 | Emitted when `GitHubNowPlaying` has stopped listening to track changes via the specified source: 242 | 243 | ```ts 244 | nowPlaying.on(GitHubNowPlaying.Events.ListenStop, () => { /* ... */ }); 245 | ``` 246 | 247 | #### `StatusUpdated` 248 | 249 | Emitted when `GitHubNowPlaying` has updated the profile status with currently-playing track successfully. Listeners of this event are called with the [user-status object](https://github.com/wsmd/github-profile-status#userstatus). 250 | 251 | ```ts 252 | nowPlaying.on(GitHubNowPlaying.Events.ListenStop, (status) => { /* ... */ }); 253 | ``` 254 | 255 | #### `StatusCleared` 256 | 257 | 258 | Emitted when `GitHubNowPlaying` has cleared the profile status after it has been updated with a currently playing track. This happens when the source provider reports that there are no tracks are currently playing, or when [`stop()`](#nowplayingstop) is called. 259 | 260 | ```ts 261 | nowPlaying.on(GitHubNowPlaying.Events.StatusCleared, () => { /* ... */ }); 262 | ``` 263 | 264 | # See Also 265 | 266 | - [`github-profile-status`](https://github.com/wsmd/github-profile-status) 267 | 268 | # License 269 | 270 | MIT 271 | --------------------------------------------------------------------------------