From 0600ac7c3a1bc0cdd906634d5f77ea3e99b10e94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C4=81rti=C5=86=C5=A1=20Mo=C5=BEeiko?= Date: Thu, 7 Jan 2021 16:46:00 -0800 Subject: [PATCH] feat: map AniDB IDs from Hama agent to tvdb/tmdb/imdb IDs (#538) * feat: map AniDB IDs from Hama agent to tvdb/tmdb/imdb IDs re #453 * refactor: removes sync job for AnimeList, load mapping on demand * refactor: addressing review comments, using typescript types for xml parsing * refactor: make sure sync job does not update create same tvshow/movie twice Hama agent can have same tvdbid it for different library items - for example when user stores different seasons for same tv show separately. This change adds "AsyncLock" that guarantees code in callback runs for same id fully, before running same callback next time. * refactor: do not use season 0 tvdbid for tvshow from mapping file * refactor: support multiple imdb mappings for same anidb entry * refactor: add debug log for missing tvdb entries in tmdb lookups from anidb/hama agent --- .gitignore | 3 + server/api/animelist.ts | 223 ++++++++++++++++++++ server/api/plexapi.ts | 8 + server/job/plexsync/index.ts | 398 ++++++++++++++++++++++++----------- server/utils/asyncLock.ts | 54 +++++ 5 files changed, 558 insertions(+), 128 deletions(-) create mode 100644 server/api/animelist.ts create mode 100644 server/utils/asyncLock.ts diff --git a/.gitignore b/.gitignore index 379afe400..2f8630911 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 000000000..428684bcc --- /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 cc71b07e7..3dc9bb9d7 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 6ee93f760..5fd13c212 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 000000000..51794a989 --- /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;