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 01/43] 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 | 394 ++++++++++++++++++++++++----------- server/utils/asyncLock.ts | 54 +++++ 5 files changed, 556 insertions(+), 126 deletions(-) create mode 100644 server/api/animelist.ts create mode 100644 server/utils/asyncLock.ts 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]) }); } - } + } else if (metadata.guid.match(hamaTvdbRegex)) { + const matched = metadata.guid.match(hamaTvdbRegex); + const tvdbId = matched?.[1]; - if (tvShow && metadata) { - // Lets get the available seasons from plex - const seasons = tvShow.seasons; - const media = await this.getExisting(tvShow.id, MediaType.TV); + 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 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 + if (!animeList.isLoaded()) { + this.log( + `Hama id ${plexitem.guid} detected, but library agent is not set to Hama`, + 'warn' ); + } else if (matched?.[1]) { + const anidbId = Number(matched[1]); + const result = animeList.getFromAnidbId(anidbId); - 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; + // 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; From 79629645aacc1a042919834da79bff0c1f69c9d6 Mon Sep 17 00:00:00 2001 From: sct Date: Fri, 8 Jan 2021 14:13:35 +0000 Subject: [PATCH 02/43] revert(deps): revert back to next@10.0.3 until sharp optional dependency bug is fixed see: https://github.com/vercel/next.js/issues/20456 --- package.json | 2 +- yarn.lock | 225 +++++++++++++++++++++++++-------------------------- 2 files changed, 110 insertions(+), 117 deletions(-) diff --git a/package.json b/package.json index 5b6fa8c6..a99c656f 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "formik": "^2.2.6", "intl": "^1.2.5", "lodash": "^4.17.20", - "next": "^10.0.4", + "next": "10.0.3", "node-schedule": "^1.3.2", "nodemailer": "^6.4.17", "nookies": "^2.5.0", diff --git a/yarn.lock b/yarn.lock index 28cb1d09..80c17d5e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,7 +2,7 @@ # yarn lockfile v1 -"@ampproject/toolbox-core@2.7.4", "@ampproject/toolbox-core@^2.7.1-alpha.0": +"@ampproject/toolbox-core@2.7.4", "@ampproject/toolbox-core@^2.6.0": version "2.7.4" resolved "https://registry.yarnpkg.com/@ampproject/toolbox-core/-/toolbox-core-2.7.4.tgz#8355136f16301458ce942acf6c55952c9a415627" integrity sha512-qpBhcS4urB7IKc+jx2kksN7BuvvwCo7Y3IstapWo+EW+COY5EYAUwb2pil37v3TsaqHKgX//NloFP1SKzGZAnw== @@ -10,31 +10,31 @@ cross-fetch "3.0.6" lru-cache "6.0.0" -"@ampproject/toolbox-optimizer@2.7.1-alpha.0": - version "2.7.1-alpha.0" - resolved "https://registry.yarnpkg.com/@ampproject/toolbox-optimizer/-/toolbox-optimizer-2.7.1-alpha.0.tgz#1571dcd02608223ff68f6b7223102a123e381197" - integrity sha512-WGPZKVQvHgNYJk1XVJCCmY+NVGTGJtvn0OALDyiegN4FJWOcilQUhCIcjMkZN59u1flz/u+sEKccM5qsROqVyg== +"@ampproject/toolbox-optimizer@2.7.0-alpha.1": + version "2.7.0-alpha.1" + resolved "https://registry.yarnpkg.com/@ampproject/toolbox-optimizer/-/toolbox-optimizer-2.7.0-alpha.1.tgz#ab4c386645f991e5da5a9d2967ed2bb734a9f6c4" + integrity sha512-2wTvOyM6GP6FrYQzxSQCg43STo1jMRGeDKa6YUkYXYH9fm9Wbt2wTRx+ajjb48JQ6WwUnGwga1MhQhVFzRQ+wQ== dependencies: - "@ampproject/toolbox-core" "^2.7.1-alpha.0" - "@ampproject/toolbox-runtime-version" "^2.7.1-alpha.0" + "@ampproject/toolbox-core" "^2.6.0" + "@ampproject/toolbox-runtime-version" "^2.7.0-alpha.1" "@ampproject/toolbox-script-csp" "^2.5.4" - "@ampproject/toolbox-validator-rules" "^2.7.1-alpha.0" + "@ampproject/toolbox-validator-rules" "^2.5.4" abort-controller "3.0.0" - cross-fetch "3.0.6" - cssnano-simple "1.2.1" - dom-serializer "1.1.0" - domhandler "3.3.0" - domutils "2.4.2" - htmlparser2 "5.0.1" + cross-fetch "3.0.5" + cssnano-simple "1.2.0" + dom-serializer "1.0.1" + domhandler "3.0.0" + domutils "2.1.0" + htmlparser2 "4.1.0" https-proxy-agent "5.0.0" lru-cache "6.0.0" - node-fetch "2.6.1" + node-fetch "2.6.0" normalize-html-whitespace "1.0.0" postcss "7.0.32" postcss-safe-parser "4.0.2" - terser "5.5.1" + terser "5.1.0" -"@ampproject/toolbox-runtime-version@^2.7.1-alpha.0": +"@ampproject/toolbox-runtime-version@^2.7.0-alpha.1": version "2.7.4" resolved "https://registry.yarnpkg.com/@ampproject/toolbox-runtime-version/-/toolbox-runtime-version-2.7.4.tgz#f49da0dab122101ef75ed3caed3a0142487b73e1" integrity sha512-SAdOUOERp42thVNWaBJlnFvFVvnacMVnz5z9LyUZHSnoL1EqrAW5Sz5jv+Ly+gkA8NYsEaUxAdSCBAzE9Uzb4Q== @@ -46,7 +46,7 @@ resolved "https://registry.yarnpkg.com/@ampproject/toolbox-script-csp/-/toolbox-script-csp-2.5.4.tgz#d8b7b91a678ae8f263cb36d9b74e441b7d633aad" integrity sha512-+knTYetI5nWllRZ9wFcj7mYxelkiiFVRAAW/hl0ad8EnKHMH82tRlk40CapEnUHhp6Er5sCYkumQ8dngs3Q4zQ== -"@ampproject/toolbox-validator-rules@^2.7.1-alpha.0": +"@ampproject/toolbox-validator-rules@^2.5.4": version "2.7.4" resolved "https://registry.yarnpkg.com/@ampproject/toolbox-validator-rules/-/toolbox-validator-rules-2.7.4.tgz#a58b5eca723f6c3557ac83b696de0247f5f03ce4" integrity sha512-z3JRcpIZLLdVC9XVe7YTZuB3a/eR9s2SjElYB9AWRdyJyL5Jt7+pGNv4Uwh1uHVoBXsWEVQzNOWSNtrO3mSwZA== @@ -1506,20 +1506,20 @@ titleize "^2.1.0" tlds "^1.212.0" -"@next/env@10.0.4": - version "10.0.4" - resolved "https://registry.yarnpkg.com/@next/env/-/env-10.0.4.tgz#ac759094d021853616af56a7bd6720e44a92a303" - integrity sha512-U+XIL75XM1pCmY4+9kYbst/0IptlfDnkFfKdgADBZulQlfng4RB3zirdzkoBtod0lVcrGgDryzOi1mM23RiiVQ== +"@next/env@10.0.3": + version "10.0.3" + resolved "https://registry.yarnpkg.com/@next/env/-/env-10.0.3.tgz#ef1077d78bf500855576f83090d6fb1ec96272f8" + integrity sha512-xjJt2VXoSxAydskmt77nJuEtRL782E4ltaj5JtMzJ8YkNUMMu3d5ktpCR+Q3INKHF/RY6zHJ9QzyE3/s1ikbNg== -"@next/polyfill-module@10.0.4": - version "10.0.4" - resolved "https://registry.yarnpkg.com/@next/polyfill-module/-/polyfill-module-10.0.4.tgz#c34391a12ad80d6e373c403f96c8e2bbd793dca1" - integrity sha512-i2gLUa3YuZ2eQg+d91n+jS4YbPVKg1v0HHIUeJFJMMtpG/apBkTuTLBQGJXe4nKNf7/41NWLDft4ihC3Zfd+Yw== +"@next/polyfill-module@10.0.3": + version "10.0.3" + resolved "https://registry.yarnpkg.com/@next/polyfill-module/-/polyfill-module-10.0.3.tgz#507e99f6dd351dc4a6e45b63dbd397087ece459a" + integrity sha512-JaiycQZZbqViaMZgRGYcPIdCPDz+qRnqEGxbhQlrxyPaBaOtsrAEkGf1SS2wJZKa/ncxqWHMfSvizDcGcz/ygQ== -"@next/react-dev-overlay@10.0.4": - version "10.0.4" - resolved "https://registry.yarnpkg.com/@next/react-dev-overlay/-/react-dev-overlay-10.0.4.tgz#c578a3c71e2f8a8fe2aae8007cc40d1cf10bc768" - integrity sha512-8pKN0PspEtfVFqeSpNQymfXWyV95OTIT0xP9IqILJX2+52ICdU5D+YNuNIwpc4ZOZ0CssM/uYsz6K1FHbCaU7A== +"@next/react-dev-overlay@10.0.3": + version "10.0.3" + resolved "https://registry.yarnpkg.com/@next/react-dev-overlay/-/react-dev-overlay-10.0.3.tgz#99f3151677747d8be08a9314fa7ab3611e8161b8" + integrity sha512-ykiKeUhTsMRoyyYnx4jM8xeOPfKGqQ7xgx2dNXOu4tbPpdivzjJp2+K6+xnqhTmZ7uxfFBV+b1OE1ZzA8qyX5Q== dependencies: "@babel/code-frame" "7.10.4" ally.js "1.4.1" @@ -1532,10 +1532,10 @@ stacktrace-parser "0.1.10" strip-ansi "6.0.0" -"@next/react-refresh-utils@10.0.4": - version "10.0.4" - resolved "https://registry.yarnpkg.com/@next/react-refresh-utils/-/react-refresh-utils-10.0.4.tgz#5ad753572891aa7cb1010b358cc4241d7be20d20" - integrity sha512-kZ/37aSQaR0GCZVqh7WDLkeEZqzjPQoZUDdo6TOWiIEb+089AmfYp8A4/1ra9Fu8T4b4wnB76TRl6tp6DeJLXg== +"@next/react-refresh-utils@10.0.3": + version "10.0.3" + resolved "https://registry.yarnpkg.com/@next/react-refresh-utils/-/react-refresh-utils-10.0.3.tgz#276bec60eae18768f96baf8a52f668f657f50ab4" + integrity sha512-XtzzPX2R4+MIyu1waEQUo2tiNwWVEkmObA6pboRCDTPOs4Ri8ckaIE08lN5A5opyF6GVN+IEq/J8KQrgsePsZQ== "@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents": version "2.1.8-no-fsevents" @@ -1678,18 +1678,6 @@ "@octokit/openapi-types" "^1.2.0" "@types/node" ">= 8" -"@opentelemetry/api@0.14.0": - version "0.14.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-0.14.0.tgz#4e17d8d2f1da72b19374efa7b6526aa001267cae" - integrity sha512-L7RMuZr5LzMmZiQSQDy9O1jo0q+DaLy6XpYJfIGfYSfoJA5qzYwUP3sP1uMIQ549DvxAgM3ng85EaPTM/hUHwQ== - dependencies: - "@opentelemetry/context-base" "^0.14.0" - -"@opentelemetry/context-base@^0.14.0": - version "0.14.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/context-base/-/context-base-0.14.0.tgz#c67fc20a4d891447ca1a855d7d70fa79a3533001" - integrity sha512-sDOAZcYwynHFTbLo6n8kIbLiVF3a3BLkrmehJUyEbT9F+Smbi47kLGS2gG2g0fjBLR/Lr1InPD7kXL7FaTqEkw== - "@semantic-release/changelog@^5.0.1": version "5.0.1" resolved "https://registry.yarnpkg.com/@semantic-release/changelog/-/changelog-5.0.1.tgz#50a84b63e5d391b7debfe021421589fa2bcdafe4" @@ -4541,6 +4529,13 @@ cron-parser@^2.7.3: is-nan "^1.3.0" moment-timezone "^0.5.31" +cross-fetch@3.0.5: + version "3.0.5" + resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.0.5.tgz#2739d2981892e7ab488a7ad03b92df2816e03f4c" + integrity sha512-FFLcLtraisj5eteosnX1gf01qYDCOc4fDy0+euOt8Kn9YBY2NtXL/pCoYPavw24NIQkQqm5ZOLsGD5Zzj0gyew== + dependencies: + node-fetch "2.6.0" + cross-fetch@3.0.6: version "3.0.6" resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.0.6.tgz#3a4040bc8941e653e0e9cf17f29ebcd177d3365c" @@ -4730,6 +4725,14 @@ cssesc@^3.0.0: resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee" integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg== +cssnano-preset-simple@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/cssnano-preset-simple/-/cssnano-preset-simple-1.2.0.tgz#afcf13eb076e8ebd91c4f311cd449781c14c7371" + integrity sha512-zojGlY+KasFeQT/SnD/WqYXHcKddz2XHRDtIwxrWpGqGHp5IyLWsWFS3UW7pOf3AWvfkpYSRdxOSlYuJPz8j8g== + dependencies: + caniuse-lite "^1.0.30001093" + postcss "^7.0.32" + cssnano-preset-simple@1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/cssnano-preset-simple/-/cssnano-preset-simple-1.2.1.tgz#8976013114b1fc4718253d30f21aaed1780fb80e" @@ -4738,6 +4741,14 @@ cssnano-preset-simple@1.2.1: caniuse-lite "^1.0.30001093" postcss "^7.0.32" +cssnano-simple@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/cssnano-simple/-/cssnano-simple-1.2.0.tgz#b8cc5f52c2a52e6513b4636d0da165ec9d48d327" + integrity sha512-pton9cZ70/wOCWMAbEGHO1ACsW1KggTB6Ikj7k71uOEsz6SfByH++86+WAmXjRSc9q/g9gxkpFP9bDX9vRotdA== + dependencies: + cssnano-preset-simple "1.2.0" + postcss "^7.0.32" + cssnano-simple@1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/cssnano-simple/-/cssnano-simple-1.2.1.tgz#6de5d9dd75774bc8f31767573410a952c7dd8a12" @@ -5163,10 +5174,10 @@ dom-serializer@0, dom-serializer@^0.2.1: domelementtype "^2.0.1" entities "^2.0.0" -dom-serializer@1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.1.0.tgz#5f7c828f1bfc44887dc2a315ab5c45691d544b58" - integrity sha512-ox7bvGXt2n+uLWtCRLybYx60IrOlWL/aCebWJk1T0d4m3y2tzf4U3ij9wBMUb6YJZpz06HCCYuyCDveE2xXmzQ== +dom-serializer@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.0.1.tgz#79695eb49af3cd8abc8d93a73da382deb1ca0795" + integrity sha512-1Aj1Qy3YLbdslkI75QEOfdp9TkQ3o8LRISAzxOibjBs/xWwr1WxZFOQphFkZuepHFGo+kB8e5FVJSS0faAJ4Rw== dependencies: domelementtype "^2.0.1" domhandler "^3.0.0" @@ -5209,10 +5220,10 @@ domelementtype@^2.1.0: resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.1.0.tgz#a851c080a6d1c3d94344aed151d99f669edf585e" integrity sha512-LsTgx/L5VpD+Q8lmsXSHW2WpA+eBlZ9HPf3erD1IoPF00/3JKHZ3BknUVA2QGDNu69ZNmyFmCWBSO45XjYKC5w== -domhandler@3.3.0, domhandler@^3.3.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-3.3.0.tgz#6db7ea46e4617eb15cf875df68b2b8524ce0037a" - integrity sha512-J1C5rIANUbuYK+FuFL98650rihynUOEzRLxW+90bKZRWB6A1X1Tf82GxR1qAWLyfNPRvjqfip3Q5tdYlmAa9lA== +domhandler@3.0.0, domhandler@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-3.0.0.tgz#51cd13efca31da95bbb0c5bee3a48300e333b3e9" + integrity sha512-eKLdI5v9m67kbXQbJSNn1zjh0SDzvzWVWtX+qEI3eMjZw8daH9k8rlj1FZY9memPwjiskQFbe7vHVVJIAqoEhw== dependencies: domelementtype "^2.0.1" @@ -5223,10 +5234,10 @@ domhandler@^2.3.0: dependencies: domelementtype "1" -domhandler@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-3.0.0.tgz#51cd13efca31da95bbb0c5bee3a48300e333b3e9" - integrity sha512-eKLdI5v9m67kbXQbJSNn1zjh0SDzvzWVWtX+qEI3eMjZw8daH9k8rlj1FZY9memPwjiskQFbe7vHVVJIAqoEhw== +domhandler@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-3.3.0.tgz#6db7ea46e4617eb15cf875df68b2b8524ce0037a" + integrity sha512-J1C5rIANUbuYK+FuFL98650rihynUOEzRLxW+90bKZRWB6A1X1Tf82GxR1qAWLyfNPRvjqfip3Q5tdYlmAa9lA== dependencies: domelementtype "^2.0.1" @@ -5245,14 +5256,14 @@ domutils@1.5.1: dom-serializer "0" domelementtype "1" -domutils@2.4.2: - version "2.4.2" - resolved "https://registry.yarnpkg.com/domutils/-/domutils-2.4.2.tgz#7ee5be261944e1ad487d9aa0616720010123922b" - integrity sha512-NKbgaM8ZJOecTZsIzW5gSuplsX2IWW2mIK7xVr8hTQF2v1CJWTmLZ1HOCh5sH+IzVPAGE5IucooOkvwBRAdowA== +domutils@2.1.0, domutils@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-2.1.0.tgz#7ade3201af43703fde154952e3a868eb4b635f16" + integrity sha512-CD9M0Dm1iaHfQ1R/TI+z3/JWp/pgub0j4jIQKH89ARR4ATAV2nbaOQS5XxU9maJP5jHaPdDDQSEHuE2UmpUTKg== dependencies: - dom-serializer "^1.0.1" + dom-serializer "^0.2.1" domelementtype "^2.0.1" - domhandler "^3.3.0" + domhandler "^3.0.0" domutils@^1.5.1, domutils@^1.7.0: version "1.7.0" @@ -5262,15 +5273,6 @@ domutils@^1.5.1, domutils@^1.7.0: dom-serializer "0" domelementtype "1" -domutils@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/domutils/-/domutils-2.1.0.tgz#7ade3201af43703fde154952e3a868eb4b635f16" - integrity sha512-CD9M0Dm1iaHfQ1R/TI+z3/JWp/pgub0j4jIQKH89ARR4ATAV2nbaOQS5XxU9maJP5jHaPdDDQSEHuE2UmpUTKg== - dependencies: - dom-serializer "^0.2.1" - domelementtype "^2.0.1" - domhandler "^3.0.0" - domutils@^2.4.2: version "2.4.4" resolved "https://registry.yarnpkg.com/domutils/-/domutils-2.4.4.tgz#282739c4b150d022d34699797369aad8d19bbbd3" @@ -6936,14 +6938,14 @@ html-to-text@6.0.0, html-to-text@^6.0.0: lodash "^4.17.20" minimist "^1.2.5" -htmlparser2@5.0.1, htmlparser2@^5.0: - version "5.0.1" - resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-5.0.1.tgz#7daa6fc3e35d6107ac95a4fc08781f091664f6e7" - integrity sha512-vKZZra6CSe9qsJzh0BjBGXo8dvzNsq/oGvsjfRdOrrryfeD9UOBEEQdeoqCRmKZchF5h2zOBMQ6YuQ0uRUmdbQ== +htmlparser2@4.1.0, htmlparser2@^4.0.0, htmlparser2@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-4.1.0.tgz#9a4ef161f2e4625ebf7dfbe6c0a2f52d18a59e78" + integrity sha512-4zDq1a1zhE4gQso/c5LP1OtrhYTncXNSpvJYtWJBtXAETPlMfi3IFNjGuQbYLuVY4ZR0QMqRVvo4Pdy9KLyP8Q== dependencies: domelementtype "^2.0.1" - domhandler "^3.3.0" - domutils "^2.4.2" + domhandler "^3.0.0" + domutils "^2.0.0" entities "^2.0.0" htmlparser2@^3.9.1: @@ -6958,14 +6960,14 @@ htmlparser2@^3.9.1: inherits "^2.0.1" readable-stream "^3.1.1" -htmlparser2@^4.0.0, htmlparser2@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-4.1.0.tgz#9a4ef161f2e4625ebf7dfbe6c0a2f52d18a59e78" - integrity sha512-4zDq1a1zhE4gQso/c5LP1OtrhYTncXNSpvJYtWJBtXAETPlMfi3IFNjGuQbYLuVY4ZR0QMqRVvo4Pdy9KLyP8Q== +htmlparser2@^5.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-5.0.1.tgz#7daa6fc3e35d6107ac95a4fc08781f091664f6e7" + integrity sha512-vKZZra6CSe9qsJzh0BjBGXo8dvzNsq/oGvsjfRdOrrryfeD9UOBEEQdeoqCRmKZchF5h2zOBMQ6YuQ0uRUmdbQ== dependencies: domelementtype "^2.0.1" - domhandler "^3.0.0" - domutils "^2.0.0" + domhandler "^3.3.0" + domutils "^2.4.2" entities "^2.0.0" http-cache-semantics@^3.8.1: @@ -9254,19 +9256,18 @@ next-tick@~1.0.0: resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.0.0.tgz#ca86d1fe8828169b0120208e3dc8424b9db8342c" integrity sha1-yobR/ogoFpsBICCOPchCS524NCw= -next@^10.0.4: - version "10.0.4" - resolved "https://registry.yarnpkg.com/next/-/next-10.0.4.tgz#0d256f58a57d6bab7db7e533900c15f322960b4a" - integrity sha512-WXEYr1FuR2cLuWGN8peYGM6ykmbtwaHvrI6RqR2qrTXUNsW+KU5pzIMK5WPcpqP+xOuMhlykOCJvwJH8qU9FZQ== +next@10.0.3: + version "10.0.3" + resolved "https://registry.yarnpkg.com/next/-/next-10.0.3.tgz#2bf9a1625dcd0afc8c31be19fc5516af68d99e80" + integrity sha512-QYCfjZgowjaLUFvyV8959SmkUZU/edFgHeiXNtWDv7kffo/oTm891p0KZAkk5cMIHcsDX3g3UuQdw/zmui783g== dependencies: - "@ampproject/toolbox-optimizer" "2.7.1-alpha.0" + "@ampproject/toolbox-optimizer" "2.7.0-alpha.1" "@babel/runtime" "7.12.5" "@hapi/accept" "5.0.1" - "@next/env" "10.0.4" - "@next/polyfill-module" "10.0.4" - "@next/react-dev-overlay" "10.0.4" - "@next/react-refresh-utils" "10.0.4" - "@opentelemetry/api" "0.14.0" + "@next/env" "10.0.3" + "@next/polyfill-module" "10.0.3" + "@next/react-dev-overlay" "10.0.3" + "@next/react-refresh-utils" "10.0.3" ast-types "0.13.2" babel-plugin-transform-define "2.0.0" babel-plugin-transform-react-remove-prop-types "0.4.24" @@ -9285,7 +9286,6 @@ next@^10.0.4: native-url "0.3.4" node-fetch "2.6.1" node-html-parser "1.4.9" - p-limit "3.1.0" path-browserify "1.0.1" pnp-webpack-plugin "1.6.4" postcss "8.1.7" @@ -9346,6 +9346,11 @@ node-fetch-npm@^2.0.2: json-parse-better-errors "^1.0.0" safe-buffer "^5.1.1" +node-fetch@2.6.0: + version "2.6.0" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.0.tgz#e633456386d4aa55863f676a7ab0daa8fdecb0fd" + integrity sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA== + node-fetch@2.6.1, node-fetch@^2.6.0, node-fetch@^2.6.1: version "2.6.1" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" @@ -10101,13 +10106,6 @@ p-is-promise@^3.0.0: resolved "https://registry.yarnpkg.com/p-is-promise/-/p-is-promise-3.0.0.tgz#58e78c7dfe2e163cf2a04ff869e7c1dba64a5971" integrity sha512-Wo8VsW4IRQSKVXsJCn7TomUaVtyfjVDn3nUP7kE967BQk0CwFpdbZs0X0uk5sW9mkBa9eNM7hCMaG93WUAwxYQ== -p-limit@3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" - integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== - dependencies: - yocto-queue "^0.1.0" - p-limit@^1.1.0: version "1.3.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-1.3.0.tgz#b86bd5f0c25690911c7590fcbfc2010d54b3ccb8" @@ -12591,7 +12589,7 @@ source-map-resolve@^0.5.0, source-map-resolve@^0.5.2: source-map-url "^0.4.0" urix "^0.1.0" -source-map-support@^0.5.17, source-map-support@~0.5.12, source-map-support@~0.5.19: +source-map-support@^0.5.17, source-map-support@~0.5.12: version "0.5.19" resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.19.tgz#a98b62f86dcaf4f67399648c085291ab9e8fed61" integrity sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw== @@ -12609,7 +12607,7 @@ source-map@0.6.1, source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.1: resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== -source-map@0.7.3, source-map@~0.7.2: +source-map@0.7.3: version "0.7.3" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383" integrity sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ== @@ -13254,14 +13252,14 @@ terser-webpack-plugin@^1.4.3: webpack-sources "^1.4.0" worker-farm "^1.7.0" -terser@5.5.1: - version "5.5.1" - resolved "https://registry.yarnpkg.com/terser/-/terser-5.5.1.tgz#540caa25139d6f496fdea056e414284886fb2289" - integrity sha512-6VGWZNVP2KTUcltUQJ25TtNjx/XgdDsBDKGt8nN0MpydU36LmbPPcMBd2kmtZNNGVVDLg44k7GKeHHj+4zPIBQ== +terser@5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/terser/-/terser-5.1.0.tgz#1f4ab81c8619654fdded51f3157b001e1747281d" + integrity sha512-pwC1Jbzahz1ZPU87NQ8B3g5pKbhyJSiHih4gLH6WZiPU8mmS1IlGbB0A2Nuvkj/LCNsgIKctg6GkYwWCeTvXZQ== dependencies: commander "^2.20.0" - source-map "~0.7.2" - source-map-support "~0.5.19" + source-map "~0.6.1" + source-map-support "~0.5.12" terser@^4.1.2: version "4.8.0" @@ -14544,11 +14542,6 @@ yn@3.1.1: resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50" integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q== -yocto-queue@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" - integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== - yup@^0.32.8: version "0.32.8" resolved "https://registry.yarnpkg.com/yup/-/yup-0.32.8.tgz#16e4a949a86a69505abf99fd0941305ac9adfc39" From 6b2df24a2e8f96dd2277a814d7e02015d1f80cdc Mon Sep 17 00:00:00 2001 From: sct Date: Mon, 11 Jan 2021 23:42:33 +0900 Subject: [PATCH 03/43] feat: 4K Requests (#559) --- .gitignore | 2 +- README.md | 6 +- overseerr-api.yml | 18 + server/api/plexapi.ts | 17 + server/entity/Media.ts | 3 + server/entity/MediaRequest.ts | 55 +- server/entity/Season.ts | 3 + server/index.ts | 1 + server/interfaces/api/settingsInterfaces.ts | 6 + server/job/plexsync/index.ts | 276 +++++++-- server/lib/permissions.ts | 3 + server/lib/settings.ts | 17 + .../1610370640747-Add4kStatusFields.ts | 91 +++ server/routes/index.ts | 2 +- server/routes/request.ts | 29 +- .../MovieDetails/RequestButton/index.tsx | 582 ++++++++++++++++++ src/components/MovieDetails/index.tsx | 166 +---- src/components/RequestBlock/index.tsx | 26 +- src/components/RequestCard/index.tsx | 23 +- .../RequestModal/MovieRequestModal.tsx | 36 +- .../RequestModal/TvRequestModal.tsx | 68 +- src/components/RequestModal/index.tsx | 4 + src/components/Settings/SettingsMain.tsx | 24 + src/components/Settings/SettingsServices.tsx | 45 +- src/components/StatusBadge/index.tsx | 48 +- src/components/TvDetails/index.tsx | 168 +---- src/components/UserEdit/index.tsx | 26 + src/context/SettingsContext.tsx | 39 ++ src/i18n/locale/en.json | 32 +- src/pages/_app.tsx | 35 +- 30 files changed, 1384 insertions(+), 467 deletions(-) create mode 100644 server/migration/1610370640747-Add4kStatusFields.ts create mode 100644 src/components/MovieDetails/RequestButton/index.tsx create mode 100644 src/context/SettingsContext.tsx diff --git a/.gitignore b/.gitignore index 2f863091..968e5492 100644 --- a/.gitignore +++ b/.gitignore @@ -32,7 +32,7 @@ yarn-error.log* .vercel # database -config/db/db.sqlite3 +config/db/*.sqlite3 config/settings.json # logs diff --git a/README.md b/README.md index 179a3124..271a58d6 100644 --- a/README.md +++ b/README.md @@ -36,14 +36,13 @@ - User profiles. - User settings page (to give users the ability to modify their Overseerr experience to their liking). -- 4K requests (Includes multi-radarr/sonarr management for media) +- Local user system (for those who don't use Plex). ## Planned Features - More notification types. - Issues system. This will allow users to report issues with content on your media server. -- Local user system (for those who don't use Plex). -- Compatibility APIs (to work with existing tools in your system). +- And a ton more! Check out our [issue tracker](https://github.com/sct/overseerr/issues) to see what features people have already requested. ## Getting Started @@ -142,4 +141,5 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d + diff --git a/overseerr-api.yml b/overseerr-api.yml index 3dd8d31e..16668a10 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -701,6 +701,15 @@ components: - $ref: '#/components/schemas/User' - type: string nullable: true + is4k: + type: boolean + example: false + serverId: + type: number + profileId: + type: number + rootFolder: + type: string required: - id - status @@ -2364,6 +2373,15 @@ paths: type: array items: type: number + is4k: + type: boolean + example: false + serverId: + type: number + profileId: + type: number + rootFolder: + type: string required: - mediaType - mediaId diff --git a/server/api/plexapi.ts b/server/api/plexapi.ts index 3dc9bb9d..d460fe77 100644 --- a/server/api/plexapi.ts +++ b/server/api/plexapi.ts @@ -48,6 +48,23 @@ export interface PlexMetadata { parentIndex?: number; leafCount: number; viewedLeafCount: number; + Media: Media[]; +} + +interface Media { + id: number; + duration: number; + bitrate: number; + width: number; + height: number; + aspectRatio: number; + audioChannels: number; + audioCodec: string; + videoCodec: string; + videoResolution: string; + container: string; + videoFrameRate: string; + videoProfile: string; } interface PlexMetadataResponse { diff --git a/server/entity/Media.ts b/server/entity/Media.ts index 92a74774..2a918575 100644 --- a/server/entity/Media.ts +++ b/server/entity/Media.ts @@ -80,6 +80,9 @@ class Media { @Column({ type: 'int', default: MediaStatus.UNKNOWN }) public status: MediaStatus; + @Column({ type: 'int', default: MediaStatus.UNKNOWN }) + public status4k: MediaStatus; + @OneToMany(() => MediaRequest, (request) => request.media, { cascade: true }) public requests: MediaRequest[]; diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index ebf0b1c9..0b2d5284 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -65,6 +65,18 @@ export class MediaRequest { }) public seasons: SeasonRequest[]; + @Column({ default: false }) + public is4k: boolean; + + @Column({ nullable: true }) + public serverId: number; + + @Column({ nullable: true }) + public profileId: number; + + @Column({ nullable: true }) + public rootFolder: string; + constructor(init?: Partial) { Object.assign(this, init); } @@ -181,7 +193,11 @@ export class MediaRequest { } const seasonRequestRepository = getRepository(SeasonRequest); if (this.status === MediaRequestStatus.APPROVED) { - media.status = MediaStatus.PROCESSING; + if (this.is4k) { + media.status4k = MediaStatus.PROCESSING; + } else { + media.status = MediaStatus.PROCESSING; + } mediaRepository.save(media); } @@ -189,7 +205,11 @@ export class MediaRequest { this.media.mediaType === MediaType.MOVIE && this.status === MediaRequestStatus.DECLINED ) { - media.status = MediaStatus.UNKNOWN; + if (this.is4k) { + media.status4k = MediaStatus.UNKNOWN; + } else { + media.status = MediaStatus.UNKNOWN; + } mediaRepository.save(media); } @@ -224,15 +244,28 @@ export class MediaRequest { } @AfterRemove() - private async _handleRemoveParentUpdate() { + public async handleRemoveParentUpdate(): Promise { const mediaRepository = getRepository(Media); const fullMedia = await mediaRepository.findOneOrFail({ where: { id: this.media.id }, + relations: ['requests'], }); - if (!fullMedia.requests || fullMedia.requests.length === 0) { + + if ( + !fullMedia.requests.some((request) => !request.is4k) && + fullMedia.status !== MediaStatus.AVAILABLE + ) { fullMedia.status = MediaStatus.UNKNOWN; - mediaRepository.save(fullMedia); } + + if ( + !fullMedia.requests.some((request) => request.is4k) && + fullMedia.status4k !== MediaStatus.AVAILABLE + ) { + fullMedia.status4k = MediaStatus.UNKNOWN; + } + + mediaRepository.save(fullMedia); } private async _sendToRadarr() { @@ -252,12 +285,14 @@ export class MediaRequest { } const radarrSettings = settings.radarr.find( - (radarr) => radarr.isDefault && !radarr.is4k + (radarr) => radarr.isDefault && this.is4k ); if (!radarrSettings) { logger.info( - 'There is no default radarr configured. Did you set any of your Radarr servers as default?', + `There is no default ${ + this.is4k ? '4K ' : '' + }radarr configured. Did you set any of your Radarr servers as default?`, { label: 'Media Request' } ); return; @@ -342,12 +377,14 @@ export class MediaRequest { } const sonarrSettings = settings.sonarr.find( - (sonarr) => sonarr.isDefault && !sonarr.is4k + (sonarr) => sonarr.isDefault && this.is4k ); if (!sonarrSettings) { logger.info( - 'There is no default sonarr configured. Did you set any of your Sonarr servers as default?', + `There is no default ${ + this.is4k ? '4K ' : '' + }sonarr configured. Did you set any of your Sonarr servers as default?`, { label: 'Media Request' } ); return; diff --git a/server/entity/Season.ts b/server/entity/Season.ts index d66805cb..77f9c760 100644 --- a/server/entity/Season.ts +++ b/server/entity/Season.ts @@ -20,6 +20,9 @@ class Season { @Column({ type: 'int', default: MediaStatus.UNKNOWN }) public status: MediaStatus; + @Column({ type: 'int', default: MediaStatus.UNKNOWN }) + public status4k: MediaStatus; + @ManyToOne(() => Media, (media) => media.seasons, { onDelete: 'CASCADE' }) public media: Promise; diff --git a/server/index.ts b/server/index.ts index 32b14447..2ab14e17 100644 --- a/server/index.ts +++ b/server/index.ts @@ -98,6 +98,7 @@ app }; next(); }); + server.use('/api/v1', routes); server.get('*', (req, res) => handle(req, res)); server.use( diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts index 2938e696..eba40ee2 100644 --- a/server/interfaces/api/settingsInterfaces.ts +++ b/server/interfaces/api/settingsInterfaces.ts @@ -4,3 +4,9 @@ export interface SettingsAboutResponse { totalMediaItems: number; tz?: string; } + +export interface PublicSettingsResponse { + initialized: boolean; + movie4kEnabled: boolean; + series4kEnabled: boolean; +} diff --git a/server/job/plexsync/index.ts b/server/job/plexsync/index.ts index 5fd13c21..bf43fe6f 100644 --- a/server/job/plexsync/index.ts +++ b/server/job/plexsync/index.ts @@ -45,6 +45,8 @@ class JobPlexSync { private currentLibrary: Library; private running = false; private isRecentOnly = false; + private enable4kMovie = false; + private enable4kShow = false; private asyncLock = new AsyncLock(); constructor({ isRecentOnly }: { isRecentOnly?: boolean } = {}) { @@ -86,23 +88,59 @@ class JobPlexSync { } }); + const has4k = metadata.Media.some( + (media) => media.videoResolution === '4k' + ); + const hasOtherResolution = metadata.Media.some( + (media) => media.videoResolution !== '4k' + ); + await this.asyncLock.dispatch(newMedia.tmdbId, async () => { 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' - ); + if (existing) { + let changedExisting = false; + + if ( + (hasOtherResolution || (!this.enable4kMovie && has4k)) && + existing.status !== MediaStatus.AVAILABLE + ) { + existing.status = MediaStatus.AVAILABLE; + changedExisting = true; + } + + if ( + has4k && + this.enable4kMovie && + existing.status4k !== MediaStatus.AVAILABLE + ) { + existing.status4k = MediaStatus.AVAILABLE; + changedExisting = true; + } + + if (changedExisting) { + await mediaRepository.save(existing); + this.log( + `Request for ${metadata.title} exists. New media types set to AVAILABLE`, + 'info' + ); + } else { + this.log( + `Title already exists and no new media types found ${metadata.title}` + ); + } } else { - newMedia.status = MediaStatus.AVAILABLE; + newMedia.status = + hasOtherResolution || (!this.enable4kMovie && has4k) + ? MediaStatus.AVAILABLE + : MediaStatus.UNKNOWN; + newMedia.status4k = + has4k && this.enable4kMovie + ? MediaStatus.AVAILABLE + : MediaStatus.UNKNOWN; newMedia.mediaType = MediaType.MOVIE; await mediaRepository.save(newMedia); this.log(`Saved ${plexitem.title}`); @@ -150,16 +188,47 @@ class JobPlexSync { const mediaRepository = getRepository(Media); await this.asyncLock.dispatch(tmdbMovieId, async () => { + const metadata = await this.plexClient.getMetadata(plexitem.ratingKey); 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' - ); + + const has4k = metadata.Media.some( + (media) => media.videoResolution === '4k' + ); + const hasOtherResolution = metadata.Media.some( + (media) => media.videoResolution !== '4k' + ); + + if (existing) { + let changedExisting = false; + + if ( + (hasOtherResolution || (!this.enable4kMovie && has4k)) && + existing.status !== MediaStatus.AVAILABLE + ) { + existing.status = MediaStatus.AVAILABLE; + changedExisting = true; + } + + if ( + has4k && + this.enable4kMovie && + existing.status4k !== MediaStatus.AVAILABLE + ) { + existing.status4k = MediaStatus.AVAILABLE; + changedExisting = true; + } + + if (changedExisting) { + await mediaRepository.save(existing); + this.log( + `Request for ${metadata.title} exists. New media types set to AVAILABLE`, + 'info' + ); + } else { + this.log( + `Title already exists and no new media types found ${metadata.title}` + ); + } } 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) @@ -169,7 +238,14 @@ class JobPlexSync { const newMedia = new Media(); newMedia.imdbId = tmdbMovie.external_ids.imdb_id; newMedia.tmdbId = tmdbMovie.id; - newMedia.status = MediaStatus.AVAILABLE; + newMedia.status = + hasOtherResolution || (!this.enable4kMovie && has4k) + ? MediaStatus.AVAILABLE + : MediaStatus.UNKNOWN; + newMedia.status4k = + has4k && this.enable4kMovie + ? MediaStatus.AVAILABLE + : MediaStatus.UNKNOWN; newMedia.mediaType = MediaType.MOVIE; await mediaRepository.save(newMedia); this.log(`Saved ${tmdbMovie.title}`); @@ -316,13 +392,18 @@ class JobPlexSync { const newSeasons: Season[] = []; - const currentSeasonAvailable = ( + const currentStandardSeasonAvailable = ( media?.seasons.filter( (season) => season.status === MediaStatus.AVAILABLE ) ?? [] ).length; + const current4kSeasonAvailable = ( + media?.seasons.filter( + (season) => season.status4k === MediaStatus.AVAILABLE + ) ?? [] + ).length; - seasons.forEach((season) => { + for (const season of seasons) { const matchedPlexSeason = metadata.Children?.Metadata.find( (md) => Number(md.index) === season.season_number ); @@ -332,68 +413,136 @@ class JobPlexSync { ); // Check if we found the matching season and it has all the available episodes - if ( - matchedPlexSeason && - Number(matchedPlexSeason.leafCount) === season.episode_count - ) { + if (matchedPlexSeason) { + // If we have a matched plex season, get its children metadata so we can check details + const episodes = await this.plexClient.getChildrenMetadata( + matchedPlexSeason.ratingKey + ); + // Total episodes that are in standard definition (not 4k) + const totalStandard = episodes.filter((episode) => + episode.Media.some((media) => media.videoResolution !== '4k') + ).length; + + // Total episodes that are in 4k + const total4k = episodes.filter((episode) => + episode.Media.some((media) => media.videoResolution === '4k') + ).length; + if (existingSeason) { - existingSeason.status = MediaStatus.AVAILABLE; + // These ternary statements look super confusing, but they are simply + // setting the status to AVAILABLE if all of a type is there, partially if some, + // and then not modifying the status if there are 0 items + existingSeason.status = + totalStandard === season.episode_count + ? MediaStatus.AVAILABLE + : totalStandard > 0 + ? MediaStatus.PARTIALLY_AVAILABLE + : existingSeason.status; + existingSeason.status4k = + total4k === season.episode_count + ? MediaStatus.AVAILABLE + : total4k > 0 + ? MediaStatus.PARTIALLY_AVAILABLE + : existingSeason.status4k; } 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, + // This ternary is the same as the ones above, but it just falls back to "UNKNOWN" + // if we dont have any items for the season + status: + totalStandard === season.episode_count + ? MediaStatus.AVAILABLE + : totalStandard > 0 + ? MediaStatus.PARTIALLY_AVAILABLE + : MediaStatus.UNKNOWN, + status4k: + total4k === season.episode_count + ? MediaStatus.AVAILABLE + : total4k > 0 + ? MediaStatus.PARTIALLY_AVAILABLE + : MediaStatus.UNKNOWN, }) ); } } - }); + } // 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) >= + const isAllStandardSeasons = + newSeasons.filter( + (season) => season.status === MediaStatus.AVAILABLE + ).length + + (media?.seasons.filter( + (season) => season.status === MediaStatus.AVAILABLE + ).length ?? 0) >= + filteredSeasons.length; + + const isAll4kSeasons = + newSeasons.filter( + (season) => season.status4k === MediaStatus.AVAILABLE + ).length + + (media?.seasons.filter( + (season) => season.status4k === MediaStatus.AVAILABLE + ).length ?? 0) >= filteredSeasons.length; if (media) { // Update existing media.seasons = [...media.seasons, ...newSeasons]; - const newSeasonAvailable = ( + const newStandardSeasonAvailable = ( media.seasons.filter( (season) => season.status === MediaStatus.AVAILABLE ) ?? [] ).length; + const new4kSeasonAvailable = ( + media.seasons.filter( + (season) => season.status4k === MediaStatus.AVAILABLE + ) ?? [] + ).length; + // If at least one new season has become available, update // the lastSeasonChange field so we can trigger notifications - if (newSeasonAvailable > currentSeasonAvailable) { + if (newStandardSeasonAvailable > currentStandardSeasonAvailable) { this.log( `Detected ${ - newSeasonAvailable - currentSeasonAvailable - } new season(s) for ${tvShow.name}`, + newStandardSeasonAvailable - currentStandardSeasonAvailable + } new standard season(s) for ${tvShow.name}`, 'debug' ); media.lastSeasonChange = new Date(); } - media.status = isAllSeasons + if (new4kSeasonAvailable > current4kSeasonAvailable) { + this.log( + `Detected ${ + new4kSeasonAvailable - current4kSeasonAvailable + } new 4K season(s) for ${tvShow.name}`, + 'debug' + ); + media.lastSeasonChange = new Date(); + } + + media.status = isAllStandardSeasons ? MediaStatus.AVAILABLE - : MediaStatus.PARTIALLY_AVAILABLE; + : media.seasons.some( + (season) => season.status !== MediaStatus.UNKNOWN + ) + ? MediaStatus.PARTIALLY_AVAILABLE + : MediaStatus.UNKNOWN; + media.status4k = isAll4kSeasons + ? MediaStatus.AVAILABLE + : media.seasons.some( + (season) => season.status4k !== MediaStatus.UNKNOWN + ) + ? MediaStatus.PARTIALLY_AVAILABLE + : MediaStatus.UNKNOWN; await mediaRepository.save(media); this.log(`Updating existing title: ${tvShow.name}`); } else { @@ -402,9 +551,20 @@ class JobPlexSync { seasons: newSeasons, tmdbId: tvShow.id, tvdbId: tvShow.external_ids.tvdb_id, - status: isAllSeasons + status: isAllStandardSeasons ? MediaStatus.AVAILABLE - : MediaStatus.PARTIALLY_AVAILABLE, + : newSeasons.some( + (season) => season.status !== MediaStatus.UNKNOWN + ) + ? MediaStatus.PARTIALLY_AVAILABLE + : MediaStatus.UNKNOWN, + status4k: isAll4kSeasons + ? MediaStatus.AVAILABLE + : newSeasons.some( + (season) => season.status4k !== MediaStatus.UNKNOWN + ) + ? MediaStatus.PARTIALLY_AVAILABLE + : MediaStatus.UNKNOWN, }); await mediaRepository.save(newMedia); this.log(`Saved ${tvShow.name}`); @@ -508,6 +668,22 @@ class JobPlexSync { (library) => library.enabled ); + this.enable4kMovie = settings.radarr.some((radarr) => radarr.is4k); + if (this.enable4kMovie) { + this.log( + 'At least one 4K Radarr server was detected, so 4K movie detection is now enabled', + 'info' + ); + } + + this.enable4kShow = settings.sonarr.some((sonarr) => sonarr.is4k); + if (this.enable4kShow) { + this.log( + 'At least one 4K Sonarr server was detected, so 4K series detection is now enabled', + 'info' + ); + } + const hasHama = await this.hasHamaAgent(); if (hasHama) { await animeList.sync(); diff --git a/server/lib/permissions.ts b/server/lib/permissions.ts index 6d328f8c..b1b559c4 100644 --- a/server/lib/permissions.ts +++ b/server/lib/permissions.ts @@ -9,6 +9,9 @@ export enum Permission { AUTO_APPROVE = 128, AUTO_APPROVE_MOVIE = 256, AUTO_APPROVE_TV = 512, + REQUEST_4K = 1024, + REQUEST_4K_MOVIE = 2048, + REQUEST_4K_TV = 4096, } /** diff --git a/server/lib/settings.ts b/server/lib/settings.ts index cea7774a..e4f19bde 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -55,6 +55,11 @@ interface PublicSettings { initialized: boolean; } +interface FullPublicSettings extends PublicSettings { + movie4kEnabled: boolean; + series4kEnabled: boolean; +} + export interface NotificationAgentConfig { enabled: boolean; types: number; @@ -246,6 +251,18 @@ class Settings { this.data.public = data; } + get fullPublicSettings(): FullPublicSettings { + return { + ...this.data.public, + movie4kEnabled: this.data.radarr.some( + (radarr) => radarr.is4k && radarr.isDefault + ), + series4kEnabled: this.data.sonarr.some( + (sonarr) => sonarr.is4k && sonarr.isDefault + ), + }; + } + get notifications(): NotificationSettings { return this.data.notifications; } diff --git a/server/migration/1610370640747-Add4kStatusFields.ts b/server/migration/1610370640747-Add4kStatusFields.ts new file mode 100644 index 00000000..a313bf13 --- /dev/null +++ b/server/migration/1610370640747-Add4kStatusFields.ts @@ -0,0 +1,91 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class Add4kStatusFields1610370640747 implements MigrationInterface { + name = 'Add4kStatusFields1610370640747'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "temporary_season" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "seasonNumber" integer NOT NULL, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "mediaId" integer, "status4k" integer NOT NULL DEFAULT (1), CONSTRAINT "FK_087099b39600be695591da9a49c" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_season"("id", "seasonNumber", "status", "createdAt", "updatedAt", "mediaId") SELECT "id", "seasonNumber", "status", "createdAt", "updatedAt", "mediaId" FROM "season"` + ); + await queryRunner.query(`DROP TABLE "season"`); + await queryRunner.query( + `ALTER TABLE "temporary_season" RENAME TO "season"` + ); + await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`); + await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`); + await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`); + await queryRunner.query( + `CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "status4k" integer NOT NULL DEFAULT (1), CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))` + ); + await queryRunner.query( + `INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange" FROM "media"` + ); + await queryRunner.query(`DROP TABLE "media"`); + await queryRunner.query(`ALTER TABLE "temporary_media" RENAME TO "media"`); + await queryRunner.query( + `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ` + ); + await queryRunner.query( + `CREATE TABLE "temporary_media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById" FROM "media_request"` + ); + await queryRunner.query(`DROP TABLE "media_request"`); + await queryRunner.query( + `ALTER TABLE "temporary_media_request" RENAME TO "media_request"` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "media_request" RENAME TO "temporary_media_request"` + ); + await queryRunner.query( + `CREATE TABLE "media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById" FROM "temporary_media_request"` + ); + await queryRunner.query(`DROP TABLE "temporary_media_request"`); + await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`); + await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`); + await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`); + await queryRunner.query(`ALTER TABLE "media" RENAME TO "temporary_media"`); + await queryRunner.query( + `CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))` + ); + await queryRunner.query( + `INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange" FROM "temporary_media"` + ); + await queryRunner.query(`DROP TABLE "temporary_media"`); + await queryRunner.query( + `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ` + ); + await queryRunner.query( + `ALTER TABLE "season" RENAME TO "temporary_season"` + ); + await queryRunner.query( + `CREATE TABLE "season" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "seasonNumber" integer NOT NULL, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "mediaId" integer, CONSTRAINT "FK_087099b39600be695591da9a49c" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "season"("id", "seasonNumber", "status", "createdAt", "updatedAt", "mediaId") SELECT "id", "seasonNumber", "status", "createdAt", "updatedAt", "mediaId" FROM "temporary_season"` + ); + await queryRunner.query(`DROP TABLE "temporary_season"`); + } +} diff --git a/server/routes/index.ts b/server/routes/index.ts index 29466bd7..3e10bc9c 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -30,7 +30,7 @@ router.use('/user', isAuthenticated(Permission.MANAGE_USERS), user); router.get('/settings/public', (_req, res) => { const settings = getSettings(); - return res.status(200).json(settings.public); + return res.status(200).json(settings.fullPublicSettings); }); router.use( '/settings', diff --git a/server/routes/request.ts b/server/routes/request.ts index ea82825c..155371bb 100644 --- a/server/routes/request.ts +++ b/server/routes/request.ts @@ -110,15 +110,21 @@ requestRoutes.post( media = new Media({ tmdbId: tmdbMedia.id, tvdbId: tmdbMedia.external_ids.tvdb_id, - status: MediaStatus.PENDING, + status: !req.body.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN, + status4k: req.body.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN, mediaType: req.body.mediaType, }); await mediaRepository.save(media); } else { - if (media.status === MediaStatus.UNKNOWN) { + if (media.status === MediaStatus.UNKNOWN && !req.body.is4k) { media.status = MediaStatus.PENDING; await mediaRepository.save(media); } + + if (media.status4k === MediaStatus.UNKNOWN && req.body.is4k) { + media.status4k = MediaStatus.PENDING; + await mediaRepository.save(media); + } } if (req.body.mediaType === 'movie') { @@ -137,6 +143,10 @@ requestRoutes.post( req.user?.hasPermission(Permission.AUTO_APPROVE_MOVIE) ? req.user : undefined, + is4k: req.body.is4k, + serverId: req.body.serverId, + profileId: req.body.profileId, + rootFolder: req.body.rootFolder, }); await requestRepository.save(request); @@ -149,13 +159,15 @@ requestRoutes.post( // already requested. In the case they were, we just throw out any duplicates but still approve the request. // (Unless there are no seasons, in which case we abort) if (media.requests) { - existingSeasons = media.requests.reduce((seasons, request) => { - const combinedSeasons = request.seasons.map( - (season) => season.seasonNumber - ); + existingSeasons = media.requests + .filter((request) => request.is4k === req.body.is4k) + .reduce((seasons, request) => { + const combinedSeasons = request.seasons.map( + (season) => season.seasonNumber + ); - return [...seasons, ...combinedSeasons]; - }, [] as number[]); + return [...seasons, ...combinedSeasons]; + }, [] as number[]); } const finalSeasons = requestedSeasons.filter( @@ -186,6 +198,7 @@ requestRoutes.post( req.user?.hasPermission(Permission.AUTO_APPROVE_TV) ? req.user : undefined, + is4k: req.body.is4k, seasons: finalSeasons.map( (sn) => new SeasonRequest({ diff --git a/src/components/MovieDetails/RequestButton/index.tsx b/src/components/MovieDetails/RequestButton/index.tsx new file mode 100644 index 00000000..22e072a6 --- /dev/null +++ b/src/components/MovieDetails/RequestButton/index.tsx @@ -0,0 +1,582 @@ +import axios from 'axios'; +import React, { useContext, useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { + MediaRequestStatus, + MediaStatus, +} from '../../../../server/constants/media'; +import Media from '../../../../server/entity/Media'; +import { MediaRequest } from '../../../../server/entity/MediaRequest'; +import { SettingsContext } from '../../../context/SettingsContext'; +import { Permission, useUser } from '../../../hooks/useUser'; +import ButtonWithDropdown from '../../Common/ButtonWithDropdown'; +import RequestModal from '../../RequestModal'; + +const messages = defineMessages({ + viewrequest: 'View Request', + viewrequest4k: 'View 4K Request', + request: 'Request', + request4k: 'Request 4K', + requestmore: 'Request More', + requestmore4k: 'Request More 4K', + approverequest: 'Approve Request', + approverequest4k: 'Approve 4K Request', + declinerequest: 'Decline Request', + declinerequest4k: 'Decline 4K Request', + approverequests: + 'Approve {requestCount} {requestCount, plural, one {Request} other {Requests}}', + declinerequests: + 'Decline {requestCount} {requestCount, plural, one {Request} other {Requests}}', + approve4krequests: + 'Approve {requestCount} 4K {requestCount, plural, one {Request} other {Requests}}', + decline4krequests: + 'Decline {requestCount} 4K {requestCount, plural, one {Request} other {Requests}}', +}); + +interface ButtonOption { + id: string; + text: string; + action: () => void; + svg?: React.ReactNode; +} + +interface RequestButtonProps { + mediaType: 'movie' | 'tv'; + onUpdate: () => void; + tmdbId: number; + media?: Media; + isShowComplete?: boolean; + is4kShowComplete?: boolean; +} + +const RequestButton: React.FC = ({ + tmdbId, + onUpdate, + media, + mediaType, + isShowComplete = false, + is4kShowComplete = false, +}) => { + const intl = useIntl(); + const settings = useContext(SettingsContext); + const { hasPermission } = useUser(); + const [showRequestModal, setShowRequestModal] = useState(false); + const [showRequest4kModal, setShowRequest4kModal] = useState(false); + + const activeRequest = media?.requests.find( + (request) => request.status === MediaRequestStatus.PENDING && !request.is4k + ); + const active4kRequest = media?.requests.find( + (request) => request.status === MediaRequestStatus.PENDING && request.is4k + ); + + // All pending + const activeRequests = media?.requests.filter( + (request) => request.status === MediaRequestStatus.PENDING && !request.is4k + ); + + const active4kRequests = media?.requests.filter( + (request) => request.status === MediaRequestStatus.PENDING && request.is4k + ); + + const modifyRequest = async ( + request: MediaRequest, + type: 'approve' | 'decline' + ) => { + const response = await axios.get(`/api/v1/request/${request.id}/${type}`); + + if (response) { + onUpdate(); + } + }; + + const modifyRequests = async ( + requests: MediaRequest[], + type: 'approve' | 'decline' + ): Promise => { + if (!requests) { + return; + } + + await Promise.all( + requests.map(async (request) => { + return axios.get(`/api/v1/request/${request.id}/${type}`); + }) + ); + + onUpdate(); + }; + + const buttons: ButtonOption[] = []; + if ( + (!media || media.status === MediaStatus.UNKNOWN) && + hasPermission(Permission.REQUEST) + ) { + buttons.push({ + id: 'request', + text: intl.formatMessage(messages.request), + action: () => { + setShowRequestModal(true); + }, + svg: ( + + + + ), + }); + } + + if ( + hasPermission(Permission.REQUEST) && + mediaType === 'tv' && + media && + media.status !== MediaStatus.AVAILABLE && + media.status !== MediaStatus.UNKNOWN && + !isShowComplete + ) { + buttons.push({ + id: 'request-more', + text: intl.formatMessage(messages.requestmore), + action: () => { + setShowRequestModal(true); + }, + svg: ( + + + + ), + }); + } + + if ( + (!media || media.status4k === MediaStatus.UNKNOWN) && + (hasPermission(Permission.REQUEST_4K) || + (mediaType === 'movie' && hasPermission(Permission.REQUEST_4K_MOVIE)) || + (mediaType === 'tv' && hasPermission(Permission.REQUEST_4K_TV))) && + ((settings.currentSettings.movie4kEnabled && mediaType === 'movie') || + (settings.currentSettings.series4kEnabled && mediaType === 'tv')) + ) { + buttons.push({ + id: 'request4k', + text: intl.formatMessage(messages.request4k), + action: () => { + setShowRequest4kModal(true); + }, + svg: ( + + + + ), + }); + } + + if ( + mediaType === 'tv' && + (hasPermission(Permission.REQUEST_4K) || + (mediaType === 'tv' && hasPermission(Permission.REQUEST_4K_TV))) && + media && + media.status4k !== MediaStatus.AVAILABLE && + media.status4k !== MediaStatus.UNKNOWN && + !is4kShowComplete && + settings.currentSettings.series4kEnabled + ) { + buttons.push({ + id: 'request-more-4k', + text: intl.formatMessage(messages.requestmore4k), + action: () => { + setShowRequest4kModal(true); + }, + svg: ( + + + + ), + }); + } + + if ( + activeRequest && + mediaType === 'movie' && + hasPermission(Permission.REQUEST) + ) { + buttons.push({ + id: 'active-request', + text: intl.formatMessage(messages.viewrequest), + action: () => setShowRequestModal(true), + svg: ( + + + + ), + }); + } + + if ( + active4kRequest && + mediaType === 'movie' && + (hasPermission(Permission.REQUEST_4K) || + hasPermission(Permission.REQUEST_4K_MOVIE)) + ) { + buttons.push({ + id: 'active-4k-request', + text: intl.formatMessage(messages.viewrequest4k), + action: () => setShowRequest4kModal(true), + svg: ( + + + + ), + }); + } + + if ( + activeRequest && + hasPermission(Permission.MANAGE_REQUESTS) && + mediaType === 'movie' + ) { + buttons.push( + { + id: 'approve-request', + text: intl.formatMessage(messages.approverequest), + action: () => { + modifyRequest(activeRequest, 'approve'); + }, + svg: ( + + + + ), + }, + { + id: 'decline-request', + text: intl.formatMessage(messages.declinerequest), + action: () => { + modifyRequest(activeRequest, 'decline'); + }, + svg: ( + + + + ), + } + ); + } + + if ( + activeRequests && + activeRequests.length > 0 && + hasPermission(Permission.MANAGE_REQUESTS) && + mediaType === 'tv' + ) { + buttons.push( + { + id: 'approve-request-batch', + text: intl.formatMessage(messages.approverequests, { + requestCount: activeRequests.length, + }), + action: () => { + modifyRequests(activeRequests, 'approve'); + }, + svg: ( + + + + ), + }, + { + id: 'decline-request-batch', + text: intl.formatMessage(messages.declinerequests, { + requestCount: activeRequests.length, + }), + action: () => { + modifyRequests(activeRequests, 'decline'); + }, + svg: ( + + + + ), + } + ); + } + + if ( + active4kRequest && + hasPermission(Permission.MANAGE_REQUESTS) && + mediaType === 'movie' + ) { + buttons.push( + { + id: 'approve-4k-request', + text: intl.formatMessage(messages.approverequest4k), + action: () => { + modifyRequest(active4kRequest, 'approve'); + }, + svg: ( + + + + ), + }, + { + id: 'decline-4k-request', + text: intl.formatMessage(messages.declinerequest4k), + action: () => { + modifyRequest(active4kRequest, 'decline'); + }, + svg: ( + + + + ), + } + ); + } + + if ( + active4kRequests && + active4kRequests.length > 0 && + hasPermission(Permission.MANAGE_REQUESTS) && + mediaType === 'tv' + ) { + buttons.push( + { + id: 'approve-request-batch', + text: intl.formatMessage(messages.approve4krequests, { + requestCount: active4kRequests.length, + }), + action: () => { + modifyRequests(active4kRequests, 'approve'); + }, + svg: ( + + + + ), + }, + { + id: 'decline-request-batch', + text: intl.formatMessage(messages.decline4krequests, { + requestCount: active4kRequests.length, + }), + action: () => { + modifyRequests(active4kRequests, 'decline'); + }, + svg: ( + + + + ), + } + ); + } + + const [buttonOne, ...others] = buttons; + + if (!buttonOne) { + return null; + } + + return ( + <> + { + onUpdate(); + setShowRequestModal(false); + }} + onCancel={() => setShowRequestModal(false)} + /> + { + onUpdate(); + setShowRequest4kModal(false); + }} + onCancel={() => setShowRequest4kModal(false)} + /> + + {buttonOne.svg ?? null} + {buttonOne.text} + + } + onClick={buttonOne.action} + className="ml-2" + > + {others && others.length > 0 + ? others.map((button) => ( + + {button.svg} + {button.text} + + )) + : null} + {/* {hasPermission(Permission.MANAGE_REQUESTS) && ( + <> + modifyRequest('approve')}> + + + + {intl.formatMessage(messages.approve)} + + + )} */} + + + ); +}; + +export default RequestButton; diff --git a/src/components/MovieDetails/index.tsx b/src/components/MovieDetails/index.tsx index 323f7f7f..4ca230fc 100644 --- a/src/components/MovieDetails/index.tsx +++ b/src/components/MovieDetails/index.tsx @@ -18,12 +18,7 @@ import PersonCard from '../PersonCard'; import { LanguageContext } from '../../context/LanguageContext'; import LoadingSpinner from '../Common/LoadingSpinner'; import { useUser, Permission } from '../../hooks/useUser'; -import { - MediaStatus, - MediaRequestStatus, -} from '../../../server/constants/media'; -import RequestModal from '../RequestModal'; -import ButtonWithDropdown from '../Common/ButtonWithDropdown'; +import { MediaStatus } from '../../../server/constants/media'; import axios from 'axios'; import SlideOver from '../Common/SlideOver'; import RequestBlock from '../RequestBlock'; @@ -38,6 +33,7 @@ import Head from 'next/head'; import ExternalLinkBlock from '../ExternalLinkBlock'; import { sortCrewPriority } from '../../utils/creditHelpers'; import StatusBadge from '../StatusBadge'; +import RequestButton from './RequestButton'; const messages = defineMessages({ releasedate: 'Release Date', @@ -55,8 +51,6 @@ const messages = defineMessages({ cancelrequest: 'Cancel Request', available: 'Available', unavailable: 'Unavailable', - request: 'Request', - viewrequest: 'View Request', pending: 'Pending', overviewunavailable: 'Overview unavailable', manageModalTitle: 'Manage Movie', @@ -88,7 +82,6 @@ const MovieDetails: React.FC = ({ movie }) => { const router = useRouter(); const intl = useIntl(); const { locale } = useContext(LanguageContext); - const [showRequestModal, setShowRequestModal] = useState(false); const [showManager, setShowManager] = useState(false); const { data, error, revalidate } = useSWR( `/api/v1/movie/${router.query.movieId}?language=${locale}`, @@ -118,25 +111,11 @@ const MovieDetails: React.FC = ({ movie }) => { return ; } - const activeRequest = data?.mediaInfo?.requests?.find( - (request) => request.status === MediaRequestStatus.PENDING - ); - const trailerUrl = data.relatedVideos ?.filter((r) => r.type === 'Trailer') .sort((a, b) => a.size - b.size) .pop()?.url; - const modifyRequest = async (type: 'approve' | 'decline') => { - const response = await axios.get( - `/api/v1/request/${activeRequest?.id}/${type}` - ); - - if (response) { - revalidate(); - } - }; - const deleteMedia = async () => { if (data?.mediaInfo?.id) { await axios.delete(`/api/v1/media/${data?.mediaInfo?.id}`); @@ -155,16 +134,7 @@ const MovieDetails: React.FC = ({ movie }) => { {data.title} - Overseerr - { - revalidate(); - setShowRequestModal(false); - }} - onCancel={() => setShowRequestModal(false)} - /> + = ({ movie }) => {
- + {data.mediaInfo && data.mediaInfo.status !== MediaStatus.UNKNOWN && ( + + + + )} + + +

{data.title}{' '} @@ -263,121 +240,12 @@ const MovieDetails: React.FC = ({ movie }) => { )} - {(!data.mediaInfo || - data.mediaInfo?.status === MediaStatus.UNKNOWN) && ( - - )} - {activeRequest && ( - - - - } - text={ - <> - - - - - - } - onClick={() => setShowRequestModal(true)} - className="ml-2" - > - {hasPermission(Permission.MANAGE_REQUESTS) && ( - <> - modifyRequest('approve')} - > - - - - {intl.formatMessage(messages.approve)} - - modifyRequest('decline')} - > - - - - {intl.formatMessage(messages.decline)} - - - )} - - )} + revalidate()} + /> {hasPermission(Permission.MANAGE_REQUESTS) && (

diff --git a/src/components/RequestModal/MovieRequestModal.tsx b/src/components/RequestModal/MovieRequestModal.tsx index d1e9b068..79854c57 100644 --- a/src/components/RequestModal/MovieRequestModal.tsx +++ b/src/components/RequestModal/MovieRequestModal.tsx @@ -22,17 +22,22 @@ const messages = defineMessages({ requestSuccess: '{title} successfully requested!', requestCancel: 'Request for {title} cancelled', requesttitle: 'Request {title}', + request4ktitle: 'Request {title} in 4K', close: 'Close', cancel: 'Cancel Request', cancelling: 'Cancelling...', pendingrequest: 'Pending request for {title}', + pending4krequest: 'Pending request for {title} in 4K', requesting: 'Requesting...', request: 'Request', + request4k: 'Request 4K', requestfrom: 'There is currently a pending request from {username}', + request4kfrom: 'There is currently a pending 4K request from {username}', }); interface RequestModalProps extends React.HTMLAttributes { tmdbId: number; + is4k?: boolean; onCancel?: () => void; onComplete?: (newStatus: MediaStatus) => void; onUpdating?: (isUpdating: boolean) => void; @@ -43,6 +48,7 @@ const MovieRequestModal: React.FC = ({ onComplete, tmdbId, onUpdating, + is4k, }) => { const [isUpdating, setIsUpdating] = useState(false); const { addToast } = useToasts(); @@ -63,6 +69,7 @@ const MovieRequestModal: React.FC = ({ const response = await axios.post('/api/v1/request', { mediaId: data?.id, mediaType: 'movie', + is4k, }); if (response.data) { @@ -89,7 +96,9 @@ const MovieRequestModal: React.FC = ({ } }, [data, onComplete, addToast]); - const activeRequest = data?.mediaInfo?.requests?.[0]; + const activeRequest = data?.mediaInfo?.requests?.find( + (request) => request.is4k === !!is4k + ); const cancelRequest = async () => { setIsUpdating(true); @@ -133,9 +142,12 @@ const MovieRequestModal: React.FC = ({ onCancel={onCancel} onOk={isOwner ? () => cancelRequest() : undefined} okDisabled={isUpdating} - title={intl.formatMessage(messages.pendingrequest, { - title: data?.title, - })} + title={intl.formatMessage( + is4k ? messages.pending4krequest : messages.pendingrequest, + { + title: data?.title, + } + )} okText={ isUpdating ? intl.formatMessage(messages.cancelling) @@ -145,9 +157,12 @@ const MovieRequestModal: React.FC = ({ cancelText={intl.formatMessage(messages.close)} iconSvg={} > - {intl.formatMessage(messages.requestfrom, { - username: activeRequest.requestedBy.username, - })} + {intl.formatMessage( + is4k ? messages.request4kfrom : messages.requestfrom, + { + username: activeRequest.requestedBy.username, + } + )} ); } @@ -159,11 +174,14 @@ const MovieRequestModal: React.FC = ({ onCancel={onCancel} onOk={sendRequest} okDisabled={isUpdating} - title={intl.formatMessage(messages.requesttitle, { title: data?.title })} + title={intl.formatMessage( + is4k ? messages.request4ktitle : messages.requesttitle, + { title: data?.title } + )} okText={ isUpdating ? intl.formatMessage(messages.requesting) - : intl.formatMessage(messages.request) + : intl.formatMessage(is4k ? messages.request4k : messages.request) } okButtonType={'primary'} iconSvg={} diff --git a/src/components/RequestModal/TvRequestModal.tsx b/src/components/RequestModal/TvRequestModal.tsx index a283dba1..8df739bb 100644 --- a/src/components/RequestModal/TvRequestModal.tsx +++ b/src/components/RequestModal/TvRequestModal.tsx @@ -23,6 +23,7 @@ const messages = defineMessages({ requestSuccess: '{title} successfully requested!', requestCancel: 'Request for {title} cancelled', requesttitle: 'Request {title}', + request4ktitle: 'Request {title} in 4K', requesting: 'Requesting...', requestseasons: 'Request {seasonCount} {seasonCount, plural, one {Season} other {Seasons}}', @@ -40,6 +41,7 @@ interface RequestModalProps extends React.HTMLAttributes { onCancel?: () => void; onComplete?: (newStatus: MediaStatus) => void; onUpdating?: (isUpdating: boolean) => void; + is4k?: boolean; } const TvRequestModal: React.FC = ({ @@ -47,6 +49,7 @@ const TvRequestModal: React.FC = ({ onComplete, tmdbId, onUpdating, + is4k = false, }) => { const { addToast } = useToasts(); const { data, error } = useSWR(`/api/v1/tv/${tmdbId}`); @@ -65,6 +68,7 @@ const TvRequestModal: React.FC = ({ mediaId: data?.id, tvdbId: data?.externalIds.tvdbId, mediaType: 'tv', + is4k, seasons: selectedSeasons, }); @@ -90,21 +94,21 @@ const TvRequestModal: React.FC = ({ }; const getAllRequestedSeasons = (): number[] => { - const requestedSeasons = (data?.mediaInfo?.requests ?? []).reduce( - (requestedSeasons, request) => { + const requestedSeasons = (data?.mediaInfo?.requests ?? []) + .filter((request) => request.is4k === is4k) + .reduce((requestedSeasons, request) => { return [ ...requestedSeasons, ...request.seasons.map((sr) => sr.seasonNumber), ]; - }, - [] as number[] - ); + }, [] as number[]); const availableSeasons = (data?.mediaInfo?.seasons ?? []) .filter( (season) => - (season.status === MediaStatus.AVAILABLE || - season.status === MediaStatus.PARTIALLY_AVAILABLE) && + (season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE || + season[is4k ? 'status4k' : 'status'] === + MediaStatus.PARTIALLY_AVAILABLE) && !requestedSeasons.includes(season.seasonNumber) ) .map((season) => season.seasonNumber); @@ -176,14 +180,21 @@ const TvRequestModal: React.FC = ({ seasonNumber: number ): SeasonRequest | undefined => { let seasonRequest: SeasonRequest | undefined; - if (data?.mediaInfo && (data.mediaInfo.requests || []).length > 0) { - data.mediaInfo.requests.forEach((request) => { - if (!seasonRequest) { - seasonRequest = request.seasons.find( - (season) => season.seasonNumber === seasonNumber - ); - } - }); + + if ( + data?.mediaInfo && + (data.mediaInfo.requests || []).filter((request) => request.is4k === is4k) + .length > 0 + ) { + data.mediaInfo.requests + .filter((request) => request.is4k === is4k) + .forEach((request) => { + if (!seasonRequest) { + seasonRequest = request.seasons.find( + (season) => season.seasonNumber === seasonNumber + ); + } + }); } return seasonRequest; @@ -195,7 +206,10 @@ const TvRequestModal: React.FC = ({ backgroundClickable onCancel={onCancel} onOk={() => sendRequest()} - title={intl.formatMessage(messages.requesttitle, { title: data?.name })} + title={intl.formatMessage( + is4k ? messages.request4ktitle : messages.requesttitle, + { title: data?.name } + )} okText={ selectedSeasons.length === 0 ? intl.formatMessage(messages.selectseason) @@ -256,13 +270,13 @@ const TvRequestModal: React.FC = ({ > - + {intl.formatMessage(messages.season)} - + {intl.formatMessage(messages.numberofepisodes)} - + {intl.formatMessage(messages.status)} @@ -275,7 +289,10 @@ const TvRequestModal: React.FC = ({ season.seasonNumber ); const mediaSeason = data?.mediaInfo?.seasons.find( - (sn) => sn.seasonNumber === season.seasonNumber + (sn) => + sn.seasonNumber === season.seasonNumber && + sn[is4k ? 'status4k' : 'status'] !== + MediaStatus.UNKNOWN ); return ( @@ -320,17 +337,17 @@ const TvRequestModal: React.FC = ({ > - + {season.seasonNumber === 0 ? intl.formatMessage(messages.extras) : intl.formatMessage(messages.seasonnumber, { number: season.seasonNumber, })} - + {season.episodeCount} - + {!seasonRequest && !mediaSeason && ( {intl.formatMessage(messages.notrequested)} @@ -357,7 +374,7 @@ const TvRequestModal: React.FC = ({ {intl.formatMessage(globalMessages.available)} )} - {mediaSeason?.status === + {mediaSeason?.[is4k ? 'status4k' : 'status'] === MediaStatus.PARTIALLY_AVAILABLE && ( {intl.formatMessage( @@ -365,7 +382,8 @@ const TvRequestModal: React.FC = ({ )} )} - {mediaSeason?.status === MediaStatus.AVAILABLE && ( + {mediaSeason?.[is4k ? 'status4k' : 'status'] === + MediaStatus.AVAILABLE && ( {intl.formatMessage(globalMessages.available)} diff --git a/src/components/RequestModal/index.tsx b/src/components/RequestModal/index.tsx index ecd66062..2ec760a0 100644 --- a/src/components/RequestModal/index.tsx +++ b/src/components/RequestModal/index.tsx @@ -8,6 +8,7 @@ interface RequestModalProps { show: boolean; type: 'movie' | 'tv'; tmdbId: number; + is4k?: boolean; onComplete?: (newStatus: MediaStatus) => void; onError?: (error: string) => void; onCancel?: () => void; @@ -18,6 +19,7 @@ const RequestModal: React.FC = ({ type, show, tmdbId, + is4k, onComplete, onUpdating, onCancel, @@ -38,6 +40,7 @@ const RequestModal: React.FC = ({ onCancel={onCancel} tmdbId={tmdbId} onUpdating={onUpdating} + is4k={is4k} /> ); @@ -58,6 +61,7 @@ const RequestModal: React.FC = ({ onCancel={onCancel} tmdbId={tmdbId} onUpdating={onUpdating} + is4k={is4k} /> ); diff --git a/src/components/Settings/SettingsMain.tsx b/src/components/Settings/SettingsMain.tsx index c81b61a5..dea45fcb 100644 --- a/src/components/Settings/SettingsMain.tsx +++ b/src/components/Settings/SettingsMain.tsx @@ -89,6 +89,30 @@ const SettingsMain: React.FC = () => { description: intl.formatMessage(permissionMessages.requestDescription), permission: Permission.REQUEST, }, + { + id: 'request4k', + name: intl.formatMessage(permissionMessages.request4k), + description: intl.formatMessage(permissionMessages.request4kDescription), + permission: Permission.REQUEST_4K, + children: [ + { + id: 'request4k-movies', + name: intl.formatMessage(permissionMessages.request4kMovies), + description: intl.formatMessage( + permissionMessages.request4kMoviesDescription + ), + permission: Permission.REQUEST_4K_MOVIE, + }, + { + id: 'request4k-tv', + name: intl.formatMessage(permissionMessages.request4kTv), + description: intl.formatMessage( + permissionMessages.request4kTvDescription + ), + permission: Permission.REQUEST_4K_TV, + }, + ], + }, { id: 'autoapprove', name: intl.formatMessage(permissionMessages.autoapprove), diff --git a/src/components/Settings/SettingsServices.tsx b/src/components/Settings/SettingsServices.tsx index 1047f8a3..88557e1f 100644 --- a/src/components/Settings/SettingsServices.tsx +++ b/src/components/Settings/SettingsServices.tsx @@ -35,7 +35,6 @@ const messages = defineMessages({ nodefault: 'No default server selected!', nodefaultdescription: 'At least one server must be marked as default before any requests will make it to your services.', - no4kimplemented: '(Default 4K servers are not currently implemented)', }); interface ServerInstanceProps { @@ -63,10 +62,10 @@ const ServerInstance: React.FC = ({ }) => { return (
  • -
    +
    -
    -

    +
    +

    {name}

    {isDefault && ( @@ -85,31 +84,31 @@ const ServerInstance: React.FC = ({ )}
    -

    - +

    + {address}

    -

    - +

    + {' '} {profileName}

    -
    -
    +
    +
    -
    +
    -

    +

    -

    +

    @@ -333,9 +329,6 @@ const SettingsServices: React.FC = () => { ) && (

    {intl.formatMessage(messages.nodefaultdescription)}

    -

    - {intl.formatMessage(messages.no4kimplemented)} -

    )}
    +
    +
    +
    +
    +
    + {intl.formatMessage(messages.notificationtypes)} +
    +
    +
    +
    + + setFieldValue('types', newTypes) + } + /> +
    +
    +
    +
    +
    +
    +
    + + + + + + +
    +
    + + ); + }} + + ); +}; + +export default NotificationsWebhook; diff --git a/src/components/Settings/SettingsNotifications.tsx b/src/components/Settings/SettingsNotifications.tsx index e873ecf8..8815b14f 100644 --- a/src/components/Settings/SettingsNotifications.tsx +++ b/src/components/Settings/SettingsNotifications.tsx @@ -6,6 +6,7 @@ import DiscordLogo from '../../assets/extlogos/discord_white.svg'; import SlackLogo from '../../assets/extlogos/slack.svg'; import TelegramLogo from '../../assets/extlogos/telegram.svg'; import PushoverLogo from '../../assets/extlogos/pushover.svg'; +import Bolt from '../../assets/bolt.svg'; const messages = defineMessages({ notificationsettings: 'Notification Settings', @@ -89,6 +90,17 @@ const settingsRoutes: SettingsRoute[] = [ route: '/settings/notifications/pushover', regex: /^\/settings\/notifications\/pushover/, }, + { + text: 'Webhook', + content: ( + + + Webhook + + ), + route: '/settings/notifications/webhook', + regex: /^\/settings\/notifications\/webhook/, + }, ]; const SettingsNotifications: React.FC = ({ children }) => { diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 7e975734..c4661d77 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -162,6 +162,22 @@ "components.Settings.Notifications.NotificationsSlack.validationWebhookUrlRequired": "You must provide a webhook URL", "components.Settings.Notifications.NotificationsSlack.webhookUrl": "Webhook URL", "components.Settings.Notifications.NotificationsSlack.webhookUrlPlaceholder": "Webhook URL", + "components.Settings.Notifications.NotificationsWebhook.agentenabled": "Agent Enabled", + "components.Settings.Notifications.NotificationsWebhook.authheader": "Authorization Header", + "components.Settings.Notifications.NotificationsWebhook.customJson": "Custom JSON Payload", + "components.Settings.Notifications.NotificationsWebhook.notificationtypes": "Notification Types", + "components.Settings.Notifications.NotificationsWebhook.resetPayload": "Reset to Default JSON Payload", + "components.Settings.Notifications.NotificationsWebhook.resetPayloadSuccess": "JSON reset to default payload.", + "components.Settings.Notifications.NotificationsWebhook.save": "Save Changes", + "components.Settings.Notifications.NotificationsWebhook.saving": "Saving...", + "components.Settings.Notifications.NotificationsWebhook.test": "Test", + "components.Settings.Notifications.NotificationsWebhook.testsent": "Test notification sent!", + "components.Settings.Notifications.NotificationsWebhook.validationJsonPayloadRequired": "You must provide a JSON Payload", + "components.Settings.Notifications.NotificationsWebhook.validationWebhookUrlRequired": "You must provide a webhook URL", + "components.Settings.Notifications.NotificationsWebhook.webhookUrl": "Webhook URL", + "components.Settings.Notifications.NotificationsWebhook.webhookUrlPlaceholder": "Remote webhook URL", + "components.Settings.Notifications.NotificationsWebhook.webhooksettingsfailed": "Webhook notification settings failed to save.", + "components.Settings.Notifications.NotificationsWebhook.webhooksettingssaved": "Webhook notification settings saved!", "components.Settings.Notifications.agentenabled": "Agent Enabled", "components.Settings.Notifications.allowselfsigned": "Allow Self-Signed Certificates", "components.Settings.Notifications.authPass": "Auth Pass", diff --git a/src/pages/settings/notifications/webhook.tsx b/src/pages/settings/notifications/webhook.tsx new file mode 100644 index 00000000..1880473b --- /dev/null +++ b/src/pages/settings/notifications/webhook.tsx @@ -0,0 +1,17 @@ +import { NextPage } from 'next'; +import React from 'react'; +import NotificationsWebhook from '../../../components/Settings/Notifications/NotificationsWebhook'; +import SettingsLayout from '../../../components/Settings/SettingsLayout'; +import SettingsNotifications from '../../../components/Settings/SettingsNotifications'; + +const NotificationsPage: NextPage = () => { + return ( + + + + + + ); +}; + +export default NotificationsPage; diff --git a/yarn.lock b/yarn.lock index 80c17d5e..00a724f0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2552,6 +2552,11 @@ accepts@~1.3.7: mime-types "~2.1.24" negotiator "0.6.2" +ace-builds@^1.4.12, ace-builds@^1.4.6: + version "1.4.12" + resolved "https://registry.yarnpkg.com/ace-builds/-/ace-builds-1.4.12.tgz#888efa386e36f4345f40b5233fcc4fe4c588fae7" + integrity sha512-G+chJctFPiiLGvs3+/Mly3apXTcfgE45dT5yp12BcWZ1kUs+gm0qd3/fv4gsz6fVag4mM0moHVpjHDIgph6Psg== + acorn-jsx@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.2.0.tgz#4c66069173d6fdd68ed85239fc256226182b2ebe" @@ -5118,6 +5123,11 @@ didyoumean@^1.2.1: resolved "https://registry.yarnpkg.com/didyoumean/-/didyoumean-1.2.1.tgz#e92edfdada6537d484d73c0172fd1eba0c4976ff" integrity sha1-6S7f2tplN9SE1zwBcv0eugxJdv8= +diff-match-patch@^1.0.4: + version "1.0.5" + resolved "https://registry.yarnpkg.com/diff-match-patch/-/diff-match-patch-1.0.5.tgz#abb584d5f10cd1196dfc55aa03701592ae3f7b37" + integrity sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw== + diff@^4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" @@ -8412,6 +8422,11 @@ lodash.get@^4.4.2: resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" integrity sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk= +lodash.isequal@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0" + integrity sha1-QVxEePK8wwEgwizhDtMib30+GOA= + lodash.ismatch@^4.4.0: version "4.4.0" resolved "https://registry.yarnpkg.com/lodash.ismatch/-/lodash.ismatch-4.4.0.tgz#756cb5150ca3ba6f11085a78849645f188f85f37" @@ -11418,6 +11433,17 @@ rc@^1.0.1, rc@^1.1.6, rc@^1.2.7, rc@^1.2.8: minimist "^1.2.0" strip-json-comments "~2.0.1" +react-ace@^9.2.1: + version "9.2.1" + resolved "https://registry.yarnpkg.com/react-ace/-/react-ace-9.2.1.tgz#1efaa0476c77649136def50e5c4ca30c7e546036" + integrity sha512-2arIeMER/W6/h+QGHs0YJ0pEJo5AmBOUs/R72Poa6eXSOSTpJPp/WkwD/KE7BgNy9vZ7YjlbqA+2ZcoVf6AjsQ== + dependencies: + ace-builds "^1.4.6" + diff-match-patch "^1.0.4" + lodash.get "^4.4.2" + lodash.isequal "^4.5.0" + prop-types "^15.7.2" + react-dom@17.0.1: version "17.0.1" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.1.tgz#1de2560474ec9f0e334285662ede52dbc5426fc6" From a740b07f06f892b72a651b928af28ce71cb495ee Mon Sep 17 00:00:00 2001 From: sct Date: Tue, 12 Jan 2021 11:53:35 +0000 Subject: [PATCH 09/43] fix(plex-sync): improve plex sync error handling. add session id to fix stuck runs --- server/job/plexsync/index.ts | 57 ++++++++++++++++++++++++++++-------- 1 file changed, 44 insertions(+), 13 deletions(-) diff --git a/server/job/plexsync/index.ts b/server/job/plexsync/index.ts index bf43fe6f..1e8470fb 100644 --- a/server/job/plexsync/index.ts +++ b/server/job/plexsync/index.ts @@ -11,6 +11,7 @@ import logger from '../../logger'; import { getSettings, Library } from '../../lib/settings'; import Season from '../../entity/Season'; import { uniqWith } from 'lodash'; +import { v4 as uuid } from 'uuid'; import animeList from '../../api/animelist'; import AsyncLock from '../../utils/asyncLock'; @@ -37,6 +38,7 @@ interface SyncStatus { } class JobPlexSync { + private sessionId: string; private tmdb: TheMovieDb; private plexClient: PlexAPI; private items: PlexLibraryItem[] = []; @@ -608,22 +610,35 @@ class JobPlexSync { private async loop({ start = 0, end = BUNDLE_SIZE, + sessionId, }: { start?: number; end?: number; + sessionId?: string; } = {}) { const slicedItems = this.items.slice(start, end); - if (start < this.items.length && this.running) { + + if (!this.running) { + throw new Error('Sync was aborted.'); + } + + if (this.sessionId !== sessionId) { + throw new Error('New session was started. Old session aborted.'); + } + + if (start < this.items.length) { this.progress = start; await this.processItems(slicedItems); - await new Promise((resolve) => - setTimeout(async () => { - await this.loop({ + await new Promise((resolve, reject) => + setTimeout(() => { + this.loop({ start: start + BUNDLE_SIZE, end: end + BUNDLE_SIZE, - }); - resolve(); + sessionId, + }) + .then(() => resolve()) + .catch((e) => reject(new Error(e.message))); }, UPDATE_RATE) ); } @@ -650,7 +665,10 @@ class JobPlexSync { public async run(): Promise { const settings = getSettings(); - if (!this.running) { + const sessionId = uuid(); + this.sessionId = sessionId; + logger.info('Plex Sync Starting', { sessionId, label: 'Plex Sync' }); + try { this.running = true; const userRepository = getRepository(User); const admin = await userRepository.findOne({ @@ -671,7 +689,7 @@ class JobPlexSync { this.enable4kMovie = settings.radarr.some((radarr) => radarr.is4k); if (this.enable4kMovie) { this.log( - 'At least one 4K Radarr server was detected, so 4K movie detection is now enabled', + 'At least one 4K Radarr server was detected. 4K movie detection is now enabled', 'info' ); } @@ -679,7 +697,7 @@ class JobPlexSync { this.enable4kShow = settings.sonarr.some((sonarr) => sonarr.is4k); if (this.enable4kShow) { this.log( - 'At least one 4K Sonarr server was detected, so 4K series detection is now enabled', + 'At least one 4K Sonarr server was detected. 4K series detection is now enabled', 'info' ); } @@ -715,18 +733,31 @@ class JobPlexSync { return mediaA.ratingKey === mediaB.ratingKey; }); - await this.loop(); + await this.loop({ sessionId }); } } else { for (const library of this.libraries) { this.currentLibrary = library; this.log(`Beginning to process library: ${library.name}`, 'info'); this.items = await this.plexClient.getLibraryContents(library.id); - await this.loop(); + await this.loop({ sessionId }); } } - this.running = false; - this.log('complete'); + this.log( + this.isRecentOnly + ? 'Recently Added Scan Complete' + : 'Full Scan Complete' + ); + } catch (e) { + logger.error('Sync interrupted', { + label: 'Plex Sync', + errorMessage: e.message, + }); + } finally { + // If a new scanning session hasnt started, set running back to false + if (this.sessionId === sessionId) { + this.running = false; + } } } From d9e0c90e76d80aef0c67318e00e997804805f46e Mon Sep 17 00:00:00 2001 From: sct Date: Tue, 12 Jan 2021 12:25:57 +0000 Subject: [PATCH 10/43] fix(frontend): fix button styling on details page on small screen sizes --- .../Common/ButtonWithDropdown/index.tsx | 2 +- src/components/MovieDetails/index.tsx | 27 +++++++++------ .../RequestButton/index.tsx | 33 ++++--------------- src/components/TvDetails/index.tsx | 31 ++++++++++------- 4 files changed, 44 insertions(+), 49 deletions(-) rename src/components/{MovieDetails => }/RequestButton/index.tsx (93%) diff --git a/src/components/Common/ButtonWithDropdown/index.tsx b/src/components/Common/ButtonWithDropdown/index.tsx index 0165d5ae..12c032c3 100644 --- a/src/components/Common/ButtonWithDropdown/index.tsx +++ b/src/components/Common/ButtonWithDropdown/index.tsx @@ -54,7 +54,7 @@ const ButtonWithDropdown: React.FC = ({ {children && (
    -
    +
    {trailerUrl && ( - + )} - revalidate()} - /> +
    + revalidate()} + /> +
    {hasPermission(Permission.MANAGE_REQUESTS) && (
    -
    +
    {trailerUrl && ( - + )} - revalidate()} - tmdbId={data?.id} - media={data?.mediaInfo} - isShowComplete={isComplete} - is4kShowComplete={is4kComplete} - /> +
    + revalidate()} + tmdbId={data?.id} + media={data?.mediaInfo} + isShowComplete={isComplete} + is4kShowComplete={is4kComplete} + /> +
    {hasPermission(Permission.MANAGE_REQUESTS) && ( + + + + + {intl.formatMessage(messages.templatevariablehelp)} +
    diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index c4661d77..aecdf731 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -29,20 +29,6 @@ "components.Login.signinplex": "Sign in to continue", "components.MovieDetails.MovieCast.fullcast": "Full Cast", "components.MovieDetails.MovieCrew.fullcrew": "Full Crew", - "components.MovieDetails.RequestButton.approve4krequests": "Approve {requestCount} 4K {requestCount, plural, one {Request} other {Requests}}", - "components.MovieDetails.RequestButton.approverequest": "Approve Request", - "components.MovieDetails.RequestButton.approverequest4k": "Approve 4K Request", - "components.MovieDetails.RequestButton.approverequests": "Approve {requestCount} {requestCount, plural, one {Request} other {Requests}}", - "components.MovieDetails.RequestButton.decline4krequests": "Decline {requestCount} 4K {requestCount, plural, one {Request} other {Requests}}", - "components.MovieDetails.RequestButton.declinerequest": "Decline Request", - "components.MovieDetails.RequestButton.declinerequest4k": "Decline 4K Request", - "components.MovieDetails.RequestButton.declinerequests": "Decline {requestCount} {requestCount, plural, one {Request} other {Requests}}", - "components.MovieDetails.RequestButton.request": "Request", - "components.MovieDetails.RequestButton.request4k": "Request 4K", - "components.MovieDetails.RequestButton.requestmore": "Request More", - "components.MovieDetails.RequestButton.requestmore4k": "Request More 4K", - "components.MovieDetails.RequestButton.viewrequest": "View Request", - "components.MovieDetails.RequestButton.viewrequest4k": "View 4K Request", "components.MovieDetails.approve": "Approve", "components.MovieDetails.available": "Available", "components.MovieDetails.budget": "Budget", @@ -88,6 +74,20 @@ "components.PlexLoginButton.loggingin": "Logging in…", "components.PlexLoginButton.loginwithplex": "Login with Plex", "components.RequestBlock.seasons": "Seasons", + "components.RequestButton.approve4krequests": "Approve {requestCount} 4K {requestCount, plural, one {Request} other {Requests}}", + "components.RequestButton.approverequest": "Approve Request", + "components.RequestButton.approverequest4k": "Approve 4K Request", + "components.RequestButton.approverequests": "Approve {requestCount} {requestCount, plural, one {Request} other {Requests}}", + "components.RequestButton.decline4krequests": "Decline {requestCount} 4K {requestCount, plural, one {Request} other {Requests}}", + "components.RequestButton.declinerequest": "Decline Request", + "components.RequestButton.declinerequest4k": "Decline 4K Request", + "components.RequestButton.declinerequests": "Decline {requestCount} {requestCount, plural, one {Request} other {Requests}}", + "components.RequestButton.request": "Request", + "components.RequestButton.request4k": "Request 4K", + "components.RequestButton.requestmore": "Request More", + "components.RequestButton.requestmore4k": "Request More 4K", + "components.RequestButton.viewrequest": "View Request", + "components.RequestButton.viewrequest4k": "View 4K Request", "components.RequestCard.all": "All", "components.RequestCard.requestedby": "Requested by {username}", "components.RequestCard.seasons": "Seasons", @@ -170,6 +170,7 @@ "components.Settings.Notifications.NotificationsWebhook.resetPayloadSuccess": "JSON reset to default payload.", "components.Settings.Notifications.NotificationsWebhook.save": "Save Changes", "components.Settings.Notifications.NotificationsWebhook.saving": "Saving...", + "components.Settings.Notifications.NotificationsWebhook.templatevariablehelp": "Template Variable Help", "components.Settings.Notifications.NotificationsWebhook.test": "Test", "components.Settings.Notifications.NotificationsWebhook.testsent": "Test notification sent!", "components.Settings.Notifications.NotificationsWebhook.validationJsonPayloadRequired": "You must provide a JSON Payload", From a2627270784bdef8644875fa5c5a7349a0b7fd81 Mon Sep 17 00:00:00 2001 From: sct Date: Tue, 12 Jan 2021 23:39:39 +0000 Subject: [PATCH 13/43] fix(frontend): fix request button height --- src/components/Common/ButtonWithDropdown/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Common/ButtonWithDropdown/index.tsx b/src/components/Common/ButtonWithDropdown/index.tsx index 12c032c3..422436c3 100644 --- a/src/components/Common/ButtonWithDropdown/index.tsx +++ b/src/components/Common/ButtonWithDropdown/index.tsx @@ -39,10 +39,10 @@ const ButtonWithDropdown: React.FC = ({ useClickOutside(buttonRef, () => setIsOpen(false)); return ( - + + + + + +
    +
    + + + ); + }} + + ); +}; + +export default LocalLogin; diff --git a/src/components/Login/index.tsx b/src/components/Login/index.tsx index 18eedf12..8334d66e 100644 --- a/src/components/Login/index.tsx +++ b/src/components/Login/index.tsx @@ -4,17 +4,22 @@ import { useUser } from '../../hooks/useUser'; import axios from 'axios'; import { useRouter } from 'next/dist/client/router'; import ImageFader from '../Common/ImageFader'; -import { defineMessages, FormattedMessage } from 'react-intl'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import Transition from '../Transition'; import LanguagePicker from '../Layout/LanguagePicker'; +import Button from '../Common/Button'; +import LocalLogin from './LocalLogin'; const messages = defineMessages({ signinplex: 'Sign in to continue', + signinwithoverseerr: 'Sign in with Overseerr', }); const Login: React.FC = () => { + const intl = useIntl(); const [error, setError] = useState(''); const [isProcessing, setProcessing] = useState(false); + const [localLogin, setLocalLogin] = useState(false); const [authToken, setAuthToken] = useState(undefined); const { user, revalidate } = useUser(); const router = useRouter(); @@ -80,42 +85,67 @@ const Login: React.FC = () => { className="px-4 py-8 bg-gray-800 bg-opacity-50 shadow sm:rounded-lg" style={{ backdropFilter: 'blur(5px)' }} > - -
    -
    -
    - -
    -
    -

    {error}

    + {!localLogin ? ( + <> + +
    +
    +
    + +
    +
    +

    + {error} +

    +
    +
    +
    +
    + setAuthToken(authToken)} + />
    -
    - - setAuthToken(authToken)} - /> + + + + + ) : ( + setLocalLogin(false)} + revalidate={revalidate} + /> + )}
    diff --git a/src/components/UserList/index.tsx b/src/components/UserList/index.tsx index be3f0e59..1669f9c0 100644 --- a/src/components/UserList/index.tsx +++ b/src/components/UserList/index.tsx @@ -6,7 +6,7 @@ import Badge from '../Common/Badge'; import { FormattedDate, defineMessages, useIntl } from 'react-intl'; import Button from '../Common/Button'; import { hasPermission } from '../../../server/lib/permissions'; -import { Permission } from '../../hooks/useUser'; +import { Permission, UserType } from '../../hooks/useUser'; import { useRouter } from 'next/router'; import Header from '../Common/Header'; import Table from '../Common/Table'; @@ -15,6 +15,10 @@ import Modal from '../Common/Modal'; import axios from 'axios'; import { useToasts } from 'react-toast-notifications'; import globalMessages from '../../i18n/globalMessages'; +import { Field, Form, Formik } from 'formik'; +import * as Yup from 'yup'; +import AddUserIcon from '../../assets/useradd.svg'; +import Alert from '../Common/Alert'; const messages = defineMessages({ userlist: 'User List', @@ -38,6 +42,22 @@ const messages = defineMessages({ userdeleteerror: 'Something went wrong deleting the user', deleteconfirm: 'Are you sure you want to delete this user? All existing request data from this user will be removed.', + localuser: 'Local User', + createlocaluser: 'Create Local User', + createuser: 'Create User', + creating: 'Creating', + create: 'Create', + validationemailrequired: 'Must enter a valid email address.', + validationpasswordminchars: + 'Password is too short - should be 8 chars minimum.', + usercreatedfailed: 'Something went wrong when trying to create the user', + usercreatedsuccess: 'Successfully created the user', + email: 'Email Address', + password: 'Password', + passwordinfo: 'Password Info', + passwordinfodescription: + 'Email notification settings need to be enabled and setup in order to use the auto generated passwords', + autogeneratepassword: 'Automatically generate password', }); const UserList: React.FC = () => { @@ -53,6 +73,11 @@ const UserList: React.FC = () => { }>({ isOpen: false, }); + const [createModal, setCreateModal] = useState<{ + isOpen: boolean; + }>({ + isOpen: false, + }); const deleteUser = async () => { setDeleting(true); @@ -107,6 +132,15 @@ const UserList: React.FC = () => { return ; } + const CreateUserSchema = Yup.object().shape({ + email: Yup.string() + .email() + .required(intl.formatMessage(messages.validationemailrequired)), + password: Yup.lazy((value) => + !value ? Yup.string() : Yup.string().min(8) + ), + }); + return ( <> { {intl.formatMessage(messages.deleteconfirm)} -
    -
    {intl.formatMessage(messages.userlist)}
    - + {({ + errors, + touched, + isSubmitting, + values, + isValid, + setFieldValue, + handleSubmit, + }) => { + return ( + } + onOk={() => handleSubmit()} + okText={ + isSubmitting + ? intl.formatMessage(messages.creating) + : intl.formatMessage(messages.create) + } + okDisabled={isSubmitting || !isValid} + okButtonType="primary" + onCancel={() => setCreateModal({ isOpen: false })} + > + + {intl.formatMessage(messages.passwordinfodescription)} + +
    +
    + +
    +
    + +
    + {errors.email && touched.email && ( +
    {errors.email}
    + )} +
    + +
    + setFieldValue('password', '')} + /> +
    + +
    +
    + +
    + {errors.password && touched.password && ( +
    + {errors.password} +
    + )} +
    +
    +
    +
    + ); + }} + + +
    +
    {intl.formatMessage(messages.userlist)}
    +
    + + +
    @@ -198,9 +373,15 @@ const UserList: React.FC = () => {
    {user.requestCount}
    - - {intl.formatMessage(messages.plexuser)} - + {user.userType === UserType.PLEX ? ( + + {intl.formatMessage(messages.plexuser)} + + ) : ( + + {intl.formatMessage(messages.localuser)} + + )} {hasPermission(Permission.ADMIN, user.permissions) diff --git a/src/hooks/useUser.ts b/src/hooks/useUser.ts index bd6e0bb3..c9c9a330 100644 --- a/src/hooks/useUser.ts +++ b/src/hooks/useUser.ts @@ -1,12 +1,18 @@ import useSwr from 'swr'; import { hasPermission, Permission } from '../../server/lib/permissions'; +export enum UserType { + PLEX = 1, + LOCAL = 2, +} + export interface User { id: number; username: string; email: string; avatar: string; permissions: number; + userType: number; } export { Permission }; diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index aecdf731..d518e956 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -26,7 +26,16 @@ "components.Layout.Sidebar.users": "Users", "components.Layout.UserDropdown.signout": "Sign Out", "components.Layout.alphawarning": "This is ALPHA software. Almost everything is bound to be nearly broken and/or unstable. Please report issues to the Overseerr GitHub!", + "components.Login.email": "Email Address", + "components.Login.goback": "Go back", + "components.Login.loggingin": "Logging in...", + "components.Login.login": "Login", + "components.Login.loginerror": "Something went wrong when trying to sign in", + "components.Login.password": "Password", "components.Login.signinplex": "Sign in to continue", + "components.Login.signinwithoverseerr": "Sign in with Overseerr", + "components.Login.validationemailrequired": "Not a valid email address", + "components.Login.validationpasswordrequired": "Password required", "components.MovieDetails.MovieCast.fullcast": "Full Cast", "components.MovieDetails.MovieCrew.fullcrew": "Full Crew", "components.MovieDetails.approve": "Approve", @@ -445,24 +454,38 @@ "components.UserEdit.vote": "Vote", "components.UserEdit.voteDescription": "Grants permission to vote on requests (voting not yet implemented)", "components.UserList.admin": "Admin", + "components.UserList.autogeneratepassword": "Automatically generate password", + "components.UserList.create": "Create", "components.UserList.created": "Created", + "components.UserList.createlocaluser": "Create Local User", + "components.UserList.createuser": "Create User", + "components.UserList.creating": "Creating", "components.UserList.delete": "Delete", "components.UserList.deleteconfirm": "Are you sure you want to delete this user? All existing request data from this user will be removed.", "components.UserList.deleteuser": "Delete User", "components.UserList.edit": "Edit", + "components.UserList.email": "Email Address", "components.UserList.importedfromplex": "{userCount, plural, =0 {No new users} one {# new user} other {# new users}} imported from Plex", "components.UserList.importfromplex": "Import Users From Plex", "components.UserList.importfromplexerror": "Something went wrong importing users from Plex", "components.UserList.lastupdated": "Last Updated", + "components.UserList.localuser": "Local User", + "components.UserList.password": "Password", + "components.UserList.passwordinfo": "Password Info", + "components.UserList.passwordinfodescription": "Email notification settings need to be enabled and setup in order to use the auto generated passwords", "components.UserList.plexuser": "Plex User", "components.UserList.role": "Role", "components.UserList.totalrequests": "Total Requests", "components.UserList.user": "User", + "components.UserList.usercreatedfailed": "Something went wrong when trying to create the user", + "components.UserList.usercreatedsuccess": "Successfully created the user", "components.UserList.userdeleted": "User deleted", "components.UserList.userdeleteerror": "Something went wrong deleting the user", "components.UserList.userlist": "User List", "components.UserList.username": "Username", "components.UserList.usertype": "User Type", + "components.UserList.validationemailrequired": "Must enter a valid email address.", + "components.UserList.validationpasswordminchars": "Password is too short - should be 8 chars minimum.", "i18n.approve": "Approve", "i18n.approved": "Approved", "i18n.available": "Available", diff --git a/yarn.lock b/yarn.lock index 00a724f0..bc9ddc56 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1964,6 +1964,11 @@ dependencies: "@babel/types" "^7.3.0" +"@types/bcrypt@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/bcrypt/-/bcrypt-3.0.0.tgz#851489a9065a067cb7f3c9cbe4ce9bed8bba0876" + integrity sha512-nohgNyv+1ViVcubKBh0+XiNJ3dO8nYu///9aJ4cgSqv70gBL+94SNy/iC2NLzKPT2Zt/QavrOkBVbZRLZmw6NQ== + "@types/body-parser@*", "@types/body-parser@^1.19.0": version "1.19.0" resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.0.tgz#0685b3c47eb3006ffed117cdd55164b61f80538f" @@ -2230,6 +2235,11 @@ dependencies: schema-utils "*" +"@types/secure-random-password@^0.2.0": + version "0.2.0" + resolved "https://registry.yarnpkg.com/@types/secure-random-password/-/secure-random-password-0.2.0.tgz#d79be2c16f6866db87d816d8a5aefd7dd4764452" + integrity sha512-eRV3pVFHA5YnRlxH8DlGPCieus1jy5j6dExTABFu/pfVGEI1N+w0ej8HveAoMspr6GJkEWOS/awA71WPJemBwA== + "@types/serve-static@*": version "1.13.5" resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.13.5.tgz#3d25d941a18415d3ab092def846e135a08bbcf53" @@ -3179,6 +3189,14 @@ bcrypt-pbkdf@^1.0.0: dependencies: tweetnacl "^0.14.3" +bcrypt@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/bcrypt/-/bcrypt-5.0.0.tgz#051407c7cd5ffbfb773d541ca3760ea0754e37e2" + integrity sha512-jB0yCBl4W/kVHM2whjfyqnxTmOHkCX4kHEa5nYKSoGeYe8YrjTYTc87/6bwt1g8cmV0QrbhKriETg9jWtcREhg== + dependencies: + node-addon-api "^3.0.0" + node-pre-gyp "0.15.0" + before-after-hook@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-2.1.0.tgz#b6c03487f44e24200dd30ca5e6a1979c5d2fb635" @@ -3239,6 +3257,11 @@ bluebird@^3.3.5, bluebird@^3.5.0, bluebird@^3.5.1, bluebird@^3.5.3, bluebird@^3. resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== +blueimp-md5@^2.10.0: + version "2.18.0" + resolved "https://registry.yarnpkg.com/blueimp-md5/-/blueimp-md5-2.18.0.tgz#1152be1335f0c6b3911ed9e36db54f3e6ac52935" + integrity sha512-vE52okJvzsVWhcgUHOv+69OG3Mdg151xyn41aVQN/5W5S+S43qZhxECtYLAEHMSFWX6Mv5IZrzj3T5+JqXfj5Q== + bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.4.0: version "4.11.9" resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.9.tgz#26d556829458f9d1e81fc48952493d0ba3507828" @@ -6755,6 +6778,14 @@ graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.6 resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.4.tgz#2256bde14d3632958c465ebc96dc467ca07a29fb" integrity sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw== +gravatar-url@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/gravatar-url/-/gravatar-url-3.1.0.tgz#0cbeedab7c00a7bc9b627b3716e331359efcc999" + integrity sha512-+lOs7Rz1A051OqdqE8Tm4lmeyVgkqH8c6ll5fv///ncdIaL+XnOFmKAB70ix1du/yj8c3EWKbP6OhKjihsBSfA== + dependencies: + md5-hex "^3.0.1" + type-fest "^0.8.1" + handlebars@^4.7.6: version "4.7.6" resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.6.tgz#d4c05c1baf90e9945f77aa68a7a219aa4a7df74e" @@ -8718,6 +8749,13 @@ math-interval-parser@^2.0.1: resolved "https://registry.yarnpkg.com/math-interval-parser/-/math-interval-parser-2.0.1.tgz#e22cd6d15a0a7f4c03aec560db76513da615bed4" integrity sha512-VmlAmb0UJwlvMyx8iPhXUDnVW1F9IrGEd9CIOmv+XL8AErCUUuozoDMrgImvnYt2A+53qVX/tPW6YJurMKYsvA== +md5-hex@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/md5-hex/-/md5-hex-3.0.1.tgz#be3741b510591434b2784d79e556eefc2c9a8e5c" + integrity sha512-BUiRtTtV39LIJwinWBjqVsU9xhdnz7/i889V859IBFpuqGAj6LuOvHv5XLbgZ2R7ptJoJaEcxkv88/h25T7Ciw== + dependencies: + blueimp-md5 "^2.10.0" + md5.js@^1.3.4: version "1.3.5" resolved "https://registry.yarnpkg.com/md5.js/-/md5.js-1.3.5.tgz#b5d07b8e3216e3e27cd728d72f70d1e6a342005f" @@ -9251,6 +9289,15 @@ needle@^2.2.1: iconv-lite "^0.4.4" sax "^1.2.4" +needle@^2.5.0: + version "2.5.2" + resolved "https://registry.yarnpkg.com/needle/-/needle-2.5.2.tgz#cf1a8fce382b5a280108bba90a14993c00e4010a" + integrity sha512-LbRIwS9BfkPvNwNHlsA41Q29kL2L/6VaOJ0qisM5lLWsTV3nP15abO5ITL6L81zqFhzjRKDAYjpcBcwM0AVvLQ== + dependencies: + debug "^3.2.6" + iconv-lite "^0.4.4" + sax "^1.2.4" + negotiator@0.6.2: version "0.6.2" resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb" @@ -9340,6 +9387,11 @@ node-addon-api@2.0.0: resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-2.0.0.tgz#f9afb8d777a91525244b01775ea0ddbe1125483b" integrity sha512-ASCL5U13as7HhOExbT6OlWJJUV/lLzL2voOSP1UVehpRD8FbSrSDjfScK/KwAvVTI5AS6r4VwbOMlIqtvRidnA== +node-addon-api@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-3.1.0.tgz#98b21931557466c6729e51cb77cd39c965f42239" + integrity sha512-flmrDNB06LIl5lywUz7YlNGZH/5p0M7W28k8hzd9Lshtdh1wshD2Y+U4h9LD6KObOy1f+fEVdgprPrEymjM5uw== + node-addon-api@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-3.0.2.tgz#04bc7b83fd845ba785bb6eae25bc857e1ef75681" @@ -9442,6 +9494,22 @@ node-libs-browser@^2.2.1: util "^0.11.0" vm-browserify "^1.0.1" +node-pre-gyp@0.15.0: + version "0.15.0" + resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.15.0.tgz#c2fc383276b74c7ffa842925241553e8b40f1087" + integrity sha512-7QcZa8/fpaU/BKenjcaeFF9hLz2+7S9AqyXFhlH/rilsQ/hPZKK32RtR5EQHJElgu+q5RfbJ34KriI79UWaorA== + dependencies: + detect-libc "^1.0.2" + mkdirp "^0.5.3" + needle "^2.5.0" + nopt "^4.0.1" + npm-packlist "^1.1.6" + npmlog "^4.0.2" + rc "^1.2.7" + rimraf "^2.6.1" + semver "^5.3.0" + tar "^4.4.2" + node-pre-gyp@^0.11.0: version "0.11.0" resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.11.0.tgz#db1f33215272f692cd38f03238e3e9b47c5dd054" @@ -12200,6 +12268,18 @@ schema-utils@^1.0.0: ajv-errors "^1.0.0" ajv-keywords "^3.1.0" +secure-random-password@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/secure-random-password/-/secure-random-password-0.2.2.tgz#eb043bcada24bc372bc98457845222b2a96d2058" + integrity sha512-L1bcFB6CY/L4snizCej/yVmRGguor5ASgk2/ea4iYjYNbEPjJ7W++4o8hQGvfrS1WWqDKUNi/Z3QEHAjkibqfw== + dependencies: + secure-random "^1.1.2" + +secure-random@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/secure-random/-/secure-random-1.1.2.tgz#ed103b460a851632d420d46448b2a900a41e7f7c" + integrity sha512-H2bdSKERKdBV1SwoqYm6C0y+9EA94v6SUBOWO8kDndc4NoUih7Dv6Tsgma7zO1lv27wIvjlD0ZpMQk7um5dheQ== + semantic-release-docker@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/semantic-release-docker/-/semantic-release-docker-2.2.0.tgz#9a5e1c8b4fe2b85063e1dc64e15550e7bf26c26f" @@ -13222,7 +13302,7 @@ tar@^2.0.0: fstream "^1.0.12" inherits "2" -tar@^4, tar@^4.4.10, tar@^4.4.12, tar@^4.4.13: +tar@^4, tar@^4.4.10, tar@^4.4.12, tar@^4.4.13, tar@^4.4.2: version "4.4.13" resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.13.tgz#43b364bc52888d555298637b10d60790254ab525" integrity sha512-w2VwSrBoHa5BsSyH+KxEqeQBAllHhccyMFVHtGtdMpF4W7IRWfZjFiQceJPChOeTsSDVUpER2T8FA93pr0L+QA== From 23624bd144af5df4c31995b68ce48105b95b20f6 Mon Sep 17 00:00:00 2001 From: sct Date: Thu, 14 Jan 2021 13:08:48 +0000 Subject: [PATCH 17/43] fix(build): remove cross import from client to server for UserType --- server/constants/user.ts | 4 ++++ server/routes/auth.ts | 2 +- src/hooks/useUser.ts | 8 ++------ 3 files changed, 7 insertions(+), 7 deletions(-) create mode 100644 server/constants/user.ts diff --git a/server/constants/user.ts b/server/constants/user.ts new file mode 100644 index 00000000..38aa50a4 --- /dev/null +++ b/server/constants/user.ts @@ -0,0 +1,4 @@ +export enum UserType { + PLEX = 1, + LOCAL = 2, +} diff --git a/server/routes/auth.ts b/server/routes/auth.ts index 72f425b9..21c9397a 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -6,7 +6,7 @@ import { isAuthenticated } from '../middleware/auth'; import { Permission } from '../lib/permissions'; import logger from '../logger'; import { getSettings } from '../lib/settings'; -import { UserType } from '../../src/hooks/useUser'; +import { UserType } from '../constants/user'; const authRoutes = Router(); diff --git a/src/hooks/useUser.ts b/src/hooks/useUser.ts index c9c9a330..c2102a00 100644 --- a/src/hooks/useUser.ts +++ b/src/hooks/useUser.ts @@ -1,10 +1,6 @@ import useSwr from 'swr'; import { hasPermission, Permission } from '../../server/lib/permissions'; - -export enum UserType { - PLEX = 1, - LOCAL = 2, -} +import { UserType } from '../../server/constants/user'; export interface User { id: number; @@ -15,7 +11,7 @@ export interface User { userType: number; } -export { Permission }; +export { Permission, UserType }; interface UserHookResponse { user?: User; From b04d00ef509d6f13c1f9677b3f318331782c0086 Mon Sep 17 00:00:00 2001 From: Jakob Ankarhem Date: Fri, 15 Jan 2021 04:05:58 +0100 Subject: [PATCH 18/43] fix(user edit): fix user edit not being able to be saved (#651) Co-authored-by: sct --- overseerr-api.yml | 4 ++-- server/entity/User.ts | 5 +++-- server/routes/auth.ts | 2 ++ server/routes/user.ts | 8 +++++--- src/components/PermissionOption/index.tsx | 2 +- src/components/UserEdit/index.tsx | 1 - src/components/UserList/index.tsx | 6 ++---- 7 files changed, 15 insertions(+), 13 deletions(-) diff --git a/overseerr-api.yml b/overseerr-api.yml index 9b9f888e..6290c9cc 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -23,11 +23,13 @@ components: userType: type: integer example: 1 + readOnly: true permissions: type: number example: 0 avatar: type: string + readOnly: true createdAt: type: string example: '2020-09-02T05:02:23.000Z' @@ -47,9 +49,7 @@ components: $ref: '#/components/schemas/MediaRequest' required: - id - - userType - email - - permissions - createdAt - updatedAt MainSettings: diff --git a/server/entity/User.ts b/server/entity/User.ts index 5ba20535..0b05efb2 100644 --- a/server/entity/User.ts +++ b/server/entity/User.ts @@ -15,6 +15,7 @@ import PreparedEmail from '../lib/email'; import logger from '../logger'; import { getSettings } from '../lib/settings'; import { default as generatePassword } from 'secure-random-password'; +import { UserType } from '../constants/user'; @Entity() export class User { @@ -36,8 +37,8 @@ export class User { @Column({ nullable: true, select: false }) public password?: string; - @Column({ type: 'integer', default: 1 }) - public userType = 1; + @Column({ type: 'integer', default: UserType.PLEX }) + public userType: UserType; @Column({ nullable: true, select: false }) public plexId?: number; diff --git a/server/routes/auth.ts b/server/routes/auth.ts index 21c9397a..5f60d512 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -68,6 +68,7 @@ authRoutes.post('/login', async (req, res, next) => { plexToken: account.authToken, permissions: Permission.ADMIN, avatar: account.thumb, + userType: UserType.PLEX, }); await userRepository.save(user); } @@ -90,6 +91,7 @@ authRoutes.post('/login', async (req, res, next) => { plexToken: account.authToken, permissions: settings.main.defaultPermissions, avatar: account.thumb, + userType: UserType.PLEX, }); await userRepository.save(user); } else { diff --git a/server/routes/user.ts b/server/routes/user.ts index 3807c849..b51b56cd 100644 --- a/server/routes/user.ts +++ b/server/routes/user.ts @@ -7,6 +7,7 @@ import { hasPermission, Permission } from '../lib/permissions'; import { getSettings } from '../lib/settings'; import logger from '../logger'; import gravatarUrl from 'gravatar-url'; +import { UserType } from '../constants/user'; const router = Router(); @@ -26,7 +27,7 @@ router.post('/', async (req, res, next) => { const userRepository = getRepository(User); const passedExplicitPassword = body.password && body.password.length > 0; - const avatar = gravatarUrl(body.email); + const avatar = gravatarUrl(body.email, { default: 'mm', size: 200 }); if (!passedExplicitPassword && !settings.enabled) { throw new Error('Email notifications must be enabled'); @@ -37,9 +38,9 @@ router.post('/', async (req, res, next) => { username: body.username ?? body.email, email: body.email, password: body.password, - permissions: body.permissions, + permissions: Permission.REQUEST, plexToken: '', - userType: body.userType, + userType: UserType.LOCAL, }); if (passedExplicitPassword) { @@ -201,6 +202,7 @@ router.post('/import-from-plex', async (req, res, next) => { plexId: parseInt(account.id), plexToken: '', avatar: account.thumb, + userType: UserType.PLEX, }); await userRepository.save(newUser); createdUsers.push(newUser); diff --git a/src/components/PermissionOption/index.tsx b/src/components/PermissionOption/index.tsx index d779669d..f8c0677b 100644 --- a/src/components/PermissionOption/index.tsx +++ b/src/components/PermissionOption/index.tsx @@ -59,7 +59,7 @@ const PermissionOption: React.FC = ({ !hasPermission(Permission.MANAGE_SETTINGS, user.permissions) && option.permission === Permission.MANAGE_SETTINGS) } - onClick={() => { + onChange={() => { onUpdate( hasPermission(option.permission, currentPermission) ? currentPermission - option.permission diff --git a/src/components/UserEdit/index.tsx b/src/components/UserEdit/index.tsx index 3a09e11a..772d77ce 100644 --- a/src/components/UserEdit/index.tsx +++ b/src/components/UserEdit/index.tsx @@ -79,7 +79,6 @@ const UserEdit: React.FC = () => { await axios.put(`/api/v1/user/${user?.id}`, { permissions: currentPermission, email: user?.email, - avatar: user?.avatar, }); addToast(intl.formatMessage(messages.usersaved), { diff --git a/src/components/UserList/index.tsx b/src/components/UserList/index.tsx index 1669f9c0..7b7d6af1 100644 --- a/src/components/UserList/index.tsx +++ b/src/components/UserList/index.tsx @@ -204,8 +204,6 @@ const UserList: React.FC = () => { await axios.post('/api/v1/user', { email: values.email, password: values.genpassword ? null : values.password, - permissions: Permission.REQUEST, - userType: UserType.LOCAL, }); addToast(intl.formatMessage(messages.usercreatedsuccess), { appearance: 'success', @@ -315,7 +313,7 @@ const UserList: React.FC = () => { }} -
    +
    {intl.formatMessage(messages.userlist)}
    - + + + + )} {request.status !== MediaRequestStatus.PENDING && ( @@ -209,6 +247,39 @@ const RequestBlock: React.FC = ({ request, onUpdate }) => {
    )} + {(server || profile || rootFolder) && ( + <> +
    + {intl.formatMessage(messages.requestoverrides)} +
    +
      + {server && ( +
    • + + {intl.formatMessage(messages.server)} + + {server} +
    • + )} + {profile !== null && ( +
    • + + {intl.formatMessage(messages.profilechanged)} + + ID {profile} +
    • + )} + {rootFolder && ( +
    • + + {intl.formatMessage(messages.rootfolder)} + + {rootFolder} +
    • + )} +
    + + )}
    ); diff --git a/src/components/RequestList/RequestItem/index.tsx b/src/components/RequestList/RequestItem/index.tsx index 1ff736c3..5abf1836 100644 --- a/src/components/RequestList/RequestItem/index.tsx +++ b/src/components/RequestList/RequestItem/index.tsx @@ -24,6 +24,7 @@ import axios from 'axios'; import globalMessages from '../../../i18n/globalMessages'; import Link from 'next/link'; import { useToasts } from 'react-toast-notifications'; +import RequestModal from '../../RequestModal'; const messages = defineMessages({ requestedby: 'Requested by {username}', @@ -51,6 +52,7 @@ const RequestItem: React.FC = ({ const { addToast } = useToasts(); const intl = useIntl(); const { hasPermission } = useUser(); + const [showEditModal, setShowEditModal] = useState(false); const { locale } = useContext(LanguageContext); const url = request.type === 'movie' @@ -116,6 +118,18 @@ const RequestItem: React.FC = ({ return (
    + setShowEditModal(false)} + onComplete={() => { + revalidateList(); + setShowEditModal(false); + }} + />
    = ({ - + + + + )} diff --git a/src/components/RequestModal/AdvancedRequester/index.tsx b/src/components/RequestModal/AdvancedRequester/index.tsx new file mode 100644 index 00000000..70f59eb9 --- /dev/null +++ b/src/components/RequestModal/AdvancedRequester/index.tsx @@ -0,0 +1,312 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +import React, { useEffect, useState } from 'react'; +import useSWR from 'swr'; +import { SmallLoadingSpinner } from '../../Common/LoadingSpinner'; +import type { + ServiceCommonServer, + ServiceCommonServerWithDetails, +} from '../../../../server/interfaces/api/serviceInterfaces'; +import { defineMessages, useIntl } from 'react-intl'; + +const formatBytes = (bytes: number, decimals = 2) => { + if (bytes === 0) return '0 Bytes'; + + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; +}; + +const messages = defineMessages({ + advancedoptions: 'Advanced Options', + destinationserver: 'Destination Server', + qualityprofile: 'Quality Profile', + rootfolder: 'Root Folder', + animenote: '* This series is an anime.', + default: '(Default)', + loadingprofiles: 'Loading profiles…', + loadingfolders: 'Loading folders…', +}); + +export type RequestOverrides = { + server?: number; + profile?: number; + folder?: string; +}; + +interface AdvancedRequesterProps { + type: 'movie' | 'tv'; + is4k: boolean; + isAnime?: boolean; + defaultOverrides?: RequestOverrides; + onChange: (overrides: RequestOverrides) => void; +} + +const AdvancedRequester: React.FC = ({ + type, + is4k = false, + isAnime = false, + defaultOverrides, + onChange, +}) => { + const intl = useIntl(); + const { data, error } = useSWR( + `/api/v1/service/${type === 'movie' ? 'radarr' : 'sonarr'}`, + { + refreshInterval: 0, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateOnMount: true, + } + ); + const [selectedServer, setSelectedServer] = useState( + defaultOverrides?.server !== undefined && defaultOverrides?.server >= 0 + ? defaultOverrides?.server + : null + ); + const [selectedProfile, setSelectedProfile] = useState( + defaultOverrides?.profile ?? -1 + ); + const [selectedFolder, setSelectedFolder] = useState( + defaultOverrides?.folder ?? '' + ); + const { + data: serverData, + isValidating, + } = useSWR( + selectedServer !== null + ? `/api/v1/service/${ + type === 'movie' ? 'radarr' : 'sonarr' + }/${selectedServer}` + : null, + { + refreshInterval: 0, + refreshWhenHidden: false, + revalidateOnFocus: false, + } + ); + + useEffect(() => { + let defaultServer = data?.find( + (server) => server.isDefault && is4k === server.is4k + ); + + if (!defaultServer && (data ?? []).length > 0) { + defaultServer = data?.[0]; + } + + if ( + defaultServer && + defaultServer.id !== selectedServer && + (!defaultOverrides || defaultOverrides.server === null) + ) { + setSelectedServer(defaultServer.id); + } + }, [data]); + + useEffect(() => { + if (serverData) { + const defaultProfile = serverData.profiles.find( + (profile) => + profile.id === + (isAnime + ? serverData.server.activeAnimeProfileId + : serverData.server.activeProfileId) + ); + const defaultFolder = serverData.rootFolders.find( + (folder) => + folder.path === + (isAnime + ? serverData.server.activeAnimeDirectory + : serverData.server.activeDirectory) + ); + + if ( + defaultProfile && + defaultProfile.id !== selectedProfile && + (!defaultOverrides || defaultOverrides.profile === null) + ) { + setSelectedProfile(defaultProfile.id); + } + + if ( + defaultFolder && + defaultFolder.path !== selectedFolder && + (!defaultOverrides || defaultOverrides.folder === null) + ) { + setSelectedFolder(defaultFolder?.path ?? ''); + } + } + }, [serverData]); + + useEffect(() => { + if ( + defaultOverrides && + defaultOverrides.server !== null && + defaultOverrides.server !== undefined + ) { + setSelectedServer(defaultOverrides.server); + } + + if ( + defaultOverrides && + defaultOverrides.profile !== null && + defaultOverrides.profile !== undefined + ) { + setSelectedProfile(defaultOverrides.profile); + } + + if ( + defaultOverrides && + defaultOverrides.folder !== null && + defaultOverrides.folder !== undefined + ) { + setSelectedFolder(defaultOverrides.folder); + } + }, [ + defaultOverrides?.server, + defaultOverrides?.folder, + defaultOverrides?.profile, + ]); + + useEffect(() => { + if (selectedServer !== null) { + onChange({ + folder: selectedFolder !== '' ? selectedFolder : undefined, + profile: selectedProfile !== -1 ? selectedProfile : undefined, + server: selectedServer ?? undefined, + }); + } + }, [selectedFolder, selectedServer, selectedProfile]); + + if (!data && !error) { + return ( +
    + +
    + ); + } + + if (!data || selectedServer === null) { + return null; + } + + return ( + <> +
    + + + + + {intl.formatMessage(messages.advancedoptions)} +
    +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + {isAnime && ( +
    + {intl.formatMessage(messages.animenote)} +
    + )} +
    + + ); +}; + +export default AdvancedRequester; diff --git a/src/components/RequestModal/MovieRequestModal.tsx b/src/components/RequestModal/MovieRequestModal.tsx index 79854c57..950ae50f 100644 --- a/src/components/RequestModal/MovieRequestModal.tsx +++ b/src/components/RequestModal/MovieRequestModal.tsx @@ -13,6 +13,9 @@ import { MediaRequestStatus, } from '../../../server/constants/media'; import DownloadIcon from '../../assets/download.svg'; +import Alert from '../Common/Alert'; +import AdvancedRequester, { RequestOverrides } from './AdvancedRequester'; +import globalMessages from '../../i18n/globalMessages'; const messages = defineMessages({ requestadmin: @@ -33,11 +36,14 @@ const messages = defineMessages({ request4k: 'Request 4K', requestfrom: 'There is currently a pending request from {username}', request4kfrom: 'There is currently a pending 4K request from {username}', + errorediting: 'Something went wrong editing the request.', + requestedited: 'Request edited.', }); interface RequestModalProps extends React.HTMLAttributes { tmdbId: number; is4k?: boolean; + editRequest?: MediaRequest; onCancel?: () => void; onComplete?: (newStatus: MediaStatus) => void; onUpdating?: (isUpdating: boolean) => void; @@ -48,9 +54,14 @@ const MovieRequestModal: React.FC = ({ onComplete, tmdbId, onUpdating, - is4k, + editRequest, + is4k = false, }) => { const [isUpdating, setIsUpdating] = useState(false); + const [ + requestOverrides, + setRequestOverrides, + ] = useState(null); const { addToast } = useToasts(); const { data, error } = useSWR(`/api/v1/movie/${tmdbId}`, { revalidateOnMount: true, @@ -66,10 +77,19 @@ const MovieRequestModal: React.FC = ({ const sendRequest = useCallback(async () => { setIsUpdating(true); + let overrideParams = {}; + if (requestOverrides) { + overrideParams = { + serverId: requestOverrides.server, + profileId: requestOverrides.profile, + rootFolder: requestOverrides.folder, + }; + } const response = await axios.post('/api/v1/request', { mediaId: data?.id, mediaType: 'movie', is4k, + ...overrideParams, }); if (response.data) { @@ -94,7 +114,7 @@ const MovieRequestModal: React.FC = ({ ); setIsUpdating(false); } - }, [data, onComplete, addToast]); + }, [data, onComplete, addToast, requestOverrides]); const activeRequest = data?.mediaInfo?.requests?.find( (request) => request.is4k === !!is4k @@ -125,35 +145,64 @@ const MovieRequestModal: React.FC = ({ } }; + const updateRequest = async () => { + setIsUpdating(true); + + try { + await axios.put(`/api/v1/request/${editRequest?.id}`, { + mediaType: 'movie', + serverId: requestOverrides?.server, + profileId: requestOverrides?.profile, + rootFolder: requestOverrides?.folder, + }); + + addToast({intl.formatMessage(messages.requestedited)}, { + appearance: 'success', + autoDismiss: true, + }); + + if (onComplete) { + onComplete(MediaStatus.PENDING); + } + } catch (e) { + addToast({intl.formatMessage(messages.errorediting)}, { + appearance: 'error', + autoDismiss: true, + }); + } finally { + setIsUpdating(false); + } + }; + const isOwner = activeRequest ? activeRequest.requestedBy.id === user?.id || hasPermission(Permission.MANAGE_REQUESTS) : false; - const text = hasPermission(Permission.MANAGE_REQUESTS) - ? intl.formatMessage(messages.requestadmin) - : undefined; - if (activeRequest?.status === MediaRequestStatus.PENDING) { return ( cancelRequest() : undefined} - okDisabled={isUpdating} title={intl.formatMessage( is4k ? messages.pending4krequest : messages.pendingrequest, { title: data?.title, } )} - okText={ + onOk={() => updateRequest()} + okDisabled={isUpdating} + okText={intl.formatMessage(globalMessages.edit)} + okButtonType="primary" + onSecondary={isOwner ? () => cancelRequest() : undefined} + secondaryDisabled={isUpdating} + secondaryText={ isUpdating ? intl.formatMessage(messages.cancelling) : intl.formatMessage(messages.cancel) } - okButtonType={'danger'} + secondaryButtonType="danger" cancelText={intl.formatMessage(messages.close)} iconSvg={} > @@ -163,6 +212,26 @@ const MovieRequestModal: React.FC = ({ username: activeRequest.requestedBy.username, } )} + {hasPermission(Permission.REQUEST_ADVANCED) && ( +
    + { + setRequestOverrides(overrides); + }} + /> +
    + )}
    ); } @@ -186,7 +255,24 @@ const MovieRequestModal: React.FC = ({ okButtonType={'primary'} iconSvg={} > -

    {text}

    + {(hasPermission(Permission.MANAGE_REQUESTS) || + hasPermission(Permission.AUTO_APPROVE) || + hasPermission(Permission.AUTO_APPROVE_MOVIE)) && ( +

    + + {intl.formatMessage(messages.requestadmin)} + +

    + )} + {hasPermission(Permission.REQUEST_ADVANCED) && ( + { + setRequestOverrides(overrides); + }} + /> + )} ); }; diff --git a/src/components/RequestModal/TvRequestModal.tsx b/src/components/RequestModal/TvRequestModal.tsx index 8df739bb..b157bbff 100644 --- a/src/components/RequestModal/TvRequestModal.tsx +++ b/src/components/RequestModal/TvRequestModal.tsx @@ -6,6 +6,7 @@ import { defineMessages, useIntl } from 'react-intl'; import { MediaRequest } from '../../../server/entity/MediaRequest'; import useSWR from 'swr'; import { useToasts } from 'react-toast-notifications'; +import { ANIME_KEYWORD_ID } from '../../../server/api/themoviedb'; import axios from 'axios'; import { MediaStatus, @@ -15,13 +16,14 @@ import { TvDetails } from '../../../server/models/Tv'; import Badge from '../Common/Badge'; import globalMessages from '../../i18n/globalMessages'; import SeasonRequest from '../../../server/entity/SeasonRequest'; +import Alert from '../Common/Alert'; +import AdvancedRequester, { RequestOverrides } from './AdvancedRequester'; const messages = defineMessages({ requestadmin: 'Your request will be immediately approved.', cancelrequest: 'This will remove your request. Are you sure you want to continue?', requestSuccess: '{title} successfully requested!', - requestCancel: 'Request for {title} cancelled', requesttitle: 'Request {title}', request4ktitle: 'Request {title} in 4K', requesting: 'Requesting...', @@ -34,6 +36,9 @@ const messages = defineMessages({ seasonnumber: 'Season {number}', extras: 'Extras', notrequested: 'Not Requested', + errorediting: 'Something went wrong editing the request.', + requestedited: 'Request edited.', + requestcancelled: 'Request cancelled.', }); interface RequestModalProps extends React.HTMLAttributes { @@ -42,6 +47,7 @@ interface RequestModalProps extends React.HTMLAttributes { onComplete?: (newStatus: MediaStatus) => void; onUpdating?: (isUpdating: boolean) => void; is4k?: boolean; + editRequest?: MediaRequest; } const TvRequestModal: React.FC = ({ @@ -49,14 +55,72 @@ const TvRequestModal: React.FC = ({ onComplete, tmdbId, onUpdating, + editRequest, is4k = false, }) => { const { addToast } = useToasts(); + const editingSeasons: number[] = (editRequest?.seasons ?? []).map( + (season) => season.seasonNumber + ); const { data, error } = useSWR(`/api/v1/tv/${tmdbId}`); - const [selectedSeasons, setSelectedSeasons] = useState([]); + const [ + requestOverrides, + setRequestOverrides, + ] = useState(null); + const [selectedSeasons, setSelectedSeasons] = useState( + editRequest ? editingSeasons : [] + ); const intl = useIntl(); const { hasPermission } = useUser(); + const updateRequest = async () => { + if (!editRequest) { + return; + } + + if (onUpdating) { + onUpdating(true); + } + + try { + if (selectedSeasons.length > 0) { + await axios.put(`/api/v1/request/${editRequest.id}`, { + mediaType: 'tv', + serverId: requestOverrides?.server, + profileId: requestOverrides?.profile, + rootFolder: requestOverrides?.folder, + seasons: selectedSeasons, + }); + } else { + await axios.delete(`/api/v1/request/${editRequest.id}`); + } + + addToast( + + {selectedSeasons.length > 0 + ? intl.formatMessage(messages.requestedited) + : intl.formatMessage(messages.requestcancelled)} + , + { + appearance: 'success', + autoDismiss: true, + } + ); + if (onComplete) { + onComplete(MediaStatus.PENDING); + } + } catch (e) { + addToast({intl.formatMessage(messages.errorediting)}, { + appearance: 'error', + autoDismiss: true, + }); + } finally { + if (onUpdating) { + onUpdating(false); + } + } + }; + const sendRequest = async () => { if (selectedSeasons.length === 0) { return; @@ -64,12 +128,21 @@ const TvRequestModal: React.FC = ({ if (onUpdating) { onUpdating(true); } + let overrideParams = {}; + if (requestOverrides) { + overrideParams = { + serverId: requestOverrides.server, + profileId: requestOverrides.profile, + rootFolder: requestOverrides.folder, + }; + } const response = await axios.post('/api/v1/request', { mediaId: data?.id, tvdbId: data?.externalIds.tvdbId, mediaType: 'tv', is4k, seasons: selectedSeasons, + ...overrideParams, }); if (response.data) { @@ -99,7 +172,9 @@ const TvRequestModal: React.FC = ({ .reduce((requestedSeasons, request) => { return [ ...requestedSeasons, - ...request.seasons.map((sr) => sr.seasonNumber), + ...request.seasons + .filter((season) => !editingSeasons.includes(season.seasonNumber)) + .map((sr) => sr.seasonNumber), ]; }, [] as number[]); @@ -172,10 +247,6 @@ const TvRequestModal: React.FC = ({ ); }; - const text = hasPermission(Permission.MANAGE_REQUESTS) - ? intl.formatMessage(messages.requestadmin) - : undefined; - const getSeasonRequest = ( seasonNumber: number ): SeasonRequest | undefined => { @@ -205,20 +276,24 @@ const TvRequestModal: React.FC = ({ loading={!data && !error} backgroundClickable onCancel={onCancel} - onOk={() => sendRequest()} + onOk={() => (editRequest ? updateRequest() : sendRequest())} title={intl.formatMessage( is4k ? messages.request4ktitle : messages.requesttitle, { title: data?.name } )} okText={ - selectedSeasons.length === 0 + editRequest && selectedSeasons.length === 0 + ? 'Cancel Request' + : selectedSeasons.length === 0 ? intl.formatMessage(messages.selectseason) : intl.formatMessage(messages.requestseasons, { seasonCount: selectedSeasons.length, }) } - okDisabled={selectedSeasons.length === 0} - okButtonType="primary" + okDisabled={editRequest ? false : selectedSeasons.length === 0} + okButtonType={ + editRequest && selectedSeasons.length === 0 ? 'danger' : `primary` + } iconSvg={ = ({ } > + {(hasPermission(Permission.MANAGE_REQUESTS) || + hasPermission(Permission.AUTO_APPROVE) || + hasPermission(Permission.AUTO_APPROVE_MOVIE)) && + !editRequest && ( +

    + + {intl.formatMessage(messages.requestadmin)} + +

    + )}
    -
    +
    @@ -281,7 +366,7 @@ const TvRequestModal: React.FC = ({ - + {data?.seasons .filter((season) => season.seasonNumber !== 0) .map((season) => { @@ -302,7 +387,10 @@ const TvRequestModal: React.FC = ({ tabIndex={0} aria-checked={ !!mediaSeason || - !!seasonRequest || + (!!seasonRequest && + !editingSeasons.includes( + season.seasonNumber + )) || isSelectedSeason(season.seasonNumber) } onClick={() => toggleSeason(season.seasonNumber)} @@ -312,14 +400,21 @@ const TvRequestModal: React.FC = ({ } }} className={`group relative inline-flex items-center justify-center flex-shrink-0 h-5 w-10 cursor-pointer focus:outline-none ${ - mediaSeason || seasonRequest ? 'opacity-50' : '' + mediaSeason || + (!!seasonRequest && + !editingSeasons.includes(season.seasonNumber)) + ? 'opacity-50' + : '' }`} >
    - - - - - - - + + + + + + + - - - - - - - + + + + + + + - - - - - - - + + + + + + + - - - - - + + + + + +

    sct

    💻 🎨 🤔

    Alex Zoitos

    💻

    Brandon Cohen

    💻 📖

    Ahreluth

    🌍

    KovalevArtem

    🌍

    GiyomuWeb

    🌍

    Angry Cuban

    📖

    sct

    💻 🎨 🤔

    Alex Zoitos

    💻

    Brandon Cohen

    💻 📖

    Ahreluth

    🌍

    KovalevArtem

    🌍

    GiyomuWeb

    🌍

    Angry Cuban

    📖

    jvennik

    🌍

    darknessgp

    💻

    salty

    🚇

    Shutruk

    🌍

    Krystian Charubin

    🎨

    Kieron Boswell

    💻

    samwiseg0

    💬 🚇

    jvennik

    🌍

    darknessgp

    💻

    salty

    🚇

    Shutruk

    🌍

    Krystian Charubin

    🎨

    Kieron Boswell

    💻

    samwiseg0

    💬 🚇

    ecelebi29

    💻 📖

    Mārtiņš Možeiko

    💻

    mazzetta86

    🌍

    Paul Hagedorn

    🌍

    Shagon94

    🌍

    sebstrgg

    🌍

    Danshil Mungur

    💻 📖

    ecelebi29

    💻 📖

    Mārtiņš Možeiko

    💻

    mazzetta86

    🌍

    Paul Hagedorn

    🌍

    Shagon94

    🌍

    sebstrgg

    🌍

    Danshil Mungur

    💻 📖

    doob187

    🚇

    johnpyp

    💻

    Jakob Ankarhem

    📖 💻

    Jayesh

    💻

    flying-sausages

    📖

    doob187

    🚇

    johnpyp

    💻

    Jakob Ankarhem

    📖 💻

    Jayesh

    💻

    flying-sausages

    📖

    hirenshah

    📖
    - + From 7fe80cd4ad19e96776cb0b9aa202d9c83b70e3b8 Mon Sep 17 00:00:00 2001 From: sct Date: Mon, 18 Jan 2021 01:00:12 +0000 Subject: [PATCH 26/43] build(deps): batch update dependencies --- package.json | 36 ++--- yarn.lock | 410 +++++++++++++++++++++++++++------------------------ 2 files changed, 237 insertions(+), 209 deletions(-) diff --git a/package.json b/package.json index a8053672..2b50f625 100644 --- a/package.json +++ b/package.json @@ -25,9 +25,9 @@ "bowser": "^2.11.0", "connect-typeorm": "^1.1.4", "cookie-parser": "^1.4.5", - "email-templates": "^8.0.2", + "email-templates": "^8.0.3", "express": "^4.17.1", - "express-openapi-validator": "^4.10.2", + "express-openapi-validator": "^4.10.8", "express-session": "^1.17.1", "formik": "^2.2.6", "gravatar-url": "^3.1.0", @@ -36,26 +36,26 @@ "next": "10.0.3", "node-schedule": "^1.3.2", "nodemailer": "^6.4.17", - "nookies": "^2.5.0", + "nookies": "^2.5.1", "plex-api": "^5.3.1", "pug": "^3.0.0", "react": "17.0.1", "react-ace": "^9.2.1", "react-dom": "17.0.1", "react-intersection-observer": "^8.31.0", - "react-intl": "^5.10.11", + "react-intl": "^5.10.16", "react-markdown": "^5.0.3", "react-spring": "^8.0.27", "react-toast-notifications": "^2.4.0", "react-transition-group": "^4.4.1", - "react-truncate-markup": "^5.0.1", + "react-truncate-markup": "^5.1.0", "react-use-clipboard": "1.0.7", "reflect-metadata": "^0.1.13", "secure-random-password": "^0.2.2", "sqlite3": "^5.0.0", "swagger-ui-express": "^4.1.6", - "swr": "^0.3.11", - "typeorm": "^0.2.29", + "swr": "^0.4.0", + "typeorm": "^0.2.30", "uuid": "^8.3.2", "winston": "^3.3.3", "winston-daily-rotate-file": "^4.5.0", @@ -73,15 +73,15 @@ "@semantic-release/git": "^9.0.0", "@tailwindcss/aspect-ratio": "^0.2.0", "@tailwindcss/forms": "^0.2.1", - "@tailwindcss/typography": "^0.3.1", + "@tailwindcss/typography": "^0.4.0", "@types/bcrypt": "^3.0.0", "@types/body-parser": "^1.19.0", "@types/cookie-parser": "^1.4.2", "@types/email-templates": "^8.0.0", - "@types/express": "^4.17.9", + "@types/express": "^4.17.11", "@types/express-session": "^1.17.0", "@types/lodash": "^4.14.167", - "@types/node": "^14.14.20", + "@types/node": "^14.14.21", "@types/node-schedule": "^1.3.1", "@types/nodemailer": "^6.4.0", "@types/react": "^17.0.0", @@ -94,29 +94,29 @@ "@types/xml2js": "^0.4.7", "@types/yamljs": "^0.2.31", "@types/yup": "^0.29.11", - "@typescript-eslint/eslint-plugin": "^4.12.0", - "@typescript-eslint/parser": "^4.12.0", + "@typescript-eslint/eslint-plugin": "^4.13.0", + "@typescript-eslint/parser": "^4.13.0", "autoprefixer": "^9", "babel-plugin-react-intl": "^8.2.25", "babel-plugin-react-intl-auto": "^3.3.0", - "commitizen": "^4.2.2", + "commitizen": "^4.2.3", "copyfiles": "^2.4.1", "cz-conventional-changelog": "^3.3.0", - "eslint": "^7.17.0", + "eslint": "^7.18.0", "eslint-config-prettier": "^7.1.0", - "eslint-plugin-formatjs": "^2.10.2", + "eslint-plugin-formatjs": "^2.10.3", "eslint-plugin-jsx-a11y": "^6.4.1", "eslint-plugin-prettier": "^3.3.1", "eslint-plugin-react": "^7.22.0", "eslint-plugin-react-hooks": "^4.2.0", "extract-react-intl-messages": "^4.1.1", - "husky": "^4.3.6", + "husky": "^4.3.8", "lint-staged": "^10.5.3", - "nodemon": "^2.0.6", + "nodemon": "^2.0.7", "postcss": "^7", "postcss-preset-env": "^6.7.0", "prettier": "^2.2.1", - "semantic-release": "^17.3.1", + "semantic-release": "^17.3.3", "semantic-release-docker": "^2.2.0", "tailwindcss": "npm:@tailwindcss/postcss7-compat", "ts-node": "^9.1.1", diff --git a/yarn.lock b/yarn.lock index bc9ddc56..1a30c594 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1335,10 +1335,10 @@ resolved "https://registry.yarnpkg.com/@emotion/weak-memoize/-/weak-memoize-0.2.5.tgz#8eed982e2ee6f7f4e44c253e12962980791efd46" integrity sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA== -"@eslint/eslintrc@^0.2.2": - version "0.2.2" - resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-0.2.2.tgz#d01fc791e2fc33e88a29d6f3dc7e93d0cd784b76" - integrity sha512-EfB5OHNYp1F4px/LI/FEnGylop7nOqkQ1LRzCM0KccA2U8tvV8w01KBv37LbO7nW4H+YhKyo2LcJhRwjjV17QQ== +"@eslint/eslintrc@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-0.3.0.tgz#d736d6963d7003b6514e6324bec9c602ac340318" + integrity sha512-1JTKgrOKAHVivSvOYw+sJOunkBjUOvjqWk1DPja7ZFhIS2mX/4EgTT8M7eTK9jrKhL/FvXXEbQwIs3pg1xp3dg== dependencies: ajv "^6.12.4" debug "^4.1.1" @@ -1347,7 +1347,7 @@ ignore "^4.0.6" import-fresh "^3.2.1" js-yaml "^3.13.1" - lodash "^4.17.19" + lodash "^4.17.20" minimatch "^3.0.4" strip-json-comments "^3.1.1" @@ -1358,6 +1358,13 @@ dependencies: tslib "^2.0.1" +"@formatjs/ecma402-abstract@1.5.1": + version "1.5.1" + resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-1.5.1.tgz#629324d2bfdc570ed210fec7700ce20bbd872bed" + integrity sha512-io9XhgIpEbc6jSdn4QVnJeFaUzy6gS5fGiIRCUJ7QKqCNp69JS8EJPW8gCtvwz+JQtx2SJvhaMJbzz3rGkTXBA== + dependencies: + tslib "^2.0.1" + "@formatjs/ecma402-abstract@^1.2.1": version "1.2.4" resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-1.2.4.tgz#0f11e0309bc885d53ddc823e36d04d520fda7674" @@ -1365,28 +1372,28 @@ dependencies: tslib "^2.0.1" -"@formatjs/intl-datetimeformat@3.2.2": - version "3.2.2" - resolved "https://registry.yarnpkg.com/@formatjs/intl-datetimeformat/-/intl-datetimeformat-3.2.2.tgz#c1533cbff225ff835e9d11954fb3c77ccb68045f" - integrity sha512-wIqe4dPzbYB6SrJWNPKBnXkPj1pR68qtGCzHkPo8JGRpWQcVpHh3Cigw1cQs2I9M1F9dOtFHQP6gi+xsb1kAiA== +"@formatjs/intl-datetimeformat@3.2.4": + version "3.2.4" + resolved "https://registry.yarnpkg.com/@formatjs/intl-datetimeformat/-/intl-datetimeformat-3.2.4.tgz#082df22e68b065b9bf297bfa25b6692640af2044" + integrity sha512-gcwO+GitSavAixx7Q6qB8CQY8k4ioVSe2y6VaBiv7fMCCRMHjNzDRXXBe87Nikux4va2V25APPX7bR6+h9g4Zw== dependencies: - "@formatjs/ecma402-abstract" "1.5.0" + "@formatjs/ecma402-abstract" "1.5.1" tslib "^2.0.1" -"@formatjs/intl-displaynames@4.0.3": - version "4.0.3" - resolved "https://registry.yarnpkg.com/@formatjs/intl-displaynames/-/intl-displaynames-4.0.3.tgz#af683f455b4b28b41e45bdeee3121e202cf38a03" - integrity sha512-wNJZzIzlA88oyHZAFdAnLKV/srtIrU//md9SU2Bo0LKXKIukz/g5LmJlyhq6uF1oYoJzVvvTxoVXscBJmKcJmg== +"@formatjs/intl-displaynames@4.0.4": + version "4.0.4" + resolved "https://registry.yarnpkg.com/@formatjs/intl-displaynames/-/intl-displaynames-4.0.4.tgz#a3ef3d243a1cc6df51128ca3faa208969ab5fe57" + integrity sha512-oNeLM0vZDFNZSqrz70XhxbMGtjfQ7T/UUcA9K4DvjWX6vmgCbpw5rdwEddhTotY3EmTyUJueK+14e2gIwfCbBA== dependencies: - "@formatjs/ecma402-abstract" "1.5.0" + "@formatjs/ecma402-abstract" "1.5.1" tslib "^2.0.1" -"@formatjs/intl-listformat@5.0.3": - version "5.0.3" - resolved "https://registry.yarnpkg.com/@formatjs/intl-listformat/-/intl-listformat-5.0.3.tgz#f56a0d28d0a78bff489513eabef980a966a570f9" - integrity sha512-IaW428RI5RpwXr/+doYT1H4ZHL3mYLU3nzR7ZaTkk5dBi1PKKGk1sDwymEzVxyrELT0WHhqhqLKdLjXAqxCz9g== +"@formatjs/intl-listformat@5.0.4": + version "5.0.4" + resolved "https://registry.yarnpkg.com/@formatjs/intl-listformat/-/intl-listformat-5.0.4.tgz#32b43257a4757ceab93d469c94f0fd067b302668" + integrity sha512-0DQ2NF1PmO3+mvZp4V/SPNk7kUaLDcZR3eWbN8cGvSafWOrcv1iEcTXOd8ow8u9OA0gBTWwgPDcQFn7W0mU8kw== dependencies: - "@formatjs/ecma402-abstract" "1.5.0" + "@formatjs/ecma402-abstract" "1.5.1" tslib "^2.0.1" "@formatjs/intl-numberformat@^5.5.2": @@ -1396,27 +1403,27 @@ dependencies: "@formatjs/ecma402-abstract" "^1.2.1" -"@formatjs/intl-relativetimeformat@8.0.2": - version "8.0.2" - resolved "https://registry.yarnpkg.com/@formatjs/intl-relativetimeformat/-/intl-relativetimeformat-8.0.2.tgz#c8b999f66abe029f108a97a2105582f34b6319a8" - integrity sha512-RBO+4zxJuIYtYGfxv1GuZJQDtrA+fZlgcHjSVoo5HwznWdfg5vVTe8qdbSDbdyFobbB2OYqwj4mcRO2M5P1oNw== +"@formatjs/intl-relativetimeformat@8.0.3": + version "8.0.3" + resolved "https://registry.yarnpkg.com/@formatjs/intl-relativetimeformat/-/intl-relativetimeformat-8.0.3.tgz#614c681b64f90d7000f1bfddc86c1a9c3447ecc3" + integrity sha512-OIobPtY5vtwe5IM0B0J3KmewYB/NTcbgiW9yRdWzMA1TeFSd8LfuficICYuzUZt25Kh/eIw4g37ArhS1WH/6Iw== dependencies: - "@formatjs/ecma402-abstract" "1.5.0" + "@formatjs/ecma402-abstract" "1.5.1" tslib "^2.0.1" -"@formatjs/intl@1.4.15": - version "1.4.15" - resolved "https://registry.yarnpkg.com/@formatjs/intl/-/intl-1.4.15.tgz#b21122e7a900957fd25eed24383df4aaefeaa6ee" - integrity sha512-njrwmR/X0XwAp7yHPdAgqGeEeuohyO0JqoE798WxuzjTzLm3khAODJmyH0nZDg4DEJ4R5Uf5lj3whtfwBUVm+g== +"@formatjs/intl@1.6.1": + version "1.6.1" + resolved "https://registry.yarnpkg.com/@formatjs/intl/-/intl-1.6.1.tgz#6f6de1650136feb48c475409e7d77769ae851934" + integrity sha512-911RCkqyZuwbihUT98qBfVNsEmukdf5Y4DIVqAx+BGIyyPNUeS+JXpKx6M8awXx2L9UbeTHKKhjJbNzZlVFO+w== dependencies: - "@formatjs/ecma402-abstract" "1.5.0" - "@formatjs/intl-datetimeformat" "3.2.2" - "@formatjs/intl-displaynames" "4.0.3" - "@formatjs/intl-listformat" "5.0.3" - "@formatjs/intl-relativetimeformat" "8.0.2" + "@formatjs/ecma402-abstract" "1.5.1" + "@formatjs/intl-datetimeformat" "3.2.4" + "@formatjs/intl-displaynames" "4.0.4" + "@formatjs/intl-listformat" "5.0.4" + "@formatjs/intl-relativetimeformat" "8.0.3" fast-memoize "^2.5.2" - intl-messageformat "9.4.2" - intl-messageformat-parser "6.1.2" + intl-messageformat "9.4.3" + intl-messageformat-parser "6.1.3" tslib "^2.0.1" "@formatjs/ts-transformer@2.13.0": @@ -1428,12 +1435,12 @@ tslib "^2.0.1" typescript "^4.0" -"@formatjs/ts-transformer@3.0.0": - version "3.0.0" - resolved "https://registry.yarnpkg.com/@formatjs/ts-transformer/-/ts-transformer-3.0.0.tgz#22f7fa2be9117d1369faf9037aff336071ea6f84" - integrity sha512-Q7fBV8Rpd8WFlb4+y94/OQUG/OonE6bMBKGeS08U7ET8qL4ReI82dhewiUuYisgl+ME32xTf4hcYZAHe0L0GyQ== +"@formatjs/ts-transformer@3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@formatjs/ts-transformer/-/ts-transformer-3.0.1.tgz#4bfaab5f54ab9e014a0dc31e08462622b7ba0c6e" + integrity sha512-8Flmp10252OxlFyLYlATBZwslfWpyR71f+wfBHsxZbwPE+LJI2knogOKSjaw+EN2b6pZ3jIVarekQJDVUkyAOQ== dependencies: - intl-messageformat-parser "6.1.2" + intl-messageformat-parser "6.1.3" tslib "^2.0.1" typescript "^4.0" @@ -1921,10 +1928,15 @@ dependencies: mini-svg-data-uri "^1.2.3" -"@tailwindcss/typography@^0.3.1": - version "0.3.1" - resolved "https://registry.yarnpkg.com/@tailwindcss/typography/-/typography-0.3.1.tgz#253ce580c8e06b6163d9a288edd24f25e1d0dfee" - integrity sha512-HyZ+3Eay8SGaPq7kcFoANZLr4EjeXQ19yjjb9fp6B0PHHpvZoe00jdsnpnooMEbx9J5rQ93nxPUG3MQmXVxGMQ== +"@tailwindcss/typography@^0.4.0": + version "0.4.0" + resolved "https://registry.yarnpkg.com/@tailwindcss/typography/-/typography-0.4.0.tgz#b80974ad6af93df7b06e1981cb4d79698b6ad5c7" + integrity sha512-3BfOYT5MYNEq81Ism3L2qu/HRP2Q5vWqZtZRQqQrthHuaTK9qpuPfbMT5WATjAM5J1OePKBaI5pLoX4S1JGNMQ== + dependencies: + lodash.castarray "^4.4.0" + lodash.isplainobject "^4.0.6" + lodash.merge "^4.6.2" + lodash.uniq "^4.5.0" "@tootallnate/once@1": version "1.1.2" @@ -2037,6 +2049,15 @@ "@types/qs" "*" "@types/range-parser" "*" +"@types/express-serve-static-core@^4.17.18": + version "4.17.18" + resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.18.tgz#8371e260f40e0e1ca0c116a9afcd9426fa094c40" + integrity sha512-m4JTwx5RUBNZvky/JJ8swEJPKFd8si08pPF2PfizYjGZOKr/svUWPcoUmLow6MmPzhasphB7gSTINY67xn3JNA== + dependencies: + "@types/node" "*" + "@types/qs" "*" + "@types/range-parser" "*" + "@types/express-session@^1.15.5", "@types/express-session@^1.17.0": version "1.17.0" resolved "https://registry.yarnpkg.com/@types/express-session/-/express-session-1.17.0.tgz#770daf81368f6278e3e40dd894e1e52abbdca0cd" @@ -2055,13 +2076,13 @@ "@types/qs" "*" "@types/serve-static" "*" -"@types/express@^4.17.9": - version "4.17.9" - resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.9.tgz#f5f2df6add703ff28428add52bdec8a1091b0a78" - integrity sha512-SDzEIZInC4sivGIFY4Sz1GG6J9UObPwCInYJjko2jzOf/Imx/dlpume6Xxwj1ORL82tBbmN4cPDIDkLbWHk9hw== +"@types/express@^4.17.11": + version "4.17.11" + resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.11.tgz#debe3caa6f8e5fcda96b47bd54e2f40c4ee59545" + integrity sha512-no+R6rW60JEc59977wIxreQVsIEOAYwgCqldrA/vkpCnbD7MqTefO97lmoBe4WE0F156bC4uLSP1XHDOySnChg== dependencies: "@types/body-parser" "*" - "@types/express-serve-static-core" "*" + "@types/express-serve-static-core" "^4.17.18" "@types/qs" "*" "@types/serve-static" "*" @@ -2144,10 +2165,10 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.10.tgz#5958a82e41863cfc71f2307b3748e3491ba03785" integrity sha512-J32dgx2hw8vXrSbu4ZlVhn1Nm3GbeCFNw2FWL8S5QKucHGY0cyNwjdQdO+KMBZ4wpmC7KhLCiNsdk1RFRIYUQQ== -"@types/node@^14.14.20": - version "14.14.20" - resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.20.tgz#f7974863edd21d1f8a494a73e8e2b3658615c340" - integrity sha512-Y93R97Ouif9JEOWPIUyU+eyIdyRqQR0I8Ez1dzku4hDx34NWh4HbtIc3WNzwB1Y9ULvNGeu5B8h8bVL5cAk4/A== +"@types/node@^14.14.21": + version "14.14.21" + resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.21.tgz#d934aacc22424fe9622ebf6857370c052eae464e" + integrity sha512-cHYfKsnwllYhjOzuC5q1VpguABBeecUp24yFluHpn/BQaVxB1CuQ1FSRZCzrPxrkIfWISXV2LbeoBthLWg0+0A== "@types/nodemailer@*", "@types/nodemailer@^6.4.0": version "6.4.0" @@ -2283,66 +2304,67 @@ resolved "https://registry.yarnpkg.com/@types/yup/-/yup-0.29.11.tgz#d654a112973f5e004bf8438122bd7e56a8e5cd7e" integrity sha512-9cwk3c87qQKZrT251EDoibiYRILjCmxBvvcb4meofCmx1vdnNcR9gyildy5vOHASpOKMsn42CugxUvcwK5eu1g== -"@typescript-eslint/eslint-plugin@^4.12.0": - version "4.12.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.12.0.tgz#00d1b23b40b58031e6d7c04a5bc6c1a30a2e834a" - integrity sha512-wHKj6q8s70sO5i39H2g1gtpCXCvjVszzj6FFygneNFyIAxRvNSVz9GML7XpqrB9t7hNutXw+MHnLN/Ih6uyB8Q== +"@typescript-eslint/eslint-plugin@^4.13.0": + version "4.13.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.13.0.tgz#5f580ea520fa46442deb82c038460c3dd3524bb6" + integrity sha512-ygqDUm+BUPvrr0jrXqoteMqmIaZ/bixYOc3A4BRwzEPTZPi6E+n44rzNZWaB0YvtukgP+aoj0i/fyx7FkM2p1w== dependencies: - "@typescript-eslint/experimental-utils" "4.12.0" - "@typescript-eslint/scope-manager" "4.12.0" + "@typescript-eslint/experimental-utils" "4.13.0" + "@typescript-eslint/scope-manager" "4.13.0" debug "^4.1.1" functional-red-black-tree "^1.0.1" + lodash "^4.17.15" regexpp "^3.0.0" semver "^7.3.2" tsutils "^3.17.1" -"@typescript-eslint/experimental-utils@4.12.0": - version "4.12.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-4.12.0.tgz#372838e76db76c9a56959217b768a19f7129546b" - integrity sha512-MpXZXUAvHt99c9ScXijx7i061o5HEjXltO+sbYfZAAHxv3XankQkPaNi5myy0Yh0Tyea3Hdq1pi7Vsh0GJb0fA== +"@typescript-eslint/experimental-utils@4.13.0": + version "4.13.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-4.13.0.tgz#9dc9ab375d65603b43d938a0786190a0c72be44e" + integrity sha512-/ZsuWmqagOzNkx30VWYV3MNB/Re/CGv/7EzlqZo5RegBN8tMuPaBgNK6vPBCQA8tcYrbsrTdbx3ixMRRKEEGVw== dependencies: "@types/json-schema" "^7.0.3" - "@typescript-eslint/scope-manager" "4.12.0" - "@typescript-eslint/types" "4.12.0" - "@typescript-eslint/typescript-estree" "4.12.0" + "@typescript-eslint/scope-manager" "4.13.0" + "@typescript-eslint/types" "4.13.0" + "@typescript-eslint/typescript-estree" "4.13.0" eslint-scope "^5.0.0" eslint-utils "^2.0.0" -"@typescript-eslint/parser@^4.12.0": - version "4.12.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-4.12.0.tgz#e1cf30436e4f916c31fcc962158917bd9e9d460a" - integrity sha512-9XxVADAo9vlfjfoxnjboBTxYOiNY93/QuvcPgsiKvHxW6tOZx1W4TvkIQ2jB3k5M0pbFP5FlXihLK49TjZXhuQ== +"@typescript-eslint/parser@^4.13.0": + version "4.13.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-4.13.0.tgz#c413d640ea66120cfcc37f891e8cb3fd1c9d247d" + integrity sha512-KO0J5SRF08pMXzq9+abyHnaGQgUJZ3Z3ax+pmqz9vl81JxmTTOUfQmq7/4awVfq09b6C4owNlOgOwp61pYRBSg== dependencies: - "@typescript-eslint/scope-manager" "4.12.0" - "@typescript-eslint/types" "4.12.0" - "@typescript-eslint/typescript-estree" "4.12.0" + "@typescript-eslint/scope-manager" "4.13.0" + "@typescript-eslint/types" "4.13.0" + "@typescript-eslint/typescript-estree" "4.13.0" debug "^4.1.1" -"@typescript-eslint/scope-manager@4.12.0": - version "4.12.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-4.12.0.tgz#beeb8beca895a07b10c593185a5612f1085ef279" - integrity sha512-QVf9oCSVLte/8jvOsxmgBdOaoe2J0wtEmBr13Yz0rkBNkl5D8bfnf6G4Vhox9qqMIoG7QQoVwd2eG9DM/ge4Qg== +"@typescript-eslint/scope-manager@4.13.0": + version "4.13.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-4.13.0.tgz#5b45912a9aa26b29603d8fa28f5e09088b947141" + integrity sha512-UpK7YLG2JlTp/9G4CHe7GxOwd93RBf3aHO5L+pfjIrhtBvZjHKbMhBXTIQNkbz7HZ9XOe++yKrXutYm5KmjWgQ== dependencies: - "@typescript-eslint/types" "4.12.0" - "@typescript-eslint/visitor-keys" "4.12.0" + "@typescript-eslint/types" "4.13.0" + "@typescript-eslint/visitor-keys" "4.13.0" "@typescript-eslint/types@3.10.1": version "3.10.1" resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-3.10.1.tgz#1d7463fa7c32d8a23ab508a803ca2fe26e758727" integrity sha512-+3+FCUJIahE9q0lDi1WleYzjCwJs5hIsbugIgnbB+dSCYUxl8L6PwmsyOPFZde2hc1DlTo/xnkOgiTLSyAbHiQ== -"@typescript-eslint/types@4.12.0": - version "4.12.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-4.12.0.tgz#fb891fe7ccc9ea8b2bbd2780e36da45d0dc055e5" - integrity sha512-N2RhGeheVLGtyy+CxRmxdsniB7sMSCfsnbh8K/+RUIXYYq3Ub5+sukRCjVE80QerrUBvuEvs4fDhz5AW/pcL6g== +"@typescript-eslint/types@4.13.0": + version "4.13.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-4.13.0.tgz#6a7c6015a59a08fbd70daa8c83dfff86250502f8" + integrity sha512-/+aPaq163oX+ObOG00M0t9tKkOgdv9lq0IQv/y4SqGkAXmhFmCfgsELV7kOCTb2vVU5VOmVwXBXJTDr353C1rQ== -"@typescript-eslint/typescript-estree@4.12.0": - version "4.12.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-4.12.0.tgz#3963418c850f564bdab3882ae23795d115d6d32e" - integrity sha512-gZkFcmmp/CnzqD2RKMich2/FjBTsYopjiwJCroxqHZIY11IIoN0l5lKqcgoAPKHt33H2mAkSfvzj8i44Jm7F4w== +"@typescript-eslint/typescript-estree@4.13.0": + version "4.13.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-4.13.0.tgz#cf6e2207c7d760f5dfd8d18051428fadfc37b45e" + integrity sha512-9A0/DFZZLlGXn5XA349dWQFwPZxcyYyCFX5X88nWs2uachRDwGeyPz46oTsm9ZJE66EALvEns1lvBwa4d9QxMg== dependencies: - "@typescript-eslint/types" "4.12.0" - "@typescript-eslint/visitor-keys" "4.12.0" + "@typescript-eslint/types" "4.13.0" + "@typescript-eslint/visitor-keys" "4.13.0" debug "^4.1.1" globby "^11.0.1" is-glob "^4.0.1" @@ -2371,12 +2393,12 @@ dependencies: eslint-visitor-keys "^1.1.0" -"@typescript-eslint/visitor-keys@4.12.0": - version "4.12.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-4.12.0.tgz#a470a79be6958075fa91c725371a83baf428a67a" - integrity sha512-hVpsLARbDh4B9TKYz5cLbcdMIOAoBYgFPCSP9FFS/liSF+b33gVNq8JHY3QGhHNVz85hObvL7BEYLlgx553WCw== +"@typescript-eslint/visitor-keys@4.13.0": + version "4.13.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-4.13.0.tgz#9acb1772d3b3183182b6540d3734143dce9476fe" + integrity sha512-6RoxWK05PAibukE7jElqAtNMq+RWZyqJ6Q/GdIxaiUj2Ept8jh8+FUVlbq9WxMYxkmEOPvCE5cRSyupMpwW31g== dependencies: - "@typescript-eslint/types" "4.12.0" + "@typescript-eslint/types" "4.13.0" eslint-visitor-keys "^2.0.0" "@webassemblyjs/ast@1.9.0": @@ -3896,17 +3918,17 @@ cli-cursor@^3.1.0: dependencies: restore-cursor "^3.1.0" -cli-highlight@^2.1.4: - version "2.1.9" - resolved "https://registry.yarnpkg.com/cli-highlight/-/cli-highlight-2.1.9.tgz#4f4ecb05326d70d56d4b4249fabf9a70fb002497" - integrity sha512-t8RNIZgiI24i/mslZ8XT8o660RUj5ZbUJpEZrZa/BNekTzdC2LfMRAnt0Y7sgzNM4FGW5tmWg/YnbTH8o1eIOQ== +cli-highlight@^2.1.10: + version "2.1.10" + resolved "https://registry.yarnpkg.com/cli-highlight/-/cli-highlight-2.1.10.tgz#26a087da9209dce4fcb8cf5427dc97cd96ac173a" + integrity sha512-CcPFD3JwdQ2oSzy+AMG6j3LRTkNjM82kzcSKzoVw6cLanDCJNlsLjeqVTOTfOfucnWv5F0rmBemVf1m9JiIasw== dependencies: chalk "^4.0.0" highlight.js "^10.0.0" mz "^2.4.0" parse5 "^5.1.1" parse5-htmlparser2-tree-adapter "^6.0.0" - yargs "^15.0.0" + yargs "^16.0.0" cli-table3@^0.5.0, cli-table3@^0.5.1: version "0.5.1" @@ -4155,13 +4177,13 @@ commitizen@^4.0.3: strip-bom "4.0.0" strip-json-comments "3.0.1" -commitizen@^4.2.2: - version "4.2.2" - resolved "https://registry.yarnpkg.com/commitizen/-/commitizen-4.2.2.tgz#1a93dd07208521ea1ebbf832593542dac714cc79" - integrity sha512-uz+E6lGsDBDI2mYA4QfOxFeqdWUYwR1ky11YmLgg2BnEEP3YbeejpT4lxzGjkYqumnXr062qTOGavR9NtX/iwQ== +commitizen@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/commitizen/-/commitizen-4.2.3.tgz#088d0ef72500240d331b11e02e288223667c1475" + integrity sha512-pYlYEng7XMV2TW4xtjDKBGqeJ0Teq2zyRSx2S3Ml1XAplHSlJZK8vm1KdGclpMEZuGafbS5TeHXIVnHk8RWIzQ== dependencies: cachedir "2.2.0" - cz-conventional-changelog "3.3.0" + cz-conventional-changelog "3.2.0" dedent "0.7.0" detect-indent "6.0.0" find-node-modules "2.0.0" @@ -4821,7 +4843,7 @@ cz-conventional-changelog@3.2.0: optionalDependencies: "@commitlint/load" ">6.1.1" -cz-conventional-changelog@3.3.0, cz-conventional-changelog@^3.3.0: +cz-conventional-changelog@^3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/cz-conventional-changelog/-/cz-conventional-changelog-3.3.0.tgz#9246947c90404149b3fe2cf7ee91acad3b7d22d2" integrity sha512-U466fIzU5U22eES5lTNiNbZ+d8dfcHcssH4o7QsdWaCcRs/feIPCxKYSWkYBNs5mny7MvEfwpTLWjvbm94hecw== @@ -5407,10 +5429,10 @@ elliptic@^6.5.3: minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.0" -email-templates@^8.0.2: - version "8.0.2" - resolved "https://registry.yarnpkg.com/email-templates/-/email-templates-8.0.2.tgz#a77a4434f22836824e1bed710b8efd33723da9b2" - integrity sha512-HUTLDidFMs1c1QWGQuc0fxuhmwlbDsm/mUX4b5gAH72afC0159GExZK0UZkRaB4XIuPbfRVwZ9xWt0VGaHrMmw== +email-templates@^8.0.3: + version "8.0.3" + resolved "https://registry.yarnpkg.com/email-templates/-/email-templates-8.0.3.tgz#d713770b37c34b7c5ca1f955a8e48bcefba68103" + integrity sha512-k4siLBwwd7B69pT8obgs94ZMkn5UzNRomHgMd2ibmCgwDxmGXEvf63oZzMSXcdTnmMX9wH7VJubtxDcEtC4yrQ== dependencies: "@ladjs/i18n" "^7.0.1" consolidate "^0.16.0" @@ -5419,7 +5441,7 @@ email-templates@^8.0.2: html-to-text "^6.0.0" juice "^7.0.0" lodash "^4.17.20" - nodemailer "^6.4.16" + nodemailer "^6.4.17" preview-email "^3.0.3" emoji-regex@^7.0.1: @@ -5651,18 +5673,18 @@ eslint-config-prettier@^7.1.0: resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-7.1.0.tgz#5402eb559aa94b894effd6bddfa0b1ca051c858f" integrity sha512-9sm5/PxaFG7qNJvJzTROMM1Bk1ozXVTKI0buKOyb0Bsr1hrwi0H/TzxF/COtf1uxikIK8SwhX7K6zg78jAzbeA== -eslint-plugin-formatjs@^2.10.2: - version "2.10.2" - resolved "https://registry.yarnpkg.com/eslint-plugin-formatjs/-/eslint-plugin-formatjs-2.10.2.tgz#4bc2f5f57669ea761e66c56fbbd7099d644c3d6e" - integrity sha512-xi9EvtFalho/ZIny9QyoC84J+YHTh/k+ZRU5m8g7ClyRTyjPU1JTBA4IuukW5/rRb92Ah6f1vpQXwIajzgzGvA== +eslint-plugin-formatjs@^2.10.3: + version "2.10.3" + resolved "https://registry.yarnpkg.com/eslint-plugin-formatjs/-/eslint-plugin-formatjs-2.10.3.tgz#ebc0fdbfda1a5568ed27fef564ff60df3faae418" + integrity sha512-mxbX7JKIUnKCn5+7O0GdGFrYNGj+gHbr8upJUumaAWCpCqPW54Zngg7X5qNtIPNvLZ68MAWNnJ5iEb5P3s9+RA== dependencies: - "@formatjs/ts-transformer" "3.0.0" + "@formatjs/ts-transformer" "3.0.1" "@types/emoji-regex" "^8.0.0" "@types/eslint" "^7.2.0" "@types/estree" "^0.0.45" "@typescript-eslint/typescript-estree" "^3.6.0" emoji-regex "^9.0.0" - intl-messageformat-parser "6.1.2" + intl-messageformat-parser "6.1.3" tslib "^2.0.1" eslint-plugin-jsx-a11y@^6.4.1: @@ -5744,13 +5766,13 @@ eslint-visitor-keys@^2.0.0: resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-2.0.0.tgz#21fdc8fbcd9c795cc0321f0563702095751511a8" integrity sha512-QudtT6av5WXels9WjIM7qz1XD1cWGvX4gGXvp/zBn9nXG02D0utdU3Em2m/QjTnrsk6bBjmCygl3rmj118msQQ== -eslint@^7.17.0: - version "7.17.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-7.17.0.tgz#4ccda5bf12572ad3bf760e6f195886f50569adb0" - integrity sha512-zJk08MiBgwuGoxes5sSQhOtibZ75pz0J35XTRlZOk9xMffhpA9BTbQZxoXZzOl5zMbleShbGwtw+1kGferfFwQ== +eslint@^7.18.0: + version "7.18.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-7.18.0.tgz#7fdcd2f3715a41fe6295a16234bd69aed2c75e67" + integrity sha512-fbgTiE8BfUJZuBeq2Yi7J3RB3WGUQ9PNuNbmgi6jt9Iv8qrkxfy19Ds3OpL1Pm7zg3BtTVhvcUZbIRQ0wmSjAQ== dependencies: "@babel/code-frame" "^7.0.0" - "@eslint/eslintrc" "^0.2.2" + "@eslint/eslintrc" "^0.3.0" ajv "^6.10.0" chalk "^4.0.0" cross-spawn "^7.0.2" @@ -5774,7 +5796,7 @@ eslint@^7.17.0: js-yaml "^3.13.1" json-stable-stringify-without-jsonify "^1.0.1" levn "^0.4.1" - lodash "^4.17.19" + lodash "^4.17.20" minimatch "^3.0.4" natural-compare "^1.4.0" optionator "^0.9.1" @@ -5935,18 +5957,16 @@ expand-tilde@^2.0.0, expand-tilde@^2.0.2: dependencies: homedir-polyfill "^1.0.1" -express-openapi-validator@^4.10.2: - version "4.10.2" - resolved "https://registry.yarnpkg.com/express-openapi-validator/-/express-openapi-validator-4.10.2.tgz#55ba35b96522b5ef67f8a8d6b8b4a1cf0be96e91" - integrity sha512-dNfTt5US6ZEtycoTBS82uspMYwyQJsb2+S1RVvPg4OqCcqs44XUfn7R1pPDYQM3kWuDPxbE2Dzc8KbKxR+VKeA== +express-openapi-validator@^4.10.8: + version "4.10.8" + resolved "https://registry.yarnpkg.com/express-openapi-validator/-/express-openapi-validator-4.10.8.tgz#80f2e4c39d3c8e9203b2a7c79801bd573c9e2e9d" + integrity sha512-PF40cfxOlPWgMBTquLP8v3JCP8HF6QMLl5i4LbTqncowZRF1gcQVShxSu7hRRSS2chkOQaLSYWYxQVt8ZTeBlA== dependencies: ajv "^6.12.6" content-type "^1.0.4" - js-yaml "^3.14.0" json-schema-ref-parser "^9.0.6" lodash.clonedeep "^4.5.0" lodash.get "^4.4.2" - lodash.merge "^4.6.2" lodash.uniq "^4.5.0" lodash.zipobject "^4.1.3" media-typer "^1.1.0" @@ -6278,13 +6298,6 @@ find-up@^5.0.0: locate-path "^6.0.0" path-exists "^4.0.0" -find-versions@^3.2.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/find-versions/-/find-versions-3.2.0.tgz#10297f98030a786829681690545ef659ed1d254e" - integrity sha512-P8WRou2S+oe222TOCHitLy8zj+SIsVJh52VP4lvXkaFVnOFFdoWv1H1Jjvel1aI6NCFOAaeAVm8qrI0odiLcww== - dependencies: - semver-regex "^2.0.0" - find-versions@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/find-versions/-/find-versions-4.0.0.tgz#3c57e573bf97769b8cb8df16934b627915da4965" @@ -7102,18 +7115,18 @@ humanize-ms@^1.2.1: dependencies: ms "^2.0.0" -husky@^4.3.6: - version "4.3.6" - resolved "https://registry.yarnpkg.com/husky/-/husky-4.3.6.tgz#ebd9dd8b9324aa851f1587318db4cccb7665a13c" - integrity sha512-o6UjVI8xtlWRL5395iWq9LKDyp/9TE7XMOTvIpEVzW638UcGxTmV5cfel6fsk/jbZSTlvfGVJf2svFtybcIZag== +husky@^4.3.8: + version "4.3.8" + resolved "https://registry.yarnpkg.com/husky/-/husky-4.3.8.tgz#31144060be963fd6850e5cc8f019a1dfe194296d" + integrity sha512-LCqqsB0PzJQ/AlCgfrfzRe3e3+NvmefAdKQhRYpxS4u6clblBoDdzzvHi8fmxKRzvMxPY/1WZWzomPZww0Anow== dependencies: chalk "^4.0.0" ci-info "^2.0.0" compare-versions "^3.6.0" cosmiconfig "^7.0.0" - find-versions "^3.2.0" + find-versions "^4.0.0" opencollective-postinstall "^2.0.2" - pkg-dir "^4.2.0" + pkg-dir "^5.0.0" please-upgrade-node "^3.2.0" slash "^3.0.0" which-pm-runs "^1.0.0" @@ -7333,6 +7346,14 @@ intl-messageformat-parser@6.1.2: "@formatjs/ecma402-abstract" "1.5.0" tslib "^2.0.1" +intl-messageformat-parser@6.1.3: + version "6.1.3" + resolved "https://registry.yarnpkg.com/intl-messageformat-parser/-/intl-messageformat-parser-6.1.3.tgz#c333850f66d686eca5c9d87eff1ad46f8721b64d" + integrity sha512-rQTtrVTFy/Z6Lg0ieHkkhdFfi/47BKv1P9+wMWlKWaAxpdDP0FIsp2LRyLPpIVKTwUfL3xf26QT25d69cSkZgQ== + dependencies: + "@formatjs/ecma402-abstract" "1.5.1" + tslib "^2.0.1" + intl-messageformat-parser@^5.3.7: version "5.5.1" resolved "https://registry.yarnpkg.com/intl-messageformat-parser/-/intl-messageformat-parser-5.5.1.tgz#f09a692755813e6220081e3374df3fb1698bd0c6" @@ -7340,13 +7361,13 @@ intl-messageformat-parser@^5.3.7: dependencies: "@formatjs/intl-numberformat" "^5.5.2" -intl-messageformat@9.4.2: - version "9.4.2" - resolved "https://registry.yarnpkg.com/intl-messageformat/-/intl-messageformat-9.4.2.tgz#4ac2290d9d2dd6d76e60a2b355eeb8d8bc310c9c" - integrity sha512-34XevT4ADM/DCwtHIgXcL+LNtAnqPM6LSYUx7itlqm+9b2zMv8hUPlj4Ct94cl6qWymWAq5UiHbj85XAdwvq1A== +intl-messageformat@9.4.3: + version "9.4.3" + resolved "https://registry.yarnpkg.com/intl-messageformat/-/intl-messageformat-9.4.3.tgz#c769923deced44b4c13ad35f84333a20c4f2bf38" + integrity sha512-vTn8gCY5EvHYCYha22QcX4zMSYxkIT8r+3vXeUtvb2udicb7Z+0Ev9p/8hHzcvyrMJk0HPADnkNLVsx8EUfRkg== dependencies: fast-memoize "^2.5.2" - intl-messageformat-parser "6.1.2" + intl-messageformat-parser "6.1.3" tslib "^2.0.1" intl@^1.2.5: @@ -8438,6 +8459,11 @@ lodash.capitalize@^4.2.1: resolved "https://registry.yarnpkg.com/lodash.capitalize/-/lodash.capitalize-4.2.1.tgz#f826c9b4e2a8511d84e3aca29db05e1a4f3b72a9" integrity sha1-+CbJtOKoUR2E46yinbBeGk87cqk= +lodash.castarray@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/lodash.castarray/-/lodash.castarray-4.4.0.tgz#c02513515e309daddd4c24c60cfddcf5976d9115" + integrity sha1-wCUTUV4wna3dTCTGDP3c9ZdtkRU= + lodash.clonedeep@^4.5.0, lodash.clonedeep@~4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef" @@ -9555,10 +9581,10 @@ nodemailer@^6.4.17: resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.4.17.tgz#8de98618028953b80680775770f937243a7d7877" integrity sha512-89ps+SBGpo0D4Bi5ZrxcrCiRFaMmkCt+gItMXQGzEtZVR3uAD3QAQIDoxTWnx3ky0Dwwy/dhFrQ+6NNGXpw/qQ== -nodemon@^2.0.6: - version "2.0.6" - resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-2.0.6.tgz#1abe1937b463aaf62f0d52e2b7eaadf28cc2240d" - integrity sha512-4I3YDSKXg6ltYpcnZeHompqac4E6JeAMpGm8tJnB9Y3T0ehasLa4139dJOcCrB93HHrUMsCrKtoAlXTqT5n4AQ== +nodemon@^2.0.7: + version "2.0.7" + resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-2.0.7.tgz#6f030a0a0ebe3ea1ba2a38f71bf9bab4841ced32" + integrity sha512-XHzK69Awgnec9UzHr1kc8EomQh4sjTQ8oRf8TsGrSmHDx9/UmiGG9E/mM3BuTfNeFwdNBvrqQq/RHL0xIeyFOA== dependencies: chokidar "^3.2.2" debug "^3.2.6" @@ -9579,10 +9605,10 @@ noms@0.0.0: inherits "^2.0.1" readable-stream "~1.0.31" -nookies@^2.5.0: - version "2.5.0" - resolved "https://registry.yarnpkg.com/nookies/-/nookies-2.5.0.tgz#00c78ac9a91c3af82ab4bc8b193ad7bd41ee7aa5" - integrity sha512-yFIKz80h6SRqO1a6fcgO7D2qfnXAjXmGftPw5KrMWH+zGm1xHvyHyZ93z1yul9snwG/9LLvSY8pyZ3G5s6/UJQ== +nookies@^2.5.1: + version "2.5.1" + resolved "https://registry.yarnpkg.com/nookies/-/nookies-2.5.1.tgz#f6988a3b0abb4378b710a75860fe247e873bc666" + integrity sha512-5zKrA+KpwlzxohICzdzrll3+Er0yBZvRgkIwXDUYE7U7G2KSw3Npir+oo+z5ahwMRyCnpv8G/YLYh+oarllSrQ== dependencies: cookie "^0.4.1" set-cookie-parser "^2.4.6" @@ -10570,13 +10596,20 @@ pkg-dir@^3.0.0: dependencies: find-up "^3.0.0" -pkg-dir@^4.1.0, pkg-dir@^4.2.0: +pkg-dir@^4.1.0: version "4.2.0" resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3" integrity sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ== dependencies: find-up "^4.0.0" +pkg-dir@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-5.0.0.tgz#a02d6aebe6ba133a928f74aec20bafdfe6b8e760" + integrity sha512-NPE8TDbzl/3YQYY7CSS228s3g2ollTFnc+Qi3tqmqJp9Vg2ovUpixcJEo2HJScN2Ez+kEaal6y70c0ehqJBJeA== + dependencies: + find-up "^5.0.0" + platform@1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/platform/-/platform-1.3.3.tgz#646c77011899870b6a0903e75e997e8e51da7461" @@ -11531,21 +11564,21 @@ react-intersection-observer@^8.31.0: resolved "https://registry.yarnpkg.com/react-intersection-observer/-/react-intersection-observer-8.31.0.tgz#0ed21aaf93c4c0475b22b0ccaba6169076d01605" integrity sha512-XraIC/tkrD9JtrmVA7ypEN1QIpKc52mXBH1u/bz/aicRLo8QQEJQAMUTb8mz4B6dqpPwyzgjrr7Ljv/2ACDtqw== -react-intl@^5.10.11: - version "5.10.11" - resolved "https://registry.yarnpkg.com/react-intl/-/react-intl-5.10.11.tgz#7a2815ddfc889dc6053c04c650f58673d41784cc" - integrity sha512-nF25oSFSeMLzqH/RncdgdS6e/lDQIpkQo14zQVwSOD5zu1wDEX6Wbn//+FyqkC+tdqTbpCAKWNg5dSD5Fjli5Q== +react-intl@^5.10.16: + version "5.10.16" + resolved "https://registry.yarnpkg.com/react-intl/-/react-intl-5.10.16.tgz#2ccde74acd26cbe4f9e6e42765048a7f2f40e645" + integrity sha512-Cp0p9MGGWYNsl3hamJcrRqKid2HZun4MsdIlHV9fdmYVoLOII1+YynH6UBBwLuDlrLi8JrSniH1G1pqUlwQZMw== dependencies: - "@formatjs/ecma402-abstract" "1.5.0" - "@formatjs/intl" "1.4.15" - "@formatjs/intl-displaynames" "4.0.3" - "@formatjs/intl-listformat" "5.0.3" - "@formatjs/intl-relativetimeformat" "8.0.2" + "@formatjs/ecma402-abstract" "1.5.1" + "@formatjs/intl" "1.6.1" + "@formatjs/intl-displaynames" "4.0.4" + "@formatjs/intl-listformat" "5.0.4" + "@formatjs/intl-relativetimeformat" "8.0.3" "@types/hoist-non-react-statics" "^3.3.1" fast-memoize "^2.5.2" hoist-non-react-statics "^3.3.2" - intl-messageformat "9.4.2" - intl-messageformat-parser "6.1.2" + intl-messageformat "9.4.3" + intl-messageformat-parser "6.1.3" shallow-equal "^1.2.1" tslib "^2.0.1" @@ -11601,10 +11634,10 @@ react-transition-group@^4.3.0, react-transition-group@^4.4.1: loose-envify "^1.4.0" prop-types "^15.6.2" -react-truncate-markup@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/react-truncate-markup/-/react-truncate-markup-5.0.1.tgz#5c52bda712f7e185e84969b2b79440ec8b422441" - integrity sha512-WG1E9FLyTrq5ERaDbIq0DjWVs3JrAdr93fasdQqbVlEifBUp27kGM7ws4xCBIh2keDjumTPjw3iiHNNmD+YtcQ== +react-truncate-markup@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/react-truncate-markup/-/react-truncate-markup-5.1.0.tgz#632f263a7d70925ab6368d5620ef32f066c2857a" + integrity sha512-TTp8gj63w/o5jQC7HnLMEeAHp9jjSxFlA5lzklvPzcSubcurkEI4g0YjW67YG1yNAJHqQwCadj4mTUvz58S1wQ== dependencies: line-height "0.3.1" memoize-one "^5.1.1" @@ -12288,10 +12321,10 @@ semantic-release-docker@^2.2.0: "@semantic-release/error" "^2.1.0" execa "^0.10.0" -semantic-release@^17.3.1: - version "17.3.1" - resolved "https://registry.yarnpkg.com/semantic-release/-/semantic-release-17.3.1.tgz#8904ef1ca8e704394de0e204b284f6c252284da4" - integrity sha512-NSdxvnBTklrRBYRexVUx44Hri9sTu9b8x+1HfWDGIWemDTFQfWOTbT1N3oy5l8WcZHodhRvtyI7gm50SfAa3Fg== +semantic-release@^17.3.3: + version "17.3.3" + resolved "https://registry.yarnpkg.com/semantic-release/-/semantic-release-17.3.3.tgz#8f3d2909e9570d9053ef195c138d8d15d7fe5815" + integrity sha512-StUBghTuh98O0XpDWj5aRLmZwY9zV6gGgbXnw5faon3Br1fRk8r2VSaYozfQTx8o5C8Mtem+a0KWEiTTd4Iylw== dependencies: "@semantic-release/commit-analyzer" "^8.0.0" "@semantic-release/error" "^2.2.0" @@ -12341,11 +12374,6 @@ semver-diff@^3.1.1: dependencies: semver "^6.3.0" -semver-regex@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/semver-regex/-/semver-regex-2.0.0.tgz#a93c2c5844539a770233379107b38c7b4ac9d338" - integrity sha512-mUdIBBvdn0PLOeP3TEkMH7HHeUP3GjsXCwKarjv/kGmUFOYg1VqEemKhoQpWMu6X2I8kHeuVdGibLGkVK+/5Qw== - semver-regex@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/semver-regex/-/semver-regex-3.1.2.tgz#34b4c0d361eef262e07199dbef316d0f2ab11807" @@ -13222,10 +13250,10 @@ swagger-ui-express@^4.1.6: dependencies: swagger-ui-dist "^3.18.1" -swr@^0.3.11: - version "0.3.11" - resolved "https://registry.yarnpkg.com/swr/-/swr-0.3.11.tgz#f7f50ed26c06afea4249482cec504768a2272664" - integrity sha512-ya30LuRGK2R7eDlttnb7tU5EmJYJ+N6ytIOM2j0Hqs0qauJcDjVLDOGy7KmFeH5ivOwLHalFaIyYl2K+SGa7HQ== +swr@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/swr/-/swr-0.4.0.tgz#e76da9f981fe6dee0e133289e9b582fc80d9c41d" + integrity sha512-70qd1FHYHwIdYXW0jTpm5ktitzvPBCtyKz8ZzynWlY/rMqe4drYPgcl/H9Ipuh+Xv6ZW5viNx13ro8EKIWZcoQ== dependencies: dequal "2.0.2" @@ -13714,16 +13742,16 @@ typedarray@^0.0.6: resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= -typeorm@^0.2.29: - version "0.2.29" - resolved "https://registry.yarnpkg.com/typeorm/-/typeorm-0.2.29.tgz#401289dc91900d72eccb26e31cdb7f0591a2272e" - integrity sha512-ih1vrTe3gEAGKRcWlcsTRxTL7gNjacQE498wVGuJ3ZRujtMqPZlbAWuC7xDzWCRjQnkZYNwZQeG9UgKfxSHB5g== +typeorm@^0.2.30: + version "0.2.30" + resolved "https://registry.yarnpkg.com/typeorm/-/typeorm-0.2.30.tgz#a0df2256402cbcdde8049a244437560495ce9b38" + integrity sha512-qpr8AO3Phi6ZF7qMHOrRdNisVt8jE1KfmW0ooLFcXscA87aJ12aBPyB9cJfxGNjNwd7B3WIK9ZlBveWiqd74QA== dependencies: "@sqltools/formatter" "1.2.2" app-root-path "^3.0.0" buffer "^5.5.0" chalk "^4.1.0" - cli-highlight "^2.1.4" + cli-highlight "^2.1.10" debug "^4.1.1" dotenv "^8.2.0" glob "^7.1.6" @@ -14594,7 +14622,7 @@ yargs@^14.2.3: y18n "^4.0.0" yargs-parser "^15.0.1" -yargs@^15.0.0, yargs@^15.0.1, yargs@^15.1.0: +yargs@^15.0.1, yargs@^15.1.0: version "15.4.1" resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.4.1.tgz#0d87a16de01aee9d8bec2bfbf74f67851730f4f8" integrity sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A== @@ -14611,7 +14639,7 @@ yargs@^15.0.0, yargs@^15.0.1, yargs@^15.1.0: y18n "^4.0.0" yargs-parser "^18.1.2" -yargs@^16.0.3, yargs@^16.1.0: +yargs@^16.0.0, yargs@^16.0.3, yargs@^16.1.0: version "16.2.0" resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66" integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw== From b0ce0406bbec5a332023ff3c3737fc73e94c6f80 Mon Sep 17 00:00:00 2001 From: sct Date: Mon, 18 Jan 2021 01:05:50 +0000 Subject: [PATCH 27/43] chore: remove old unused logo.svg [skip ci] --- src/assets/logo.svg | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 src/assets/logo.svg diff --git a/src/assets/logo.svg b/src/assets/logo.svg deleted file mode 100644 index ce93f0ca..00000000 --- a/src/assets/logo.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - From fa8f112c31ccb5ee6244f776bc97e76d81958539 Mon Sep 17 00:00:00 2001 From: sct Date: Mon, 18 Jan 2021 01:15:08 +0000 Subject: [PATCH 28/43] feat(server): add CONFIG_DIRECTORY env var to control config directory location --- ormconfig.js | 4 ++-- server/lib/settings.ts | 6 +++++- server/logger.ts | 6 +++++- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/ormconfig.js b/ormconfig.js index 2cc4533b..5ff4d06b 100644 --- a/ormconfig.js +++ b/ormconfig.js @@ -1,6 +1,6 @@ const devConfig = { type: 'sqlite', - database: 'config/db/db.sqlite3', + database: `${process.env.CONFIG_DIRECTORY || 'config'}/db/db.sqlite3`, synchronize: true, migrationsRun: false, logging: false, @@ -15,7 +15,7 @@ const devConfig = { const prodConfig = { type: 'sqlite', - database: 'config/db/db.sqlite3', + database: `${process.env.CONFIG_DIRECTORY || 'config'}/db/db.sqlite3`, synchronize: false, logging: false, entities: ['dist/entity/**/*.js'], diff --git a/server/lib/settings.ts b/server/lib/settings.ts index 8d1db87b..01e73b89 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -137,7 +137,11 @@ interface AllSettings { notifications: NotificationSettings; } -const SETTINGS_PATH = path.join(__dirname, '../../config/settings.json'); +const SETTINGS_PATH = path.join( + __dirname, + '../../', + `${process.env.CONFIG_DIRECTORY || '/config'}/settings.json` +); class Settings { private data: AllSettings; diff --git a/server/logger.ts b/server/logger.ts index 75e80151..014bd336 100644 --- a/server/logger.ts +++ b/server/logger.ts @@ -42,7 +42,11 @@ const logger = winston.createLogger({ ), }), new winston.transports.DailyRotateFile({ - filename: path.join(__dirname, '../config/logs/overseerr-%DATE%.log'), + filename: path.join( + __dirname, + '../', + `${process.env.CONFIG_DIRECTORY || '/config'}/logs/overseerr-%DATE%.log` + ), datePattern: 'YYYY-MM-DD', zippedArchive: true, maxSize: '20m', From 51d8fba9162b9e148a35ced69e7e035438c8b0f1 Mon Sep 17 00:00:00 2001 From: sct Date: Mon, 18 Jan 2021 01:25:44 +0000 Subject: [PATCH 29/43] fix(server): support absolute paths for CONFIG_DIRECTORY --- ormconfig.js | 8 ++++++-- server/lib/settings.ts | 8 +++----- server/logger.ts | 8 +++----- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/ormconfig.js b/ormconfig.js index 5ff4d06b..070e0598 100644 --- a/ormconfig.js +++ b/ormconfig.js @@ -1,6 +1,8 @@ const devConfig = { type: 'sqlite', - database: `${process.env.CONFIG_DIRECTORY || 'config'}/db/db.sqlite3`, + database: process.env.CONFIG_DIRECTORY + ? `${process.env.CONFIG_DIRECTORY}/db/db.sqlite3` + : 'config/db/db.sqlite3', synchronize: true, migrationsRun: false, logging: false, @@ -15,7 +17,9 @@ const devConfig = { const prodConfig = { type: 'sqlite', - database: `${process.env.CONFIG_DIRECTORY || 'config'}/db/db.sqlite3`, + database: process.env.CONFIG_DIRECTORY + ? `${process.env.CONFIG_DIRECTORY}/db/db.sqlite3` + : 'config/db/db.sqlite3', synchronize: false, logging: false, entities: ['dist/entity/**/*.js'], diff --git a/server/lib/settings.ts b/server/lib/settings.ts index 01e73b89..b9ad92a9 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -137,11 +137,9 @@ interface AllSettings { notifications: NotificationSettings; } -const SETTINGS_PATH = path.join( - __dirname, - '../../', - `${process.env.CONFIG_DIRECTORY || '/config'}/settings.json` -); +const SETTINGS_PATH = process.env.CONFIG_DIRECTORY + ? `${process.env.CONFIG_DIRECTORY}/settings.json` + : path.join(__dirname, '../../config/settings.json'); class Settings { private data: AllSettings; diff --git a/server/logger.ts b/server/logger.ts index 014bd336..824de630 100644 --- a/server/logger.ts +++ b/server/logger.ts @@ -42,11 +42,9 @@ const logger = winston.createLogger({ ), }), new winston.transports.DailyRotateFile({ - filename: path.join( - __dirname, - '../', - `${process.env.CONFIG_DIRECTORY || '/config'}/logs/overseerr-%DATE%.log` - ), + filename: process.env.CONFIG_DIRECTORY + ? `${process.env.CONFIG_DIRECTORY}/logs/overseerr-%DATE%.log` + : path.join(__dirname, '../config/logs/overseerr-%DATE%.log'), datePattern: 'YYYY-MM-DD', zippedArchive: true, maxSize: '20m', From 860d71ed69a69a1a3f74b79290ef471e04f57a6b Mon Sep 17 00:00:00 2001 From: sct Date: Mon, 18 Jan 2021 02:40:03 +0000 Subject: [PATCH 30/43] fix(frontend): clarify which fields are required in radarr/sonarr modals closes #575 --- src/components/Settings/RadarrModal/index.tsx | 61 +++++++++-------- src/components/Settings/SonarrModal/index.tsx | 68 ++++++++++--------- 2 files changed, 71 insertions(+), 58 deletions(-) diff --git a/src/components/Settings/RadarrModal/index.tsx b/src/components/Settings/RadarrModal/index.tsx index fde94a28..db8f2807 100644 --- a/src/components/Settings/RadarrModal/index.tsx +++ b/src/components/Settings/RadarrModal/index.tsx @@ -286,7 +286,7 @@ const RadarrModal: React.FC = ({ type="checkbox" id="isDefault" name="isDefault" - className="form-checkbox h-6 w-6 text-indigo-600 transition duration-150 ease-in-out rounded-md" + className="w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox" />
    @@ -296,9 +296,10 @@ const RadarrModal: React.FC = ({ className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px" > {intl.formatMessage(messages.servername)} + *
    -
    +
    = ({ setIsValidated(false); setFieldValue('name', e.target.value); }} - className="flex-1 form-input block w-full min-w-0 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500" + className="flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5" />
    {errors.name && touched.name && ( -
    {errors.name}
    +
    {errors.name}
    )}
    @@ -324,10 +325,11 @@ const RadarrModal: React.FC = ({ className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px" > {intl.formatMessage(messages.hostname)} + *
    -
    - +
    + {values.ssl ? 'https://' : 'http://'} = ({ setIsValidated(false); setFieldValue('hostname', e.target.value); }} - className="flex-1 form-input block w-full min-w-0 rounded-r-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500" + className="flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 form-input rounded-r-md sm:text-sm sm:leading-5" />
    {errors.hostname && touched.hostname && ( -
    {errors.hostname}
    +
    {errors.hostname}
    )}
    @@ -353,6 +355,7 @@ const RadarrModal: React.FC = ({ className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px" > {intl.formatMessage(messages.port)} + *
    = ({ setIsValidated(false); setFieldValue('port', e.target.value); }} - className="rounded-md shadow-sm form-input block w-24 transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500" + className="block w-24 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md shadow-sm form-input sm:text-sm sm:leading-5" /> {errors.port && touched.port && ( -
    {errors.port}
    +
    {errors.port}
    )}
    @@ -387,7 +390,7 @@ const RadarrModal: React.FC = ({ setIsValidated(false); setFieldValue('ssl', !values.ssl); }} - className="form-checkbox h-6 w-6 rounded-md text-indigo-600 transition duration-150 ease-in-out" + className="w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox" /> @@ -397,9 +400,10 @@ const RadarrModal: React.FC = ({ className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px" > {intl.formatMessage(messages.apiKey)} + *
    -
    +
    = ({ setIsValidated(false); setFieldValue('apiKey', e.target.value); }} - className="flex-1 form-input block w-full min-w-0 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500" + className="flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5" />
    {errors.apiKey && touched.apiKey && ( -
    {errors.apiKey}
    +
    {errors.apiKey}
    )}
    @@ -427,7 +431,7 @@ const RadarrModal: React.FC = ({ {intl.formatMessage(messages.baseUrl)}
    -
    +
    = ({ setIsValidated(false); setFieldValue('baseUrl', e.target.value); }} - className="flex-1 form-input block w-full min-w-0 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500" + className="flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5" />
    {errors.baseUrl && touched.baseUrl && ( -
    {errors.baseUrl}
    +
    {errors.baseUrl}
    )}
    @@ -453,15 +457,16 @@ const RadarrModal: React.FC = ({ className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px" > {intl.formatMessage(messages.qualityprofile)} + *
    -
    +
    {errors.activeProfileId && touched.activeProfileId && ( -
    +
    {errors.activeProfileId}
    )} @@ -496,15 +501,16 @@ const RadarrModal: React.FC = ({ className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px" > {intl.formatMessage(messages.rootfolder)} + *
    -
    +
    {errors.rootFolder && touched.rootFolder && ( -
    +
    {errors.rootFolder}
    )} @@ -537,14 +543,15 @@ const RadarrModal: React.FC = ({ className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px" > {intl.formatMessage(messages.minimumAvailability)} + *
    -
    +
    @@ -554,7 +561,7 @@ const RadarrModal: React.FC = ({
    {errors.minimumAvailability && touched.minimumAvailability && ( -
    +
    {errors.minimumAvailability}
    )} @@ -572,7 +579,7 @@ const RadarrModal: React.FC = ({ type="checkbox" id="is4k" name="is4k" - className="form-checkbox h-6 w-6 rounded-md text-indigo-600 transition duration-150 ease-in-out" + className="w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox" />
    diff --git a/src/components/Settings/SonarrModal/index.tsx b/src/components/Settings/SonarrModal/index.tsx index 0fbedc64..3181eb33 100644 --- a/src/components/Settings/SonarrModal/index.tsx +++ b/src/components/Settings/SonarrModal/index.tsx @@ -295,7 +295,7 @@ const SonarrModal: React.FC = ({ type="checkbox" id="isDefault" name="isDefault" - className="form-checkbox rounded-md h-6 w-6 text-indigo-600 transition duration-150 ease-in-out" + className="w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox" />
    @@ -305,9 +305,10 @@ const SonarrModal: React.FC = ({ className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px" > {intl.formatMessage(messages.servername)} + *
    -
    +
    = ({ setIsValidated(false); setFieldValue('name', e.target.value); }} - className="flex-1 form-input block w-full min-w-0 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500" + className="flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5" />
    {errors.name && touched.name && ( -
    {errors.name}
    +
    {errors.name}
    )}
    @@ -333,10 +334,11 @@ const SonarrModal: React.FC = ({ className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px" > {intl.formatMessage(messages.hostname)} + *
    -
    - +
    + {values.ssl ? 'https://' : 'http://'} = ({ setIsValidated(false); setFieldValue('hostname', e.target.value); }} - className="flex-1 form-input block w-full min-w-0 rounded-r-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500" + className="flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 form-input rounded-r-md sm:text-sm sm:leading-5" />
    {errors.hostname && touched.hostname && ( -
    {errors.hostname}
    +
    {errors.hostname}
    )}
    @@ -362,6 +364,7 @@ const SonarrModal: React.FC = ({ className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px" > {intl.formatMessage(messages.port)} + *
    = ({ setIsValidated(false); setFieldValue('port', e.target.value); }} - className="rounded-md shadow-sm form-input block w-24 transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500" + className="block w-24 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md shadow-sm form-input sm:text-sm sm:leading-5" /> {errors.port && touched.port && ( -
    {errors.port}
    +
    {errors.port}
    )}
    @@ -396,7 +399,7 @@ const SonarrModal: React.FC = ({ setIsValidated(false); setFieldValue('ssl', !values.ssl); }} - className="form-checkbox rounded-md h-6 w-6 text-indigo-600 transition duration-150 ease-in-out" + className="w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox" />
    @@ -406,9 +409,10 @@ const SonarrModal: React.FC = ({ className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px" > {intl.formatMessage(messages.apiKey)} + *
    -
    +
    = ({ setIsValidated(false); setFieldValue('apiKey', e.target.value); }} - className="flex-1 form-input block w-full min-w-0 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500" + className="flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5" />
    {errors.apiKey && touched.apiKey && ( -
    {errors.apiKey}
    +
    {errors.apiKey}
    )}
    @@ -436,7 +440,7 @@ const SonarrModal: React.FC = ({ {intl.formatMessage(messages.baseUrl)}
    -
    +
    = ({ setIsValidated(false); setFieldValue('baseUrl', e.target.value); }} - className="flex-1 form-input block w-full min-w-0 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500" + className="flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5" />
    {errors.baseUrl && touched.baseUrl && ( -
    {errors.baseUrl}
    +
    {errors.baseUrl}
    )}
    @@ -462,15 +466,16 @@ const SonarrModal: React.FC = ({ className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px" > {intl.formatMessage(messages.qualityprofile)} + *
    -
    +
    {errors.activeProfileId && touched.activeProfileId && ( -
    +
    {errors.activeProfileId}
    )} @@ -505,15 +510,16 @@ const SonarrModal: React.FC = ({ className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px" > {intl.formatMessage(messages.rootfolder)} + *
    -
    +
    {errors.rootFolder && touched.rootFolder && ( -
    +
    {errors.rootFolder}
    )} @@ -548,13 +554,13 @@ const SonarrModal: React.FC = ({ {intl.formatMessage(messages.animequalityprofile)}
    -
    +
    {errors.activeAnimeProfileId && touched.activeAnimeProfileId && ( -
    +
    {errors.activeAnimeProfileId}
    )} @@ -592,13 +598,13 @@ const SonarrModal: React.FC = ({ {intl.formatMessage(messages.animerootfolder)}
    -
    +
    {errors.activeAnimeRootFolder && touched.activeAnimeRootFolder && ( -
    +
    {errors.rootFolder}
    )} @@ -638,7 +644,7 @@ const SonarrModal: React.FC = ({ type="checkbox" id="is4k" name="is4k" - className="form-checkbox h-6 w-6 rounded-md text-indigo-600 transition duration-150 ease-in-out" + className="w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox" />
    @@ -654,7 +660,7 @@ const SonarrModal: React.FC = ({ type="checkbox" id="enableSeasonFolders" name="enableSeasonFolders" - className="form-checkbox h-6 w-6 rounded-md text-indigo-600 transition duration-150 ease-in-out" + className="w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox" />
    From 7db62ab824eefc42e6db16e42d52f4266b136f82 Mon Sep 17 00:00:00 2001 From: sct Date: Mon, 18 Jan 2021 02:47:12 +0000 Subject: [PATCH 31/43] fix(api): improve rottentomatoes rating matching for movies fixes #494 --- server/api/rottentomatoes.ts | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/server/api/rottentomatoes.ts b/server/api/rottentomatoes.ts index 7c1c5985..cc3a562a 100644 --- a/server/api/rottentomatoes.ts +++ b/server/api/rottentomatoes.ts @@ -92,7 +92,27 @@ class RottenTomatoes { } ); - const movie = response.data.movies.find((movie) => movie.year === year); + // First, attempt to match exact name and year + let movie = response.data.movies.find( + (movie) => movie.year === year && movie.title === name + ); + + // If we don't find a movie, try to match partial name and year + if (!movie) { + movie = response.data.movies.find( + (movie) => movie.year === year && movie.title.includes(name) + ); + } + + // If we still dont find a movie, try to match just on year + if (!movie) { + movie = response.data.movies.find((movie) => movie.year === year); + } + + // One last try, try exact name match only + if (!movie) { + movie = response.data.movies.find((movie) => movie.title === name); + } if (!movie) { return null; From 00944b1ec2db8ddc5742448f6448f7364c473a98 Mon Sep 17 00:00:00 2001 From: sct Date: Mon, 18 Jan 2021 02:55:09 +0000 Subject: [PATCH 32/43] fix(frontend): do not show failed media status on request list for declined requests fixes #664 --- src/components/RequestList/RequestItem/index.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/RequestList/RequestItem/index.tsx b/src/components/RequestList/RequestItem/index.tsx index 5abf1836..e4b98a0f 100644 --- a/src/components/RequestList/RequestItem/index.tsx +++ b/src/components/RequestList/RequestItem/index.tsx @@ -180,9 +180,12 @@ const RequestItem: React.FC = ({
    - {requestData.media.status === MediaStatus.UNKNOWN ? ( + {requestData.media.status === MediaStatus.UNKNOWN || + requestData.status === MediaRequestStatus.DECLINED ? ( - {intl.formatMessage(globalMessages.failed)} + {requestData.status === MediaRequestStatus.DECLINED + ? intl.formatMessage(globalMessages.declined) + : intl.formatMessage(globalMessages.failed)} ) : ( @@ -216,6 +219,7 @@ const RequestItem: React.FC = ({ {requestData.media.status === MediaStatus.UNKNOWN && + requestData.status !== MediaRequestStatus.DECLINED && hasPermission(Permission.MANAGE_REQUESTS) && (