diff --git a/.all-contributorsrc b/.all-contributorsrc index ceb13bd2d1..356696f369 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -292,6 +292,15 @@ "contributions": [ "code" ] + }, + { + "login": "douglasparker", + "name": "Douglas Parker", + "avatar_url": "https://avatars.githubusercontent.com/u/18235822?v=4", + "profile": "https://www.douglas-parker.com", + "contributions": [ + "doc" + ] } ], "badgeTemplate": "\"All-orange.svg\"/>", diff --git a/.dockerignore b/.dockerignore index 4d49270ea3..4095dce568 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,15 +1,23 @@ -node_modules -.next +**/*.md +**/.gitkeep +**/.vscode +.all-contributorsrc +.dockerignore +.editorconfig +.eslintrc.js .git +.gitbook.yaml .gitconfig .gitignore .github -.all-contributorsrc -.editorconfig +.next .prettierignore -**/README.md -**/.vscode config/db/db.sqlite3 config/db/logs/overseerr.log -Dockerfil** -**.md +Dockerfile* +docker-compose.yml +docs +LICENSE +node_modules +snap +stylelint.config.js diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c4d690b74a..7590f941c5 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,3 +6,9 @@ updates: interval: daily time: '20:00' open-pull-requests-limit: 10 + - package-ecosystem: github-actions + directory: '/' + schedule: + interval: daily + time: '20:00' + open-pull-requests-limit: 10 diff --git a/.github/workflows/deploy_docs.yml b/.github/workflows/deploy_docs.yml new file mode 100644 index 0000000000..13e3e01d20 --- /dev/null +++ b/.github/workflows/deploy_docs.yml @@ -0,0 +1,23 @@ +name: Deploy API Docs + +on: + push: + branches: + - develop + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Generate Swagger UI + uses: Legion2/swagger-ui-action@v1 + with: + output: swagger-ui + spec-file: overseerr-api.yml + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: swagger-ui + cname: api-docs.overseerr.dev diff --git a/.stoplight.json b/.stoplight.json new file mode 100644 index 0000000000..0a876a9eb6 --- /dev/null +++ b/.stoplight.json @@ -0,0 +1,9 @@ +{ + "formats": { + "openapi": { + "rootDir": ".", + "include": ["**"] + } + }, + "exclude": ["docs"] +} diff --git a/Dockerfile b/Dockerfile index 8a0190bf8d..281ba10e4a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:12.18-alpine AS BUILD_IMAGE +FROM node:14.15-alpine AS BUILD_IMAGE ARG COMMIT_TAG ENV COMMIT_TAG=${COMMIT_TAG} @@ -11,24 +11,22 @@ RUN yarn --frozen-lockfile && \ # remove development dependencies RUN yarn install --production --ignore-scripts --prefer-offline -RUN yarn cache clean -FROM node:12.18-alpine +RUN rm -rf src && \ + rm -rf server -ARG COMMIT_TAG -ENV COMMIT_TAG=${COMMIT_TAG} +RUN touch config/DOCKER -RUN apk add tzdata +RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json -COPY . /app -WORKDIR /app -# copy from build image -COPY --from=BUILD_IMAGE /app/dist ./dist -COPY --from=BUILD_IMAGE /app/.next ./.next -COPY --from=BUILD_IMAGE /app/node_modules ./node_modules +FROM node:14.15-alpine -RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json +RUN apk add --no-cache tzdata + +# copy from build image +COPY --from=BUILD_IMAGE /app /app +WORKDIR /app CMD yarn start diff --git a/README.md b/README.md index 458448bb1d..f3b3c27b04 100644 --- a/README.md +++ b/README.md @@ -16,21 +16,21 @@ Language grade: JavaScript GitHub -All Contributors +All Contributors

-**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,11 +77,13 @@ 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 -Full API documentation will soon be published automatically and available outside of running the app. Currently, you can access the API docs by running Overseerr locally and visiting http://localhost:5055/api-docs +Our documentation is built on every commit and hosted at https://api-docs.overseerr.dev + +Also, you can access the API docs by running Overseerr locally and visiting http://localhost:5055/api-docs ## Community @@ -144,6 +145,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
Chris Pritchard

💻 📖
Tamberlox

🌍
David

💻 +
Douglas Parker

📖 diff --git a/docs/extending-overseerr/reverse-proxy-examples.md b/docs/extending-overseerr/reverse-proxy-examples.md index 74c57ced6c..609d017c99 100644 --- a/docs/extending-overseerr/reverse-proxy-examples.md +++ b/docs/extending-overseerr/reverse-proxy-examples.md @@ -115,7 +115,7 @@ server { # HTTP Strict Transport Security add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; # Reduce XSS risks (Content-Security-Policy) - uncomment to use and add URLs whenever necessary - # add_header Content-Security-Policy "default-src 'self'; connect-src 'self' https://plex.tv; style-src 'self' 'unsafe-inline' https://rsms.me/inter/inter.css; script-src 'self'; img-src 'self' data: https://plex.tv https://assets.plex.tv https://gravatar.com https://i2.wp.com https://image.tmdb.org; font-src 'self' https://rsms.me/inter/font-files/" always; + # add_header Content-Security-Policy "default-src 'self'; connect-src 'self' https://plex.tv; style-src 'self' 'unsafe-inline' https://rsms.me/inter/inter.css; script-src 'self' 'unsafe-inline'; img-src 'self' data: https://plex.tv https://assets.plex.tv https://gravatar.com https://secure.gravatar.com https://i2.wp.com https://image.tmdb.org; font-src 'self' https://rsms.me/inter/font-files/" always; # Prevent some categories of XSS attacks (X-XSS-Protection) add_header X-XSS-Protection "1; mode=block" always; # Provide clickjacking protection (X-Frame-Options) diff --git a/overseerr-api.yml b/overseerr-api.yml index 209b5ac235..380979b95d 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -2,8 +2,43 @@ openapi: '3.0.2' info: title: 'Overseerr API' version: '1.0.0' + description: | + This is the documentation for the Overseerr API backend. + + Two primary authentication methods are supported: + + - **Cookie Authentication**: A valid login to the `/auth/login` or `/auth/local` will generate a valid authentication cookie. + - **API Key Authentication**: Login is also possible by passing an `X-Api-Key` header along with a valid API Key generated by Overseerr. +tags: + - name: public + description: Public API endpoints requiring no authentication. + - name: settings + description: Endpoints related to Overseerr's settings and configuration. + - name: auth + description: Endpoints related to logging in or out, and the currently authenticated user. + - name: users + description: Endpoints related to user management. + - name: search + description: Endpoints related to search and discovery. + - name: request + description: Endpoints related to request management. + - name: movies + description: Endpoints related to retrieving movies and their details. + - name: tv + description: Endpoints related to retrieving TV series and their details. + - name: person + description: Endpoints related to retrieving Person details. + - name: media + description: Endpoints related to media management. + - name: collection + description: Endpoints related to retrieving Collection details. + - name: service + description: Endpoinst related to getting Service (Radarr/Sonarr) details. servers: - - url: /api/v1 + - url: '{server}/api/v1' + variables: + server: + default: http://localhost:5055 components: schemas: @@ -59,6 +94,9 @@ components: type: string example: 'anapikey' readOnly: true + applicationTitle: + type: string + example: Overseerr applicationUrl: type: string example: https://os.example.com @@ -71,6 +109,9 @@ components: hideAvailable: type: boolean example: false + localLogin: + type: boolean + example: true defaultPermissions: type: number example: 32 @@ -116,17 +157,6 @@ components: - machineId - ip - port - PlexStatus: - type: object - properties: - settings: - $ref: '#/components/schemas/PlexSettings' - status: - type: number - example: 200 - message: - type: string - example: 'OK' PlexConnection: type: object properties: @@ -391,29 +421,6 @@ components: initialized: type: boolean example: false - AllSettings: - type: object - properties: - main: - $ref: '#/components/schemas/MainSettings' - plex: - $ref: '#/components/schemas/PlexSettings' - radarr: - type: array - items: - $ref: '#/components/schemas/RadarrSettings' - sonarr: - type: array - items: - $ref: '#/components/schemas/SonarrSettings' - public: - $ref: '#/components/schemas/PublicSettings' - required: - - main - - plex - - radarr - - sonarr - - public MovieResult: type: object required: @@ -587,7 +594,7 @@ components: readOnly: true imdbId: type: string - example: 123 + example: 'tt123' adult: type: boolean backdropPath: @@ -1470,6 +1477,27 @@ paths: example: 1.0.0 commitTag: type: string + /status/appdata: + get: + summary: Get application data volume status + description: For Docker installs, returns whether or not the volume mount was configured properly. Always returns true for non-Docker installs. + security: [] + tags: + - public + responses: + '200': + description: Application data volume status and path + content: + application/json: + schema: + type: object + properties: + appData: + type: boolean + example: true + appDataPath: + type: string + example: /app/config /settings/main: get: summary: Get main settings @@ -2024,6 +2052,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 @@ -2504,7 +2582,8 @@ paths: application/json: schema: type: array - $ref: '#/components/schemas/User' + items: + $ref: '#/components/schemas/User' /user/import-from-plex: post: @@ -2971,7 +3050,7 @@ paths: name: requestId description: Request ID required: true - example: 1 + example: '1' schema: type: string responses: @@ -2991,7 +3070,7 @@ paths: name: requestId description: Request ID required: true - example: 1 + example: '1' schema: type: string responses: @@ -3011,7 +3090,7 @@ paths: name: requestId description: Request ID required: true - example: 1 + example: '1' schema: type: string responses: @@ -3033,7 +3112,7 @@ paths: required: true schema: type: string - example: 1 + example: '1' responses: '200': description: Retry triggered @@ -3057,7 +3136,7 @@ paths: required: true schema: type: string - example: 1 + example: '1' - in: path name: status description: New status @@ -3529,7 +3608,7 @@ paths: name: mediaId description: Media ID required: true - example: 1 + example: '1' schema: type: string responses: @@ -3546,7 +3625,7 @@ paths: name: mediaId description: Media ID required: true - example: 1 + example: '1' schema: type: string - in: path diff --git a/package.json b/package.json index 533dc17679..e1f2e78e91 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ }, "license": "MIT", "dependencies": { + "@headlessui/react": "^0.2.0-da179ca", "@supercharge/request-ip": "^1.1.2", "@svgr/webpack": "^5.5.0", "ace-builds": "^1.4.12", @@ -29,24 +30,25 @@ "csurf": "^1.11.0", "email-templates": "^8.0.3", "express": "^4.17.1", - "express-openapi-validator": "^4.10.8", + "express-openapi-validator": "^4.10.11", "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-cache": "^5.1.2", + "node-schedule": "^2.0.0", "nodemailer": "^6.4.17", "nookies": "^2.5.2", "plex-api": "^5.3.1", "pug": "^3.0.0", "react": "17.0.1", - "react-ace": "^9.2.1", + "react-ace": "^9.3.0", "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.12.0", "react-markdown": "^5.0.3", "react-spring": "^8.0.27", "react-toast-notifications": "^2.4.0", @@ -57,7 +59,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", @@ -67,7 +69,7 @@ "yup": "^0.32.8" }, "devDependencies": { - "@babel/cli": "^7.12.10", + "@babel/cli": "^7.12.13", "@commitlint/cli": "^11.0.0", "@commitlint/config-conventional": "^11.0.0", "@semantic-release/changelog": "^5.0.1", @@ -81,14 +83,14 @@ "@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/express-session": "^1.17.3", "@types/lodash": "^4.14.168", - "@types/node": "^14.14.22", + "@types/node": "^14.14.24", "@types/node-schedule": "^1.3.1", "@types/nodemailer": "^6.4.0", - "@types/react": "^17.0.0", + "@types/react": "^17.0.1", "@types/react-dom": "^17.0.0", "@types/react-toast-notifications": "^2.4.0", "@types/react-transition-group": "^4.4.0", @@ -98,17 +100,17 @@ "@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", - "autoprefixer": "^9", + "@typescript-eslint/eslint-plugin": "^4.14.2", + "@typescript-eslint/parser": "^4.14.2", + "autoprefixer": "^10.2.4", "babel-plugin-react-intl": "^8.2.25", "babel-plugin-react-intl-auto": "^3.3.0", "commitizen": "^4.2.3", "copyfiles": "^2.4.1", "cz-conventional-changelog": "^3.3.0", - "eslint": "^7.18.0", + "eslint": "^7.19.0", "eslint-config-prettier": "^7.2.0", - "eslint-plugin-formatjs": "^2.10.3", + "eslint-plugin-formatjs": "^2.12.0", "eslint-plugin-jsx-a11y": "^6.4.1", "eslint-plugin-prettier": "^3.3.1", "eslint-plugin-react": "^7.22.0", @@ -117,10 +119,10 @@ "husky": "^4.3.8", "lint-staged": "^10.5.3", "nodemon": "^2.0.7", - "postcss": "^7", + "postcss": "^8.2.4", "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/public/images/radarr_logo.png b/public/images/radarr_logo.png deleted file mode 100644 index 482b742e0c..0000000000 Binary files a/public/images/radarr_logo.png and /dev/null differ diff --git a/public/images/radarr_logo.svg b/public/images/radarr_logo.svg new file mode 100644 index 0000000000..3ccb70e931 --- /dev/null +++ b/public/images/radarr_logo.svg @@ -0,0 +1 @@ + diff --git a/public/images/sonarr_logo.png b/public/images/sonarr_logo.png deleted file mode 100644 index 3311ac54ee..0000000000 Binary files a/public/images/sonarr_logo.png and /dev/null differ diff --git a/public/images/sonarr_logo.svg b/public/images/sonarr_logo.svg new file mode 100644 index 0000000000..f45e992705 --- /dev/null +++ b/public/images/sonarr_logo.svg @@ -0,0 +1 @@ + diff --git a/server/api/animelist.ts b/server/api/animelist.ts index 4420594243..20eb2f60eb 100644 --- a/server/api/animelist.ts +++ b/server/api/animelist.ts @@ -8,7 +8,9 @@ const UPDATE_INTERVAL_MSEC = 24 * 3600 * 1000; // how often to download new mapp // originally at https://raw.githubusercontent.com/ScudLee/anime-lists/master/anime-list.xml const MAPPING_URL = 'https://raw.githubusercontent.com/Anime-Lists/anime-lists/master/anime-list.xml'; -const LOCAL_PATH = path.join(__dirname, '../../config/anime-list.xml'); +const LOCAL_PATH = process.env.CONFIG_DIRECTORY + ? `${process.env.CONFIG_DIRECTORY}/anime-list.xml` + : path.join(__dirname, '../../config/anime-list.xml'); const mappingRegexp = new RegExp(/;[0-9]+-([0-9]+)/g); diff --git a/server/api/externalapi.ts b/server/api/externalapi.ts new file mode 100644 index 0000000000..2a1d94950f --- /dev/null +++ b/server/api/externalapi.ts @@ -0,0 +1,102 @@ +import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'; +import NodeCache from 'node-cache'; + +// 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; + + // 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/plexapi.ts b/server/api/plexapi.ts index b87dc3421a..f920a5b3ec 100644 --- a/server/api/plexapi.ts +++ b/server/api/plexapi.ts @@ -118,7 +118,7 @@ class PlexAPI { options: { identifier: settings.clientId, product: 'Overseerr', - deviceName: 'Overseerr', + deviceName: settings.main.applicationTitle, platform: 'Overseerr', }, }); diff --git a/server/api/radarr.ts b/server/api/radarr.ts index ec0c795622..8e8488d039 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 cc3a562a69..e83d55723a 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 29c6b431f3..681cb1f3af 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 fddab1ba8a..0000000000 --- a/server/api/themoviedb.ts +++ /dev/null @@ -1,934 +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 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; - credits: { - cast: TmdbCreditCast[]; - 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: '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 0000000000..be475f725a --- /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 0000000000..be1a629ebd --- /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', + }, + }, + 43200 + ); + + 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', + }, + }, + 43200 + ); + + 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 0000000000..63b0ba9a29 --- /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 1ba74961bb..4337d0144c 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'; @@ -414,6 +415,15 @@ export class MediaRequest { searchNow: !radarrSettings.preventSearch, }) .then(async (radarrMovie) => { + // We grab media again here to make sure we have the latest version of it + const media = await mediaRepository.findOne({ + where: { id: this.media.id }, + }); + + if (!media) { + throw new Error('Media data is missing'); + } + media[this.is4k ? 'externalServiceId4k' : 'externalServiceId'] = radarrMovie.id; media[this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] = diff --git a/server/entity/User.ts b/server/entity/User.ts index 078fe15ee2..fd0162dda5 100644 --- a/server/entity/User.ts +++ b/server/entity/User.ts @@ -8,7 +8,11 @@ import { RelationCount, AfterLoad, } from 'typeorm'; -import { Permission, hasPermission } from '../lib/permissions'; +import { + Permission, + hasPermission, + PermissionCheckOptions, +} from '../lib/permissions'; import { MediaRequest } from './MediaRequest'; import bcrypt from 'bcrypt'; import path from 'path'; @@ -85,8 +89,11 @@ export class User { return filtered; } - public hasPermission(permissions: Permission | Permission[]): boolean { - return !!hasPermission(permissions, this.permissions); + public hasPermission( + permissions: Permission | Permission[], + options?: PermissionCheckOptions + ): boolean { + return !!hasPermission(permissions, this.permissions, options); } public passwordMatch(password: string): Promise { diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts index 52be4c4c8a..b731b979f8 100644 --- a/server/interfaces/api/settingsInterfaces.ts +++ b/server/interfaces/api/settingsInterfaces.ts @@ -7,7 +7,21 @@ export interface SettingsAboutResponse { export interface PublicSettingsResponse { initialized: boolean; + applicationTitle: string; + hideAvailable: boolean; + localLogin: boolean; movie4kEnabled: boolean; 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/job/plexsync/index.ts b/server/job/plexsync/index.ts index cab4f5ef9a..3ed5870db6 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 6ef45254c2..3685af4846 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'; @@ -242,9 +243,19 @@ class JobSonarrSync { isAllSeasons || shouldStayAvailable ? MediaStatus.AVAILABLE : media.seasons.some( - (season) => season.status !== MediaStatus.UNKNOWN + (season) => + season[server4k ? 'status4k' : 'status'] === + MediaStatus.AVAILABLE || + season[server4k ? 'status4k' : 'status'] === + MediaStatus.PARTIALLY_AVAILABLE ) ? MediaStatus.PARTIALLY_AVAILABLE + : media.seasons.some( + (season) => + season[server4k ? 'status4k' : 'status'] === + MediaStatus.PROCESSING + ) + ? MediaStatus.PROCESSING : MediaStatus.UNKNOWN; await mediaRepository.save(media); diff --git a/server/lib/cache.ts b/server/lib/cache.ts new file mode 100644 index 0000000000..aaf3bd44b0 --- /dev/null +++ b/server/lib/cache.ts @@ -0,0 +1,60 @@ +import NodeCache from 'node-cache'; + +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: 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 { + return this.availableCaches[id]; + } + + public getAllCaches(): Record { + return this.availableCaches; + } +} + +const cacheManager = new CacheManager(); + +export default cacheManager; diff --git a/server/lib/notifications/agents/discord.ts b/server/lib/notifications/agents/discord.ts index 798f6525fb..81558cdfc3 100644 --- a/server/lib/notifications/agents/discord.ts +++ b/server/lib/notifications/agents/discord.ts @@ -203,7 +203,10 @@ class DiscordAgent description: payload.message, color, timestamp: new Date().toISOString(), - author: { name: 'Overseerr', url: settings.main.applicationUrl }, + author: { + name: settings.main.applicationTitle, + url: settings.main.applicationUrl, + }, fields: [ ...fields, // If we have extra data, map it to fields for discord notifications @@ -236,6 +239,7 @@ class DiscordAgent ): Promise { logger.debug('Sending discord notification', { label: 'Notifications' }); try { + const settings = getSettings(); const webhookUrl = this.getSettings().options.webhookUrl; if (!webhookUrl) { @@ -243,7 +247,7 @@ class DiscordAgent } await axios.post(webhookUrl, { - username: 'Overseerr', + username: settings.main.applicationTitle, embeds: [this.buildEmbed(type, payload)], } as DiscordWebhookPayload); diff --git a/server/lib/notifications/agents/email.ts b/server/lib/notifications/agents/email.ts index ccb428020c..c5c2fe83de 100644 --- a/server/lib/notifications/agents/email.ts +++ b/server/lib/notifications/agents/email.ts @@ -36,7 +36,7 @@ class EmailAgent private async sendMediaRequestEmail(payload: NotificationPayload) { // This is getting main settings for the whole app - const applicationUrl = getSettings().main.applicationUrl; + const { applicationUrl, applicationTitle } = getSettings().main; try { const userRepository = getRepository(User); const users = await userRepository.find(); @@ -65,6 +65,7 @@ class EmailAgent ? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}` : undefined, applicationUrl, + applicationTitle, requestType: 'New Request', }, }); @@ -81,7 +82,7 @@ class EmailAgent private async sendMediaFailedEmail(payload: NotificationPayload) { // This is getting main settings for the whole app - const applicationUrl = getSettings().main.applicationUrl; + const { applicationUrl, applicationTitle } = getSettings().main; try { const userRepository = getRepository(User); const users = await userRepository.find(); @@ -111,6 +112,7 @@ class EmailAgent ? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}` : undefined, applicationUrl, + applicationTitle, requestType: 'Failed Request', }, }); @@ -127,7 +129,7 @@ class EmailAgent private async sendMediaApprovedEmail(payload: NotificationPayload) { // This is getting main settings for the whole app - const applicationUrl = getSettings().main.applicationUrl; + const { applicationUrl, applicationTitle } = getSettings().main; try { const email = new PreparedEmail(); @@ -149,6 +151,7 @@ class EmailAgent ? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}` : undefined, applicationUrl, + applicationTitle, requestType: 'Request Approved', }, }); @@ -164,7 +167,7 @@ class EmailAgent private async sendMediaDeclinedEmail(payload: NotificationPayload) { // This is getting main settings for the whole app - const applicationUrl = getSettings().main.applicationUrl; + const { applicationUrl, applicationTitle } = getSettings().main; try { const email = new PreparedEmail(); @@ -186,6 +189,7 @@ class EmailAgent ? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}` : undefined, applicationUrl, + applicationTitle, requestType: 'Request Declined', }, }); @@ -201,7 +205,7 @@ class EmailAgent private async sendMediaAvailableEmail(payload: NotificationPayload) { // This is getting main settings for the whole app - const applicationUrl = getSettings().main.applicationUrl; + const { applicationUrl, applicationTitle } = getSettings().main; try { const email = new PreparedEmail(); @@ -223,6 +227,7 @@ class EmailAgent ? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}` : undefined, applicationUrl, + applicationTitle, requestType: 'Now Available', }, }); @@ -238,7 +243,7 @@ class EmailAgent private async sendTestEmail(payload: NotificationPayload) { // This is getting main settings for the whole app - const applicationUrl = getSettings().main.applicationUrl; + const { applicationUrl, applicationTitle } = getSettings().main; try { const email = new PreparedEmail(); @@ -250,6 +255,7 @@ class EmailAgent locals: { body: payload.message, applicationUrl, + applicationTitle, }, }); return true; diff --git a/server/lib/notifications/agents/pushover.ts b/server/lib/notifications/agents/pushover.ts index 5b7713b067..52f538aa7a 100644 --- a/server/lib/notifications/agents/pushover.ts +++ b/server/lib/notifications/agents/pushover.ts @@ -66,7 +66,7 @@ class PushoverAgent message += `Status\nProcessing Request\n`; break; case Notification.MEDIA_AVAILABLE: - messageTitle = 'Now available!'; + messageTitle = 'Now Available'; message += `${title}\n\n`; message += `${plot}\n\n`; message += `Requested By\n${username}\n\n`; @@ -81,7 +81,6 @@ class PushoverAgent break; case Notification.TEST_NOTIFICATION: messageTitle = 'Test Notification'; - message += `${title}\n\n`; message += `${plot}\n\n`; message += `Requested By\n${username}\n`; break; @@ -89,7 +88,7 @@ class PushoverAgent if (settings.main.applicationUrl && payload.media) { const actionUrl = `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`; - message += `Open in Overseerr`; + message += `Open in ${settings.main.applicationTitle}`; } return { title: messageTitle, message }; diff --git a/server/lib/notifications/agents/slack.ts b/server/lib/notifications/agents/slack.ts index f6ca6856ba..318bbfeb2d 100644 --- a/server/lib/notifications/agents/slack.ts +++ b/server/lib/notifications/agents/slack.ts @@ -58,7 +58,7 @@ class SlackAgent payload: NotificationPayload ): SlackBlockEmbed { const settings = getSettings(); - let header = 'Overseerr'; + let header = settings.main.applicationTitle; let actionUrl: string | undefined; const fields: EmbedField[] = []; @@ -191,7 +191,7 @@ class SlackAgent value: 'open_overseerr', text: { type: 'plain_text', - text: 'Open Overseerr', + text: `Open ${settings.main.applicationTitle}`, }, }, ], diff --git a/server/lib/notifications/agents/telegram.ts b/server/lib/notifications/agents/telegram.ts index a2b09c1cd5..2e08cbdfae 100644 --- a/server/lib/notifications/agents/telegram.ts +++ b/server/lib/notifications/agents/telegram.ts @@ -98,7 +98,7 @@ class TelegramAgent if (settings.main.applicationUrl && payload.media) { const actionUrl = `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`; - message += `\[Open in Overseerr\]\(${actionUrl}\)`; + message += `\[Open in ${settings.main.applicationTitle}\]\(${actionUrl}\)`; } /* eslint-enable */ diff --git a/server/lib/permissions.ts b/server/lib/permissions.ts index cfda793c63..f0d45acd95 100644 --- a/server/lib/permissions.ts +++ b/server/lib/permissions.ts @@ -13,6 +13,11 @@ export enum Permission { REQUEST_4K_MOVIE = 2048, REQUEST_4K_TV = 4096, REQUEST_ADVANCED = 8192, + REQUEST_VIEW = 16384, +} + +export interface PermissionCheckOptions { + type: 'and' | 'or'; } /** @@ -22,10 +27,12 @@ export enum Permission { * * @param permissions Single permission or array of permissions * @param value users current permission value + * @param options Extra options to control permission check behavior (mainly for arrays) */ export const hasPermission = ( permissions: Permission | Permission[], - value: number + value: number, + options: PermissionCheckOptions = { type: 'and' } ): boolean => { let total = 0; @@ -35,8 +42,15 @@ export const hasPermission = ( } if (Array.isArray(permissions)) { - // Combine all permission values into one - total = permissions.reduce((a, v) => a + v, 0); + if (value & Permission.ADMIN) { + return true; + } + switch (options.type) { + case 'and': + return permissions.every((permission) => !!(value & permission)); + case 'or': + return permissions.some((permission) => !!(value & permission)); + } } else { total = permissions; } diff --git a/server/lib/settings.ts b/server/lib/settings.ts index 1209ec3025..f5ac5e8e80 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -50,10 +50,12 @@ export interface SonarrSettings extends DVRSettings { export interface MainSettings { apiKey: string; + applicationTitle: string; applicationUrl: string; csrfProtection: boolean; defaultPermissions: number; hideAvailable: boolean; + localLogin: boolean; trustProxy: boolean; } @@ -62,9 +64,11 @@ interface PublicSettings { } interface FullPublicSettings extends PublicSettings { + applicationTitle: string; + hideAvailable: boolean; + localLogin: boolean; movie4kEnabled: boolean; series4kEnabled: boolean; - hideAvailable: boolean; } export interface NotificationAgentConfig { @@ -158,10 +162,12 @@ class Settings { clientId: uuidv4(), main: { apiKey: '', + applicationTitle: 'Overseerr', applicationUrl: '', csrfProtection: false, defaultPermissions: Permission.REQUEST, hideAvailable: false, + localLogin: true, trustProxy: false, }, plex: { @@ -289,13 +295,15 @@ class Settings { get fullPublicSettings(): FullPublicSettings { return { ...this.data.public, + applicationTitle: this.data.main.applicationTitle, + hideAvailable: this.data.main.hideAvailable, + localLogin: this.data.main.localLogin, movie4kEnabled: this.data.radarr.some( (radarr) => radarr.is4k && radarr.isDefault ), series4kEnabled: this.data.sonarr.some( (sonarr) => sonarr.is4k && sonarr.isDefault ), - hideAvailable: this.data.main.hideAvailable, }; } diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts index 946a0b72b9..6d36bb2f98 100644 --- a/server/middleware/auth.ts +++ b/server/middleware/auth.ts @@ -1,6 +1,6 @@ import { getRepository } from 'typeorm'; import { User } from '../entity/User'; -import { Permission } from '../lib/permissions'; +import { Permission, PermissionCheckOptions } from '../lib/permissions'; import { getSettings } from '../lib/settings'; export const checkUser: Middleware = async (req, _res, next) => { @@ -34,10 +34,11 @@ export const checkUser: Middleware = async (req, _res, next) => { }; export const isAuthenticated = ( - permissions?: Permission | Permission[] + permissions?: Permission | Permission[], + options?: PermissionCheckOptions ): Middleware => { const authMiddleware: Middleware = (req, res, next) => { - if (!req.user || !req.user.hasPermission(permissions ?? 0)) { + if (!req.user || !req.user.hasPermission(permissions ?? 0, options)) { res.status(403).json({ status: 403, error: 'You do not have permission to access this endpoint', diff --git a/server/models/Collection.ts b/server/models/Collection.ts index 64709502cd..48112849bb 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 c86396130e..bfeb95ac3d 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 575e40cc65..522a8e5e99 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 7d347207d1..0dab4e5870 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 5ff2f631a0..420dca28f1 100644 --- a/server/models/Tv.ts +++ b/server/models/Tv.ts @@ -3,19 +3,19 @@ import { ProductionCompany, Cast, Crew, - mapCast, + mapAggregateCast, mapCrew, ExternalIds, mapExternalIds, 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'; @@ -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 733cf2dfc0..d26cf6379a 100644 --- a/server/models/common.ts +++ b/server/models/common.ts @@ -1,10 +1,11 @@ -import { +import type { TmdbCreditCast, + TmdbAggregateCreditCast, TmdbCreditCrew, TmdbExternalIds, TmdbVideo, TmdbVideoResult, -} from '../api/themoviedb'; +} from '../api/themoviedb/interfaces'; import { Video } from '../models/Movie'; @@ -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, diff --git a/server/routes/auth.ts b/server/routes/auth.ts index f1b68d349a..1fd21dacf9 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -134,10 +134,13 @@ authRoutes.post('/login', async (req, res, next) => { }); authRoutes.post('/local', async (req, res, next) => { + const settings = getSettings(); const userRepository = getRepository(User); const body = req.body as { email?: string; password?: string }; - if (!body.email || !body.password) { + if (!settings.main.localLogin) { + return res.status(500).json({ error: 'Local user login is disabled' }); + } else if (!body.email || !body.password) { return res .status(500) .json({ error: 'You must provide an email and a password' }); diff --git a/server/routes/index.ts b/server/routes/index.ts index 282f0be813..78324025fa 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -15,6 +15,7 @@ import personRoutes from './person'; import collectionRoutes from './collection'; import { getAppVersion, getCommitTag } from '../utils/appVersion'; import serviceRoutes from './service'; +import { appDataStatus, appDataPath } from '../utils/appDataVolume'; const router = Router(); @@ -27,6 +28,13 @@ router.get('/status', (req, res) => { }); }); +router.get('/status/appdata', (_req, res) => { + return res.status(200).json({ + appData: appDataStatus(), + appDataPath: appDataPath(), + }); +}); + router.use('/user', isAuthenticated(Permission.MANAGE_USERS), user); router.get('/settings/public', (_req, res) => { const settings = getSettings(); diff --git a/server/routes/person.ts b/server/routes/person.ts index add0b0f1cd..7b8d90c4fb 100644 --- a/server/routes/person.ts +++ b/server/routes/person.ts @@ -42,24 +42,28 @@ personRoutes.get('/:id/combined_credits', async (req, res) => { ); return res.status(200).json({ - cast: combinedCredits.cast.map((result) => - mapCastCredits( - result, - castMedia.find( - (med) => - med.tmdbId === result.id && med.mediaType === result.media_type + cast: combinedCredits.cast + .map((result) => + mapCastCredits( + result, + castMedia.find( + (med) => + med.tmdbId === result.id && med.mediaType === result.media_type + ) ) ) - ), - crew: combinedCredits.crew.map((result) => - mapCrewCredits( - result, - crewMedia.find( - (med) => - med.tmdbId === result.id && med.mediaType === result.media_type + .filter((item) => !item.adult), + crew: combinedCredits.crew + .map((result) => + mapCrewCredits( + result, + crewMedia.find( + (med) => + med.tmdbId === result.id && med.mediaType === result.media_type + ) ) ) - ), + .filter((item) => !item.adult), id: combinedCredits.id, }); }); diff --git a/server/routes/request.ts b/server/routes/request.ts index 2a5e7c4153..25ba9c0eee 100644 --- a/server/routes/request.ts +++ b/server/routes/request.ts @@ -9,6 +9,7 @@ import { MediaStatus, MediaRequestStatus, MediaType } from '../constants/media'; import SeasonRequest from '../entity/SeasonRequest'; import logger from '../logger'; import { RequestResultsResponse } from '../interfaces/api/requestInterfaces'; +import { User } from '../entity/User'; const requestRoutes = Router(); @@ -56,7 +57,8 @@ requestRoutes.get('/', async (req, res, next) => { } const [requests, requestCount] = req.user?.hasPermission( - Permission.MANAGE_REQUESTS + [Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW], + { type: 'or' } ) ? await requestRepository.findAndCount({ order: sortFilter, @@ -94,8 +96,28 @@ requestRoutes.post( const tmdb = new TheMovieDb(); const mediaRepository = getRepository(Media); const requestRepository = getRepository(MediaRequest); + const userRepository = getRepository(User); try { + let requestUser = req.user; + + if ( + req.body.userId && + !req.user?.hasPermission([ + Permission.MANAGE_USERS, + Permission.MANAGE_REQUESTS, + ]) + ) { + return next({ + status: 403, + message: 'You do not have permission to modify the request user.', + }); + } else if (req.body.userId) { + requestUser = await userRepository.findOneOrFail({ + where: { id: req.body.userId }, + }); + } + const tmdbMedia = req.body.mediaType === 'movie' ? await tmdb.getMovie({ movieId: req.body.mediaId }) @@ -151,7 +173,7 @@ requestRoutes.post( const request = new MediaRequest({ type: MediaType.MOVIE, media, - requestedBy: req.user, + requestedBy: requestUser, // If the user is an admin or has the "auto approve" permission, automatically approve the request status: req.user?.hasPermission(Permission.AUTO_APPROVE) || @@ -212,7 +234,7 @@ requestRoutes.post( media: { id: media.id, } as Media, - requestedBy: req.user, + requestedBy: requestUser, // If the user is an admin or has the "auto approve" permission, automatically approve the request status: req.user?.hasPermission(Permission.AUTO_APPROVE) || @@ -292,6 +314,7 @@ requestRoutes.put<{ requestId: string }>( isAuthenticated(Permission.MANAGE_REQUESTS), async (req, res, next) => { const requestRepository = getRepository(MediaRequest); + const userRepository = getRepository(User); try { const request = await requestRepository.findOne( Number(req.params.requestId) @@ -301,10 +324,30 @@ requestRoutes.put<{ requestId: string }>( return next({ status: 404, message: 'Request not found' }); } + let requestUser = req.user; + + if ( + req.body.userId && + !( + req.user?.hasPermission(Permission.MANAGE_USERS) && + req.user?.hasPermission(Permission.MANAGE_REQUESTS) + ) + ) { + return next({ + status: 403, + message: 'You do not have permission to modify the request user.', + }); + } else if (req.body.userId) { + requestUser = await userRepository.findOneOrFail({ + where: { id: req.body.userId }, + }); + } + if (req.body.mediaType === 'movie') { request.serverId = req.body.serverId; request.profileId = req.body.profileId; request.rootFolder = req.body.rootFolder; + request.requestedBy = requestUser as User; requestRepository.save(request); } else if (req.body.mediaType === 'tv') { @@ -312,6 +355,7 @@ requestRoutes.put<{ requestId: string }>( request.serverId = req.body.serverId; request.profileId = req.body.profileId; request.rootFolder = req.body.rootFolder; + request.requestedBy = requestUser as User; const requestedSeasons = req.body.seasons as number[] | undefined; diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 1d87c12e4d..61dabe2174 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/server/templates/email/media-request/html.pug b/server/templates/email/media-request/html.pug index 5304d41b06..814fcab06c 100644 --- a/server/templates/email/media-request/html.pug +++ b/server/templates/email/media-request/html.pug @@ -54,7 +54,7 @@ div(role='article' aria-roledescription='email' aria-label='' lang='en') color: #a8aaaf;\ text-decoration: none;\ ') - | Overseerr + | #{applicationTitle} tr td(style='width: 100%' width='100%') table.sm-w-full(align='center' style='\ @@ -75,8 +75,8 @@ div(role='article' aria-roledescription='email' aria-label='' lang='en') tr td table(cellpadding='0' cellspacing='0' role='presentation') - img(src=imageUrl alt='') - p + a(href=actionUrl style='color: #3869d4') + img(src=imageUrl alt='') p(style='\ font-size: 16px;\ line-height: 24px;\ @@ -92,7 +92,7 @@ div(role='article' aria-roledescription='email' aria-label='' lang='en') margin-bottom: 20px;\ color: #51545e;\ ') - a(href=actionUrl style='color: #3869d4') Open Media in Overseerr + a(href=actionUrl style='color: #3869d4') Open in #{applicationTitle} tr td table.sm-w-full(align='center' style='\ @@ -111,4 +111,4 @@ tr text-align: center;\ color: #a8aaaf;\ ') - | Overseerr. + | #{applicationTitle} diff --git a/server/templates/email/media-request/media-request.html b/server/templates/email/media-request/media-request.html deleted file mode 100644 index 3f38d04bc2..0000000000 --- a/server/templates/email/media-request/media-request.html +++ /dev/null @@ -1,224 +0,0 @@ - - - - - - - - - - - - - - -
- - - - -
- - - - - - - -
- - Overseerr - -
- - - - - -
-

- Requested by {{requester_name}} at {{timestamp}} -

-

- Open detail page -

-
-
- - - - - - - - - - - diff --git a/server/templates/email/media-request/subject.pug b/server/templates/email/media-request/subject.pug index 02046bd5e1..e1c43065fa 100644 --- a/server/templates/email/media-request/subject.pug +++ b/server/templates/email/media-request/subject.pug @@ -1 +1 @@ -= `${requestType}: ${mediaName} - Overseerr` += `${requestType}: ${mediaName} - ${applicationTitle}` diff --git a/server/templates/email/password/html.pug b/server/templates/email/password/html.pug index afa2cdb807..1fa4713f65 100644 --- a/server/templates/email/password/html.pug +++ b/server/templates/email/password/html.pug @@ -54,7 +54,7 @@ div(role='article' aria-roledescription='email' aria-label='' lang='en') color: #a8aaaf;\ text-decoration: none;\ ') - | Overseerr + | #{applicationTitle} tr td(style='width: 100%' width='100%') table.sm-w-full(align='center' style='\ @@ -76,7 +76,7 @@ div(role='article' aria-roledescription='email' aria-label='' lang='en') margin-bottom: 20px;\ color: #51545e;\ ') - a(href=applicationUrl style='color: #3869d4') Open Overseerr + a(href=applicationUrl style='color: #3869d4') Open #{applicationTitle} tr td table.sm-w-full(align='center' style='\ @@ -95,4 +95,4 @@ tr text-align: center;\ color: #a8aaaf;\ ') - | Overseerr. + | #{applicationTitle} diff --git a/server/templates/email/password/subject.pug b/server/templates/email/password/subject.pug index 51196b1dba..e9135b7e4f 100644 --- a/server/templates/email/password/subject.pug +++ b/server/templates/email/password/subject.pug @@ -1 +1 @@ -= `Password reset - Overseerr` += `Password Reset - ${applicationTitle}` diff --git a/server/templates/email/test-email/html.pug b/server/templates/email/test-email/html.pug index d9f6063db5..b4abfebbf3 100644 --- a/server/templates/email/test-email/html.pug +++ b/server/templates/email/test-email/html.pug @@ -54,7 +54,7 @@ div(role='article' aria-roledescription='email' aria-label='' lang='en') color: #a8aaaf;\ text-decoration: none;\ ') - | Overseerr + | #{applicationTitle} tr td(style='width: 100%' width='100%') table.sm-w-full(align='center' style='\ @@ -74,7 +74,7 @@ div(role='article' aria-roledescription='email' aria-label='' lang='en') margin-bottom: 20px;\ color: #51545e;\ ') - a(href=applicationUrl style='color: #3869d4') Open Overseerr + a(href=applicationUrl style='color: #3869d4') Open #{applicationTitle} tr td table.sm-w-full(align='center' style='\ @@ -93,4 +93,4 @@ tr text-align: center;\ color: #a8aaaf;\ ') - | Overseerr. + | #{applicationTitle} diff --git a/server/templates/email/test-email/subject.pug b/server/templates/email/test-email/subject.pug index 6e50c1b5c5..c138fe152d 100644 --- a/server/templates/email/test-email/subject.pug +++ b/server/templates/email/test-email/subject.pug @@ -1 +1 @@ -= `Test Notification - Overseerr` += `Test Notification - ${applicationTitle}` diff --git a/server/types/express.d.ts b/server/types/express.d.ts index 61f30f0f15..90a8800697 100644 --- a/server/types/express.d.ts +++ b/server/types/express.d.ts @@ -4,10 +4,6 @@ import type { User } from '../entity/User'; declare global { namespace Express { - export interface Session { - userId?: number; - } - export interface Request { user?: User; } @@ -19,3 +15,11 @@ declare global { next: NextFunction ) => Promise | void | NextFunction; } + +// Declaration merging to apply our own types to SessionData +// See: (https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/express-session/index.d.ts#L23) +declare module 'express-session' { + export interface SessionData { + userId: number; + } +} diff --git a/server/utils/appDataVolume.ts b/server/utils/appDataVolume.ts new file mode 100644 index 0000000000..73c80b2c5b --- /dev/null +++ b/server/utils/appDataVolume.ts @@ -0,0 +1,16 @@ +import { existsSync } from 'fs'; +import path from 'path'; + +const CONFIG_PATH = process.env.CONFIG_DIRECTORY + ? process.env.CONFIG_DIRECTORY + : path.join(__dirname, '../../config'); + +const DOCKER_PATH = `${CONFIG_PATH}/DOCKER`; + +export const appDataStatus = (): boolean => { + return !existsSync(DOCKER_PATH); +}; + +export const appDataPath = (): string => { + return CONFIG_PATH; +}; diff --git a/server/utils/typeHelpers.ts b/server/utils/typeHelpers.ts index e7d76d78e7..ca12ddf45a 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/assets/services/tvdb.svg b/src/assets/services/tvdb.svg new file mode 100644 index 0000000000..f9369b4fe7 --- /dev/null +++ b/src/assets/services/tvdb.svg @@ -0,0 +1 @@ +image/svg+xml \ No newline at end of file diff --git a/src/components/AppDataWarning/index.tsx b/src/components/AppDataWarning/index.tsx new file mode 100644 index 0000000000..3023db81a1 --- /dev/null +++ b/src/components/AppDataWarning/index.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import useSWR from 'swr'; +import Alert from '../Common/Alert'; + +const messages = defineMessages({ + dockerVolumeMissing: 'Docker Volume Mount Missing', + dockerVolumeMissingDescription: + 'The {appDataPath} volume mount was not configured properly. All data will be cleared when the container is stopped or restarted.', +}); + +const AppDataWarning: React.FC = () => { + const intl = useIntl(); + const { data, error } = useSWR<{ appData: boolean; appDataPath: string }>( + '/api/v1/status/appdata' + ); + + if (!data && !error) { + return null; + } + + if (!data) { + return null; + } + + return ( + <> + {!data.appData && ( + + {intl.formatMessage(messages.dockerVolumeMissingDescription, { + code: function code(msg) { + return {msg}; + }, + appDataPath: data.appDataPath, + })} + + )} + + ); +}; + +export default AppDataWarning; diff --git a/src/components/CollectionDetails/index.tsx b/src/components/CollectionDetails/index.tsx index d286b83b13..b6bec5e211 100644 --- a/src/components/CollectionDetails/index.tsx +++ b/src/components/CollectionDetails/index.tsx @@ -1,5 +1,4 @@ import axios from 'axios'; -import Head from 'next/head'; import { useRouter } from 'next/router'; import React, { useContext, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; @@ -18,6 +17,7 @@ import Modal from '../Common/Modal'; import Slider from '../Slider'; import TitleCard from '../TitleCard'; import Transition from '../Transition'; +import PageTitle from '../Common/PageTitle'; const messages = defineMessages({ overviewunavailable: 'Overview unavailable.', @@ -108,9 +108,7 @@ const CollectionDetails: React.FC = ({ backgroundImage: `linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%), url(//image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath})`, }} > - - {data.name} - Overseerr - + = ({ title, children, type }) => { } return ( -
+
{design.svg}
diff --git a/src/components/Common/ButtonWithDropdown/index.tsx b/src/components/Common/ButtonWithDropdown/index.tsx index ecca1e15e6..054ecaea17 100644 --- a/src/components/Common/ButtonWithDropdown/index.tsx +++ b/src/components/Common/ButtonWithDropdown/index.tsx @@ -92,8 +92,8 @@ const ButtonWithDropdown: React.FC = ({ > {text} - - {children && ( + {children && ( + - )} - -
-
-
{children}
+ +
+
+
{children}
+
-
- - + + + )} ); }; diff --git a/src/components/Common/Header/index.tsx b/src/components/Common/Header/index.tsx index 3c743a266f..77dbd1590a 100644 --- a/src/components/Common/Header/index.tsx +++ b/src/components/Common/Header/index.tsx @@ -11,14 +11,14 @@ const Header: React.FC = ({ subtext, }) => { return ( -
+
-

- +

+ {children}

- {subtext &&
{subtext}
} + {subtext &&
{subtext}
}

); diff --git a/src/components/Common/List/index.tsx b/src/components/Common/List/index.tsx index 89ba88e13b..689fba5cb4 100644 --- a/src/components/Common/List/index.tsx +++ b/src/components/Common/List/index.tsx @@ -7,11 +7,13 @@ interface ListItemProps { const ListItem: React.FC = ({ title, children }) => { return ( -
-
{title}
-
- {children} -
+
+
+
{title}
+
+ {children} +
+
); }; @@ -25,12 +27,10 @@ const List: React.FC = ({ title, subTitle, children }) => { return ( <>
-

{title}

- {subTitle && ( -

{subTitle}

- )} +

{title}

+ {subTitle &&

{subTitle}

}
-
+
{children}
diff --git a/src/components/Common/Modal/index.tsx b/src/components/Common/Modal/index.tsx index 18b6c074d6..77abc64628 100644 --- a/src/components/Common/Modal/index.tsx +++ b/src/components/Common/Modal/index.tsx @@ -112,7 +112,7 @@ const Modal: React.FC = ({ )}
{title && ( diff --git a/src/components/Common/PageTitle/index.tsx b/src/components/Common/PageTitle/index.tsx new file mode 100644 index 0000000000..a7224b226f --- /dev/null +++ b/src/components/Common/PageTitle/index.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import useSettings from '../../../hooks/useSettings'; +import Head from 'next/head'; + +interface PageTitleProps { + title: string | (string | undefined)[]; +} + +const PageTitle: React.FC = ({ title }) => { + const settings = useSettings(); + + return ( + + + {Array.isArray(title) ? title.filter(Boolean).join(' - ') : title} -{' '} + {settings.currentSettings.applicationTitle} + + + ); +}; + +export default PageTitle; diff --git a/src/components/Common/Table/index.tsx b/src/components/Common/Table/index.tsx index f9f1040fc3..3bf03b7bb8 100644 --- a/src/components/Common/Table/index.tsx +++ b/src/components/Common/Table/index.tsx @@ -3,7 +3,7 @@ import { withProperties } from '../../../utils/typeHelpers'; const TBody: React.FC = ({ children }) => { return ( - {children} + {children} ); }; @@ -71,9 +71,9 @@ const TD: React.FC = ({ const Table: React.FC = ({ children }) => { return (
-
-
-
+
+
+
{children}
diff --git a/src/components/Discover/DiscoverMovies.tsx b/src/components/Discover/DiscoverMovies.tsx index 8491aa304f..4ebad14305 100644 --- a/src/components/Discover/DiscoverMovies.tsx +++ b/src/components/Discover/DiscoverMovies.tsx @@ -3,10 +3,11 @@ import { useSWRInfinite } from 'swr'; import type { MovieResult } from '../../../server/models/Search'; import ListView from '../Common/ListView'; import { LanguageContext } from '../../context/LanguageContext'; -import { defineMessages, FormattedMessage } from 'react-intl'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import Header from '../Common/Header'; import useSettings from '../../hooks/useSettings'; import { MediaStatus } from '../../../server/constants/media'; +import PageTitle from '../Common/PageTitle'; const messages = defineMessages({ discovermovies: 'Popular Movies', @@ -20,6 +21,7 @@ interface SearchResult { } const DiscoverMovies: React.FC = () => { + const intl = useIntl(); const settings = useSettings(); const { locale } = useContext(LanguageContext); const { data, error, size, setSize } = useSWRInfinite( @@ -68,9 +70,12 @@ const DiscoverMovies: React.FC = () => { return ( <> -
- -
+ +
+
+ +
+
{ + const intl = useIntl(); const settings = useSettings(); const { locale } = useContext(LanguageContext); const { data, error, size, setSize } = useSWRInfinite( @@ -67,9 +69,12 @@ const DiscoverTv: React.FC = () => { return ( <> -
- -
+ +
+
+ +
+
{ + const intl = useIntl(); const settings = useSettings(); const { locale } = useContext(LanguageContext); const { data, error, size, setSize } = useSWRInfinite( @@ -74,9 +76,12 @@ const Trending: React.FC = () => { return ( <> -
- -
+ +
+
+ +
+
{ + const intl = useIntl(); const settings = useSettings(); const { locale } = useContext(LanguageContext); const { data, error, size, setSize } = useSWRInfinite( @@ -69,9 +71,12 @@ const UpcomingMovies: React.FC = () => { return ( <> -
- -
+ +
+
+ +
+
{ return ( <> +
diff --git a/src/components/DownloadBlock/index.tsx b/src/components/DownloadBlock/index.tsx index 3783d8c25b..7799ac346b 100644 --- a/src/components/DownloadBlock/index.tsx +++ b/src/components/DownloadBlock/index.tsx @@ -5,9 +5,13 @@ import Badge from '../Common/Badge'; interface DownloadBlockProps { downloadItem: DownloadingItem; + is4k?: boolean; } -const DownloadBlock: React.FC = ({ downloadItem }) => { +const DownloadBlock: React.FC = ({ + downloadItem, + is4k = false, +}) => { return (
@@ -17,26 +21,39 @@ const DownloadBlock: React.FC = ({ downloadItem }) => {
- {Math.round( - ((downloadItem.size - downloadItem.sizeLeft) / - downloadItem.size) * - 100 - )} + {downloadItem.size + ? Math.round( + ((downloadItem.size - downloadItem.sizeLeft) / + downloadItem.size) * + 100 + ) + : 0} %
- {downloadItem.status} + + {is4k && ( + + 4K + + )} + {downloadItem.status} + ETA{' '} {downloadItem.estimatedCompletionTime ? ( diff --git a/src/components/ExternalLinkBlock/index.tsx b/src/components/ExternalLinkBlock/index.tsx index 22c2a3caf3..680cf1ee19 100644 --- a/src/components/ExternalLinkBlock/index.tsx +++ b/src/components/ExternalLinkBlock/index.tsx @@ -1,30 +1,34 @@ import React from 'react'; import TmdbLogo from '../../assets/services/tmdb.svg'; +import TvdbLogo from '../../assets/services/tvdb.svg'; import ImdbLogo from '../../assets/services/imdb.svg'; import RTLogo from '../../assets/services/rt.svg'; import PlexLogo from '../../assets/services/plex.svg'; +import { MediaType } from '../../../server/constants/media'; interface ExternalLinkBlockProps { mediaType: 'movie' | 'tv'; - imdbId?: string; tmdbId?: number; + tvdbId?: number; + imdbId?: string; rtUrl?: string; plexUrl?: string; } const ExternalLinkBlock: React.FC = ({ - imdbId, + mediaType, tmdbId, + tvdbId, + imdbId, rtUrl, - mediaType, plexUrl, }) => { return ( -
+
{plexUrl && ( @@ -34,17 +38,27 @@ const ExternalLinkBlock: React.FC = ({ {tmdbId && ( )} + {tvdbId && mediaType === MediaType.TV && ( + + + + )} {imdbId && ( @@ -54,7 +68,7 @@ const ExternalLinkBlock: React.FC = ({ {rtUrl && ( diff --git a/src/components/Layout/LanguagePicker/index.tsx b/src/components/Layout/LanguagePicker/index.tsx index c3677c67af..50b2caf805 100644 --- a/src/components/Layout/LanguagePicker/index.tsx +++ b/src/components/Layout/LanguagePicker/index.tsx @@ -17,61 +17,65 @@ 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', + it: { + code: 'it', + display: 'Italiano', }, - ru: { - code: 'ru', - display: 'Russian', + hu: { + code: 'hu', + display: 'Magyar', }, 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)', + display: 'Português (Portugal)', + }, + sv: { + code: 'sv', + display: 'Svenska', + }, + ru: { + code: 'ru', + display: 'pусский', }, sr: { code: 'sr', - display: 'Serbian', + display: 'српски језик‬', }, - sv: { - code: 'sv', - display: 'Swedish', + ja: { + code: 'ja', + display: '日本語', }, - 'zh-Hant': { - code: 'zh-Hant', - display: 'Chinese (Traditional)', + 'zh-TW': { + code: 'zh-TW', + display: '中文(臺灣)', }, }; @@ -113,10 +117,10 @@ const LanguagePicker: React.FC = () => { leaveTo="transform opacity-0 scale-95" >
-
+
= ({ } />
-
+
diff --git a/src/components/PermissionEdit/index.tsx b/src/components/PermissionEdit/index.tsx index 88f2781023..58428b75ea 100644 --- a/src/components/PermissionEdit/index.tsx +++ b/src/components/PermissionEdit/index.tsx @@ -39,6 +39,8 @@ export const messages = defineMessages({ advancedrequest: 'Advanced Requests', advancedrequestDescription: 'Grants permission to use advanced request options. (Ex. Changing servers/profiles/paths)', + viewrequests: 'View Requests', + viewrequestsDescription: "Grants permission to view other user's requests.", }); interface PermissionEditProps { @@ -85,6 +87,12 @@ export const PermissionEdit: React.FC = ({ description: intl.formatMessage(messages.advancedrequestDescription), permission: Permission.REQUEST_ADVANCED, }, + { + id: 'viewrequests', + name: intl.formatMessage(messages.viewrequests), + description: intl.formatMessage(messages.viewrequestsDescription), + permission: Permission.REQUEST_VIEW, + }, ], }, { diff --git a/src/components/PermissionOption/index.tsx b/src/components/PermissionOption/index.tsx index f8c0677bb5..b6de4e39aa 100644 --- a/src/components/PermissionOption/index.tsx +++ b/src/components/PermissionOption/index.tsx @@ -41,12 +41,11 @@ const PermissionOption: React.FC = ({ : '' }`} > -
+
= ({ } />
-
-
{(option.children ?? []).map((child) => ( -
+
= ({ className={`relative ${ canExpand ? 'w-full' : 'w-36 sm:w-36 md:w-44' } rounded-lg text-white shadow-lg transition ease-in-out duration-150 cursor-pointer transform-gpu ${ - isHovered ? 'bg-gray-500 scale-105' : 'bg-gray-600 scale-100' + isHovered ? 'bg-gray-600 scale-105' : 'bg-gray-700 scale-100' }`} >
- {profilePath && ( -
+
+ {profilePath ? ( -
- )} - {!profilePath && ( - - - - )} + ) : ( + + + + )} +
{name}
{subName && (
= ({ {subName}
)} -
+
diff --git a/src/components/PersonDetails/index.tsx b/src/components/PersonDetails/index.tsx index 73d77dba3f..6b090bcf68 100644 --- a/src/components/PersonDetails/index.tsx +++ b/src/components/PersonDetails/index.tsx @@ -12,6 +12,7 @@ import { LanguageContext } from '../../context/LanguageContext'; import ImageFader from '../Common/ImageFader'; import Ellipsis from '../../assets/ellipsis.svg'; import { groupBy } from 'lodash'; +import PageTitle from '../Common/PageTitle'; const messages = defineMessages({ appearsin: 'Appears in', @@ -172,6 +173,7 @@ const PersonDetails: React.FC = () => { return ( <> + {(sortedCrew || sortedCast) && (
= ({ request }) => { {isMovie(title) ? title.title : title.name} -
- {intl.formatMessage(messages.requestedby, { - username: requestData.requestedBy.displayName, - })} +
+ + + {requestData.requestedBy.displayName} +
{requestData.media.status && (
diff --git a/src/components/RequestList/RequestItem/index.tsx b/src/components/RequestList/RequestItem/index.tsx index 807555ef76..d7e3cae37a 100644 --- a/src/components/RequestList/RequestItem/index.tsx +++ b/src/components/RequestList/RequestItem/index.tsx @@ -27,7 +27,6 @@ import { useToasts } from 'react-toast-notifications'; import RequestModal from '../../RequestModal'; const messages = defineMessages({ - requestedby: 'Requested by {username}', seasons: 'Seasons', notavailable: 'N/A', failedretry: 'Something went wrong while retrying the request.', @@ -102,7 +101,7 @@ const RequestItem: React.FC = ({ if (!title && !error) { return ( - + ); @@ -110,14 +109,14 @@ const RequestItem: React.FC = ({ if (!title || !requestData) { return ( - + ); } return ( - + = ({ {isMovie(title) ? title.title : title.name} -
- {intl.formatMessage(messages.requestedby, { - username: requestData.requestedBy.displayName, - })} +
+ + + {requestData.requestedBy.displayName} +
{requestData.seasons.length > 0 && (
@@ -193,7 +197,7 @@ const RequestItem: React.FC = ({ ) : ( = ({ ] ?? [] ).length > 0 } + is4k={requestData.is4k} /> )} @@ -215,16 +220,24 @@ const RequestItem: React.FC = ({
{requestData.modifiedBy ? ( - {requestData.modifiedBy.displayName} - ( - - ) +
+ + + {requestData.modifiedBy.displayName} ( + + ) + +
) : ( N/A diff --git a/src/components/RequestList/index.tsx b/src/components/RequestList/index.tsx index 7b682ac530..39dca9124e 100644 --- a/src/components/RequestList/index.tsx +++ b/src/components/RequestList/index.tsx @@ -7,6 +7,7 @@ import Header from '../Common/Header'; import Table from '../Common/Table'; import Button from '../Common/Button'; import { defineMessages, useIntl } from 'react-intl'; +import PageTitle from '../Common/PageTitle'; const messages = defineMessages({ requests: 'Requests', @@ -54,9 +55,10 @@ const RequestList: React.FC = () => { return ( <> +
{intl.formatMessage(messages.requests)}
-
+
{ setCurrentFilter(e.target.value as Filter); }} value={currentFilter} - className="flex-1 block w-full py-2 pl-3 pr-10 text-base leading-6 text-white bg-gray-700 border-gray-500 rounded-r-md form-select focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5 disabled:opacity-50" + className="rounded-r-only" >
- {intl.formatMessage(messages.mediaInfo)} - {intl.formatMessage(messages.status)} - {intl.formatMessage(messages.requestedAt)} - {intl.formatMessage(messages.modifiedBy)} - + + {intl.formatMessage(messages.mediaInfo)} + {intl.formatMessage(messages.status)} + {intl.formatMessage(messages.requestedAt)} + {intl.formatMessage(messages.modifiedBy)} + + {data.results.map((request) => { @@ -152,10 +156,12 @@ const RequestList: React.FC = () => { })} {data.results.length === 0 && ( - + -
- {intl.formatMessage(messages.noresults)} +
+ + {intl.formatMessage(messages.noresults)} + {currentFilter !== 'all' && (
)} - +
- - {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 0000000000..0a3c87d92b --- /dev/null +++ b/src/components/Settings/SettingsJobsCache/index.tsx @@ -0,0 +1,207 @@ +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.', + process: 'Process', + command: 'Command', + 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 === 'process' + ? intl.formatMessage(messages.process) + : intl.formatMessage(messages.command)} + + + +
+ +
+
+ + {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} + {intl.formatNumber(cache.stats.hits)} + {intl.formatNumber(cache.stats.misses)} + {intl.formatNumber(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 13bc18015b..3a49889dc8 100644 --- a/src/components/Settings/SettingsLayout.tsx +++ b/src/components/Settings/SettingsLayout.tsx @@ -2,14 +2,16 @@ import React from 'react'; import Link from 'next/link'; import { useRouter } from 'next/router'; import { defineMessages, useIntl } from 'react-intl'; +import PageTitle from '../Common/PageTitle'; const messages = defineMessages({ + settings: 'Settings', menuGeneralSettings: 'General Settings', menuPlexSettings: 'Plex', menuServices: 'Services', menuNotifications: 'Notifications', menuLogs: 'Logs', - menuJobs: 'Jobs', + menuJobs: 'Jobs & Cache', menuAbout: 'About', }); @@ -91,6 +93,7 @@ const SettingsLayout: React.FC = ({ children }) => { }; return ( <> +
@@ -137,7 +164,7 @@ const SettingsMain: React.FC = () => { e.preventDefault(); regenerate(); }} - className="relative inline-flex items-center px-4 py-2 -ml-px text-sm font-medium leading-5 text-white transition duration-150 ease-in-out bg-indigo-500 border border-gray-500 rounded-r-md hover:bg-indigo-400 focus:outline-none focus:ring-blue focus:border-blue-300 active:bg-gray-100 active:text-gray-700" + className="relative inline-flex items-center px-4 py-2 -ml-px text-sm font-medium leading-5 text-white transition duration-150 ease-in-out bg-indigo-600 border border-gray-500 rounded-r-md hover:bg-indigo-500 focus:outline-none focus:ring-blue focus:border-blue-300 active:bg-gray-100 active:text-gray-700" > {
)} -
-
+ -
+
+ {errors.applicationUrl && touched.applicationUrl && ( +
{errors.applicationUrl}
+ )}
-
-
+ -
+
{ onChange={() => { setFieldValue('trustProxy', !values.trustProxy); }} - className="w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox" />
-
-
+ -
+
{ setFieldValue('csrfProtection', !values.csrfProtection); }} - className="w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox" />
-
-
+ -
+
{ onChange={() => { setFieldValue('hideAvailable', !values.hideAvailable); }} - className="w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox" />
-
-
-
-
-
- {intl.formatMessage(messages.defaultPermissions)} -
-
-
-
- - setFieldValue( - 'defaultPermissions', - newPermissions - ) - } - /> -
+
+ +
+ { + setFieldValue('localLogin', !values.localLogin); + }} + /> +
+
+
+
+ + {intl.formatMessage(messages.defaultPermissions)} + +
+
+ + setFieldValue('defaultPermissions', newPermissions) + } + />
-
+
-

+

{intl.formatMessage(messages.notificationAgentsSettings)}

-

+

{intl.formatMessage(messages.notificationAgentSettingsDescription)}

@@ -294,7 +286,6 @@ const SettingsNotifications: React.FC = ({ children }) => { )?.route } aria-label="Selected tab" - 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 22a2dc3e1b..db1c80f7d3 100644 --- a/src/components/Settings/SettingsPlex.tsx +++ b/src/components/Settings/SettingsPlex.tsx @@ -12,6 +12,7 @@ import Badge from '../Common/Badge'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import * as Yup from 'yup'; import Alert from '../Common/Alert'; +import Spinner from '../../assets/spinner.svg'; const messages = defineMessages({ plexsettings: 'Plex Settings', @@ -25,7 +26,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', @@ -43,7 +44,6 @@ const messages = defineMessages({ port: 'Port', ssl: 'SSL', timeout: 'Timeout', - ms: 'ms', save: 'Save Changes', saving: 'Saving…', plexlibraries: 'Plex Libraries', @@ -259,14 +259,14 @@ const SettingsPlex: React.FC = ({ onComplete }) => { } return ( <> -
-

+
+

-

+

-
+
{intl.formatMessage(messages.settingUpPlexDescription, { RegisterPlexTVLink: function RegisterPlexTVLink(msg) { @@ -346,109 +346,102 @@ const SettingsPlex: React.FC = ({ onComplete }) => { isSubmitting, }) => { return ( - -
-
- -
-
- -
+ +
+ +
+
+
-
- -
-
- { + const targPreset = + availablePresets[Number(e.target.value)]; + if (targPreset) { + setFieldValue('hostname', targPreset.host); + setFieldValue('port', targPreset.port); + setFieldValue('useSsl', targPreset.ssl); + } + setFieldTouched('hostname'); + setFieldTouched('port'); + setFieldTouched('useSsl'); + }} + > + + {availablePresets.map((server, index) => ( + - {availablePresets.map((server, index) => ( - - ))} - - -
+ )} +
-
-
-
- -
-
- - {values.useSsl ? 'https://' : 'http://'} - - -
- {errors.hostname && touched.hostname && ( -
- {errors.hostname} -
- )} -
-
-
-
-
- -
-
- -
- {errors.port && touched.port && ( -
{errors.port}
- )} -
-
+
+
+ +
+
+ + {values.useSsl ? 'https://' : 'http://'} + +
-
- -
- { - setFieldValue('useSsl', !values.useSsl); - }} - className="w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox" - /> -
+ {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 }} />
- - )} -
- {!dataSync?.running && ( - )} +
+ + + library.id === dataSync.currentLibrary?.id + ) + 1 + ).length + : 0, + }} + /> + +
+ + )} +
+ {!dataSync?.running && ( + + )} - {dataSync?.running && ( - - )} -
+ {dataSync?.running && ( + + )}
diff --git a/src/components/Settings/SettingsServices.tsx b/src/components/Settings/SettingsServices.tsx index a58cb5e7c5..e61ea10252 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 && ( @@ -99,7 +99,7 @@ const ServerInstance: React.FC = ({
@@ -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.status !== MediaStatus.AVAILABLE || - data.mediaInfo.status4k !== MediaStatus.AVAILABLE) && ( + (data.mediaInfo.status4k !== MediaStatus.AVAILABLE && + settings.currentSettings.series4kEnabled)) && (
    -
    - {data?.mediaInfo && - data?.mediaInfo.status !== MediaStatus.AVAILABLE && ( + {data?.mediaInfo && + data?.mediaInfo.status !== MediaStatus.AVAILABLE && ( +
    - )} - {data?.mediaInfo && - data?.mediaInfo.status4k !== MediaStatus.AVAILABLE && ( +
    + )} + {data?.mediaInfo && + data?.mediaInfo.status4k !== MediaStatus.AVAILABLE && + settings.currentSettings.series4kEnabled && ( +
    - )} -
    +
    + )}
    {intl.formatMessage(messages.allseasonsmarkedavailable)}
    @@ -433,35 +446,41 @@ const TvDetails: React.FC = ({ tv }) => { } }} > - {data.mediaInfo?.plexUrl || - (data.mediaInfo?.plexUrl4k && - (hasPermission(Permission.REQUEST_4K) || - hasPermission(Permission.REQUEST_4K_TV))) ? ( - <> - {data.mediaInfo?.plexUrl && + {( + trailerUrl + ? data.mediaInfo?.plexUrl || + (data.mediaInfo?.plexUrl4k && + (hasPermission(Permission.REQUEST_4K) || + hasPermission(Permission.REQUEST_4K_TV))) + : data.mediaInfo?.plexUrl && data.mediaInfo?.plexUrl4k && (hasPermission(Permission.REQUEST_4K) || - hasPermission(Permission.REQUEST_4K_TV)) && ( - { - window.open(data.mediaInfo?.plexUrl4k, '_blank'); - }} - buttonType="ghost" - > - {intl.formatMessage(messages.play4konplex)} - - )} - {(data.mediaInfo?.plexUrl || data.mediaInfo?.plexUrl4k) && - trailerUrl && ( - { - window.open(trailerUrl, '_blank'); - }} - buttonType="ghost" - > - {intl.formatMessage(messages.watchtrailer)} - - )} + hasPermission(Permission.REQUEST_4K_TV)) + ) ? ( + <> + {data.mediaInfo?.plexUrl && + data.mediaInfo?.plexUrl4k && + (hasPermission(Permission.REQUEST_4K) || + hasPermission(Permission.REQUEST_4K_TV)) ? ( + { + window.open(data.mediaInfo?.plexUrl4k, '_blank'); + }} + buttonType="ghost" + > + {intl.formatMessage(messages.play4konplex)} + + ) : null} + {trailerUrl ? ( + { + window.open(trailerUrl, '_blank'); + }} + buttonType="ghost" + > + {intl.formatMessage(messages.watchtrailer)} + + ) : null} ) : null} @@ -643,6 +662,21 @@ const TvDetails: React.FC = ({ tv }) => {
    )} + {data.nextEpisodeToAir && ( +
    + + + + + + +
    + )}
    @@ -682,6 +716,7 @@ const TvDetails: React.FC = ({ tv }) => { { @@ -43,14 +45,16 @@ const UserEdit: React.FC = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [user]); + const UserEditSchema = Yup.object().shape({ + email: Yup.string() + .required(intl.formatMessage(messages.validationEmail)) + .email(intl.formatMessage(messages.validationEmail)), + }); + if (!user && !error) { return ; } - const UserEditSchema = Yup.object().shape({ - username: Yup.string(), - }); - return ( { } }} > - {({ isSubmitting, handleSubmit }) => ( + {({ errors, touched, isSubmitting, handleSubmit }) => ( -
    - -
    -
    -
    -
    - {user?.userType === UserType.PLEX && ( -
    - -
    - -
    -
    - )} -
    - -
    - -
    -
    -
    - -
    + +
    +
    +
    + +
    +
    + {user?.userType === UserType.PLEX && ( +
    + +
    +
    - -
    - -
    -
    - -
    + )} +
    + +
    +
    +
    - -
    +
    +
    +
    + +
    +
    + +
    + {errors.email && touched.email && ( +
    {errors.email}
    + )} +
    +
    +
    + + + +
    +
    -
    -
    -
    -
    -
    -
    - -
    -
    -
    -
    - - setCurrentPermission(newPermission) - } - /> -
    -
    -
    -
    -
    -
    -
    - - - +
    +
    +
    + + + +
    +
    + + setCurrentPermission(newPermission) + } + />
    +
    +
    + + + +
    +
    )} diff --git a/src/components/UserList/BulkEditModal.tsx b/src/components/UserList/BulkEditModal.tsx index d382808296..b036e8e2bc 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 86e1b1892f..38e4656945 100644 --- a/src/components/UserList/index.tsx +++ b/src/components/UserList/index.tsx @@ -20,8 +20,10 @@ import * as Yup from 'yup'; import AddUserIcon from '../../assets/useradd.svg'; import Alert from '../Common/Alert'; import BulkEditModal from './BulkEditModal'; +import PageTitle from '../Common/PageTitle'; const messages = defineMessages({ + users: 'Users', userlist: 'User List', importfromplex: 'Import Users from Plex', importfromplexerror: 'Something went wrong while importing users from Plex.', @@ -47,9 +49,8 @@ const messages = defineMessages({ localuser: 'Local User', createlocaluser: 'Create Local User', createuser: 'Create User', - creating: 'Creating', + creating: 'Creating…', create: 'Create', - validationemailrequired: 'Must enter a valid email address', validationpasswordminchars: 'Password is too short; should be a minimum of 8 characters', usercreatedfailed: 'Something went wrong while creating the user.', @@ -60,6 +61,7 @@ const messages = defineMessages({ passwordinfodescription: 'Email notifications need to be configured and enabled in order to automatically generate passwords.', autogeneratepassword: 'Automatically generate password', + validationEmail: 'You must provide a valid email address', }); const UserList: React.FC = () => { @@ -169,15 +171,21 @@ const UserList: React.FC = () => { const CreateUserSchema = Yup.object().shape({ email: Yup.string() - .email() - .required(intl.formatMessage(messages.validationemailrequired)), + .required(intl.formatMessage(messages.validationEmail)) + .email(intl.formatMessage(messages.validationEmail)), password: Yup.lazy((value) => - !value ? Yup.string() : Yup.string().min(8) + !value + ? Yup.string() + : Yup.string().min( + 8, + intl.formatMessage(messages.validationpasswordminchars) + ) ), }); return ( <> + { {intl.formatMessage(messages.passwordinfodescription)} -
    -
    -