diff --git a/.gitignore b/.gitignore index 379afe40..2f863091 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,9 @@ config/settings.json config/logs/*.log* config/logs/*.json +# anidb mapping file +config/anime-list.xml + # dist files dist diff --git a/server/api/animelist.ts b/server/api/animelist.ts new file mode 100644 index 00000000..428684bc --- /dev/null +++ b/server/api/animelist.ts @@ -0,0 +1,223 @@ +import axios from 'axios'; +import xml2js from 'xml2js'; +import fs, { promises as fsp } from 'fs'; +import path from 'path'; +import logger from '../logger'; + +const UPDATE_INTERVAL_MSEC = 24 * 3600 * 1000; // how often to download new mapping in milliseconds +// originally at https://raw.githubusercontent.com/ScudLee/anime-lists/master/anime-list.xml +const MAPPING_URL = + 'https://raw.githubusercontent.com/Anime-Lists/anime-lists/master/anime-list.xml'; +const LOCAL_PATH = path.join(__dirname, '../../config/anime-list.xml'); + +const mappingRegexp = new RegExp(/;[0-9]+-([0-9]+)/g); + +// Anime-List xml files are community maintained mappings that Hama agent uses to map AniDB IDs to tvdb/tmdb IDs +// https://github.com/Anime-Lists/anime-lists/ + +interface AnimeMapping { + $: { + anidbseason: string; + tvdbseason: string; + }; + _: string; +} + +interface Anime { + $: { + anidbid: number; + tvdbid?: string; + defaulttvdbseason?: string; + tmdbid?: number; + imdbid?: string; + }; + 'mapping-list'?: { + mapping: AnimeMapping[]; + }[]; +} + +interface AnimeList { + 'anime-list': { + anime: Anime[]; + }; +} + +export interface AnidbItem { + tvdbId?: number; + tmdbId?: number; + imdbId?: string; +} + +class AnimeListMapping { + private syncing = false; + + private mapping: { [anidbId: number]: AnidbItem } = {}; + + // mapping file modification date when it was loaded + private mappingModified: Date | null = null; + + // each episode in season 0 from TVDB can map to movie + private specials: { [tvdbId: number]: { [episode: number]: AnidbItem } } = {}; + + public isLoaded = () => Object.keys(this.mapping).length !== 0; + + private loadFromFile = async () => { + logger.info('Loading mapping file', { label: 'Anime-List Sync' }); + try { + const mappingStat = await fsp.stat(LOCAL_PATH); + const file = await fsp.readFile(LOCAL_PATH); + const xml = (await xml2js.parseStringPromise(file)) as AnimeList; + + this.mapping = {}; + this.specials = {}; + for (const anime of xml['anime-list'].anime) { + // tvdbId can be nonnumber, like 'movie' string + let tvdbId: number | undefined; + if (anime.$.tvdbid && !isNaN(Number(anime.$.tvdbid))) { + tvdbId = Number(anime.$.tvdbid); + } else { + tvdbId = undefined; + } + + let imdbIds: (string | undefined)[]; + if (anime.$.imdbid) { + // if there are multiple imdb entries, then they map to different movies + imdbIds = anime.$.imdbid.split(','); + } else { + // in case there is no imdbid, that's ok as there will be tmdbid + imdbIds = [undefined]; + } + + const tmdbId = anime.$.tmdbid ? Number(anime.$.tmdbid) : undefined; + const anidbId = Number(anime.$.anidbid); + this.mapping[anidbId] = { + // for season 0 ignore tvdbid, because this must be movie/OVA + tvdbId: anime.$.defaulttvdbseason === '0' ? undefined : tvdbId, + tmdbId: tmdbId, + imdbId: imdbIds[0], // this is used for one AniDB -> one imdb movie mapping + }; + + if (tvdbId) { + const mappingList = anime['mapping-list']; + if (mappingList && mappingList.length != 0) { + let imdbIndex = 0; + for (const mapping of mappingList[0].mapping) { + const text = mapping._; + if (text && mapping.$.tvdbseason === '0') { + let matches; + while ((matches = mappingRegexp.exec(text)) !== null) { + const episode = Number(matches[1]); + if (!this.specials[tvdbId]) { + this.specials[tvdbId] = {}; + } + // map next available imdbid to episode in s0 + const imdbId = + imdbIndex > imdbIds.length ? undefined : imdbIds[imdbIndex]; + if (tmdbId || imdbId) { + this.specials[tvdbId][episode] = { + tmdbId: tmdbId, + imdbId: imdbId, + }; + imdbIndex++; + } + } + } + } + } else { + // some movies do not have mapping-list, so map episode 1,2,3,..to movies + // movies must have imdbid or tmdbid + const hasImdb = imdbIds.length > 1 || imdbIds[0] !== undefined; + if ((hasImdb || tmdbId) && anime.$.defaulttvdbseason === '0') { + if (!this.specials[tvdbId]) { + this.specials[tvdbId] = {}; + } + // map each imdbid to episode in s0, episode index starts with 1 + for (let idx = 0; idx < imdbIds.length; idx++) { + this.specials[tvdbId][idx + 1] = { + tmdbId: tmdbId, + imdbId: imdbIds[idx], + }; + } + } + } + } + } + this.mappingModified = mappingStat.mtime; + logger.info( + `Loaded ${ + Object.keys(this.mapping).length + } AniDB items from mapping file`, + { label: 'Anime-List Sync' } + ); + } catch (e) { + throw new Error(`Failed to load Anime-List mappings: ${e.message}`); + } + }; + + private downloadFile = async () => { + logger.info('Downloading latest mapping file', { + label: 'Anime-List Sync', + }); + try { + const response = await axios.get(MAPPING_URL, { + responseType: 'stream', + }); + await new Promise((resolve) => { + const writer = fs.createWriteStream(LOCAL_PATH); + writer.on('finish', resolve); + response.data.pipe(writer); + }); + } catch (e) { + throw new Error(`Failed to download Anime-List mapping: ${e.message}`); + } + }; + + public sync = async () => { + // make sure only one sync runs at a time + if (this.syncing) { + return; + } + + this.syncing = true; + try { + // check if local file is not "expired" yet + if (fs.existsSync(LOCAL_PATH)) { + const now = new Date(); + const stat = await fsp.stat(LOCAL_PATH); + if (now.getTime() - stat.mtime.getTime() < UPDATE_INTERVAL_MSEC) { + if (!this.isLoaded()) { + // no need to download, but make sure file is loaded + await this.loadFromFile(); + } else if ( + this.mappingModified && + stat.mtime.getTime() > this.mappingModified.getTime() + ) { + // if file has been modified externally since last load, reload it + await this.loadFromFile(); + } + return; + } + } + await this.downloadFile(); + await this.loadFromFile(); + } finally { + this.syncing = false; + } + }; + + public getFromAnidbId = (anidbId: number): AnidbItem | undefined => { + return this.mapping[anidbId]; + }; + + public getSpecialEpisode = ( + tvdbId: number, + episode: number + ): AnidbItem | undefined => { + const episodes = this.specials[tvdbId]; + return episodes ? episodes[episode] : undefined; + }; +} + +const animeList = new AnimeListMapping(); + +export default animeList; diff --git a/server/api/plexapi.ts b/server/api/plexapi.ts index cc71b07e..3dc9bb9d 100644 --- a/server/api/plexapi.ts +++ b/server/api/plexapi.ts @@ -123,6 +123,14 @@ class PlexAPI { return response.MediaContainer.Metadata[0]; } + public async getChildrenMetadata(key: string): Promise { + const response = await this.plexClient.query( + `/library/metadata/${key}/children` + ); + + return response.MediaContainer.Metadata; + } + public async getRecentlyAdded(id: string): Promise { const response = await this.plexClient.query( `/library/sections/${id}/recentlyAdded` diff --git a/server/job/plexsync/index.ts b/server/job/plexsync/index.ts index 6ee93f76..5fd13c21 100644 --- a/server/job/plexsync/index.ts +++ b/server/job/plexsync/index.ts @@ -1,6 +1,6 @@ import { getRepository } from 'typeorm'; import { User } from '../../entity/User'; -import PlexAPI, { PlexLibraryItem } from '../../api/plexapi'; +import PlexAPI, { PlexLibraryItem, PlexMetadata } from '../../api/plexapi'; import TheMovieDb, { TmdbMovieDetails, TmdbTvDetails, @@ -11,15 +11,22 @@ import logger from '../../logger'; import { getSettings, Library } from '../../lib/settings'; import Season from '../../entity/Season'; import { uniqWith } from 'lodash'; +import animeList from '../../api/animelist'; +import AsyncLock from '../../utils/asyncLock'; const BUNDLE_SIZE = 20; const UPDATE_RATE = 4 * 1000; const imdbRegex = new RegExp(/imdb:\/\/(tt[0-9]+)/); const tmdbRegex = new RegExp(/tmdb:\/\/([0-9]+)/); -const tvdbRegex = new RegExp(/tvdb:\/\/([0-9]+)|hama:\/\/tvdb-([0-9]+)/); +const tvdbRegex = new RegExp(/tvdb:\/\/([0-9]+)/); const tmdbShowRegex = new RegExp(/themoviedb:\/\/([0-9]+)/); const plexRegex = new RegExp(/plex:\/\//); +// Hama agent uses ASS naming, see details here: +// https://github.com/ZeroQI/Absolute-Series-Scanner/blob/master/README.md#forcing-the-movieseries-id +const hamaTvdbRegex = new RegExp(/hama:\/\/tvdb[0-9]?-([0-9]+)/); +const hamaAnidbRegex = new RegExp(/hama:\/\/anidb[0-9]?-([0-9]+)/); +const HAMA_AGENT = 'com.plexapp.agents.hama'; interface SyncStatus { running: boolean; @@ -38,6 +45,7 @@ class JobPlexSync { private currentLibrary: Library; private running = false; private isRecentOnly = false; + private asyncLock = new AsyncLock(); constructor({ isRecentOnly }: { isRecentOnly?: boolean } = {}) { this.tmdb = new TheMovieDb(); @@ -78,26 +86,28 @@ class JobPlexSync { } }); - const existing = await this.getExisting( - newMedia.tmdbId, - MediaType.MOVIE - ); - - if (existing && existing.status === MediaStatus.AVAILABLE) { - this.log(`Title exists and is already available ${metadata.title}`); - } else if (existing && existing.status !== MediaStatus.AVAILABLE) { - existing.status = MediaStatus.AVAILABLE; - mediaRepository.save(existing); - this.log( - `Request for ${metadata.title} exists. Setting status AVAILABLE`, - 'info' + await this.asyncLock.dispatch(newMedia.tmdbId, async () => { + const existing = await this.getExisting( + newMedia.tmdbId, + MediaType.MOVIE ); - } else { - newMedia.status = MediaStatus.AVAILABLE; - newMedia.mediaType = MediaType.MOVIE; - await mediaRepository.save(newMedia); - this.log(`Saved ${plexitem.title}`); - } + + if (existing && existing.status === MediaStatus.AVAILABLE) { + this.log(`Title exists and is already available ${metadata.title}`); + } else if (existing && existing.status !== MediaStatus.AVAILABLE) { + existing.status = MediaStatus.AVAILABLE; + mediaRepository.save(existing); + this.log( + `Request for ${metadata.title} exists. Setting status AVAILABLE`, + 'info' + ); + } else { + newMedia.status = MediaStatus.AVAILABLE; + newMedia.mediaType = MediaType.MOVIE; + await mediaRepository.save(newMedia); + this.log(`Saved ${plexitem.title}`); + } + }); } else { let tmdbMovieId: number | undefined; let tmdbMovie: TmdbMovieDetails | undefined; @@ -118,30 +128,7 @@ class JobPlexSync { throw new Error('Unable to find TMDB ID'); } - const existing = await this.getExisting(tmdbMovieId, MediaType.MOVIE); - if (existing && existing.status === MediaStatus.AVAILABLE) { - this.log(`Title exists and is already available ${plexitem.title}`); - } else if (existing && existing.status !== MediaStatus.AVAILABLE) { - existing.status = MediaStatus.AVAILABLE; - await mediaRepository.save(existing); - this.log( - `Request for ${plexitem.title} exists. Setting status AVAILABLE`, - 'info' - ); - } else { - // If we have a tmdb movie guid but it didn't already exist, only then - // do we request the movie from tmdb (to reduce api requests) - if (!tmdbMovie) { - tmdbMovie = await this.tmdb.getMovie({ movieId: tmdbMovieId }); - } - const newMedia = new Media(); - newMedia.imdbId = tmdbMovie.external_ids.imdb_id; - newMedia.tmdbId = tmdbMovie.id; - newMedia.status = MediaStatus.AVAILABLE; - newMedia.mediaType = MediaType.MOVIE; - await mediaRepository.save(newMedia); - this.log(`Saved ${tmdbMovie.title}`); - } + await this.processMovieWithId(plexitem, tmdbMovie, tmdbMovieId); } } catch (e) { this.log( @@ -155,6 +142,71 @@ class JobPlexSync { } } + private async processMovieWithId( + plexitem: PlexLibraryItem, + tmdbMovie: TmdbMovieDetails | undefined, + tmdbMovieId: number + ) { + const mediaRepository = getRepository(Media); + + await this.asyncLock.dispatch(tmdbMovieId, async () => { + const existing = await this.getExisting(tmdbMovieId, MediaType.MOVIE); + if (existing && existing.status === MediaStatus.AVAILABLE) { + this.log(`Title exists and is already available ${plexitem.title}`); + } else if (existing && existing.status !== MediaStatus.AVAILABLE) { + existing.status = MediaStatus.AVAILABLE; + await mediaRepository.save(existing); + this.log( + `Request for ${plexitem.title} exists. Setting status AVAILABLE`, + 'info' + ); + } else { + // If we have a tmdb movie guid but it didn't already exist, only then + // do we request the movie from tmdb (to reduce api requests) + if (!tmdbMovie) { + tmdbMovie = await this.tmdb.getMovie({ movieId: tmdbMovieId }); + } + const newMedia = new Media(); + newMedia.imdbId = tmdbMovie.external_ids.imdb_id; + newMedia.tmdbId = tmdbMovie.id; + newMedia.status = MediaStatus.AVAILABLE; + newMedia.mediaType = MediaType.MOVIE; + await mediaRepository.save(newMedia); + this.log(`Saved ${tmdbMovie.title}`); + } + }); + } + + // this adds all movie episodes from specials season for Hama agent + private async processHamaSpecials(metadata: PlexMetadata, tvdbId: number) { + const specials = metadata.Children?.Metadata.find( + (md) => Number(md.index) === 0 + ); + if (specials) { + const episodes = await this.plexClient.getChildrenMetadata( + specials.ratingKey + ); + if (episodes) { + for (const episode of episodes) { + const special = await animeList.getSpecialEpisode( + tvdbId, + episode.index + ); + if (special) { + if (special.tmdbId) { + await this.processMovieWithId(episode, undefined, special.tmdbId); + } else if (special.imdbId) { + const tmdbMovie = await this.tmdb.getMovieByImdbId({ + imdbId: special.imdbId, + }); + await this.processMovieWithId(episode, tmdbMovie, tmdbMovie.id); + } + } + } + } + } + } + private async processShow(plexitem: PlexLibraryItem) { const mediaRepository = getRepository(Media); @@ -182,108 +234,182 @@ class JobPlexSync { if (matchedtmdb?.[1]) { tvShow = await this.tmdb.getTvShow({ tvId: Number(matchedtmdb[1]) }); } - } - - if (tvShow && metadata) { - // Lets get the available seasons from plex - const seasons = tvShow.seasons; - const media = await this.getExisting(tvShow.id, MediaType.TV); - - const newSeasons: Season[] = []; - - const currentSeasonAvailable = ( - media?.seasons.filter( - (season) => season.status === MediaStatus.AVAILABLE - ) ?? [] - ).length; - - seasons.forEach((season) => { - const matchedPlexSeason = metadata.Children?.Metadata.find( - (md) => Number(md.index) === season.season_number - ); + } else if (metadata.guid.match(hamaTvdbRegex)) { + const matched = metadata.guid.match(hamaTvdbRegex); + const tvdbId = matched?.[1]; + + if (tvdbId) { + tvShow = await this.tmdb.getShowByTvdbId({ tvdbId: Number(tvdbId) }); + if (animeList.isLoaded()) { + await this.processHamaSpecials(metadata, Number(tvdbId)); + } else { + this.log( + `Hama id ${plexitem.guid} detected, but library agent is not set to Hama`, + 'warn' + ); + } + } + } else if (metadata.guid.match(hamaAnidbRegex)) { + const matched = metadata.guid.match(hamaAnidbRegex); - const existingSeason = media?.seasons.find( - (es) => es.seasonNumber === season.season_number + if (!animeList.isLoaded()) { + this.log( + `Hama id ${plexitem.guid} detected, but library agent is not set to Hama`, + 'warn' ); - - // Check if we found the matching season and it has all the available episodes - if ( - matchedPlexSeason && - Number(matchedPlexSeason.leafCount) === season.episode_count - ) { - if (existingSeason) { - existingSeason.status = MediaStatus.AVAILABLE; + } else if (matched?.[1]) { + const anidbId = Number(matched[1]); + const result = animeList.getFromAnidbId(anidbId); + + // first try to lookup tvshow by tvdbid + if (result?.tvdbId) { + const extResponse = await this.tmdb.getByExternalId({ + externalId: result.tvdbId, + type: 'tvdb', + }); + if (extResponse.tv_results[0]) { + tvShow = await this.tmdb.getTvShow({ + tvId: extResponse.tv_results[0].id, + }); } else { - newSeasons.push( - new Season({ - seasonNumber: season.season_number, - status: MediaStatus.AVAILABLE, - }) + this.log( + `Missing TVDB ${result.tvdbId} entry in TMDB for AniDB ${anidbId}` ); } - } else if (matchedPlexSeason) { - if (existingSeason) { - existingSeason.status = MediaStatus.PARTIALLY_AVAILABLE; - } else { - newSeasons.push( - new Season({ - seasonNumber: season.season_number, - status: MediaStatus.PARTIALLY_AVAILABLE, - }) + await this.processHamaSpecials(metadata, result.tvdbId); + } + + if (!tvShow) { + // if lookup of tvshow above failed, then try movie with tmdbid/imdbid + // note - some tv shows have imdbid set too, that's why this need to go second + if (result?.tmdbId) { + return await this.processMovieWithId( + plexitem, + undefined, + result.tmdbId + ); + } else if (result?.imdbId) { + const tmdbMovie = await this.tmdb.getMovieByImdbId({ + imdbId: result.imdbId, + }); + return await this.processMovieWithId( + plexitem, + tmdbMovie, + tmdbMovie.id ); } } - }); + } + } - // Remove extras season. We dont count it for determining availability - const filteredSeasons = tvShow.seasons.filter( - (season) => season.season_number !== 0 - ); + if (tvShow) { + await this.asyncLock.dispatch(tvShow.id, async () => { + if (!tvShow) { + // this will never execute, but typescript thinks somebody could reset tvShow from + // outer scope back to null before this async gets called + return; + } - const isAllSeasons = - newSeasons.length + (media?.seasons.length ?? 0) >= - filteredSeasons.length; + // Lets get the available seasons from plex + const seasons = tvShow.seasons; + const media = await this.getExisting(tvShow.id, MediaType.TV); - if (media) { - // Update existing - media.seasons = [...media.seasons, ...newSeasons]; + const newSeasons: Season[] = []; - const newSeasonAvailable = ( - media.seasons.filter( + const currentSeasonAvailable = ( + media?.seasons.filter( (season) => season.status === MediaStatus.AVAILABLE ) ?? [] ).length; - // If at least one new season has become available, update - // the lastSeasonChange field so we can trigger notifications - if (newSeasonAvailable > currentSeasonAvailable) { - this.log( - `Detected ${ - newSeasonAvailable - currentSeasonAvailable - } new season(s) for ${tvShow.name}`, - 'debug' + seasons.forEach((season) => { + const matchedPlexSeason = metadata.Children?.Metadata.find( + (md) => Number(md.index) === season.season_number ); - media.lastSeasonChange = new Date(); - } - media.status = isAllSeasons - ? MediaStatus.AVAILABLE - : MediaStatus.PARTIALLY_AVAILABLE; - await mediaRepository.save(media); - this.log(`Updating existing title: ${tvShow.name}`); - } else { - const newMedia = new Media({ - mediaType: MediaType.TV, - seasons: newSeasons, - tmdbId: tvShow.id, - tvdbId: tvShow.external_ids.tvdb_id, - status: isAllSeasons - ? MediaStatus.AVAILABLE - : MediaStatus.PARTIALLY_AVAILABLE, + const existingSeason = media?.seasons.find( + (es) => es.seasonNumber === season.season_number + ); + + // Check if we found the matching season and it has all the available episodes + if ( + matchedPlexSeason && + Number(matchedPlexSeason.leafCount) === season.episode_count + ) { + if (existingSeason) { + existingSeason.status = MediaStatus.AVAILABLE; + } else { + newSeasons.push( + new Season({ + seasonNumber: season.season_number, + status: MediaStatus.AVAILABLE, + }) + ); + } + } else if (matchedPlexSeason) { + if (existingSeason) { + existingSeason.status = MediaStatus.PARTIALLY_AVAILABLE; + } else { + newSeasons.push( + new Season({ + seasonNumber: season.season_number, + status: MediaStatus.PARTIALLY_AVAILABLE, + }) + ); + } + } }); - await mediaRepository.save(newMedia); - this.log(`Saved ${tvShow.name}`); - } + + // Remove extras season. We dont count it for determining availability + const filteredSeasons = tvShow.seasons.filter( + (season) => season.season_number !== 0 + ); + + const isAllSeasons = + newSeasons.length + (media?.seasons.length ?? 0) >= + filteredSeasons.length; + + if (media) { + // Update existing + media.seasons = [...media.seasons, ...newSeasons]; + + const newSeasonAvailable = ( + media.seasons.filter( + (season) => season.status === MediaStatus.AVAILABLE + ) ?? [] + ).length; + + // If at least one new season has become available, update + // the lastSeasonChange field so we can trigger notifications + if (newSeasonAvailable > currentSeasonAvailable) { + this.log( + `Detected ${ + newSeasonAvailable - currentSeasonAvailable + } new season(s) for ${tvShow.name}`, + 'debug' + ); + media.lastSeasonChange = new Date(); + } + + media.status = isAllSeasons + ? MediaStatus.AVAILABLE + : MediaStatus.PARTIALLY_AVAILABLE; + await mediaRepository.save(media); + this.log(`Updating existing title: ${tvShow.name}`); + } else { + const newMedia = new Media({ + mediaType: MediaType.TV, + seasons: newSeasons, + tmdbId: tvShow.id, + tvdbId: tvShow.external_ids.tvdb_id, + status: isAllSeasons + ? MediaStatus.AVAILABLE + : MediaStatus.PARTIALLY_AVAILABLE, + }); + await mediaRepository.save(newMedia); + this.log(`Saved ${tvShow.name}`); + } + }); } else { this.log(`failed show: ${plexitem.guid}`); } @@ -351,6 +477,17 @@ class JobPlexSync { logger[level](message, { label: 'Plex Sync', ...optional }); } + // checks if any of this.libraries has Hama agent set in Plex + private async hasHamaAgent() { + const plexLibraries = await this.plexClient.getLibraries(); + return this.libraries.some((library) => + plexLibraries.some( + (plexLibrary) => + plexLibrary.agent === HAMA_AGENT && library.id === plexLibrary.key + ) + ); + } + public async run(): Promise { const settings = getSettings(); if (!this.running) { @@ -371,6 +508,11 @@ class JobPlexSync { (library) => library.enabled ); + const hasHama = await this.hasHamaAgent(); + if (hasHama) { + await animeList.sync(); + } + if (this.isRecentOnly) { for (const library of this.libraries) { this.currentLibrary = library; diff --git a/server/utils/asyncLock.ts b/server/utils/asyncLock.ts new file mode 100644 index 00000000..51794a98 --- /dev/null +++ b/server/utils/asyncLock.ts @@ -0,0 +1,54 @@ +import { EventEmitter } from 'events'; + +// whenever you need to run async code on tv show or movie that does "get existing" / "check if need to create new" / "save" +// then you need to put all of that code in "await asyncLock.dispatch" callback based on media id +// this will guarantee that only one part of code will run at the same for this media id to avoid code +// trying to create two or more entries for same movie/tvshow (which would result in sqlite unique constraint failrue) + +class AsyncLock { + private locked: { [key: string]: boolean } = {}; + private ee = new EventEmitter(); + + constructor() { + this.ee.setMaxListeners(0); + } + + private acquire = async (key: string) => { + return new Promise((resolve) => { + if (!this.locked[key]) { + this.locked[key] = true; + return resolve(undefined); + } + + const nextAcquire = () => { + if (!this.locked[key]) { + this.locked[key] = true; + this.ee.removeListener(key, nextAcquire); + return resolve(undefined); + } + }; + + this.ee.on(key, nextAcquire); + }); + }; + + private release = (key: string): void => { + delete this.locked[key]; + setImmediate(() => this.ee.emit(key)); + }; + + public dispatch = async ( + key: string | number, + callback: () => Promise + ) => { + const skey = String(key); + await this.acquire(skey); + try { + await callback(); + } finally { + this.release(skey); + } + }; +} + +export default AsyncLock;