From 5bb2d75fe1582a30c8f183afd8096f58421bfb83 Mon Sep 17 00:00:00 2001 From: sct Date: Sat, 30 Jan 2021 06:09:13 +0000 Subject: [PATCH 01/66] build(deps): bump dependencies --- package.json | 18 ++-- yarn.lock | 283 ++++++++++++++++++++++++++------------------------- 2 files changed, 154 insertions(+), 147 deletions(-) diff --git a/package.json b/package.json index b696336c..1f9acfe9 100644 --- a/package.json +++ b/package.json @@ -29,14 +29,14 @@ "csurf": "^1.11.0", "email-templates": "^8.0.3", "express": "^4.17.1", - "express-openapi-validator": "^4.10.8", + "express-openapi-validator": "^4.10.9", "express-session": "^1.17.1", "formik": "^2.2.6", "gravatar-url": "^3.1.0", "intl": "^1.2.5", "lodash": "^4.17.20", "next": "10.0.3", - "node-schedule": "^1.3.2", + "node-schedule": "^1.3.3", "nodemailer": "^6.4.17", "nookies": "^2.5.2", "plex-api": "^5.3.1", @@ -46,7 +46,7 @@ "react-animate-height": "^2.0.23", "react-dom": "17.0.1", "react-intersection-observer": "^8.31.0", - "react-intl": "^5.10.16", + "react-intl": "^5.11.0", "react-markdown": "^5.0.3", "react-spring": "^8.0.27", "react-toast-notifications": "^2.4.0", @@ -57,7 +57,7 @@ "secure-random-password": "^0.2.2", "sqlite3": "^5.0.0", "swagger-ui-express": "^4.1.6", - "swr": "^0.4.0", + "swr": "^0.4.1", "typeorm": "^0.2.30", "uuid": "^8.3.2", "winston": "^3.3.3", @@ -81,7 +81,7 @@ "@types/body-parser": "^1.19.0", "@types/cookie-parser": "^1.4.2", "@types/csurf": "^1.11.0", - "@types/email-templates": "^8.0.0", + "@types/email-templates": "^8.0.1", "@types/express": "^4.17.11", "@types/express-session": "^1.17.0", "@types/lodash": "^4.14.168", @@ -98,8 +98,8 @@ "@types/xml2js": "^0.4.8", "@types/yamljs": "^0.2.31", "@types/yup": "^0.29.11", - "@typescript-eslint/eslint-plugin": "^4.14.0", - "@typescript-eslint/parser": "^4.14.0", + "@typescript-eslint/eslint-plugin": "^4.14.1", + "@typescript-eslint/parser": "^4.14.1", "autoprefixer": "^9", "babel-plugin-react-intl": "^8.2.25", "babel-plugin-react-intl-auto": "^3.3.0", @@ -108,7 +108,7 @@ "cz-conventional-changelog": "^3.3.0", "eslint": "^7.18.0", "eslint-config-prettier": "^7.2.0", - "eslint-plugin-formatjs": "^2.10.3", + "eslint-plugin-formatjs": "^2.11.0", "eslint-plugin-jsx-a11y": "^6.4.1", "eslint-plugin-prettier": "^3.3.1", "eslint-plugin-react": "^7.22.0", @@ -120,7 +120,7 @@ "postcss": "^7", "postcss-preset-env": "^6.7.0", "prettier": "^2.2.1", - "semantic-release": "^17.3.6", + "semantic-release": "^17.3.7", "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 3254f9e5..d269381c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1358,10 +1358,10 @@ 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== +"@formatjs/ecma402-abstract@1.5.2": + version "1.5.2" + resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-1.5.2.tgz#6c20c24f814ebf8e9dd46e34310a67895853a931" + integrity sha512-rscxoLyIwH2x+l15Z4eD580ioO3CkFVoWDLgDtgiOnWzDzpL5EigDRg9V4mINb8W6bQRT1xnCxiRwvw3bgvqrA== dependencies: tslib "^2.0.1" @@ -1372,28 +1372,28 @@ dependencies: tslib "^2.0.1" -"@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== +"@formatjs/intl-datetimeformat@3.2.6": + version "3.2.6" + resolved "https://registry.yarnpkg.com/@formatjs/intl-datetimeformat/-/intl-datetimeformat-3.2.6.tgz#58c0902b576337cc1407e4f84ab37f3f6c073fa5" + integrity sha512-6tO95ajF+GOGQ4q6sBEayiytAkFcgXu2eWGvo8i24zmx51cbvVTrgc/+S6dccdsbYtvarD2LjLcY3I0RxZ09iw== dependencies: - "@formatjs/ecma402-abstract" "1.5.1" + "@formatjs/ecma402-abstract" "1.5.2" tslib "^2.0.1" -"@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== +"@formatjs/intl-displaynames@4.0.5": + version "4.0.5" + resolved "https://registry.yarnpkg.com/@formatjs/intl-displaynames/-/intl-displaynames-4.0.5.tgz#2102a2bc984ce8391bf1216f6cde8d68d9f741ae" + integrity sha512-+01kv4Lqao245SrNW1uhYjzYlUgg8xLjxqSlbWrkAplwGBG9JNnmOze1Jmv3ls+cUe9KA0oifL6Yn28u7Gm78g== dependencies: - "@formatjs/ecma402-abstract" "1.5.1" + "@formatjs/ecma402-abstract" "1.5.2" tslib "^2.0.1" -"@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== +"@formatjs/intl-listformat@5.0.5": + version "5.0.5" + resolved "https://registry.yarnpkg.com/@formatjs/intl-listformat/-/intl-listformat-5.0.5.tgz#a00b5b9c5dc140fa81cba8dea7356fe40e91fb01" + integrity sha512-YHmAlmUkrnoJvZtUnEuxC0N8dWPABRNDdSJksjKC1xjdpZrzHplsiY4ugw7eO56lLjsBQ1DPOAfH10iZDtmzxA== dependencies: - "@formatjs/ecma402-abstract" "1.5.1" + "@formatjs/ecma402-abstract" "1.5.2" tslib "^2.0.1" "@formatjs/intl-numberformat@^5.5.2": @@ -1403,27 +1403,27 @@ dependencies: "@formatjs/ecma402-abstract" "^1.2.1" -"@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== +"@formatjs/intl-relativetimeformat@8.0.4": + version "8.0.4" + resolved "https://registry.yarnpkg.com/@formatjs/intl-relativetimeformat/-/intl-relativetimeformat-8.0.4.tgz#eca86236320802c7c7d0f7500573ebab679d607d" + integrity sha512-MUxsXa/cukBa4+8waUS7rQcVz0CKt7UufU9nXcUGh1FR85urqh42dz+3bM2klg51P5Mhove2mecQGeFwOhFYBQ== dependencies: - "@formatjs/ecma402-abstract" "1.5.1" + "@formatjs/ecma402-abstract" "1.5.2" tslib "^2.0.1" -"@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== +"@formatjs/intl@1.6.5": + version "1.6.5" + resolved "https://registry.yarnpkg.com/@formatjs/intl/-/intl-1.6.5.tgz#a67c94113fcd58160bcf04f9aa9aefad7c95204b" + integrity sha512-uJ7ukBulcaN3nT5x5d7CkvzdtVYQNoEyp1Ob8N6zkibA08DMdGy1pqq5n3y3Z2GBemMUJ4qFgGSVww4Z5SXf2A== dependencies: - "@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" + "@formatjs/ecma402-abstract" "1.5.2" + "@formatjs/intl-datetimeformat" "3.2.6" + "@formatjs/intl-displaynames" "4.0.5" + "@formatjs/intl-listformat" "5.0.5" + "@formatjs/intl-relativetimeformat" "8.0.4" fast-memoize "^2.5.2" - intl-messageformat "9.4.3" - intl-messageformat-parser "6.1.3" + intl-messageformat "9.4.6" + intl-messageformat-parser "6.3.1" tslib "^2.0.1" "@formatjs/ts-transformer@2.13.0": @@ -1435,12 +1435,12 @@ tslib "^2.0.1" typescript "^4.0" -"@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== +"@formatjs/ts-transformer@3.0.4": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@formatjs/ts-transformer/-/ts-transformer-3.0.4.tgz#82979813749ddfdeb87fdc73c25ff18bb53b9d2e" + integrity sha512-x+0KRnA4ptfkM2W60tT5UaVOL39YEhVtvZc/FvysYDm6J76d/ucRd2qNJq6Iz+L0tygmCXURPttRQ1t3r5o79g== dependencies: - intl-messageformat-parser "6.1.3" + intl-messageformat-parser "6.3.1" tslib "^2.0.1" typescript "^4.0" @@ -2025,10 +2025,10 @@ resolved "https://registry.yarnpkg.com/@types/debug/-/debug-0.0.31.tgz#bac8d8aab6a823e91deb7f79083b2a35fa638f33" integrity sha512-LS1MCPaQKqspg7FvexuhmDbWUhE2yIJ+4AgVIyObfc06/UKZ8REgxGNjZc82wPLWmbeOm7S+gSsLgo75TanG4A== -"@types/email-templates@^8.0.0": - version "8.0.0" - resolved "https://registry.yarnpkg.com/@types/email-templates/-/email-templates-8.0.0.tgz#9bd4e328be02253d65609ff078181e98e3c2818a" - integrity sha512-oPZQ9qkidtJCTWH3XKwfcqKdAuBrFZ6L+yVr7FQONivDm0g88MoKR0cCn13Hrt79R4r+FFAinELC22+6eXFa0Q== +"@types/email-templates@^8.0.1": + version "8.0.1" + resolved "https://registry.yarnpkg.com/@types/email-templates/-/email-templates-8.0.1.tgz#ed7f47b8aad12b17401d56e973589ab5049c536f" + integrity sha512-hc3nEX8VgeA6etrlK6Grx8wZ1QAvICfCYwTNLNRHUG1A/pT7wWivVNeKdOhmpIvkOY8qNc7LxqPRK3O9W+dDJg== dependencies: "@types/html-to-text" "*" "@types/nodemailer" "*" @@ -2047,7 +2047,7 @@ "@types/estree" "*" "@types/json-schema" "*" -"@types/estree@*", "@types/estree@^0.0.45": +"@types/estree@*": version "0.0.45" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.45.tgz#e9387572998e5ecdac221950dab3e8c3b16af884" integrity sha512-jnqIUKDUqJbDIUxm0Uj7bnlMnRm1T/eZ9N+AVMqhPgzrba2GhGG5o/jCTwmdPK709nEZsGoMzXEDUjcXHa3W0g== @@ -2160,6 +2160,13 @@ resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.0.tgz#69a23a3ad29caf0097f06eda59b361ee2f0639f6" integrity sha1-aaI6OtKcrwCX8G7aWbNh7i8GOfY= +"@types/multer@^1.4.2": + version "1.4.5" + resolved "https://registry.yarnpkg.com/@types/multer/-/multer-1.4.5.tgz#db0557562307e9adb6661a9500c334cd7ddd0cd9" + integrity sha512-9b/0a8JyrR0r2nQhL73JR86obWL7cogfX12augvlrvcpciCo/hkvEsgu80Z4S2g2DHGVXHr8pUIi1VhqFJ8Ufw== + dependencies: + "@types/express" "*" + "@types/node-schedule@^1.3.1": version "1.3.1" resolved "https://registry.yarnpkg.com/@types/node-schedule/-/node-schedule-1.3.1.tgz#6785ea71b12b0b8899c3fce0650b2ef5a7ea9d1e" @@ -2316,13 +2323,13 @@ resolved "https://registry.yarnpkg.com/@types/yup/-/yup-0.29.11.tgz#d654a112973f5e004bf8438122bd7e56a8e5cd7e" integrity sha512-9cwk3c87qQKZrT251EDoibiYRILjCmxBvvcb4meofCmx1vdnNcR9gyildy5vOHASpOKMsn42CugxUvcwK5eu1g== -"@typescript-eslint/eslint-plugin@^4.14.0": - version "4.14.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.14.0.tgz#92db8e7c357ed7d69632d6843ca70b71be3a721d" - integrity sha512-IJ5e2W7uFNfg4qh9eHkHRUCbgZ8VKtGwD07kannJvM5t/GU8P8+24NX8gi3Hf5jST5oWPY8kyV1s/WtfiZ4+Ww== +"@typescript-eslint/eslint-plugin@^4.14.1": + version "4.14.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.14.1.tgz#22dd301ce228aaab3416b14ead10b1db3e7d3180" + integrity sha512-5JriGbYhtqMS1kRcZTQxndz1lKMwwEXKbwZbkUZNnp6MJX0+OVXnG0kOlBZP4LUAxEyzu3cs+EXd/97MJXsGfw== dependencies: - "@typescript-eslint/experimental-utils" "4.14.0" - "@typescript-eslint/scope-manager" "4.14.0" + "@typescript-eslint/experimental-utils" "4.14.1" + "@typescript-eslint/scope-manager" "4.14.1" debug "^4.1.1" functional-red-black-tree "^1.0.1" lodash "^4.17.15" @@ -2330,53 +2337,53 @@ semver "^7.3.2" tsutils "^3.17.1" -"@typescript-eslint/experimental-utils@4.14.0": - version "4.14.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-4.14.0.tgz#5aa7b006736634f588a69ee343ca959cd09988df" - integrity sha512-6i6eAoiPlXMKRbXzvoQD5Yn9L7k9ezzGRvzC/x1V3650rUk3c3AOjQyGYyF9BDxQQDK2ElmKOZRD0CbtdkMzQQ== +"@typescript-eslint/experimental-utils@4.14.1": + version "4.14.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-4.14.1.tgz#a5c945cb24dabb96747180e1cfc8487f8066f471" + integrity sha512-2CuHWOJwvpw0LofbyG5gvYjEyoJeSvVH2PnfUQSn0KQr4v8Dql2pr43ohmx4fdPQ/eVoTSFjTi/bsGEXl/zUUQ== dependencies: "@types/json-schema" "^7.0.3" - "@typescript-eslint/scope-manager" "4.14.0" - "@typescript-eslint/types" "4.14.0" - "@typescript-eslint/typescript-estree" "4.14.0" + "@typescript-eslint/scope-manager" "4.14.1" + "@typescript-eslint/types" "4.14.1" + "@typescript-eslint/typescript-estree" "4.14.1" eslint-scope "^5.0.0" eslint-utils "^2.0.0" -"@typescript-eslint/parser@^4.14.0": - version "4.14.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-4.14.0.tgz#62d4cd2079d5c06683e9bfb200c758f292c4dee7" - integrity sha512-sUDeuCjBU+ZF3Lzw0hphTyScmDDJ5QVkyE21pRoBo8iDl7WBtVFS+WDN3blY1CH3SBt7EmYCw6wfmJjF0l/uYg== +"@typescript-eslint/parser@^4.14.1": + version "4.14.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-4.14.1.tgz#3bd6c24710cd557d8446625284bcc9c6d52817c6" + integrity sha512-mL3+gU18g9JPsHZuKMZ8Z0Ss9YP1S5xYZ7n68Z98GnPq02pYNQuRXL85b9GYhl6jpdvUc45Km7hAl71vybjUmw== dependencies: - "@typescript-eslint/scope-manager" "4.14.0" - "@typescript-eslint/types" "4.14.0" - "@typescript-eslint/typescript-estree" "4.14.0" + "@typescript-eslint/scope-manager" "4.14.1" + "@typescript-eslint/types" "4.14.1" + "@typescript-eslint/typescript-estree" "4.14.1" debug "^4.1.1" -"@typescript-eslint/scope-manager@4.14.0": - version "4.14.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-4.14.0.tgz#55a4743095d684e1f7b7180c4bac2a0a3727f517" - integrity sha512-/J+LlRMdbPh4RdL4hfP1eCwHN5bAhFAGOTsvE6SxsrM/47XQiPSgF5MDgLyp/i9kbZV9Lx80DW0OpPkzL+uf8Q== +"@typescript-eslint/scope-manager@4.14.1": + version "4.14.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-4.14.1.tgz#8444534254c6f370e9aa974f035ced7fe713ce02" + integrity sha512-F4bjJcSqXqHnC9JGUlnqSa3fC2YH5zTtmACS1Hk+WX/nFB0guuynVK5ev35D4XZbdKjulXBAQMyRr216kmxghw== dependencies: - "@typescript-eslint/types" "4.14.0" - "@typescript-eslint/visitor-keys" "4.14.0" + "@typescript-eslint/types" "4.14.1" + "@typescript-eslint/visitor-keys" "4.14.1" "@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.14.0": - version "4.14.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-4.14.0.tgz#d8a8202d9b58831d6fd9cee2ba12f8a5a5dd44b6" - integrity sha512-VsQE4VvpldHrTFuVPY1ZnHn/Txw6cZGjL48e+iBxTi2ksa9DmebKjAeFmTVAYoSkTk7gjA7UqJ7pIsyifTsI4A== +"@typescript-eslint/types@4.14.1": + version "4.14.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-4.14.1.tgz#b3d2eb91dafd0fd8b3fce7c61512ac66bd0364aa" + integrity sha512-SkhzHdI/AllAgQSxXM89XwS1Tkic7csPdndUuTKabEwRcEfR8uQ/iPA3Dgio1rqsV3jtqZhY0QQni8rLswJM2w== -"@typescript-eslint/typescript-estree@4.14.0": - version "4.14.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-4.14.0.tgz#4bcd67486e9acafc3d0c982b23a9ab8ac8911ed7" - integrity sha512-wRjZ5qLao+bvS2F7pX4qi2oLcOONIB+ru8RGBieDptq/SudYwshveORwCVU4/yMAd4GK7Fsf8Uq1tjV838erag== +"@typescript-eslint/typescript-estree@4.14.1": + version "4.14.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-4.14.1.tgz#20d3b8c8e3cdc8f764bdd5e5b0606dd83da6075b" + integrity sha512-M8+7MbzKC1PvJIA8kR2sSBnex8bsR5auatLCnVlNTJczmJgqRn8M+sAlQfkEq7M4IY3WmaNJ+LJjPVRrREVSHQ== dependencies: - "@typescript-eslint/types" "4.14.0" - "@typescript-eslint/visitor-keys" "4.14.0" + "@typescript-eslint/types" "4.14.1" + "@typescript-eslint/visitor-keys" "4.14.1" debug "^4.1.1" globby "^11.0.1" is-glob "^4.0.1" @@ -2405,12 +2412,12 @@ dependencies: eslint-visitor-keys "^1.1.0" -"@typescript-eslint/visitor-keys@4.14.0": - version "4.14.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-4.14.0.tgz#b1090d9d2955b044b2ea2904a22496849acbdf54" - integrity sha512-MeHHzUyRI50DuiPgV9+LxcM52FCJFYjJiWHtXlbyC27b80mfOwKeiKI+MHOTEpcpfmoPFm/vvQS88bYIx6PZTA== +"@typescript-eslint/visitor-keys@4.14.1": + version "4.14.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-4.14.1.tgz#e93c2ff27f47ee477a929b970ca89d60a117da91" + integrity sha512-TAblbDXOI7bd0C/9PE1G+AFo7R5uc+ty1ArDoxmrC1ah61Hn6shURKy7gLdRb1qKJmjHkqu5Oq+e4Kt0jwf1IA== dependencies: - "@typescript-eslint/types" "4.14.0" + "@typescript-eslint/types" "4.14.1" eslint-visitor-keys "^2.0.0" "@webassemblyjs/ast@1.9.0": @@ -4576,10 +4583,10 @@ create-require@^1.1.0: resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== -cron-parser@^2.7.3: - version "2.17.0" - resolved "https://registry.yarnpkg.com/cron-parser/-/cron-parser-2.17.0.tgz#5707421a7e0a73ee74675d1c032a2f14123f2cf8" - integrity sha512-oTmzVEwlurRe51HqTm4afshVr8Rkxy9kFiWxh5e6SmrY2o9NDYU4S6SduanBZYXLgkLy0skA98y7/tztW/DmjQ== +cron-parser@^2.18.0: + version "2.18.0" + resolved "https://registry.yarnpkg.com/cron-parser/-/cron-parser-2.18.0.tgz#de1bb0ad528c815548371993f81a54e5a089edcf" + integrity sha512-s4odpheTyydAbTBQepsqd2rNWGa2iV3cyo8g7zbI2QQYGLVsfbhmwukayS1XHppe02Oy1fg7mg6xoaraVJeEcg== dependencies: is-nan "^1.3.0" moment-timezone "^0.5.31" @@ -5697,18 +5704,17 @@ eslint-config-prettier@^7.2.0: resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-7.2.0.tgz#f4a4bd2832e810e8cc7c1411ec85b3e85c0c53f9" integrity sha512-rV4Qu0C3nfJKPOAhFujFxB7RMP+URFyQqqOZW9DMRD7ZDTFyjaIlETU3xzHELt++4ugC0+Jm084HQYkkJe+Ivg== -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== +eslint-plugin-formatjs@^2.11.0: + version "2.11.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-formatjs/-/eslint-plugin-formatjs-2.11.0.tgz#46dac11f9c0f96b72fad2da55812c25b9d864f0a" + integrity sha512-/HVmY343p44str15ETCejlPct25Yi1yZTEyv8MONZuUKCTG1YEeyQ+SuAC8y6Xpt/z5mFC0G5dXxz6/zc/uRJw== dependencies: - "@formatjs/ts-transformer" "3.0.1" + "@formatjs/ts-transformer" "3.0.4" "@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.3" + intl-messageformat-parser "6.3.1" tslib "^2.0.1" eslint-plugin-jsx-a11y@^6.4.1: @@ -5996,11 +6002,12 @@ expand-tilde@^2.0.0, expand-tilde@^2.0.2: dependencies: homedir-polyfill "^1.0.1" -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== +express-openapi-validator@^4.10.9: + version "4.10.9" + resolved "https://registry.yarnpkg.com/express-openapi-validator/-/express-openapi-validator-4.10.9.tgz#cad485c12516cefde971135edef8c855c6facbf0" + integrity sha512-bsjNn9N3wzib7J3l9w1yOhvqz4lBX6ZAkaTBOjjmXVxCkkxr6VUkw0vgT28k183jR41aYYmKKJu4wUE7O3RlWQ== dependencies: + "@types/multer" "^1.4.2" ajv "^6.12.6" content-type "^1.0.4" json-schema-ref-parser "^9.0.6" @@ -7385,12 +7392,12 @@ 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== +intl-messageformat-parser@6.3.1: + version "6.3.1" + resolved "https://registry.yarnpkg.com/intl-messageformat-parser/-/intl-messageformat-parser-6.3.1.tgz#da343ea34ed061db1597e46885d6f5a8febb68e4" + integrity sha512-/aK5zKeNfVO9OEGji6X8y11J0Xse7cQFVJo4YPJwQ/ts85O2J4QQsxXZBh5mMVHvHuKDDdsX+FY4/YTD9bwoNQ== dependencies: - "@formatjs/ecma402-abstract" "1.5.1" + "@formatjs/ecma402-abstract" "1.5.2" tslib "^2.0.1" intl-messageformat-parser@^5.3.7: @@ -7400,13 +7407,13 @@ intl-messageformat-parser@^5.3.7: dependencies: "@formatjs/intl-numberformat" "^5.5.2" -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== +intl-messageformat@9.4.6: + version "9.4.6" + resolved "https://registry.yarnpkg.com/intl-messageformat/-/intl-messageformat-9.4.6.tgz#dc447cd338023f7c1ad478bd8c1807691cd4f01b" + integrity sha512-7GSvbRcM2iuWGWoHrvqB/Yu0vvidj3eTbsjFGI9CeYxboPxlmmTGAXGJo9gbOqM5dw6zL0rFFWyMUS1b4yNPMw== dependencies: fast-memoize "^2.5.2" - intl-messageformat-parser "6.1.3" + intl-messageformat-parser "6.3.1" tslib "^2.0.1" intl@^1.2.5: @@ -9583,14 +9590,14 @@ node-releases@^1.1.65, node-releases@^1.1.67: resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.67.tgz#28ebfcccd0baa6aad8e8d4d8fe4cbc49ae239c12" integrity sha512-V5QF9noGFl3EymEwUYzO+3NTDpGfQB4ve6Qfnzf3UNydMhjQRVPR1DZTuvWiLzaFJYw2fmDwAfnRNEVb64hSIg== -node-schedule@^1.3.2: - version "1.3.2" - resolved "https://registry.yarnpkg.com/node-schedule/-/node-schedule-1.3.2.tgz#d774b383e2a6f6ade59eecc62254aea07cd758cb" - integrity sha512-GIND2pHMHiReSZSvS6dpZcDH7pGPGFfWBIEud6S00Q8zEIzAs9ommdyRK1ZbQt8y1LyZsJYZgPnyi7gpU2lcdw== +node-schedule@^1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/node-schedule/-/node-schedule-1.3.3.tgz#f8e01c5fb9597f09ecf9c4c25d6938e5e7a06f48" + integrity sha512-uF9Ubn6luOPrcAYKfsXWimcJ1tPFtQ8I85wb4T3NgJQrXazEzojcFZVk46ZlLHby3eEJChgkV/0T689IsXh2Gw== dependencies: - cron-parser "^2.7.3" + cron-parser "^2.18.0" long-timeout "0.1.1" - sorted-array-functions "^1.0.0" + sorted-array-functions "^1.3.0" nodemailer@6.4.16, nodemailer@^6.4.16: version "6.4.16" @@ -11586,21 +11593,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.16: - version "5.10.16" - resolved "https://registry.yarnpkg.com/react-intl/-/react-intl-5.10.16.tgz#2ccde74acd26cbe4f9e6e42765048a7f2f40e645" - integrity sha512-Cp0p9MGGWYNsl3hamJcrRqKid2HZun4MsdIlHV9fdmYVoLOII1+YynH6UBBwLuDlrLi8JrSniH1G1pqUlwQZMw== +react-intl@^5.11.0: + version "5.11.0" + resolved "https://registry.yarnpkg.com/react-intl/-/react-intl-5.11.0.tgz#1606941ec402b9b566e6191f5ddb3efea967b31b" + integrity sha512-1ZOlKdRD1yKcm+TuuGpHrp7dGRrQeozH7n4/i5r8c6RM74ByO00bBAS1Ut4hJJcCqbMa7AqLD69kFe6Y7j46RA== dependencies: - "@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" + "@formatjs/ecma402-abstract" "1.5.2" + "@formatjs/intl" "1.6.5" + "@formatjs/intl-displaynames" "4.0.5" + "@formatjs/intl-listformat" "5.0.5" + "@formatjs/intl-relativetimeformat" "8.0.4" "@types/hoist-non-react-statics" "^3.3.1" fast-memoize "^2.5.2" hoist-non-react-statics "^3.3.2" - intl-messageformat "9.4.3" - intl-messageformat-parser "6.1.3" + intl-messageformat "9.4.6" + intl-messageformat-parser "6.3.1" shallow-equal "^1.2.1" tslib "^2.0.1" @@ -12348,10 +12355,10 @@ semantic-release-docker@^2.2.0: "@semantic-release/error" "^2.1.0" execa "^0.10.0" -semantic-release@^17.3.6: - version "17.3.6" - resolved "https://registry.yarnpkg.com/semantic-release/-/semantic-release-17.3.6.tgz#0390eb9c4d13e82dbf0f8e5700f196b599fa2a12" - integrity sha512-zPAFxmnMtEVbN1Lzxz+CMXlt5a9txB/PRWaVq+oAC9Mppbax/vWXZ0kisHX92O+BjEBbsaFtISjz82E+2Ro9gQ== +semantic-release@^17.3.7: + version "17.3.7" + resolved "https://registry.yarnpkg.com/semantic-release/-/semantic-release-17.3.7.tgz#a54a8ccf443c7389706c9e3c52f2a9433e0581f7" + integrity sha512-mMWaxykq1+mZu7hvUjEdUhJXb+BvEJhcuy15hog3+8XWZqmxRfcdlW/aTBav8rWUZ16bEevWVc6qHwsq3aPGiQ== dependencies: "@semantic-release/commit-analyzer" "^8.0.0" "@semantic-release/error" "^2.2.0" @@ -12365,7 +12372,7 @@ semantic-release@^17.3.6: execa "^5.0.0" figures "^3.0.0" find-versions "^4.0.0" - get-stream "^5.0.0" + get-stream "^6.0.0" git-log-parser "^1.2.0" hook-std "^2.0.0" hosted-git-info "^3.0.0" @@ -12711,7 +12718,7 @@ sort-keys@^4.0.0: dependencies: is-plain-obj "^2.0.0" -sorted-array-functions@^1.0.0: +sorted-array-functions@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/sorted-array-functions/-/sorted-array-functions-1.3.0.tgz#8605695563294dffb2c9796d602bd8459f7a0dd5" integrity sha512-2sqgzeFlid6N4Z2fUQ1cvFmTOLRi/sEDzSQ0OKYchqgoPmQBVyM3959qYx3fpS6Esef80KjmpgPeEr028dP3OA== @@ -13272,10 +13279,10 @@ swagger-ui-express@^4.1.6: dependencies: swagger-ui-dist "^3.18.1" -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== +swr@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/swr/-/swr-0.4.1.tgz#4bb1f9e994a8f8d7a2e9220dbc7aef296bc4a673" + integrity sha512-/jQWPjCwy7rNbEJKpuObfbtJHtneTKhlzjy8VkuAEGg/kkYhZoKWVdGtIXrz9vuTGdy/grLVWpA6DfiNk9ECWg== dependencies: dequal "2.0.2" From e34482bc3f9d83071437c82d93e809966fbdc93e Mon Sep 17 00:00:00 2001 From: sct Date: Sat, 30 Jan 2021 06:31:34 +0000 Subject: [PATCH 02/66] ci(snapcraft): remove --unshallow argument from fetch in Prepare step --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3bd56884..92223b51 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -64,7 +64,7 @@ jobs: - name: Prepare id: prepare run: | - git fetch --prune --unshallow --tags + git fetch --prune --tags if [[ $GITHUB_REF == refs/tags/* || $GITHUB_REF == refs/heads/master ]]; then echo ::set-output name=RELEASE::stable else From e2b800000d76c3dda9659d0d1a6ca3ffdcc17a4f Mon Sep 17 00:00:00 2001 From: TheCatLady <52870424+TheCatLady@users.noreply.github.com> Date: Sat, 30 Jan 2021 10:48:47 -0500 Subject: [PATCH 03/66] docs(readme): remove PROXY env var and correct grammar/inconsistencies (#788) --- README.md | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 458448bb..547e0420 100644 --- a/README.md +++ b/README.md @@ -20,17 +20,17 @@

-**Overseerr** is a free and open source software application for managing requests for your media library. It integrates with your existing services such as **Sonarr**, **Radarr** and **Plex**! +**Overseerr** is a free and open source software application for managing requests for your media library. It integrates with your existing services such as **[Sonarr](https://sonarr.tv/)**, **[Radarr](https://radarr.video/)** and **[Plex](https://www.plex.tv/)**! ## Current Features - Full Plex integration. Login and manage user access with Plex! -- Integrates easily with your existing services. Currently Overseerr supports Sonarr and Radarr. More to come! -- Syncs to your Plex library to know what titles you already have. +- Easy integration with your existing services. Currently Overseerr supports Sonarr and Radarr. More to come! +- Plex libraries sync to know what titles you already have. - Complex request system allowing users to request individual seasons or movies in a friendly, easy to use UI. -- Incredibly simple request management UI. Don't dig through the app to simply approve recent requests. -- Granular permission system -- Mobile friendly design, for when you need to approve requests on the go! +- Incredibly simple request management UI. Don't dig through the app to simply approve recent requests! +- Granular permission system. +- Mobile-friendly design, for when you need to approve requests on the go! ## In Development @@ -46,19 +46,18 @@ ## Getting Started -Check out our documentation for steps on how to install and run Overseerr: +Check out our documentation for instructions on how to install and run Overseerr: https://docs.overseerr.dev/getting-started/installation ## Running Overseerr -Currently, Overseerr is only distributed through Docker images. If you have Docker, you can run Overseerr as per: +Currently, Overseerr is primarily distributed as Docker images. If you have Docker, you can run Overseerr with: ``` docker run -d \ -e LOG_LEVEL=info \ -e TZ=Asia/Tokyo \ - -e PROXY= -p 5055:5055 \ -v /path/to/appdata/config:/app/config \ --restart unless-stopped \ @@ -67,7 +66,7 @@ docker run -d \ After running Overseerr for the first time, configure it by visiting the web UI at http://[address]:5055 and completing the setup steps. -⚠️ Overseerr is currently under very heavy, rapid development and things are likely to break often. We need all the help we can get to find bugs and get them fixed to hit a more stable release. If you would like to help test the bleeding edge, please use the image **sctx/overseerr:develop** instead! ⚠️ +⚠️ Overseerr is currently under very heavy, rapid development and things are likely to break often. We need all the help we can get to find bugs and get them fixed to hit a more stable release. If you would like to help test the bleeding edge, please use the `sctx/overseerr:develop` image instead! ⚠️ ## Preview @@ -78,7 +77,7 @@ After running Overseerr for the first time, configure it by visiting the web UI - Check out the [Overseerr Documentation](https://docs.overseerr.dev/) before asking for help. Your question might already be in the [FAQ](https://docs.overseerr.dev/support/faq). - You can get support on [Discord](https://discord.gg/PkCWJSeCk7). - You can ask questions in the Help category of our [GitHub Discussions](https://github.com/sct/overseerr/discussions). -- Bugs/Feature Requests can be opened via a [GitHub issue](https://github.com/sct/overseerr/issues). +- Bug reports and feature requests can be submitted via [GitHub Issues](https://github.com/sct/overseerr/issues). ## API Documentation From b239598e64d33b78dc5d7972878840149aff360a Mon Sep 17 00:00:00 2001 From: Danshil Mungur Date: Sun, 31 Jan 2021 04:50:15 +0400 Subject: [PATCH 04/66] feat(tv): show cast for the entire show instead of only the last season (#778) This uses TMDb's `aggregate_credits` instead of `credits` to get the show's cast for all seasons. Fixes #775 --- server/api/themoviedb.ts | 15 +++++++++++++-- server/models/Tv.ts | 4 ++-- server/models/common.ts | 13 +++++++++++++ 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/server/api/themoviedb.ts b/server/api/themoviedb.ts index fddab1ba..47dca67a 100644 --- a/server/api/themoviedb.ts +++ b/server/api/themoviedb.ts @@ -126,6 +126,14 @@ export interface TmdbCreditCast { profile_path?: string; } +export interface TmdbAggregateCreditCast extends TmdbCreditCast { + roles: { + credit_id: string; + character: string; + episode_count: number; + }[]; +} + export interface TmdbCreditCrew { credit_id: string; gender?: number; @@ -293,8 +301,10 @@ export interface TmdbTvDetails { type: string; vote_average: number; vote_count: number; + aggregate_credits: { + cast: TmdbAggregateCreditCast[]; + }; credits: { - cast: TmdbCreditCast[]; crew: TmdbCreditCrew[]; }; external_ids: TmdbExternalIds; @@ -499,7 +509,8 @@ class TheMovieDb { const response = await this.axios.get(`/tv/${tvId}`, { params: { language, - append_to_response: 'credits,external_ids,keywords,videos', + append_to_response: + 'aggregate_credits,credits,external_ids,keywords,videos', }, }); diff --git a/server/models/Tv.ts b/server/models/Tv.ts index 5ff2f631..5f84315f 100644 --- a/server/models/Tv.ts +++ b/server/models/Tv.ts @@ -3,7 +3,7 @@ import { ProductionCompany, Cast, Crew, - mapCast, + mapAggregateCast, mapCrew, ExternalIds, mapExternalIds, @@ -193,7 +193,7 @@ export const mapTvDetails = ( : undefined, posterPath: show.poster_path, credits: { - cast: show.credits.cast.map(mapCast), + cast: show.aggregate_credits.cast.map(mapAggregateCast), crew: show.credits.crew.map(mapCrew), }, externalIds: mapExternalIds(show.external_ids), diff --git a/server/models/common.ts b/server/models/common.ts index 733cf2df..dcd81934 100644 --- a/server/models/common.ts +++ b/server/models/common.ts @@ -1,5 +1,6 @@ import { TmdbCreditCast, + TmdbAggregateCreditCast, TmdbCreditCrew, TmdbExternalIds, TmdbVideo, @@ -68,6 +69,18 @@ export const mapCast = (person: TmdbCreditCast): Cast => ({ profilePath: person.profile_path, }); +export const mapAggregateCast = (person: TmdbAggregateCreditCast): Cast => ({ + castId: person.cast_id, + // the first role is the one for which the actor appears the most as + character: person.roles[0].character, + creditId: person.roles[0].credit_id, + id: person.id, + name: person.name, + order: person.order, + gender: person.gender, + profilePath: person.profile_path, +}); + export const mapCrew = (person: TmdbCreditCrew): Crew => ({ creditId: person.credit_id, department: person.department, From 20289b5960a93545cdff9331a1a7b613f382e702 Mon Sep 17 00:00:00 2001 From: sct Date: Sun, 31 Jan 2021 17:24:45 +0900 Subject: [PATCH 05/66] feat(cache): external API cache (#786) --- package.json | 1 + server/api/externalapi.ts | 106 ++ server/api/radarr.ts | 33 +- server/api/rottentomatoes.ts | 57 +- server/api/sonarr.ts | 33 +- server/api/themoviedb.ts | 945 ------------------ server/api/themoviedb/constants.ts | 1 + server/api/themoviedb/index.ts | 599 +++++++++++ server/api/themoviedb/interfaces.ts | 346 +++++++ server/entity/MediaRequest.ts | 3 +- server/job/plexsync/index.ts | 5 +- server/job/sonarrsync/index.ts | 3 +- server/lib/cache.ts | 56 ++ server/models/Collection.ts | 2 +- server/models/Movie.ts | 2 +- server/models/Person.ts | 4 +- server/models/Search.ts | 2 +- server/models/Tv.ts | 4 +- server/models/common.ts | 4 +- server/utils/typeHelpers.ts | 2 +- .../RequestModal/TvRequestModal.tsx | 2 +- src/components/TvDetails/index.tsx | 2 +- yarn.lock | 12 + 23 files changed, 1210 insertions(+), 1014 deletions(-) create mode 100644 server/api/externalapi.ts delete mode 100644 server/api/themoviedb.ts create mode 100644 server/api/themoviedb/constants.ts create mode 100644 server/api/themoviedb/index.ts create mode 100644 server/api/themoviedb/interfaces.ts create mode 100644 server/lib/cache.ts diff --git a/package.json b/package.json index 1f9acfe9..47e4455d 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "intl": "^1.2.5", "lodash": "^4.17.20", "next": "10.0.3", + "node-cache": "^5.1.2", "node-schedule": "^1.3.3", "nodemailer": "^6.4.17", "nookies": "^2.5.2", diff --git a/server/api/externalapi.ts b/server/api/externalapi.ts new file mode 100644 index 00000000..6ca4c1fd --- /dev/null +++ b/server/api/externalapi.ts @@ -0,0 +1,106 @@ +import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'; +import NodeCache from 'node-cache'; +import logger from '../logger'; + +// 5 minute default TTL (in seconds) +const DEFAULT_TTL = 300; + +// 10 seconds default rolling buffer (in ms) +const DEFAULT_ROLLING_BUFFER = 10000; + +interface ExternalAPIOptions { + nodeCache?: NodeCache; + headers?: Record; +} + +class ExternalAPI { + protected axios: AxiosInstance; + private baseUrl: string; + private cache?: NodeCache; + + constructor( + baseUrl: string, + params: Record, + options: ExternalAPIOptions = {} + ) { + this.axios = axios.create({ + baseURL: baseUrl, + params, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + ...options.headers, + }, + }); + this.baseUrl = baseUrl; + this.cache = options.nodeCache; + } + + protected async get( + endpoint: string, + config?: AxiosRequestConfig, + ttl?: number + ): Promise { + const cacheKey = this.serializeCacheKey(endpoint, config?.params); + const cachedItem = this.cache?.get(cacheKey); + if (cachedItem) { + return cachedItem; + } + + const response = await this.axios.get(endpoint, config); + + if (this.cache) { + this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL); + } + + return response.data; + } + + protected async getRolling( + endpoint: string, + config?: AxiosRequestConfig, + ttl?: number + ): Promise { + const cacheKey = this.serializeCacheKey(endpoint, config?.params); + const cachedItem = this.cache?.get(cacheKey); + + if (cachedItem) { + const keyTtl = this.cache?.getTtl(cacheKey) ?? 0; + logger.debug(`Loaded item from cache: ${cacheKey}`, { + keyTtl, + }); + + // If the item has passed our rolling check, fetch again in background + if ( + keyTtl - (ttl ?? DEFAULT_TTL) * 1000 < + Date.now() - DEFAULT_ROLLING_BUFFER + ) { + this.axios.get(endpoint, config).then((response) => { + this.cache?.set(cacheKey, response.data, ttl ?? DEFAULT_TTL); + }); + } + return cachedItem; + } + + const response = await this.axios.get(endpoint, config); + + if (this.cache) { + this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL); + } + + return response.data; + } + + private serializeCacheKey( + endpoint: string, + params?: Record + ) { + if (!params) { + return `${this.baseUrl}${endpoint}`; + } + + return `${this.baseUrl}${endpoint}${JSON.stringify(params)}`; + } +} + +export default ExternalAPI; diff --git a/server/api/radarr.ts b/server/api/radarr.ts index ec0c7956..8e8488d0 100644 --- a/server/api/radarr.ts +++ b/server/api/radarr.ts @@ -1,6 +1,7 @@ -import Axios, { AxiosInstance } from 'axios'; +import cacheManager from '../lib/cache'; import { RadarrSettings } from '../lib/settings'; import logger from '../logger'; +import ExternalAPI from './externalapi'; interface RadarrMovieOptions { title: string; @@ -73,21 +74,23 @@ interface QueueResponse { records: QueueItem[]; } -class RadarrAPI { +class RadarrAPI extends ExternalAPI { static buildRadarrUrl(radarrSettings: RadarrSettings, path?: string): string { return `${radarrSettings.useSsl ? 'https' : 'http'}://${ radarrSettings.hostname }:${radarrSettings.port}${radarrSettings.baseUrl ?? ''}${path}`; } - private axios: AxiosInstance; constructor({ url, apiKey }: { url: string; apiKey: string }) { - this.axios = Axios.create({ - baseURL: url, - params: { + super( + url, + { apikey: apiKey, }, - }); + { + nodeCache: cacheManager.getCache('radarr').data, + } + ); } public getMovies = async (): Promise => { @@ -238,9 +241,13 @@ class RadarrAPI { public getProfiles = async (): Promise => { try { - const response = await this.axios.get(`/profile`); + const data = await this.getRolling( + `/profile`, + undefined, + 3600 + ); - return response.data; + return data; } catch (e) { throw new Error(`[Radarr] Failed to retrieve profiles: ${e.message}`); } @@ -248,9 +255,13 @@ class RadarrAPI { public getRootFolders = async (): Promise => { try { - const response = await this.axios.get(`/rootfolder`); + const data = await this.getRolling( + `/rootfolder`, + undefined, + 3600 + ); - return response.data; + return data; } catch (e) { throw new Error(`[Radarr] Failed to retrieve root folders: ${e.message}`); } diff --git a/server/api/rottentomatoes.ts b/server/api/rottentomatoes.ts index cc3a562a..e83d5572 100644 --- a/server/api/rottentomatoes.ts +++ b/server/api/rottentomatoes.ts @@ -1,4 +1,5 @@ -import axios, { AxiosInstance } from 'axios'; +import cacheManager from '../lib/cache'; +import ExternalAPI from './externalapi'; interface RTMovieOldSearchResult { id: number; @@ -55,17 +56,19 @@ export interface RTRating { * Unfortunately, we need to do it by searching for the movie name, so it's * not always accurate. */ -class RottenTomatoes { - private axios: AxiosInstance; - +class RottenTomatoes extends ExternalAPI { constructor() { - this.axios = axios.create({ - baseURL: 'https://www.rottentomatoes.com/api/private', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - }); + super( + 'https://www.rottentomatoes.com/api/private', + {}, + { + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + nodeCache: cacheManager.getCache('rt').data, + } + ); } /** @@ -85,33 +88,30 @@ class RottenTomatoes { year: number ): Promise { try { - const response = await this.axios.get( - '/v1.0/movies', - { - params: { q: name }, - } - ); + const data = await this.get('/v1.0/movies', { + params: { q: name }, + }); // First, attempt to match exact name and year - let movie = response.data.movies.find( + let movie = 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 = 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); + movie = 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); + movie = data.movies.find((movie) => movie.title === name); } if (!movie) { @@ -139,19 +139,14 @@ class RottenTomatoes { year?: number ): Promise { try { - const response = await this.axios.get( - '/v2.0/search/', - { - params: { q: name, limit: 10 }, - } - ); + const data = await this.get('/v2.0/search/', { + params: { q: name, limit: 10 }, + }); - let tvshow: RTTvSearchResult | undefined = response.data.tvSeries[0]; + let tvshow: RTTvSearchResult | undefined = data.tvSeries[0]; if (year) { - tvshow = response.data.tvSeries.find( - (series) => series.startYear === year - ); + tvshow = data.tvSeries.find((series) => series.startYear === year); } if (!tvshow) { diff --git a/server/api/sonarr.ts b/server/api/sonarr.ts index 29c6b431..681cb1f3 100644 --- a/server/api/sonarr.ts +++ b/server/api/sonarr.ts @@ -1,6 +1,7 @@ -import Axios, { AxiosInstance } from 'axios'; +import cacheManager from '../lib/cache'; import { SonarrSettings } from '../lib/settings'; import logger from '../logger'; +import ExternalAPI from './externalapi'; interface SonarrSeason { seasonNumber: number; @@ -119,21 +120,23 @@ interface AddSeriesOptions { searchNow?: boolean; } -class SonarrAPI { +class SonarrAPI extends ExternalAPI { static buildSonarrUrl(sonarrSettings: SonarrSettings, path?: string): string { return `${sonarrSettings.useSsl ? 'https' : 'http'}://${ sonarrSettings.hostname }:${sonarrSettings.port}${sonarrSettings.baseUrl ?? ''}${path}`; } - private axios: AxiosInstance; constructor({ url, apiKey }: { url: string; apiKey: string }) { - this.axios = Axios.create({ - baseURL: url, - params: { + super( + url, + { apikey: apiKey, }, - }); + { + nodeCache: cacheManager.getCache('sonarr').data, + } + ); } public async getSeries(): Promise { @@ -280,9 +283,13 @@ class SonarrAPI { public async getProfiles(): Promise { try { - const response = await this.axios.get('/profile'); + const data = await this.getRolling( + '/profile', + undefined, + 3600 + ); - return response.data; + return data; } catch (e) { logger.error('Something went wrong while retrieving Sonarr profiles.', { label: 'Sonarr API', @@ -294,9 +301,13 @@ class SonarrAPI { public async getRootFolders(): Promise { try { - const response = await this.axios.get('/rootfolder'); + const data = await this.getRolling( + '/rootfolder', + undefined, + 3600 + ); - return response.data; + return data; } catch (e) { logger.error( 'Something went wrong while retrieving Sonarr root folders.', diff --git a/server/api/themoviedb.ts b/server/api/themoviedb.ts deleted file mode 100644 index 47dca67a..00000000 --- a/server/api/themoviedb.ts +++ /dev/null @@ -1,945 +0,0 @@ -import axios, { AxiosInstance } from 'axios'; - -export const ANIME_KEYWORD_ID = 210024; - -interface SearchOptions { - query: string; - page?: number; - includeAdult?: boolean; - language?: string; -} - -interface DiscoverMovieOptions { - page?: number; - includeAdult?: boolean; - language?: string; - sortBy?: - | 'popularity.asc' - | 'popularity.desc' - | 'release_date.asc' - | 'release_date.desc' - | 'revenue.asc' - | 'revenue.desc' - | 'primary_release_date.asc' - | 'primary_release_date.desc' - | 'original_title.asc' - | 'original_title.desc' - | 'vote_average.asc' - | 'vote_average.desc' - | 'vote_count.asc' - | 'vote_count.desc'; -} - -interface DiscoverTvOptions { - page?: number; - language?: string; - sortBy?: - | 'popularity.asc' - | 'popularity.desc' - | 'vote_average.asc' - | 'vote_average.desc' - | 'vote_count.asc' - | 'vote_count.desc' - | 'first_air_date.asc' - | 'first_air_date.desc'; -} - -interface TmdbMediaResult { - id: number; - media_type: string; - popularity: number; - poster_path?: string; - backdrop_path?: string; - vote_count: number; - vote_average: number; - genre_ids: number[]; - overview: string; - original_language: string; -} - -export interface TmdbMovieResult extends TmdbMediaResult { - media_type: 'movie'; - title: string; - original_title: string; - release_date: string; - adult: boolean; - video: boolean; -} - -export interface TmdbTvResult extends TmdbMediaResult { - media_type: 'tv'; - name: string; - original_name: string; - origin_country: string[]; - first_air_date: string; -} - -export interface TmdbPersonResult { - id: number; - name: string; - popularity: number; - profile_path?: string; - adult: boolean; - media_type: 'person'; - known_for: (TmdbMovieResult | TmdbTvResult)[]; -} - -interface TmdbPaginatedResponse { - page: number; - total_results: number; - total_pages: number; -} - -interface TmdbSearchMultiResponse extends TmdbPaginatedResponse { - results: (TmdbMovieResult | TmdbTvResult | TmdbPersonResult)[]; -} - -interface TmdbSearchMovieResponse extends TmdbPaginatedResponse { - results: TmdbMovieResult[]; -} - -interface TmdbSearchTvResponse extends TmdbPaginatedResponse { - results: TmdbTvResult[]; -} - -interface TmdbUpcomingMoviesResponse extends TmdbPaginatedResponse { - dates: { - maximum: string; - minimum: string; - }; - results: TmdbMovieResult[]; -} - -interface TmdbExternalIdResponse { - movie_results: TmdbMovieResult[]; - tv_results: TmdbTvResult[]; -} - -export interface TmdbCreditCast { - cast_id: number; - character: string; - credit_id: string; - gender?: number; - id: number; - name: string; - order: number; - profile_path?: string; -} - -export interface TmdbAggregateCreditCast extends TmdbCreditCast { - roles: { - credit_id: string; - character: string; - episode_count: number; - }[]; -} - -export interface TmdbCreditCrew { - credit_id: string; - gender?: number; - id: number; - name: string; - profile_path?: string; - job: string; - department: string; -} - -export interface TmdbExternalIds { - imdb_id?: string; - freebase_mid?: string; - freebase_id?: string; - tvdb_id?: number; - tvrage_id?: string; - facebook_id?: string; - instagram_id?: string; - twitter_id?: string; -} - -export interface TmdbMovieDetails { - id: number; - imdb_id?: string; - adult: boolean; - backdrop_path?: string; - poster_path?: string; - budget: number; - genres: { - id: number; - name: string; - }[]; - homepage?: string; - original_language: string; - original_title: string; - overview?: string; - popularity: number; - production_companies: { - id: number; - name: string; - logo_path?: string; - origin_country: string; - }[]; - production_countries: { - iso_3166_1: string; - name: string; - }[]; - release_date: string; - revenue: number; - runtime?: number; - spoken_languages: { - iso_639_1: string; - name: string; - }[]; - status: string; - tagline?: string; - title: string; - video: boolean; - vote_average: number; - vote_count: number; - credits: { - cast: TmdbCreditCast[]; - crew: TmdbCreditCrew[]; - }; - belongs_to_collection?: { - id: number; - name: string; - poster_path?: string; - backdrop_path?: string; - }; - external_ids: TmdbExternalIds; - videos: TmdbVideoResult; -} - -export interface TmdbVideo { - id: string; - key: string; - name: string; - site: 'YouTube'; - size: number; - type: - | 'Clip' - | 'Teaser' - | 'Trailer' - | 'Featurette' - | 'Opening Credits' - | 'Behind the Scenes' - | 'Bloopers'; -} - -export interface TmdbTvEpisodeResult { - id: number; - air_date: string; - episode_number: number; - name: string; - overview: string; - production_code: string; - season_number: number; - show_id: number; - still_path: string; - vote_average: number; - vote_cuont: number; -} - -export interface TmdbTvSeasonResult { - id: number; - air_date: string; - episode_count: number; - name: string; - overview: string; - poster_path?: string; - season_number: number; -} - -export interface TmdbTvDetails { - id: number; - backdrop_path?: string; - created_by: { - id: number; - credit_id: string; - name: string; - gender: number; - profile_path?: string; - }[]; - episode_run_time: number[]; - first_air_date: string; - genres: { - id: number; - name: string; - }[]; - homepage: string; - in_production: boolean; - languages: string[]; - last_air_date: string; - last_episode_to_air?: TmdbTvEpisodeResult; - name: string; - next_episode_to_air?: TmdbTvEpisodeResult; - networks: { - id: number; - name: string; - logo_path: string; - origin_country: string; - }[]; - number_of_episodes: number; - number_of_seasons: number; - origin_country: string[]; - original_language: string; - original_name: string; - overview: string; - popularity: number; - poster_path?: string; - production_companies: { - id: number; - logo_path?: string; - name: string; - origin_country: string; - }[]; - spoken_languages: { - english_name: string; - iso_639_1: string; - name: string; - }[]; - seasons: TmdbTvSeasonResult[]; - status: string; - type: string; - vote_average: number; - vote_count: number; - aggregate_credits: { - cast: TmdbAggregateCreditCast[]; - }; - credits: { - crew: TmdbCreditCrew[]; - }; - external_ids: TmdbExternalIds; - keywords: { - results: TmdbKeyword[]; - }; - videos: TmdbVideoResult; -} - -export interface TmdbVideoResult { - results: TmdbVideo[]; -} - -export interface TmdbKeyword { - id: number; - name: string; -} - -export interface TmdbPersonDetail { - id: number; - name: string; - deathday: string; - known_for_department: string; - also_known_as?: string[]; - gender: number; - biography: string; - popularity: string; - place_of_birth?: string; - profile_path?: string; - adult: boolean; - imdb_id?: string; - homepage?: string; -} - -export interface TmdbPersonCredit { - id: number; - original_language: string; - episode_count: number; - overview: string; - origin_country: string[]; - original_name: string; - vote_count: number; - name: string; - media_type?: string; - popularity: number; - credit_id: string; - backdrop_path?: string; - first_air_date: string; - vote_average: number; - genre_ids?: number[]; - poster_path?: string; - original_title: string; - video?: boolean; - title: string; - adult: boolean; - release_date: string; -} -export interface TmdbPersonCreditCast extends TmdbPersonCredit { - character: string; -} - -export interface TmdbPersonCreditCrew extends TmdbPersonCredit { - department: string; - job: string; -} - -export interface TmdbPersonCombinedCredits { - id: number; - cast: TmdbPersonCreditCast[]; - crew: TmdbPersonCreditCrew[]; -} - -export interface TmdbSeasonWithEpisodes extends TmdbTvSeasonResult { - episodes: TmdbTvEpisodeResult[]; - external_ids: TmdbExternalIds; -} - -export interface TmdbCollection { - id: number; - name: string; - overview?: string; - poster_path?: string; - backdrop_path?: string; - parts: TmdbMovieResult[]; -} - -class TheMovieDb { - private apiKey = 'db55323b8d3e4154498498a75642b381'; - private axios: AxiosInstance; - - constructor() { - this.axios = axios.create({ - baseURL: 'https://api.themoviedb.org/3', - params: { - api_key: this.apiKey, - }, - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - }); - } - - public searchMulti = async ({ - query, - page = 1, - includeAdult = false, - language = 'en-US', - }: SearchOptions): Promise => { - try { - const response = await this.axios.get('/search/multi', { - params: { query, page, include_adult: includeAdult, language }, - }); - - return response.data; - } catch (e) { - return { - page: 1, - results: [], - total_pages: 1, - total_results: 0, - }; - } - }; - - public getPerson = async ({ - personId, - language = 'en-US', - }: { - personId: number; - language?: string; - }): Promise => { - try { - const response = await this.axios.get( - `/person/${personId}`, - { - params: { language }, - } - ); - - return response.data; - } catch (e) { - throw new Error(`[TMDB] Failed to fetch person details: ${e.message}`); - } - }; - - public getPersonCombinedCredits = async ({ - personId, - language = 'en-US', - }: { - personId: number; - language?: string; - }): Promise => { - try { - const response = await this.axios.get( - `/person/${personId}/combined_credits`, - { - params: { language }, - } - ); - - return response.data; - } catch (e) { - throw new Error( - `[TMDB] Failed to fetch person combined credits: ${e.message}` - ); - } - }; - - public getMovie = async ({ - movieId, - language = 'en-US', - }: { - movieId: number; - language?: string; - }): Promise => { - try { - const response = await this.axios.get( - `/movie/${movieId}`, - { - params: { - language, - append_to_response: 'credits,external_ids,videos', - }, - } - ); - - return response.data; - } catch (e) { - throw new Error(`[TMDB] Failed to fetch movie details: ${e.message}`); - } - }; - - public getTvShow = async ({ - tvId, - language = 'en-US', - }: { - tvId: number; - language?: string; - }): Promise => { - try { - const response = await this.axios.get(`/tv/${tvId}`, { - params: { - language, - append_to_response: - 'aggregate_credits,credits,external_ids,keywords,videos', - }, - }); - - return response.data; - } catch (e) { - throw new Error(`[TMDB] Failed to fetch tv show details: ${e.message}`); - } - }; - - public getTvSeason = async ({ - tvId, - seasonNumber, - language, - }: { - tvId: number; - seasonNumber: number; - language?: string; - }): Promise => { - try { - const response = await this.axios.get( - `/tv/${tvId}/season/${seasonNumber}`, - { - params: { - language, - append_to_response: 'external_ids', - }, - } - ); - - return response.data; - } catch (e) { - throw new Error(`[TMDB] Failed to fetch tv show details: ${e.message}`); - } - }; - - public async getMovieRecommendations({ - movieId, - page = 1, - language = 'en-US', - }: { - movieId: number; - page?: number; - language?: string; - }): Promise { - try { - const response = await this.axios.get( - `/movie/${movieId}/recommendations`, - { - params: { - page, - language, - }, - } - ); - - return response.data; - } catch (e) { - throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`); - } - } - - public async getMovieSimilar({ - movieId, - page = 1, - language = 'en-US', - }: { - movieId: number; - page?: number; - language?: string; - }): Promise { - try { - const response = await this.axios.get( - `/movie/${movieId}/similar`, - { - params: { - page, - language, - }, - } - ); - - return response.data; - } catch (e) { - throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`); - } - } - - public async getMoviesByKeyword({ - keywordId, - page = 1, - language = 'en-US', - }: { - keywordId: number; - page?: number; - language?: string; - }): Promise { - try { - const response = await this.axios.get( - `/keyword/${keywordId}/movies`, - { - params: { - page, - language, - }, - } - ); - - return response.data; - } catch (e) { - throw new Error(`[TMDB] Failed to fetch movies by keyword: ${e.message}`); - } - } - - public async getTvRecommendations({ - tvId, - page = 1, - language = 'en-US', - }: { - tvId: number; - page?: number; - language?: string; - }): Promise { - try { - const response = await this.axios.get( - `/tv/${tvId}/recommendations`, - { - params: { - page, - language, - }, - } - ); - - return response.data; - } catch (e) { - throw new Error( - `[TMDB] Failed to fetch tv recommendations: ${e.message}` - ); - } - } - - public async getTvSimilar({ - tvId, - page = 1, - language = 'en-US', - }: { - tvId: number; - page?: number; - language?: string; - }): Promise { - try { - const response = await this.axios.get( - `/tv/${tvId}/similar`, - { - params: { - page, - language, - }, - } - ); - - return response.data; - } catch (e) { - throw new Error(`[TMDB] Failed to fetch tv similar: ${e.message}`); - } - } - - public getDiscoverMovies = async ({ - sortBy = 'popularity.desc', - page = 1, - includeAdult = false, - language = 'en-US', - }: DiscoverMovieOptions = {}): Promise => { - try { - const response = await this.axios.get( - '/discover/movie', - { - params: { - sort_by: sortBy, - page, - include_adult: includeAdult, - language, - }, - } - ); - - return response.data; - } catch (e) { - throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`); - } - }; - - public getDiscoverTv = async ({ - sortBy = 'popularity.desc', - page = 1, - language = 'en-US', - }: DiscoverTvOptions = {}): Promise => { - try { - const response = await this.axios.get( - '/discover/tv', - { - params: { - sort_by: sortBy, - page, - language, - }, - } - ); - - return response.data; - } catch (e) { - throw new Error(`[TMDB] Failed to fetch discover tv: ${e.message}`); - } - }; - - public getUpcomingMovies = async ({ - page = 1, - language = 'en-US', - }: { - page: number; - language: string; - }): Promise => { - try { - const response = await this.axios.get( - '/movie/upcoming', - { - params: { - page, - language, - }, - } - ); - - return response.data; - } catch (e) { - throw new Error(`[TMDB] Failed to fetch upcoming movies: ${e.message}`); - } - }; - - public getAllTrending = async ({ - page = 1, - timeWindow = 'day', - language = 'en-US', - }: { - page?: number; - timeWindow?: 'day' | 'week'; - language?: string; - } = {}): Promise => { - try { - const response = await this.axios.get( - `/trending/all/${timeWindow}`, - { - params: { - page, - language, - }, - } - ); - - return response.data; - } catch (e) { - throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`); - } - }; - - public getMovieTrending = async ({ - page = 1, - timeWindow = 'day', - }: { - page?: number; - timeWindow?: 'day' | 'week'; - } = {}): Promise => { - try { - const response = await this.axios.get( - `/trending/movie/${timeWindow}`, - { - params: { - page, - }, - } - ); - - return response.data; - } catch (e) { - throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`); - } - }; - - public getTvTrending = async ({ - page = 1, - timeWindow = 'day', - }: { - page?: number; - timeWindow?: 'day' | 'week'; - } = {}): Promise => { - try { - const response = await this.axios.get( - `/trending/tv/${timeWindow}`, - { - params: { - page, - }, - } - ); - - return response.data; - } catch (e) { - throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`); - } - }; - - public async getByExternalId({ - externalId, - type, - language = 'en-US', - }: - | { - externalId: string; - type: 'imdb'; - language?: string; - } - | { - externalId: number; - type: 'tvdb'; - language?: string; - }): Promise { - try { - const response = await this.axios.get( - `/find/${externalId}`, - { - params: { - external_source: type === 'imdb' ? 'imdb_id' : 'tvdb_id', - language, - }, - } - ); - - return response.data; - } catch (e) { - throw new Error(`[TMDB] Failed to find by external ID: ${e.message}`); - } - } - - public async getMovieByImdbId({ - imdbId, - language = 'en-US', - }: { - imdbId: string; - language?: string; - }): Promise { - try { - const extResponse = await this.getByExternalId({ - externalId: imdbId, - type: 'imdb', - }); - - if (extResponse.movie_results[0]) { - const movie = await this.getMovie({ - movieId: extResponse.movie_results[0].id, - language, - }); - - return movie; - } - - throw new Error( - '[TMDB] Failed to find a title with the provided IMDB id' - ); - } catch (e) { - throw new Error( - `[TMDB] Failed to get movie by external imdb ID: ${e.message}` - ); - } - } - - public async getShowByTvdbId({ - tvdbId, - language = 'en-US', - }: { - tvdbId: number; - language?: string; - }): Promise { - try { - const extResponse = await this.getByExternalId({ - externalId: tvdbId, - type: 'tvdb', - }); - - if (extResponse.tv_results[0]) { - const tvshow = await this.getTvShow({ - tvId: extResponse.tv_results[0].id, - language, - }); - - return tvshow; - } - - throw new Error( - `[TMDB] Failed to find a TV show with the provided TVDB ID: ${tvdbId}` - ); - } catch (e) { - throw new Error( - `[TMDB] Failed to get TV show using the external TVDB ID: ${e.message}` - ); - } - } - - public async getCollection({ - collectionId, - language = 'en-US', - }: { - collectionId: number; - language?: string; - }): Promise { - try { - const response = await this.axios.get( - `/collection/${collectionId}`, - { - params: { - language, - }, - } - ); - - return response.data; - } catch (e) { - throw new Error(`[TMDB] Failed to fetch collection: ${e.message}`); - } - } -} - -export default TheMovieDb; diff --git a/server/api/themoviedb/constants.ts b/server/api/themoviedb/constants.ts new file mode 100644 index 00000000..be475f72 --- /dev/null +++ b/server/api/themoviedb/constants.ts @@ -0,0 +1 @@ +export const ANIME_KEYWORD_ID = 210024; diff --git a/server/api/themoviedb/index.ts b/server/api/themoviedb/index.ts new file mode 100644 index 00000000..bf716385 --- /dev/null +++ b/server/api/themoviedb/index.ts @@ -0,0 +1,599 @@ +import cacheManager from '../../lib/cache'; +import ExternalAPI from '../externalapi'; +import { + TmdbCollection, + TmdbExternalIdResponse, + TmdbMovieDetails, + TmdbPersonCombinedCredits, + TmdbPersonDetail, + TmdbSearchMovieResponse, + TmdbSearchMultiResponse, + TmdbSearchTvResponse, + TmdbSeasonWithEpisodes, + TmdbTvDetails, + TmdbUpcomingMoviesResponse, +} from './interfaces'; + +interface SearchOptions { + query: string; + page?: number; + includeAdult?: boolean; + language?: string; +} + +interface DiscoverMovieOptions { + page?: number; + includeAdult?: boolean; + language?: string; + sortBy?: + | 'popularity.asc' + | 'popularity.desc' + | 'release_date.asc' + | 'release_date.desc' + | 'revenue.asc' + | 'revenue.desc' + | 'primary_release_date.asc' + | 'primary_release_date.desc' + | 'original_title.asc' + | 'original_title.desc' + | 'vote_average.asc' + | 'vote_average.desc' + | 'vote_count.asc' + | 'vote_count.desc'; +} + +interface DiscoverTvOptions { + page?: number; + language?: string; + sortBy?: + | 'popularity.asc' + | 'popularity.desc' + | 'vote_average.asc' + | 'vote_average.desc' + | 'vote_count.asc' + | 'vote_count.desc' + | 'first_air_date.asc' + | 'first_air_date.desc'; +} + +class TheMovieDb extends ExternalAPI { + constructor() { + super( + 'https://api.themoviedb.org/3', + { + api_key: 'db55323b8d3e4154498498a75642b381', + }, + { + nodeCache: cacheManager.getCache('tmdb').data, + } + ); + } + + public searchMulti = async ({ + query, + page = 1, + includeAdult = false, + language = 'en', + }: SearchOptions): Promise => { + try { + const data = await this.get('/search/multi', { + params: { query, page, include_adult: includeAdult, language }, + }); + + return data; + } catch (e) { + return { + page: 1, + results: [], + total_pages: 1, + total_results: 0, + }; + } + }; + + public getPerson = async ({ + personId, + language = 'en', + }: { + personId: number; + language?: string; + }): Promise => { + try { + const data = await this.get(`/person/${personId}`, { + params: { language }, + }); + + return data; + } catch (e) { + throw new Error(`[TMDB] Failed to fetch person details: ${e.message}`); + } + }; + + public getPersonCombinedCredits = async ({ + personId, + language = 'en', + }: { + personId: number; + language?: string; + }): Promise => { + try { + const data = await this.get( + `/person/${personId}/combined_credits`, + { + params: { language }, + } + ); + + return data; + } catch (e) { + throw new Error( + `[TMDB] Failed to fetch person combined credits: ${e.message}` + ); + } + }; + + public getMovie = async ({ + movieId, + language = 'en', + }: { + movieId: number; + language?: string; + }): Promise => { + try { + const data = await this.get( + `/movie/${movieId}`, + { + params: { + language, + append_to_response: 'credits,external_ids,videos', + }, + }, + 900 + ); + + return data; + } catch (e) { + throw new Error(`[TMDB] Failed to fetch movie details: ${e.message}`); + } + }; + + public getTvShow = async ({ + tvId, + language = 'en', + }: { + tvId: number; + language?: string; + }): Promise => { + try { + const data = await this.get( + `/tv/${tvId}`, + { + params: { + language, + append_to_response: + 'aggregate_credits,credits,external_ids,keywords,videos', + }, + }, + 900 + ); + + return data; + } catch (e) { + throw new Error(`[TMDB] Failed to fetch tv show details: ${e.message}`); + } + }; + + public getTvSeason = async ({ + tvId, + seasonNumber, + language, + }: { + tvId: number; + seasonNumber: number; + language?: string; + }): Promise => { + try { + const data = await this.get( + `/tv/${tvId}/season/${seasonNumber}`, + { + params: { + language, + append_to_response: 'external_ids', + }, + } + ); + + return data; + } catch (e) { + throw new Error(`[TMDB] Failed to fetch tv show details: ${e.message}`); + } + }; + + public async getMovieRecommendations({ + movieId, + page = 1, + language = 'en', + }: { + movieId: number; + page?: number; + language?: string; + }): Promise { + try { + const data = await this.get( + `/movie/${movieId}/recommendations`, + { + params: { + page, + language, + }, + } + ); + + return data; + } catch (e) { + throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`); + } + } + + public async getMovieSimilar({ + movieId, + page = 1, + language = 'en', + }: { + movieId: number; + page?: number; + language?: string; + }): Promise { + try { + const data = await this.get( + `/movie/${movieId}/similar`, + { + params: { + page, + language, + }, + } + ); + + return data; + } catch (e) { + throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`); + } + } + + public async getMoviesByKeyword({ + keywordId, + page = 1, + language = 'en', + }: { + keywordId: number; + page?: number; + language?: string; + }): Promise { + try { + const data = await this.get( + `/keyword/${keywordId}/movies`, + { + params: { + page, + language, + }, + } + ); + + return data; + } catch (e) { + throw new Error(`[TMDB] Failed to fetch movies by keyword: ${e.message}`); + } + } + + public async getTvRecommendations({ + tvId, + page = 1, + language = 'en', + }: { + tvId: number; + page?: number; + language?: string; + }): Promise { + try { + const data = await this.get( + `/tv/${tvId}/recommendations`, + { + params: { + page, + language, + }, + } + ); + + return data; + } catch (e) { + throw new Error( + `[TMDB] Failed to fetch tv recommendations: ${e.message}` + ); + } + } + + public async getTvSimilar({ + tvId, + page = 1, + language = 'en', + }: { + tvId: number; + page?: number; + language?: string; + }): Promise { + try { + const data = await this.get(`/tv/${tvId}/similar`, { + params: { + page, + language, + }, + }); + + return data; + } catch (e) { + throw new Error(`[TMDB] Failed to fetch tv similar: ${e.message}`); + } + } + + public getDiscoverMovies = async ({ + sortBy = 'popularity.desc', + page = 1, + includeAdult = false, + language = 'en', + }: DiscoverMovieOptions = {}): Promise => { + try { + const data = await this.get('/discover/movie', { + params: { + sort_by: sortBy, + page, + include_adult: includeAdult, + language, + }, + }); + + return data; + } catch (e) { + throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`); + } + }; + + public getDiscoverTv = async ({ + sortBy = 'popularity.desc', + page = 1, + language = 'en', + }: DiscoverTvOptions = {}): Promise => { + try { + const data = await this.get('/discover/tv', { + params: { + sort_by: sortBy, + page, + language, + }, + }); + + return data; + } catch (e) { + throw new Error(`[TMDB] Failed to fetch discover tv: ${e.message}`); + } + }; + + public getUpcomingMovies = async ({ + page = 1, + language = 'en', + }: { + page: number; + language: string; + }): Promise => { + try { + const data = await this.get( + '/movie/upcoming', + { + params: { + page, + language, + }, + } + ); + + return data; + } catch (e) { + throw new Error(`[TMDB] Failed to fetch upcoming movies: ${e.message}`); + } + }; + + public getAllTrending = async ({ + page = 1, + timeWindow = 'day', + language = 'en', + }: { + page?: number; + timeWindow?: 'day' | 'week'; + language?: string; + } = {}): Promise => { + try { + const data = await this.get( + `/trending/all/${timeWindow}`, + { + params: { + page, + language, + }, + } + ); + + return data; + } catch (e) { + throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`); + } + }; + + public getMovieTrending = async ({ + page = 1, + timeWindow = 'day', + }: { + page?: number; + timeWindow?: 'day' | 'week'; + } = {}): Promise => { + try { + const data = await this.get( + `/trending/movie/${timeWindow}`, + { + params: { + page, + }, + } + ); + + return data; + } catch (e) { + throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`); + } + }; + + public getTvTrending = async ({ + page = 1, + timeWindow = 'day', + }: { + page?: number; + timeWindow?: 'day' | 'week'; + } = {}): Promise => { + try { + const data = await this.get( + `/trending/tv/${timeWindow}`, + { + params: { + page, + }, + } + ); + + return data; + } catch (e) { + throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`); + } + }; + + public async getByExternalId({ + externalId, + type, + language = 'en', + }: + | { + externalId: string; + type: 'imdb'; + language?: string; + } + | { + externalId: number; + type: 'tvdb'; + language?: string; + }): Promise { + try { + const data = await this.get( + `/find/${externalId}`, + { + params: { + external_source: type === 'imdb' ? 'imdb_id' : 'tvdb_id', + language, + }, + } + ); + + return data; + } catch (e) { + throw new Error(`[TMDB] Failed to find by external ID: ${e.message}`); + } + } + + public async getMovieByImdbId({ + imdbId, + language = 'en', + }: { + imdbId: string; + language?: string; + }): Promise { + try { + const extResponse = await this.getByExternalId({ + externalId: imdbId, + type: 'imdb', + }); + + if (extResponse.movie_results[0]) { + const movie = await this.getMovie({ + movieId: extResponse.movie_results[0].id, + language, + }); + + return movie; + } + + throw new Error( + '[TMDB] Failed to find a title with the provided IMDB id' + ); + } catch (e) { + throw new Error( + `[TMDB] Failed to get movie by external imdb ID: ${e.message}` + ); + } + } + + public async getShowByTvdbId({ + tvdbId, + language = 'en', + }: { + tvdbId: number; + language?: string; + }): Promise { + try { + const extResponse = await this.getByExternalId({ + externalId: tvdbId, + type: 'tvdb', + }); + + if (extResponse.tv_results[0]) { + const tvshow = await this.getTvShow({ + tvId: extResponse.tv_results[0].id, + language, + }); + + return tvshow; + } + + throw new Error( + `[TMDB] Failed to find a TV show with the provided TVDB ID: ${tvdbId}` + ); + } catch (e) { + throw new Error( + `[TMDB] Failed to get TV show using the external TVDB ID: ${e.message}` + ); + } + } + + public async getCollection({ + collectionId, + language = 'en', + }: { + collectionId: number; + language?: string; + }): Promise { + try { + const data = await this.get( + `/collection/${collectionId}`, + { + params: { + language, + }, + } + ); + + return data; + } catch (e) { + throw new Error(`[TMDB] Failed to fetch collection: ${e.message}`); + } + } +} + +export default TheMovieDb; diff --git a/server/api/themoviedb/interfaces.ts b/server/api/themoviedb/interfaces.ts new file mode 100644 index 00000000..63b0ba9a --- /dev/null +++ b/server/api/themoviedb/interfaces.ts @@ -0,0 +1,346 @@ +interface TmdbMediaResult { + id: number; + media_type: string; + popularity: number; + poster_path?: string; + backdrop_path?: string; + vote_count: number; + vote_average: number; + genre_ids: number[]; + overview: string; + original_language: string; +} + +export interface TmdbMovieResult extends TmdbMediaResult { + media_type: 'movie'; + title: string; + original_title: string; + release_date: string; + adult: boolean; + video: boolean; +} + +export interface TmdbTvResult extends TmdbMediaResult { + media_type: 'tv'; + name: string; + original_name: string; + origin_country: string[]; + first_air_date: string; +} + +export interface TmdbPersonResult { + id: number; + name: string; + popularity: number; + profile_path?: string; + adult: boolean; + media_type: 'person'; + known_for: (TmdbMovieResult | TmdbTvResult)[]; +} + +interface TmdbPaginatedResponse { + page: number; + total_results: number; + total_pages: number; +} + +export interface TmdbSearchMultiResponse extends TmdbPaginatedResponse { + results: (TmdbMovieResult | TmdbTvResult | TmdbPersonResult)[]; +} + +export interface TmdbSearchMovieResponse extends TmdbPaginatedResponse { + results: TmdbMovieResult[]; +} + +export interface TmdbSearchTvResponse extends TmdbPaginatedResponse { + results: TmdbTvResult[]; +} + +export interface TmdbUpcomingMoviesResponse extends TmdbPaginatedResponse { + dates: { + maximum: string; + minimum: string; + }; + results: TmdbMovieResult[]; +} + +export interface TmdbExternalIdResponse { + movie_results: TmdbMovieResult[]; + tv_results: TmdbTvResult[]; +} + +export interface TmdbCreditCast { + cast_id: number; + character: string; + credit_id: string; + gender?: number; + id: number; + name: string; + order: number; + profile_path?: string; +} + +export interface TmdbAggregateCreditCast extends TmdbCreditCast { + roles: { + credit_id: string; + character: string; + episode_count: number; + }[]; +} + +export interface TmdbCreditCrew { + credit_id: string; + gender?: number; + id: number; + name: string; + profile_path?: string; + job: string; + department: string; +} + +export interface TmdbExternalIds { + imdb_id?: string; + freebase_mid?: string; + freebase_id?: string; + tvdb_id?: number; + tvrage_id?: string; + facebook_id?: string; + instagram_id?: string; + twitter_id?: string; +} + +export interface TmdbMovieDetails { + id: number; + imdb_id?: string; + adult: boolean; + backdrop_path?: string; + poster_path?: string; + budget: number; + genres: { + id: number; + name: string; + }[]; + homepage?: string; + original_language: string; + original_title: string; + overview?: string; + popularity: number; + production_companies: { + id: number; + name: string; + logo_path?: string; + origin_country: string; + }[]; + production_countries: { + iso_3166_1: string; + name: string; + }[]; + release_date: string; + revenue: number; + runtime?: number; + spoken_languages: { + iso_639_1: string; + name: string; + }[]; + status: string; + tagline?: string; + title: string; + video: boolean; + vote_average: number; + vote_count: number; + credits: { + cast: TmdbCreditCast[]; + crew: TmdbCreditCrew[]; + }; + belongs_to_collection?: { + id: number; + name: string; + poster_path?: string; + backdrop_path?: string; + }; + external_ids: TmdbExternalIds; + videos: TmdbVideoResult; +} + +export interface TmdbVideo { + id: string; + key: string; + name: string; + site: 'YouTube'; + size: number; + type: + | 'Clip' + | 'Teaser' + | 'Trailer' + | 'Featurette' + | 'Opening Credits' + | 'Behind the Scenes' + | 'Bloopers'; +} + +export interface TmdbTvEpisodeResult { + id: number; + air_date: string; + episode_number: number; + name: string; + overview: string; + production_code: string; + season_number: number; + show_id: number; + still_path: string; + vote_average: number; + vote_cuont: number; +} + +export interface TmdbTvSeasonResult { + id: number; + air_date: string; + episode_count: number; + name: string; + overview: string; + poster_path?: string; + season_number: number; +} + +export interface TmdbTvDetails { + id: number; + backdrop_path?: string; + created_by: { + id: number; + credit_id: string; + name: string; + gender: number; + profile_path?: string; + }[]; + episode_run_time: number[]; + first_air_date: string; + genres: { + id: number; + name: string; + }[]; + homepage: string; + in_production: boolean; + languages: string[]; + last_air_date: string; + last_episode_to_air?: TmdbTvEpisodeResult; + name: string; + next_episode_to_air?: TmdbTvEpisodeResult; + networks: { + id: number; + name: string; + logo_path: string; + origin_country: string; + }[]; + number_of_episodes: number; + number_of_seasons: number; + origin_country: string[]; + original_language: string; + original_name: string; + overview: string; + popularity: number; + poster_path?: string; + production_companies: { + id: number; + logo_path?: string; + name: string; + origin_country: string; + }[]; + spoken_languages: { + english_name: string; + iso_639_1: string; + name: string; + }[]; + seasons: TmdbTvSeasonResult[]; + status: string; + type: string; + vote_average: number; + vote_count: number; + aggregate_credits: { + cast: TmdbAggregateCreditCast[]; + }; + credits: { + crew: TmdbCreditCrew[]; + }; + external_ids: TmdbExternalIds; + keywords: { + results: TmdbKeyword[]; + }; + videos: TmdbVideoResult; +} + +export interface TmdbVideoResult { + results: TmdbVideo[]; +} + +export interface TmdbKeyword { + id: number; + name: string; +} + +export interface TmdbPersonDetail { + id: number; + name: string; + deathday: string; + known_for_department: string; + also_known_as?: string[]; + gender: number; + biography: string; + popularity: string; + place_of_birth?: string; + profile_path?: string; + adult: boolean; + imdb_id?: string; + homepage?: string; +} + +export interface TmdbPersonCredit { + id: number; + original_language: string; + episode_count: number; + overview: string; + origin_country: string[]; + original_name: string; + vote_count: number; + name: string; + media_type?: string; + popularity: number; + credit_id: string; + backdrop_path?: string; + first_air_date: string; + vote_average: number; + genre_ids?: number[]; + poster_path?: string; + original_title: string; + video?: boolean; + title: string; + adult: boolean; + release_date: string; +} +export interface TmdbPersonCreditCast extends TmdbPersonCredit { + character: string; +} + +export interface TmdbPersonCreditCrew extends TmdbPersonCredit { + department: string; + job: string; +} + +export interface TmdbPersonCombinedCredits { + id: number; + cast: TmdbPersonCreditCast[]; + crew: TmdbPersonCreditCrew[]; +} + +export interface TmdbSeasonWithEpisodes extends TmdbTvSeasonResult { + episodes: TmdbTvEpisodeResult[]; + external_ids: TmdbExternalIds; +} + +export interface TmdbCollection { + id: number; + name: string; + overview?: string; + poster_path?: string; + backdrop_path?: string; + parts: TmdbMovieResult[]; +} diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index 1ba74961..3c090035 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -15,7 +15,8 @@ import { User } from './User'; import Media from './Media'; import { MediaStatus, MediaRequestStatus, MediaType } from '../constants/media'; import { getSettings } from '../lib/settings'; -import TheMovieDb, { ANIME_KEYWORD_ID } from '../api/themoviedb'; +import TheMovieDb from '../api/themoviedb'; +import { ANIME_KEYWORD_ID } from '../api/themoviedb/constants'; import RadarrAPI from '../api/radarr'; import logger from '../logger'; import SeasonRequest from './SeasonRequest'; diff --git a/server/job/plexsync/index.ts b/server/job/plexsync/index.ts index cab4f5ef..3ed5870d 100644 --- a/server/job/plexsync/index.ts +++ b/server/job/plexsync/index.ts @@ -1,10 +1,11 @@ import { getRepository } from 'typeorm'; import { User } from '../../entity/User'; import PlexAPI, { PlexLibraryItem, PlexMetadata } from '../../api/plexapi'; -import TheMovieDb, { +import TheMovieDb from '../../api/themoviedb'; +import { TmdbMovieDetails, TmdbTvDetails, -} from '../../api/themoviedb'; +} from '../../api/themoviedb/interfaces'; import Media from '../../entity/Media'; import { MediaStatus, MediaType } from '../../constants/media'; import logger from '../../logger'; diff --git a/server/job/sonarrsync/index.ts b/server/job/sonarrsync/index.ts index 6ef45254..1fdbac0a 100644 --- a/server/job/sonarrsync/index.ts +++ b/server/job/sonarrsync/index.ts @@ -2,7 +2,8 @@ import { uniqWith } from 'lodash'; import { getRepository } from 'typeorm'; import { v4 as uuid } from 'uuid'; import SonarrAPI, { SonarrSeries } from '../../api/sonarr'; -import TheMovieDb, { TmdbTvDetails } from '../../api/themoviedb'; +import TheMovieDb from '../../api/themoviedb'; +import { TmdbTvDetails } from '../../api/themoviedb/interfaces'; import { MediaStatus, MediaType } from '../../constants/media'; import Media from '../../entity/Media'; import Season from '../../entity/Season'; diff --git a/server/lib/cache.ts b/server/lib/cache.ts new file mode 100644 index 00000000..5624527f --- /dev/null +++ b/server/lib/cache.ts @@ -0,0 +1,56 @@ +import NodeCache from 'node-cache'; + +type AvailableCacheIds = 'tmdb' | 'radarr' | 'sonarr' | 'rt'; + +interface Cache { + id: AvailableCacheIds; + data: NodeCache; +} + +const DEFAULT_TTL = 300; +const DEFAULT_CHECK_PERIOD = 120; + +class CacheManager { + private availableCaches: Record = { + tmdb: { + id: 'tmdb', + data: new NodeCache({ + stdTTL: DEFAULT_TTL, + checkperiod: DEFAULT_CHECK_PERIOD, + }), + }, + radarr: { + id: 'radarr', + data: new NodeCache({ + stdTTL: DEFAULT_TTL, + checkperiod: DEFAULT_CHECK_PERIOD, + }), + }, + sonarr: { + id: 'sonarr', + data: new NodeCache({ + stdTTL: DEFAULT_TTL, + checkperiod: DEFAULT_CHECK_PERIOD, + }), + }, + rt: { + id: 'rt', + data: new NodeCache({ + stdTTL: 21600, // 12 hours TTL + checkperiod: 60 * 30, // 30 minutes check period + }), + }, + }; + + public getCache(id: AvailableCacheIds): Cache { + return this.availableCaches[id]; + } + + public getAllCaches(): Record { + return this.availableCaches; + } +} + +const cacheManager = new CacheManager(); + +export default cacheManager; diff --git a/server/models/Collection.ts b/server/models/Collection.ts index 64709502..48112849 100644 --- a/server/models/Collection.ts +++ b/server/models/Collection.ts @@ -1,4 +1,4 @@ -import { TmdbCollection } from '../api/themoviedb'; +import type { TmdbCollection } from '../api/themoviedb/interfaces'; import { MediaType } from '../constants/media'; import Media from '../entity/Media'; import { mapMovieResult, MovieResult } from './Search'; diff --git a/server/models/Movie.ts b/server/models/Movie.ts index c8639613..bfeb95ac 100644 --- a/server/models/Movie.ts +++ b/server/models/Movie.ts @@ -1,4 +1,4 @@ -import { TmdbMovieDetails } from '../api/themoviedb'; +import type { TmdbMovieDetails } from '../api/themoviedb/interfaces'; import { ProductionCompany, Genre, diff --git a/server/models/Person.ts b/server/models/Person.ts index 575e40cc..522a8e5e 100644 --- a/server/models/Person.ts +++ b/server/models/Person.ts @@ -1,8 +1,8 @@ -import { +import type { TmdbPersonCreditCast, TmdbPersonCreditCrew, TmdbPersonDetail, -} from '../api/themoviedb'; +} from '../api/themoviedb/interfaces'; import Media from '../entity/Media'; export interface PersonDetail { diff --git a/server/models/Search.ts b/server/models/Search.ts index 7d347207..0dab4e58 100644 --- a/server/models/Search.ts +++ b/server/models/Search.ts @@ -2,7 +2,7 @@ import type { TmdbMovieResult, TmdbPersonResult, TmdbTvResult, -} from '../api/themoviedb'; +} from '../api/themoviedb/interfaces'; import { MediaType as MainMediaType } from '../constants/media'; import Media from '../entity/Media'; diff --git a/server/models/Tv.ts b/server/models/Tv.ts index 5f84315f..420dca28 100644 --- a/server/models/Tv.ts +++ b/server/models/Tv.ts @@ -10,12 +10,12 @@ import { Keyword, mapVideos, } from './common'; -import { +import type { TmdbTvEpisodeResult, TmdbTvSeasonResult, TmdbTvDetails, TmdbSeasonWithEpisodes, -} from '../api/themoviedb'; +} from '../api/themoviedb/interfaces'; import type Media from '../entity/Media'; import { Video } from './Movie'; diff --git a/server/models/common.ts b/server/models/common.ts index dcd81934..d26cf637 100644 --- a/server/models/common.ts +++ b/server/models/common.ts @@ -1,11 +1,11 @@ -import { +import type { TmdbCreditCast, TmdbAggregateCreditCast, TmdbCreditCrew, TmdbExternalIds, TmdbVideo, TmdbVideoResult, -} from '../api/themoviedb'; +} from '../api/themoviedb/interfaces'; import { Video } from '../models/Movie'; diff --git a/server/utils/typeHelpers.ts b/server/utils/typeHelpers.ts index e7d76d78..ca12ddf4 100644 --- a/server/utils/typeHelpers.ts +++ b/server/utils/typeHelpers.ts @@ -2,7 +2,7 @@ import type { TmdbMovieResult, TmdbTvResult, TmdbPersonResult, -} from '../api/themoviedb'; +} from '../api/themoviedb/interfaces'; export const isMovie = ( movie: TmdbMovieResult | TmdbTvResult | TmdbPersonResult diff --git a/src/components/RequestModal/TvRequestModal.tsx b/src/components/RequestModal/TvRequestModal.tsx index 3a331d89..172abdd9 100644 --- a/src/components/RequestModal/TvRequestModal.tsx +++ b/src/components/RequestModal/TvRequestModal.tsx @@ -6,7 +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 { ANIME_KEYWORD_ID } from '../../../server/api/themoviedb/constants'; import axios from 'axios'; import { MediaStatus, diff --git a/src/components/TvDetails/index.tsx b/src/components/TvDetails/index.tsx index c998e7b4..16977793 100644 --- a/src/components/TvDetails/index.tsx +++ b/src/components/TvDetails/index.tsx @@ -28,7 +28,7 @@ import RTAudFresh from '../../assets/rt_aud_fresh.svg'; import RTAudRotten from '../../assets/rt_aud_rotten.svg'; import type { RTRating } from '../../../server/api/rottentomatoes'; import Head from 'next/head'; -import { ANIME_KEYWORD_ID } from '../../../server/api/themoviedb'; +import { ANIME_KEYWORD_ID } from '../../../server/api/themoviedb/constants'; import ExternalLinkBlock from '../ExternalLinkBlock'; import { sortCrewPriority } from '../../utils/creditHelpers'; import { Crew } from '../../../server/models/common'; diff --git a/yarn.lock b/yarn.lock index d269381c..445f1d33 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4015,6 +4015,11 @@ clone-response@^1.0.2: dependencies: mimic-response "^1.0.0" +clone@2.x: + version "2.1.2" + resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f" + integrity sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18= + clone@^1.0.2: version "1.0.4" resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e" @@ -9469,6 +9474,13 @@ node-addon-api@^3.0.2: resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-3.0.2.tgz#04bc7b83fd845ba785bb6eae25bc857e1ef75681" integrity sha512-+D4s2HCnxPd5PjjI0STKwncjXTUKKqm74MDMz9OPXavjsGmjkvwgLtA5yoxJUdmpj52+2u+RrXgPipahKczMKg== +node-cache@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/node-cache/-/node-cache-5.1.2.tgz#f264dc2ccad0a780e76253a694e9fd0ed19c398d" + integrity sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg== + dependencies: + clone "2.x" + node-emoji@^1.10.0, node-emoji@^1.8.1: version "1.10.0" resolved "https://registry.yarnpkg.com/node-emoji/-/node-emoji-1.10.0.tgz#8886abd25d9c7bb61802a658523d1f8d2a89b2da" From 3c5ae360fd179d794a78cc918fe97a09216ca6b2 Mon Sep 17 00:00:00 2001 From: TheCatLady <52870424+TheCatLady@users.noreply.github.com> Date: Sun, 31 Jan 2021 04:51:33 -0500 Subject: [PATCH 06/66] fix(lang): translate language names & change zh-Hant language code to zh-TW (#793) --- .../Layout/LanguagePicker/index.tsx | 62 +++++++++---------- src/context/LanguageContext.tsx | 10 +-- src/pages/_app.tsx | 22 +++---- 3 files changed, 47 insertions(+), 47 deletions(-) diff --git a/src/components/Layout/LanguagePicker/index.tsx b/src/components/Layout/LanguagePicker/index.tsx index c3677c67..db7a5c33 100644 --- a/src/components/Layout/LanguagePicker/index.tsx +++ b/src/components/Layout/LanguagePicker/index.tsx @@ -17,61 +17,61 @@ type AvailableLanguageObject = Record< >; const availableLanguages: AvailableLanguageObject = { + de: { + code: 'de', + display: 'Deutsch', + }, en: { code: 'en', display: 'English', }, - ja: { - code: 'ja', - display: 'Japanese', + es: { + code: 'es', + display: 'Español', }, fr: { code: 'fr', display: 'Français', }, - 'nb-NO': { - code: 'nb-NO', - display: 'Norwegian Bokmål', - }, - de: { - code: 'de', - display: 'German', - }, - ru: { - code: 'ru', - display: 'Russian', + it: { + code: 'it', + display: 'Italiano', }, nl: { code: 'nl', display: 'Nederlands', }, - es: { - code: 'es', - display: 'Spanish', - }, - it: { - code: 'it', - display: 'Italian', + 'nb-NO': { + code: 'nb-NO', + display: 'Norsk Bokmål', }, 'pt-BR': { code: 'pt-BR', - display: 'Portuguese (Brazil)', + display: 'Português (Brasil)', }, 'pt-PT': { code: 'pt-PT', - display: 'Portuguese (Portugal)', - }, - sr: { - code: 'sr', - display: 'Serbian', + display: 'Português (Portugal)', }, sv: { code: 'sv', - display: 'Swedish', + display: 'Svenska', }, - 'zh-Hant': { - code: 'zh-Hant', - display: 'Chinese (Traditional)', + ru: { + code: 'ru', + display: 'pусский', + }, + sr: { + code: 'sr', + display: 'српски језик‬', + }, + ja: { + code: 'ja', + display: '日本語', + }, + 'zh-TW': { + code: 'zh-TW', + display: '中文(臺灣)', }, }; diff --git a/src/context/LanguageContext.tsx b/src/context/LanguageContext.tsx index a4c95873..d55797ab 100644 --- a/src/context/LanguageContext.tsx +++ b/src/context/LanguageContext.tsx @@ -1,20 +1,20 @@ import React, { ReactNode } from 'react'; export type AvailableLocales = + | 'de' | 'en' + | 'es' + | 'it' | 'ja' | 'fr' | 'nb-NO' - | 'de' - | 'ru' | 'nl' - | 'es' - | 'it' | 'pt-BR' | 'pt-PT' + | 'ru' | 'sr' | 'sv' - | 'zh-Hant'; + | 'zh-TW'; interface LanguageContextProps { locale: AvailableLocales; diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 2009f560..86f54615 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -20,31 +20,31 @@ import { SettingsProvider } from '../context/SettingsContext'; // eslint-disable-next-line @typescript-eslint/no-explicit-any const loadLocaleData = (locale: AvailableLocales): Promise => { switch (locale) { - case 'ja': - return import('../i18n/locale/ja.json'); - case 'fr': - return import('../i18n/locale/fr.json'); - case 'nb-NO': - return import('../i18n/locale/nb_NO.json'); case 'de': return import('../i18n/locale/de.json'); - case 'ru': - return import('../i18n/locale/ru.json'); - case 'nl': - return import('../i18n/locale/nl.json'); case 'es': return import('../i18n/locale/es.json'); + case 'fr': + return import('../i18n/locale/fr.json'); case 'it': return import('../i18n/locale/it.json'); + case 'ja': + return import('../i18n/locale/ja.json'); + case 'nb-NO': + return import('../i18n/locale/nb_NO.json'); + case 'nl': + return import('../i18n/locale/nl.json'); case 'pt-BR': return import('../i18n/locale/pt_BR.json'); case 'pt-PT': return import('../i18n/locale/pt_PT.json'); + case 'ru': + return import('../i18n/locale/ru.json'); case 'sr': return import('../i18n/locale/sr.json'); case 'sv': return import('../i18n/locale/sv.json'); - case 'zh-Hant': + case 'zh-TW': return import('../i18n/locale/zh_Hant.json'); default: return import('../i18n/locale/en.json'); From 996bd9f14ed0f56767892c169b071be4f0f628d0 Mon Sep 17 00:00:00 2001 From: sct Date: Sun, 31 Jan 2021 13:11:12 +0000 Subject: [PATCH 07/66] feat(cache): add cache table and flush cache option to settings also increases tmdb cache times to about 6 hours (12 hours for detail requests) --- overseerr-api.yml | 50 +++++ server/api/themoviedb/index.ts | 4 +- server/interfaces/api/settingsInterfaces.ts | 12 ++ server/lib/cache.ts | 72 ++++--- server/routes/settings/index.ts | 27 +++ .../RequestModal/AdvancedRequester/index.tsx | 13 +- src/components/Settings/SettingsJobs.tsx | 118 ----------- .../Settings/SettingsJobsCache/index.tsx | 198 ++++++++++++++++++ src/components/Settings/SettingsLayout.tsx | 6 +- src/i18n/locale/en.json | 28 ++- src/pages/settings/jobs.tsx | 2 +- src/utils/numberHelpers.ts | 11 + 12 files changed, 363 insertions(+), 178 deletions(-) delete mode 100644 src/components/Settings/SettingsJobs.tsx create mode 100644 src/components/Settings/SettingsJobsCache/index.tsx create mode 100644 src/utils/numberHelpers.ts diff --git a/overseerr-api.yml b/overseerr-api.yml index 209b5ac2..56ae1916 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -2024,6 +2024,56 @@ paths: running: type: boolean example: false + /settings/cache: + get: + summary: Get a list of active caches + description: Retrieves a list of all active caches and their current stats. + tags: + - settings + responses: + '200': + description: Caches returned + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: string + example: cache-id + name: + type: string + example: cache name + stats: + type: object + properties: + hits: + type: number + misses: + type: number + keys: + type: number + ksize: + type: number + vsize: + type: number + /settings/cache/{cacheId}/flush: + get: + summary: Flush a specific cache + description: Flushes all data from the cache ID provided + tags: + - settings + parameters: + - in: path + name: cacheId + required: true + schema: + type: string + responses: + '204': + description: 'Flushed cache' /settings/notifications: get: summary: Return notification settings diff --git a/server/api/themoviedb/index.ts b/server/api/themoviedb/index.ts index bf716385..be1a629e 100644 --- a/server/api/themoviedb/index.ts +++ b/server/api/themoviedb/index.ts @@ -148,7 +148,7 @@ class TheMovieDb extends ExternalAPI { append_to_response: 'credits,external_ids,videos', }, }, - 900 + 43200 ); return data; @@ -174,7 +174,7 @@ class TheMovieDb extends ExternalAPI { 'aggregate_credits,credits,external_ids,keywords,videos', }, }, - 900 + 43200 ); return data; diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts index 52be4c4c..41abaf91 100644 --- a/server/interfaces/api/settingsInterfaces.ts +++ b/server/interfaces/api/settingsInterfaces.ts @@ -11,3 +11,15 @@ export interface PublicSettingsResponse { series4kEnabled: boolean; hideAvailable: boolean; } + +export interface CacheItem { + id: string; + name: string; + stats: { + hits: number; + misses: number; + keys: number; + ksize: number; + vsize: number; + }; +} diff --git a/server/lib/cache.ts b/server/lib/cache.ts index 5624527f..aaf3bd44 100644 --- a/server/lib/cache.ts +++ b/server/lib/cache.ts @@ -1,45 +1,49 @@ import NodeCache from 'node-cache'; -type AvailableCacheIds = 'tmdb' | 'radarr' | 'sonarr' | 'rt'; - -interface Cache { - id: AvailableCacheIds; - data: NodeCache; -} +export type AvailableCacheIds = 'tmdb' | 'radarr' | 'sonarr' | 'rt'; const DEFAULT_TTL = 300; const DEFAULT_CHECK_PERIOD = 120; +class Cache { + public id: AvailableCacheIds; + public data: NodeCache; + public name: string; + + constructor( + id: AvailableCacheIds, + name: string, + options: { stdTtl?: number; checkPeriod?: number } = {} + ) { + this.id = id; + this.name = name; + this.data = new NodeCache({ + stdTTL: options.stdTtl ?? DEFAULT_TTL, + checkperiod: options.checkPeriod ?? DEFAULT_CHECK_PERIOD, + }); + } + + public getStats() { + return this.data.getStats(); + } + + public flush(): void { + this.data.flushAll(); + } +} + class CacheManager { private availableCaches: Record = { - tmdb: { - id: 'tmdb', - data: new NodeCache({ - stdTTL: DEFAULT_TTL, - checkperiod: DEFAULT_CHECK_PERIOD, - }), - }, - radarr: { - id: 'radarr', - data: new NodeCache({ - stdTTL: DEFAULT_TTL, - checkperiod: DEFAULT_CHECK_PERIOD, - }), - }, - sonarr: { - id: 'sonarr', - data: new NodeCache({ - stdTTL: DEFAULT_TTL, - checkperiod: DEFAULT_CHECK_PERIOD, - }), - }, - rt: { - id: 'rt', - data: new NodeCache({ - stdTTL: 21600, // 12 hours TTL - checkperiod: 60 * 30, // 30 minutes check period - }), - }, + tmdb: new Cache('tmdb', 'TMDb API', { + stdTtl: 21600, + checkPeriod: 60 * 30, + }), + radarr: new Cache('radarr', 'Radarr API'), + sonarr: new Cache('sonarr', 'Sonarr API'), + rt: new Cache('rt', 'Rotten Tomatoes API', { + stdTtl: 43200, + checkPeriod: 60 * 30, + }), }; public getCache(id: AvailableCacheIds): Cache { diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 1d87c12e..61dabe21 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -16,6 +16,7 @@ import { SettingsAboutResponse } from '../../interfaces/api/settingsInterfaces'; import notificationRoutes from './notifications'; import sonarrRoutes from './sonarr'; import radarrRoutes from './radarr'; +import cacheManager, { AvailableCacheIds } from '../../lib/cache'; const settingsRoutes = Router(); @@ -273,6 +274,32 @@ settingsRoutes.get<{ jobId: string }>( } ); +settingsRoutes.get('/cache', (req, res) => { + const caches = cacheManager.getAllCaches(); + + return res.status(200).json( + Object.values(caches).map((cache) => ({ + id: cache.id, + name: cache.name, + stats: cache.getStats(), + })) + ); +}); + +settingsRoutes.get<{ cacheId: AvailableCacheIds }>( + '/cache/:cacheId/flush', + (req, res, next) => { + const cache = cacheManager.getCache(req.params.cacheId); + + if (cache) { + cache.flush(); + return res.status(204).send(); + } + + next({ status: 404, message: 'Cache does not exist.' }); + } +); + settingsRoutes.get( '/initialize', isAuthenticated(Permission.ADMIN), diff --git a/src/components/RequestModal/AdvancedRequester/index.tsx b/src/components/RequestModal/AdvancedRequester/index.tsx index 70f59eb9..0d5a4dea 100644 --- a/src/components/RequestModal/AdvancedRequester/index.tsx +++ b/src/components/RequestModal/AdvancedRequester/index.tsx @@ -7,18 +7,7 @@ import type { 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]; -}; +import { formatBytes } from '../../../utils/numberHelpers'; const messages = defineMessages({ advancedoptions: 'Advanced Options', diff --git a/src/components/Settings/SettingsJobs.tsx b/src/components/Settings/SettingsJobs.tsx deleted file mode 100644 index bf891fc5..00000000 --- a/src/components/Settings/SettingsJobs.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import React from 'react'; -import useSWR from 'swr'; -import LoadingSpinner from '../Common/LoadingSpinner'; -import { FormattedRelativeTime, defineMessages, useIntl } from 'react-intl'; -import Button from '../Common/Button'; -import Table from '../Common/Table'; -import Spinner from '../../assets/spinner.svg'; -import axios from 'axios'; -import { useToasts } from 'react-toast-notifications'; -import Badge from '../Common/Badge'; - -const messages = defineMessages({ - jobname: 'Job Name', - jobtype: 'Type', - nextexecution: 'Next Execution', - runnow: 'Run Now', - canceljob: 'Cancel Job', - jobstarted: '{jobname} started.', - jobcancelled: '{jobname} cancelled.', -}); - -interface Job { - id: string; - name: string; - type: 'process' | 'command'; - nextExecutionTime: string; - running: boolean; -} - -const SettingsJobs: React.FC = () => { - const intl = useIntl(); - const { addToast } = useToasts(); - const { data, error, revalidate } = useSWR('/api/v1/settings/jobs', { - refreshInterval: 5000, - }); - - if (!data && !error) { - return ; - } - - const runJob = async (job: Job) => { - await axios.get(`/api/v1/settings/jobs/${job.id}/run`); - addToast( - intl.formatMessage(messages.jobstarted, { - jobname: job.name, - }), - { - appearance: 'success', - autoDismiss: true, - } - ); - revalidate(); - }; - - const cancelJob = async (job: Job) => { - await axios.get(`/api/v1/settings/jobs/${job.id}/cancel`); - addToast(intl.formatMessage(messages.jobcancelled, { jobname: job.name }), { - appearance: 'error', - autoDismiss: true, - }); - revalidate(); - }; - - return ( - - - {intl.formatMessage(messages.jobname)} - {intl.formatMessage(messages.jobtype)} - {intl.formatMessage(messages.nextexecution)} - - - - {data?.map((job) => ( - - -
- {job.running && } - {job.name} -
-
- - - {job.type} - - - -
- -
-
- - {job.running ? ( - - ) : ( - - )} - - - ))} - -
- ); -}; - -export default SettingsJobs; diff --git a/src/components/Settings/SettingsJobsCache/index.tsx b/src/components/Settings/SettingsJobsCache/index.tsx new file mode 100644 index 00000000..72493b2b --- /dev/null +++ b/src/components/Settings/SettingsJobsCache/index.tsx @@ -0,0 +1,198 @@ +import React from 'react'; +import useSWR from 'swr'; +import LoadingSpinner from '../../Common/LoadingSpinner'; +import { FormattedRelativeTime, defineMessages, useIntl } from 'react-intl'; +import Button from '../../Common/Button'; +import Table from '../../Common/Table'; +import Spinner from '../../../assets/spinner.svg'; +import axios from 'axios'; +import { useToasts } from 'react-toast-notifications'; +import Badge from '../../Common/Badge'; +import { CacheItem } from '../../../../server/interfaces/api/settingsInterfaces'; +import { formatBytes } from '../../../utils/numberHelpers'; + +const messages = defineMessages({ + jobs: 'Jobs', + jobsDescription: + 'Overseerr performs certain maintenance tasks as regularly-scheduled jobs, but they can also be manually triggered below. Manually running a job will not alter its schedule.', + jobname: 'Job Name', + jobtype: 'Type', + nextexecution: 'Next Execution', + runnow: 'Run Now', + canceljob: 'Cancel Job', + jobstarted: '{jobname} started.', + jobcancelled: '{jobname} cancelled.', + cache: 'Cache', + cacheDescription: + 'Overseerr caches requests to external API endpoints to optimize performance and avoid making unnecessary API calls.', + cacheflushed: '{cachename} cache flushed.', + cachename: 'Cache Name', + cachehits: 'Hits', + cachemisses: 'Misses', + cachekeys: 'Total Keys', + cacheksize: 'Key Size', + cachevsize: 'Value Size', + flushcache: 'Flush Cache', +}); + +interface Job { + id: string; + name: string; + type: 'process' | 'command'; + nextExecutionTime: string; + running: boolean; +} + +const SettingsJobs: React.FC = () => { + const intl = useIntl(); + const { addToast } = useToasts(); + const { data, error, revalidate } = useSWR('/api/v1/settings/jobs', { + refreshInterval: 5000, + }); + const { data: cacheData, revalidate: cacheRevalidate } = useSWR( + '/api/v1/settings/cache', + { + refreshInterval: 10000, + } + ); + + if (!data && !error) { + return ; + } + + const runJob = async (job: Job) => { + await axios.get(`/api/v1/settings/jobs/${job.id}/run`); + addToast( + intl.formatMessage(messages.jobstarted, { + jobname: job.name, + }), + { + appearance: 'success', + autoDismiss: true, + } + ); + revalidate(); + }; + + const cancelJob = async (job: Job) => { + await axios.get(`/api/v1/settings/jobs/${job.id}/cancel`); + addToast(intl.formatMessage(messages.jobcancelled, { jobname: job.name }), { + appearance: 'error', + autoDismiss: true, + }); + revalidate(); + }; + + const flushCache = async (cache: CacheItem) => { + await axios.get(`/api/v1/settings/cache/${cache.id}/flush`); + addToast( + intl.formatMessage(messages.cacheflushed, { cachename: cache.name }), + { + appearance: 'success', + autoDismiss: true, + } + ); + cacheRevalidate(); + }; + + return ( + <> +
+

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

+

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

+
+ + + {intl.formatMessage(messages.jobname)} + {intl.formatMessage(messages.jobtype)} + {intl.formatMessage(messages.nextexecution)} + + + + {data?.map((job) => ( + + +
+ {job.running && } + {job.name} +
+
+ + + {job.type} + + + +
+ +
+
+ + {job.running ? ( + + ) : ( + + )} + + + ))} + +
+
+

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

+

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

+
+ + + {intl.formatMessage(messages.cachename)} + {intl.formatMessage(messages.cachehits)} + {intl.formatMessage(messages.cachemisses)} + {intl.formatMessage(messages.cachekeys)} + {intl.formatMessage(messages.cacheksize)} + {intl.formatMessage(messages.cachevsize)} + + + + {cacheData?.map((cache) => ( + + {cache.name} + {cache.stats.hits} + {cache.stats.misses} + {cache.stats.keys} + {formatBytes(cache.stats.ksize)} + {formatBytes(cache.stats.vsize)} + + + + + ))} + +
+ + ); +}; + +export default SettingsJobs; diff --git a/src/components/Settings/SettingsLayout.tsx b/src/components/Settings/SettingsLayout.tsx index 13bc1801..8ce65432 100644 --- a/src/components/Settings/SettingsLayout.tsx +++ b/src/components/Settings/SettingsLayout.tsx @@ -9,7 +9,7 @@ const messages = defineMessages({ menuServices: 'Services', menuNotifications: 'Notifications', menuLogs: 'Logs', - menuJobs: 'Jobs', + menuJobs: 'Jobs & Cache', menuAbout: 'About', }); @@ -106,7 +106,7 @@ const SettingsLayout: React.FC = ({ children }) => { )?.route } aria-label="Selected tab" - className="bg-gray-800 text-white mt-1 rounded-md form-select block w-full pl-3 pr-10 py-2 text-base leading-6 border-gray-700 focus:outline-none focus:ring-blue focus:border-blue-300 sm:text-sm sm:leading-5 transition ease-in-out duration-150" + className="block w-full py-2 pl-3 pr-10 mt-1 text-base leading-6 text-white transition duration-150 ease-in-out bg-gray-800 border-gray-700 rounded-md form-select focus:outline-none focus:ring-blue focus:border-blue-300 sm:text-sm sm:leading-5" > {settingsRoutes.map((route, index) => ( {
-
-
{children}
+
{children}
); }; diff --git a/src/components/Settings/SettingsPlex.tsx b/src/components/Settings/SettingsPlex.tsx index 22a2dc3e..57234ec9 100644 --- a/src/components/Settings/SettingsPlex.tsx +++ b/src/components/Settings/SettingsPlex.tsx @@ -25,7 +25,7 @@ const messages = defineMessages({ serverLocal: 'local', serverRemote: 'remote', serverConnected: 'connected', - serverpresetManualMessage: 'Manually configure', + serverpresetManualMessage: 'Manual configuration', serverpresetRefreshing: 'Retrieving servers…', serverpresetLoad: 'Press the button to load available servers', toastPlexRefresh: 'Retrieving server list from Plex', @@ -259,14 +259,14 @@ const SettingsPlex: React.FC = ({ onComplete }) => { } return ( <> -
-

+
+

-

+

-
+
{intl.formatMessage(messages.settingUpPlexDescription, { RegisterPlexTVLink: function RegisterPlexTVLink(msg) { @@ -346,200 +346,172 @@ const SettingsPlex: React.FC = ({ onComplete }) => { isSubmitting, }) => { return ( - -
-
- -
-
- -
+ +
+
-
- -
-
- - -
-
-
-
-
-
- -
-
- - {values.useSsl ? 'https://' : 'http://'} - - -
- {errors.hostname && touched.hostname && ( -
- {errors.hostname} -
- )} -
-
-
-
-
- -
-
- -
- {errors.port && touched.port && ( -
{errors.port}
- )} -
-
-
-
- -
- { - setFieldValue('useSsl', !values.useSsl); - }} - className="w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox" - /> -
+ +
+
+
+
+ +
+
+ + +
+
+
+
+ +
+
+ + {values.useSsl ? 'https://' : 'http://'} + + +
+ {errors.hostname && touched.hostname && ( +
{errors.hostname}
+ )} +
+
+
+ +
+
+ +
+ {errors.port && touched.port && ( +
{errors.port}
+ )} +
+
+
+ +
+ { + setFieldValue('useSsl', !values.useSsl); + }} + /> +
+
{submitError && ( -
+
= ({ onComplete }) => {
)} -
+
-
+
+
+
    {data?.libraries.map((library) => ( = ({ onComplete }) => { ))}
-
-

+
+

-

+

-
-
-
- {dataSync?.running && ( -
- )} -
- - {dataSync?.running - ? `${dataSync.progress} of ${dataSync.total}` - : 'Not running'} - -
+
+
+
+
+ {dataSync?.running && ( +
+ )} +
+ + {dataSync?.running + ? `${dataSync.progress} of ${dataSync.total}` + : 'Not running'} +
-
- {dataSync?.running && ( - <> - {dataSync.currentLibrary && ( -
- - - -
- )} -
- +
+
+ {dataSync?.running && ( + <> + {dataSync.currentLibrary && ( +
+ - library.id === dataSync.currentLibrary?.id - ) + 1 - ).length - : 0, - }} + {...messages.currentlibrary} + values={{ name: dataSync.currentLibrary.name }} />
- + )} +
+ + + library.id === dataSync.currentLibrary?.id + ) + 1 + ).length + : 0, + }} + /> + +
+ + )} +
+ {!dataSync?.running && ( + )} -
- {!dataSync?.running && ( - - )} - {dataSync?.running && ( - - )} -
+ {dataSync?.running && ( + + )}
diff --git a/src/components/Settings/SettingsServices.tsx b/src/components/Settings/SettingsServices.tsx index a58cb5e7..7c68cb0d 100644 --- a/src/components/Settings/SettingsServices.tsx +++ b/src/components/Settings/SettingsServices.tsx @@ -18,10 +18,10 @@ import Alert from '../Common/Alert'; const messages = defineMessages({ radarrsettings: 'Radarr Settings', radarrSettingsDescription: - 'Configure your Radarr connection below. You can have multiple Radarr configurations but only two can be active as defaults at any time (one for standard HD and one for 4K). Administrators can override the server will be used when a new request is made.', + 'Configure your Radarr connection below. You can have multiple Radarr configurations, but only two can be active as defaults at any time (one for standard HD and one for 4K). Administrators can override the server which is used for new requests.', sonarrsettings: 'Sonarr Settings', sonarrSettingsDescription: - 'Configure your Sonarr connection below. You can have multiple Sonarr configurations but only two can be active as defaults at any time (one for standard HD and one for 4K). Administrators can override the server will be used when a new request is made.', + 'Configure your Sonarr connection below. You can have multiple Sonarr configurations, but only two can be active as defaults at any time (one for standard HD and one for 4K). Administrators can override the server which is used for new requests.', deleteserverconfirm: 'Are you sure you want to delete this server?', edit: 'Edit', delete: 'Delete', @@ -65,7 +65,7 @@ const ServerInstance: React.FC = ({
-

+

{name}

{isDefault && ( @@ -198,11 +198,11 @@ const SettingsServices: React.FC = () => { return ( <> -
-

+
+

-

+

@@ -251,7 +251,7 @@ const SettingsServices: React.FC = () => { -
+
{!radarrData && !radarrError && } {radarrData && !radarrError && ( <> @@ -283,10 +283,11 @@ const SettingsServices: React.FC = () => { } /> ))} -
  • +
  • -
    -

    +
    +

    -

    +

    -
    +
    {!sonarrData && !sonarrError && } {sonarrData && !sonarrError && ( <> @@ -352,7 +353,7 @@ const SettingsServices: React.FC = () => { } /> ))} -
  • +
  • - )} - {data?.mediaInfo && - data?.mediaInfo.status4k !== MediaStatus.AVAILABLE && ( +
    + )} + {data?.mediaInfo && + data?.mediaInfo.status4k !== MediaStatus.AVAILABLE && ( +
    - )} -
    +
  • + )}
    {intl.formatMessage(messages.allseasonsmarkedavailable)}
    diff --git a/src/components/UserEdit/index.tsx b/src/components/UserEdit/index.tsx index 5c90eb76..8cccad9e 100644 --- a/src/components/UserEdit/index.tsx +++ b/src/components/UserEdit/index.tsx @@ -19,9 +19,9 @@ export const messages = defineMessages({ avatar: 'Avatar', email: 'Email', permissions: 'Permissions', - save: 'Save', + save: 'Save Changes', saving: 'Saving…', - usersaved: 'User saved', + usersaved: 'User saved!', userfail: 'Something went wrong while saving the user.', }); @@ -85,141 +85,98 @@ const UserEdit: React.FC = () => { > {({ isSubmitting, handleSubmit }) => ( -
    - -
    -
    -
    -
    - {user?.userType === UserType.PLEX && ( -
    - -
    - -
    -
    - )} -
    - -
    +
    +
    +
    + +
    +
    + {user?.userType === UserType.PLEX && ( +
    + +
    +
    -
    -
    -
    - -
    -
    - -
    - -
    -
    - -
    + )} +
    + +
    +
    +
    - -
    +
    +
    +
    + +
    +
    + +
    +
    +
    +
    + + + +
    +
    -
    -
    -
    -
    -
    -
    - -
    -
    -
    -
    - - setCurrentPermission(newPermission) - } - /> -
    -
    -
    -
    -
    -
    -
    - - - +
    +
    +
    + + + +
    +
    + + setCurrentPermission(newPermission) + } + />
    +
    +
    + + + +
    +
    )} diff --git a/src/components/UserList/BulkEditModal.tsx b/src/components/UserList/BulkEditModal.tsx index d3828082..b036e8e2 100644 --- a/src/components/UserList/BulkEditModal.tsx +++ b/src/components/UserList/BulkEditModal.tsx @@ -89,22 +89,25 @@ const BulkEditModal: React.FC = ({ okText={intl.formatMessage(userEditMessages.save)} onCancel={onCancel} > -
    -
    -
    - -
    -
    -
    -
    - setCurrentPermission(newPermission)} - /> +
    +
    +
    +
    +
    + +
    +
    +
    +
    + + setCurrentPermission(newPermission) + } + /> +
    +
    diff --git a/src/components/UserList/index.tsx b/src/components/UserList/index.tsx index 86e1b189..6bd3da6a 100644 --- a/src/components/UserList/index.tsx +++ b/src/components/UserList/index.tsx @@ -282,50 +282,43 @@ const UserList: React.FC = () => { {intl.formatMessage(messages.passwordinfodescription)} -
    -
    -